diff --git a/app/components/Dialog.tsx b/app/components/Dialog.tsx index c3f2448f7..b782c3c12 100644 --- a/app/components/Dialog.tsx +++ b/app/components/Dialog.tsx @@ -9,7 +9,7 @@ export function Dialog({ children: React.ReactNode; isOpen: boolean; close?: () => void; - className: string; + className?: string; }) { const ref = useDOMSync(isOpen); diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx new file mode 100644 index 000000000..63de77269 --- /dev/null +++ b/app/components/FormWithConfirm.tsx @@ -0,0 +1,50 @@ +import { Form } from "@remix-run/react"; +import React from "react"; +import invariant from "tiny-invariant"; +import { Button } from "./Button"; +import { Dialog } from "./Dialog"; + +export function FormWithConfirm({ + fields, + children, + dialogHeading, +}: { + fields: [name: string, value: string | number][]; + children: React.ReactNode; + dialogHeading: string; +}) { + const [dialogOpen, setDialogOpen] = React.useState(false); + const formRef = React.useRef(null); + const id = React.useId(); + + const openDialog = () => setDialogOpen(true); + const closeDialog = () => setDialogOpen(false); + + invariant(React.isValidElement(children)); + + return ( + <> +
+ {fields.map(([name, value]) => ( + + ))} +
+ +
+

{dialogHeading}

+
+ + +
+
+
+ {React.cloneElement(children, { + onClick: openDialog, + })} + + ); +} diff --git a/app/db/models/plusSuggestions.server.ts b/app/db/models/plusSuggestions.server.ts index b5a305fd3..fbaffe458 100644 --- a/app/db/models/plusSuggestions.server.ts +++ b/app/db/models/plusSuggestions.server.ts @@ -53,6 +53,8 @@ WITH gs as ( 'suggestions', json_group_array( json_object( + 'id', + "PlusSuggestion"."id", 'author', json_object( 'id', @@ -103,7 +105,7 @@ export type FindResult = { | "discordDiscriminator" | "discordAvatar" >; - suggestions: (Pick & { + suggestions: (Pick & { author: Pick< User, "id" | "discordId" | "discordName" | "discordDiscriminator" @@ -141,3 +143,13 @@ const tiersSuggestedForStm = sql.prepare(` export function tiersSuggestedFor(args: MonthYear & { userId: User["id"] }) { return JSON.parse(tiersSuggestedForStm.pluck().get(args)) as User["id"][]; } + +const delStm = sql.prepare(` + DELETE FROM "PlusSuggestion" + WHERE + "id" = $id +`); + +export function del(id: PlusSuggestion["id"]) { + delStm.run({ id }); +} diff --git a/app/db/types.ts b/app/db/types.ts index a99651c59..3ff9bd73b 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -14,6 +14,7 @@ export interface User { } export interface PlusSuggestion { + id: number; text: string; authorId: number; suggestedId: number; diff --git a/app/permissions.ts b/app/permissions.ts index 8c1cd00d3..32471be5e 100644 --- a/app/permissions.ts +++ b/app/permissions.ts @@ -29,6 +29,14 @@ export function canAddCommentToSuggestionBE({ ]); } +interface CanDeleteCommentArgs { + author: Pick; + user?: Pick; +} +export function canDeleteComment(args: CanDeleteCommentArgs) { + return isOwnComment(args); +} + // TODO: needed for new suggestions // function votingIsActive() { // const now = new Date(); @@ -72,3 +80,7 @@ function targetPlusTierIsSmallerOrEqual({ }: Pick) { return user?.plusTier && user.plusTier <= suggested.plusTier; } + +function isOwnComment({ author, user }: CanDeleteCommentArgs) { + return author.id === user?.id; +} diff --git a/app/routes/plus/suggestions.tsx b/app/routes/plus/suggestions.tsx index 86c1b8348..8c5aa479f 100644 --- a/app/routes/plus/suggestions.tsx +++ b/app/routes/plus/suggestions.tsx @@ -1,4 +1,5 @@ import type { + ActionFunction, LinksFunction, LoaderFunction, MetaFunction, @@ -7,19 +8,28 @@ import { json } from "@remix-run/node"; import { Outlet, useLoaderData } from "@remix-run/react"; import * as React from "react"; import invariant from "tiny-invariant"; +import { z } from "zod"; import { Avatar } from "~/components/Avatar"; -import { LinkButton } from "~/components/Button"; +import { Button, LinkButton } from "~/components/Button"; import { Catcher } from "~/components/Catcher"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { TrashIcon } from "~/components/icons/Trash"; import { upcomingVoting } from "~/core/plus"; import { db } from "~/db"; import type * as plusSuggestions from "~/db/models/plusSuggestions.server"; import { useUser } from "~/hooks/useUser"; -import { canAddCommentToSuggestionFE } from "~/permissions"; +import { canAddCommentToSuggestionFE, canDeleteComment } from "~/permissions"; import styles from "~/styles/plus.css"; import { databaseTimestampToDate } from "~/utils/dates"; -import { makeTitle, requireUser } from "~/utils/remix"; +import { + makeTitle, + parseRequestFormData, + requireUser, + validate, +} from "~/utils/remix"; import { discordFullName } from "~/utils/strings"; import type { Unpacked } from "~/utils/types"; +import { actualNumber } from "~/utils/zod"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -32,6 +42,35 @@ export const meta: MetaFunction = () => { }; }; +const suggestionActionSchema = z.object({ + suggestionId: z.preprocess(actualNumber, z.number()), +}); + +export const action: ActionFunction = async ({ request }) => { + const data = await parseRequestFormData({ + request, + schema: suggestionActionSchema, + }); + const user = await requireUser(request); + + const suggestions = db.plusSuggestions.find({ + ...upcomingVoting(new Date()), + plusTier: user.plusTier, + }); + + const targetSuggestion = suggestions + ?.flatMap((s) => s.users) + .flatMap((u) => u.suggestions) + .find((s) => s.id === data.suggestionId); + + validate(targetSuggestion); + validate(canDeleteComment({ user, author: targetSuggestion.author })); + + db.plusSuggestions.del(data.suggestionId); + + return null; +}; + export interface PlusSuggestionsLoaderData { suggestions?: plusSuggestions.FindResult; suggestedForTiers: number[]; @@ -181,16 +220,29 @@ function SuggestedUser({
{suggested.suggestions.map((s) => ( -
+
{discordFullName(s.author)} {s.text} - - {" "} - ―{" "} - - +
+ + + + {canDeleteComment({ author: s.author, user }) ? ( + +
))}
diff --git a/app/styles/common.css b/app/styles/common.css index e664a865e..540cf1eec 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -71,6 +71,10 @@ flex-direction: column; } +.stack.xs { + gap: var(--s-1-5); +} + .stack.sm { gap: var(--s-2); } @@ -122,6 +126,14 @@ * */ +.text-sm { + font-size: var(--fonts-sm); +} + +.text-center { + text-align: center; +} + .w-full { width: 100%; } @@ -129,3 +141,15 @@ .mt-2 { margin-block-start: var(--s-2); } + +.hidden { + display: none; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} diff --git a/app/styles/global.css b/app/styles/global.css index 291fc7341..61f3b9edc 100644 --- a/app/styles/global.css +++ b/app/styles/global.css @@ -274,11 +274,11 @@ fieldset { background-color: var(--bg-darker-transparent); border-radius: var(--rounded); font-size: var(--fonts-sm); + padding-block-end: var(--s-3); padding-inline: var(--s-3); } legend { - padding: var(--s-1); background-color: transparent; border-radius: 2px; border-radius: var(--rounded-sm); diff --git a/app/styles/plus.css b/app/styles/plus.css index 1abbac445..2af68e45f 100644 --- a/app/styles/plus.css +++ b/app/styles/plus.css @@ -58,6 +58,10 @@ font-size: var(--fonts-xxs); } +.plus__delete-button { + display: inline; +} + .plus__modal { width: min(90%, 24rem); } diff --git a/migrations/000-initial.sql b/migrations/000-initial.sql index ab3928bbc..822892b19 100644 --- a/migrations/000-initial.sql +++ b/migrations/000-initial.sql @@ -14,6 +14,7 @@ CREATE TABLE "User" ( --- CREATE TABLE "PlusSuggestion" ( + "id" integer PRIMARY KEY, "text" text NOT NULL, "authorId" integer NOT NULL, "suggestedId" integer NOT NULL,