Removing art from user profile by user feature

This commit is contained in:
Kalle 2025-04-09 22:20:03 +03:00
parent 7c470f6cc2
commit 4e7d453e8d
5 changed files with 104 additions and 23 deletions

View File

@ -0,0 +1,15 @@
import { db } from "~/db/sql";
export function unlinkUserFromArt({
userId,
artId,
}: {
userId: number;
artId: number;
}) {
return db
.deleteFrom("ArtUserMetadata")
.where("artId", "=", artId)
.where("userId", "=", userId)
.execute();
}

View File

@ -1,5 +1,6 @@
import { z } from "zod";
import {
_action,
checkboxValueToDbBoolean,
dbBoolean,
falsyToNull,
@ -43,5 +44,16 @@ export const editArtSchema = z.object({
});
export const deleteArtSchema = z.object({
_action: _action("DELETE_ART"),
id,
});
export const unlinkArtSchema = z.object({
_action: _action("UNLINK_ART"),
id,
});
export const userArtPageActionSchema = z.union([
deleteArtSchema,
unlinkArtSchema,
]);

View File

@ -9,6 +9,7 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Pagination } from "~/components/Pagination";
import { EditIcon } from "~/components/icons/Edit";
import { TrashIcon } from "~/components/icons/Trash";
import { UnlinkIcon } from "~/components/icons/Unlink";
import { useIsMounted } from "~/hooks/useIsMounted";
import { usePagination } from "~/hooks/usePagination";
import { useSearchParamState } from "~/hooks/useSearchParamState";
@ -191,8 +192,11 @@ function ImagePreview({
{t("common:actions.edit")}
</LinkButton>
<FormWithConfirm
dialogHeading="Are you sure you want to delete the art?"
fields={[["id", art.id]]}
dialogHeading={t("art:delete.title")}
fields={[
["id", art.id],
["_action", "DELETE_ART"],
]}
>
<Button icon={<TrashIcon />} variant="destructive" size="tiny" />
</FormWithConfirm>
@ -207,15 +211,35 @@ function ImagePreview({
return (
<div>
{img}
<Link
to={userArtPage(art.author, "MADE-BY")}
className={clsx("stack sm horizontal text-xs items-center mt-1", {
invisible: !imageLoaded,
<div
className={clsx("stack horizontal justify-between", {
"mt-2": canEdit,
})}
>
<Avatar user={art.author} size="xxs" />
{t("art:madeBy")} {art.author.username}
</Link>
<Link
to={userArtPage(art.author, "MADE-BY")}
className={clsx("stack sm horizontal text-xs items-center mt-1", {
invisible: !imageLoaded,
})}
>
<Avatar user={art.author} size="xxs" />
{t("art:madeBy")} {art.author.username}
</Link>
{canEdit ? (
<FormWithConfirm
dialogHeading={t("art:unlink.title", {
username: art.author.username,
})}
fields={[
["id", art.id],
["_action", "UNLINK_ART"],
]}
deleteButtonText={t("common:actions.remove")}
>
<Button icon={<UnlinkIcon />} variant="destructive" size="tiny" />
</FormWithConfirm>
) : null}
</div>
</div>
);
}

View File

@ -1,27 +1,54 @@
import type { ActionFunction } from "@remix-run/node";
import { deleteArtSchema } from "~/features/art/art-schemas.server";
import * as ArtRepository from "~/features/art/ArtRepository.server";
import { userArtPageActionSchema } from "~/features/art/art-schemas.server";
import { deleteArt } from "~/features/art/queries/deleteArt.server";
import { findArtById } from "~/features/art/queries/findArtById.server";
import { requireUserId } from "~/features/auth/core/user.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { logger } from "~/utils/logger";
import {
errorToastIfFalsy,
parseRequestPayload,
successToast,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUserId(request);
const data = await parseRequestPayload({
request,
schema: deleteArtSchema,
schema: userArtPageActionSchema,
});
// this actually doesn't delete the image itself from the static hosting
// but the idea is that storage is cheap anyway and if needed later
// then we can have a routine that checks all the images still current and nukes the rest
const artToDelete = findArtById(data.id);
errorToastIfFalsy(
artToDelete?.authorId === user.id,
"Insufficient permissions",
);
switch (data._action) {
case "DELETE_ART": {
// this actually doesn't delete the image itself from the static hosting
// but the idea is that storage is cheap anyway and if needed later
// then we can have a routine that checks all the images still current and nukes the rest
const artToDelete = findArtById(data.id);
errorToastIfFalsy(
artToDelete?.authorId === user.id,
"Insufficient permissions",
);
deleteArt(data.id);
deleteArt(data.id);
return null;
return successToast("Deleting art successful");
}
case "UNLINK_ART": {
logger.info("Unlinking art", {
userId: user.id,
artId: data.id,
});
await ArtRepository.unlinkUserFromArt({
userId: user.id,
artId: data.id,
});
return successToast("Unlinking art successful");
}
default: {
assertUnreachable(data);
}
}
};

View File

@ -24,5 +24,8 @@
"forms.tags.addNew": "Create a new one.",
"forms.tags.addNew.placeholder": "Create a new tag",
"forms.tags.searchExisting.placeholder": "Search existing tags",
"forms.tags.maxReached": "Max tags reached"
"forms.tags.maxReached": "Max tags reached",
"delete.title": "Are you sure you want to delete the art?",
"unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?"
}