mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 06:08:13 -05:00
Delete comment
This commit is contained in:
@@ -9,7 +9,7 @@ export function Dialog({
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
close?: () => void;
|
||||
className: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useDOMSync(isOpen);
|
||||
|
||||
|
||||
50
app/components/FormWithConfirm.tsx
Normal file
50
app/components/FormWithConfirm.tsx
Normal file
@@ -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<HTMLFormElement>(null);
|
||||
const id = React.useId();
|
||||
|
||||
const openDialog = () => setDialogOpen(true);
|
||||
const closeDialog = () => setDialogOpen(false);
|
||||
|
||||
invariant(React.isValidElement(children));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form id={id} className="hidden" ref={formRef} method="post">
|
||||
{fields.map(([name, value]) => (
|
||||
<input type="hidden" key={name} name={name} value={value} />
|
||||
))}
|
||||
</Form>
|
||||
<Dialog isOpen={dialogOpen} close={closeDialog} className="text-center">
|
||||
<div className="stack md">
|
||||
<h2 className="text-sm">{dialogHeading}</h2>
|
||||
<div className="stack vertical md justify-center">
|
||||
<Button form={id} variant="destructive" type="submit">
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
{React.cloneElement(children, {
|
||||
onClick: openDialog,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<PlusSuggestion, "createdAt" | "text"> & {
|
||||
suggestions: (Pick<PlusSuggestion, "id" | "createdAt" | "text"> & {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface User {
|
||||
}
|
||||
|
||||
export interface PlusSuggestion {
|
||||
id: number;
|
||||
text: string;
|
||||
authorId: number;
|
||||
suggestedId: number;
|
||||
|
||||
@@ -29,6 +29,14 @@ export function canAddCommentToSuggestionBE({
|
||||
]);
|
||||
}
|
||||
|
||||
interface CanDeleteCommentArgs {
|
||||
author: Pick<User, "id">;
|
||||
user?: Pick<User, "id">;
|
||||
}
|
||||
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<CanAddCommentToSuggestionBEArgs, "user" | "suggested">) {
|
||||
return user?.plusTier && user.plusTier <= suggested.plusTier;
|
||||
}
|
||||
|
||||
function isOwnComment({ author, user }: CanDeleteCommentArgs) {
|
||||
return author.id === user?.id;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</summary>
|
||||
<div className="stack sm mt-2">
|
||||
{suggested.suggestions.map((s) => (
|
||||
<fieldset className="plus__comment" key={s.author.id}>
|
||||
<fieldset key={s.id} className="plus__comment">
|
||||
<legend>{discordFullName(s.author)}</legend>
|
||||
{s.text}
|
||||
<span className="plus__comment-time">
|
||||
{" "}
|
||||
―{" "}
|
||||
<time>
|
||||
{databaseTimestampToDate(s.createdAt).toLocaleString()}
|
||||
</time>
|
||||
</span>
|
||||
<div className="stack vertical xs items-center">
|
||||
<span className="plus__comment-time">
|
||||
<time>
|
||||
{databaseTimestampToDate(s.createdAt).toLocaleString()}
|
||||
</time>
|
||||
</span>
|
||||
{canDeleteComment({ author: s.author, user }) ? (
|
||||
<FormWithConfirm
|
||||
fields={[["suggestionId", s.id]]}
|
||||
dialogHeading={`Delete your comment to ${suggested.info.discordName}'s +${tier} suggestion?`}
|
||||
>
|
||||
<Button
|
||||
className="plus__delete-button"
|
||||
icon={<TrashIcon />}
|
||||
variant="minimal-destructive"
|
||||
aria-label="Delete comment"
|
||||
/>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
font-size: var(--fonts-xxs);
|
||||
}
|
||||
|
||||
.plus__delete-button {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.plus__modal {
|
||||
width: min(90%, 24rem);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user