From d56f1d8acbd6502527aeb90af41f72a87737f37b Mon Sep 17 00:00:00 2001 From: Carl-Adrien Mercey Date: Sun, 21 Sep 2025 13:35:37 +0200 Subject: [PATCH] feat: add delete review action --- bun.lockb | Bin 466628 -> 466628 bytes package.json | 4 +- .../_components/reject-button/index.tsx | 6 +- .../(with-header)/friend-requests/actions.ts | 62 ++++++----- .../deny/_components/accept-button/index.tsx | 6 +- .../user-header/add-friend/actions.ts | 13 ++- .../user-header/add-friend/index.tsx | 8 +- .../delete-review-dialog/actions.ts | 42 ++++++++ .../delete-review-dialog/index.tsx | 99 ++++++++++++++++++ .../[username]/reviews/[reviewSlug]/page.tsx | 14 ++- src/app/[locale]/layout.tsx | 9 +- src/app/_components/share-button/index.tsx | 45 ++++++++ src/app/_components/ui/button/index.tsx | 15 ++- .../user-menu/language-submenu/index.tsx | 2 +- src/domain/beers/index.ts | 16 ++- src/domain/reviews/index.ts | 37 +++++++ src/domain/users/transforms.ts | 1 + src/domain/users/types.ts | 1 + src/lib/action/types.ts | 5 + src/lib/i18n/index.ts | 4 +- src/lib/i18n/translations/en.json | 19 +++- src/lib/i18n/translations/fr.json | 19 +++- src/lib/i18n/types.ts | 16 +++ src/lib/images/index.ts | 12 +-- src/lib/images/types.ts | 7 +- src/lib/storage/constants.ts | 3 + src/lib/storage/index.tsx | 22 +++- src/lib/storage/utils.ts | 8 ++ 28 files changed, 418 insertions(+), 77 deletions(-) create mode 100644 src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog/actions.ts create mode 100644 src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog/index.tsx create mode 100644 src/domain/reviews/index.ts create mode 100644 src/lib/action/types.ts create mode 100644 src/lib/i18n/types.ts create mode 100644 src/lib/storage/constants.ts create mode 100644 src/lib/storage/utils.ts diff --git a/bun.lockb b/bun.lockb index 062b65f708d588ce4082b6e6b9a6559a8e24b2bb..914179740a885bc07583f52bdc6c5a9cafb16151 100755 GIT binary patch delta 11272 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 diff --git a/package.json b/package.json index 1ebb6b9..ff12532 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,10 @@ "d3-zoom": "^3.0.0", "date-fns": "^4.1.0", "i18n-iso-countries": "^7.14.0", - "libphonenumber-js": "^1.12.33", + "libphonenumber-js": "^1.12.4", "lucide-react": "^0.562.0", "mime": "^4.1.0", - "motion": "^12.23.26", + "motion": "^12.4.3", "nanoid": "^5.1.6", "next": "^16.1.0", "next-intl": "^4.6.1", diff --git a/src/app/[locale]/(business)/(with-header)/friend-requests/accept/_components/reject-button/index.tsx b/src/app/[locale]/(business)/(with-header)/friend-requests/accept/_components/reject-button/index.tsx index b0eaf0b..b10fe1f 100644 --- a/src/app/[locale]/(business)/(with-header)/friend-requests/accept/_components/reject-button/index.tsx +++ b/src/app/[locale]/(business)/(with-header)/friend-requests/accept/_components/reject-button/index.tsx @@ -22,13 +22,13 @@ const RejectButton = ({ friendRequestId }: RejectButtonProps) => { const handleRejectFriendRequest = () => { startTransition(async () => { - const { success } = + const result = await rejectPreviouslyAcceptedFriendRequestAction(friendRequestId); - if (success) { + if (result.success) { push(Routes.DENY_FRIEND_REQUEST, { id: friendRequestId }); } else { - toast.error(t("friendRequestPage.errors.friendRequestRejectingError")); + toast.error(t(result.translationKey)); } }); }; diff --git a/src/app/[locale]/(business)/(with-header)/friend-requests/actions.ts b/src/app/[locale]/(business)/(with-header)/friend-requests/actions.ts index 37c0e15..8758943 100644 --- a/src/app/[locale]/(business)/(with-header)/friend-requests/actions.ts +++ b/src/app/[locale]/(business)/(with-header)/friend-requests/actions.ts @@ -12,53 +12,61 @@ import { } from "@/domain/users/errors"; import { getCurrentUser } from "@/lib/auth"; +import type { ActionResult } from "@/lib/action/types"; + export const rejectPreviouslyAcceptedFriendRequestAction = async ( friendRequestId: string, -) => { +): Promise> => { const user = await getCurrentUser(); if (!user) { - return { success: false }; + return { success: false, translationKey: "common.errors.unauthorized" }; } - await rejectPreviouslyAcceptedFriendRequest(friendRequestId).catch( - (error) => { - if ( - !(error instanceof UnauthorizedFriendRequestRejectionError) && - !(error instanceof UnknownFriendRequestError) && - !(error instanceof UnknownFriendshipError) - ) { - console.error(error); - } + try { + await rejectPreviouslyAcceptedFriendRequest(friendRequestId); + } catch (error) { + if ( + !(error instanceof UnauthorizedFriendRequestRejectionError) && + !(error instanceof UnknownFriendRequestError) && + !(error instanceof UnknownFriendshipError) + ) { + console.error(error); + } - return { success: false }; - }, - ); + return { + success: false, + translationKey: "friendRequestPage.errors.friendRequestRejectingError", + }; + } return { success: true }; }; export const acceptPreviouslyRejectedFriendRequestAction = async ( friendRequestId: string, -) => { +): Promise> => { const user = await getCurrentUser(); if (!user) { - return { success: false }; + return { success: false, translationKey: "common.errors.unauthorized" }; } - await acceptPreviouslyRejectedFriendRequest(friendRequestId).catch( - (error) => { - if ( - !(error instanceof UnauthorizedFriendRequestApprovalError) && - !(error instanceof UnknownFriendRequestError) - ) { - console.error(error); - } + try { + await acceptPreviouslyRejectedFriendRequest(friendRequestId); + } catch (error) { + if ( + !(error instanceof UnauthorizedFriendRequestApprovalError) && + !(error instanceof UnknownFriendRequestError) + ) { + console.error(error); + } - return { success: false }; - }, - ); + return { + success: false, + translationKey: "friendRequestPage.errors.friendRequestAcceptingError", + }; + } return { success: true }; }; diff --git a/src/app/[locale]/(business)/(with-header)/friend-requests/deny/_components/accept-button/index.tsx b/src/app/[locale]/(business)/(with-header)/friend-requests/deny/_components/accept-button/index.tsx index b6cac6a..4fb0e23 100644 --- a/src/app/[locale]/(business)/(with-header)/friend-requests/deny/_components/accept-button/index.tsx +++ b/src/app/[locale]/(business)/(with-header)/friend-requests/deny/_components/accept-button/index.tsx @@ -22,13 +22,13 @@ const AcceptButton = ({ friendRequestId }: AcceptButtonProps) => { const handleAcceptFriendRequest = () => { startTransition(async () => { - const { success } = + const result = await acceptPreviouslyRejectedFriendRequestAction(friendRequestId); - if (success) { + if (result.success) { push(Routes.ACCEPT_FRIEND_REQUEST, { id: friendRequestId }); } else { - toast.error(t("friendRequestPage.errors.friendRequestAcceptingError")); + toast.error(t(result.translationKey)); } }); }; diff --git a/src/app/[locale]/(business)/(with-header)/users/[username]/_components/user-header/add-friend/actions.ts b/src/app/[locale]/(business)/(with-header)/users/[username]/_components/user-header/add-friend/actions.ts index e42333e..c42090d 100644 --- a/src/app/[locale]/(business)/(with-header)/users/[username]/_components/user-header/add-friend/actions.ts +++ b/src/app/[locale]/(business)/(with-header)/users/[username]/_components/user-header/add-friend/actions.ts @@ -3,18 +3,25 @@ import { sendFriendRequest } from "@/domain/users"; import { getCurrentUser } from "@/lib/auth"; -export const addFriend = async (userId: string) => { +import type { ActionResult } from "@/lib/action/types"; + +export const addFriend = async ( + userId: string, +): Promise> => { const currentUser = await getCurrentUser(); if (!currentUser) { - return { success: false }; + return { success: false, translationKey: "common.errors.unauthorized" }; } try { await sendFriendRequest(userId); } catch (error) { console.error(error); - return { success: false }; + return { + success: false, + translationKey: "profilePage.friendship.toasts.friendRequestSendingError", + }; } return { success: true }; diff --git a/src/app/[locale]/(business)/(with-header)/users/[username]/_components/user-header/add-friend/index.tsx b/src/app/[locale]/(business)/(with-header)/users/[username]/_components/user-header/add-friend/index.tsx index 21276b8..8a25a6c 100644 --- a/src/app/[locale]/(business)/(with-header)/users/[username]/_components/user-header/add-friend/index.tsx +++ b/src/app/[locale]/(business)/(with-header)/users/[username]/_components/user-header/add-friend/index.tsx @@ -24,15 +24,13 @@ const AddFriendButton = ({ friendId, className }: AddFriendButtonProps) => { const handleFriendRequest = () => { startTransition(async () => { - const { success } = await addFriend(friendId); + const result = await addFriend(friendId); - if (success) { + if (result.success) { toast.success(t("profilePage.friendship.toasts.friendRequestSent")); router.refresh(); } else { - toast.error( - t("profilePage.friendship.toasts.friendRequestSendingError"), - ); + toast.error(t(result.translationKey)); } }); }; diff --git a/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog/actions.ts b/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog/actions.ts new file mode 100644 index 0000000..500818a --- /dev/null +++ b/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog/actions.ts @@ -0,0 +1,42 @@ +"use server"; + +import { deleteReview, getReviewById } from "@/domain/reviews"; +import { getCurrentUser } from "@/lib/auth"; + +import type { ActionResult } from "@/lib/action/types"; + +export const deleteReviewAction = async ( + reviewId: string, +): Promise> => { + const [user, review] = await Promise.all([ + getCurrentUser(), + getReviewById(reviewId), + ]); + + if (!review) { + return { + success: false, + translationKey: "reviewPage.actions.remove.errors.removeNotFound", + }; + } + + if (!user) { + return { success: false, translationKey: "common.errors.unauthorized" }; + } + + if (review.userId !== user.id) { + return { success: false, translationKey: "common.errors.forbidden" }; + } + + try { + await deleteReview(reviewId); + } catch (error) { + console.error(error); + return { + success: false, + translationKey: "reviewPage.actions.remove.errors.removeUnknown", + }; + } + + return { success: true }; +}; diff --git a/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog/index.tsx b/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog/index.tsx new file mode 100644 index 0000000..63d7a77 --- /dev/null +++ b/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog/index.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Trash2Icon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useTransition } from "react"; +import { toast } from "sonner"; + +import { deleteReviewAction } from "@/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog/actions"; +import Button from "@/app/_components/ui/button"; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from "@/app/_components/ui/responsive-dialog"; +import { useRouter } from "@/lib/i18n"; +import { Routes } from "@/lib/routes"; +import { generatePath } from "@/lib/routes/utils"; + +interface DeleteReviewButtonProps { + reviewId: string; + username: string; +} + +const DeleteReviewButton = ({ + reviewId, + username, +}: DeleteReviewButtonProps) => { + const t = useTranslations(); + + const router = useRouter(); + + const [isPending, startTransition] = useTransition(); + + const handleRemoveReview = async () => { + startTransition(async () => { + const result = await deleteReviewAction(reviewId); + + if (result.success) { + toast.success(t("reviewPage.actions.remove.success")); + router.push(generatePath(Routes.PROFILE, { username })); + } else { + toast.error(t(result.translationKey)); + } + }); + }; + + return ( + + + + + + + + + {t("reviewPage.actions.remove.title")} + + + + {t("reviewPage.actions.remove.description")} + + + + + + + + + + + + + ); +}; + +export default DeleteReviewButton; diff --git a/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/page.tsx b/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/page.tsx index 493911c..231749b 100644 --- a/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/page.tsx +++ b/src/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/page.tsx @@ -16,11 +16,13 @@ import { labelDesignValues, } from "@/app/[locale]/(business)/(without-header)/breweries/[brewerySlug]/beers/[beerSlug]/review/schemas"; import BackButton from "@/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/back-button"; +import DeleteReviewButton from "@/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/delete-review-dialog"; import ReviewFieldValue from "@/app/[locale]/(business)/(without-header)/users/[username]/reviews/[reviewSlug]/_components/field-value"; import ShareButton from "@/app/_components/share-button"; import DescriptionList from "@/app/_components/ui/description-list"; import { Separator } from "@/app/_components/ui/separator"; import { getReviewByUsernameAndSlug } from "@/domain/users"; +import { getCurrentUser } from "@/lib/auth"; import { publicConfig } from "@/lib/config/client-config"; import { Link } from "@/lib/i18n"; import { Routes } from "@/lib/routes"; @@ -87,6 +89,8 @@ const UserReviewPage = async ({ () => notFound(), ); + const currentUser = await getCurrentUser(); + return (
) : null} -
+
+ {currentUser && currentUser.id === review.user.id ? ( + + ) : null} + {t("reviewPage.actions.share")} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 606d971..25143c6 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -11,10 +11,9 @@ import { import { publicConfig } from "@/lib/config/client-config"; import { routing } from "@/lib/i18n"; -import type { Locale } from "@/lib/i18n"; +import type { Locale } from "@/lib/i18n/types"; import type { Metadata, Viewport } from "next"; -import type { PropsWithChildren } from "react"; import "@/app/globals.css"; @@ -31,9 +30,7 @@ const paragraph = Inter({ export async function generateMetadata({ params, -}: { - params: Promise<{ locale: string }>; -}): Promise { +}: LayoutProps<"/[locale]">): Promise { const { locale } = await params; const t = await getTranslations({ locale }); @@ -71,7 +68,7 @@ export function generateStaticParams() { export default async function RootLayout({ params, children, -}: Readonly }>>) { +}: LayoutProps<"/[locale]">) { const { locale } = await params; if (!routing.locales.includes(locale as Locale)) { diff --git a/src/app/_components/share-button/index.tsx b/src/app/_components/share-button/index.tsx index 90e1e09..5a33b17 100644 --- a/src/app/_components/share-button/index.tsx +++ b/src/app/_components/share-button/index.tsx @@ -7,8 +7,16 @@ import { useState } from "react"; import { toast } from "sonner"; import Button from "@/app/_components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/app/_components/ui/drawer"; import Input from "@/app/_components/ui/input"; import { cn } from "@/lib/tailwind"; +import { useMediaQuery } from "@/lib/tailwind/hooks"; import type { ComponentProps } from "react"; @@ -18,6 +26,8 @@ interface ShareButtonProps extends Omit< > { label: string; link: string; + title?: string; + useDrawerOnMobile?: boolean; triggerClassName?: string; contentClassName?: string; } @@ -25,6 +35,8 @@ interface ShareButtonProps extends Omit< const ShareButton = ({ label, link, + title, + useDrawerOnMobile = false, triggerClassName, contentClassName, children, @@ -40,6 +52,39 @@ const ShareButton = ({ setOpen(false); }; + const isMobile = useMediaQuery("(max-width: 768px)"); + + if (useDrawerOnMobile && isMobile) { + return ( + + + + + + + + {title ?? label} + + +
+ + + +
+
+
+ ); + } + return ( diff --git a/src/app/_components/ui/button/index.tsx b/src/app/_components/ui/button/index.tsx index c37bd01..fa18300 100644 --- a/src/app/_components/ui/button/index.tsx +++ b/src/app/_components/ui/button/index.tsx @@ -11,8 +11,8 @@ export const buttonVariants = cva( "m-0.5 flex w-[calc(100%-theme(spacing.1))] min-w-0 cursor-pointer flex-row items-center justify-center gap-x-2 rounded-md font-bold", "text-sm md:text-base", "before:bg-foreground relative before:absolute before:-inset-0.5 before:z-[-1] before:rounded", - "bottom-0 transition-[bottom] duration-300 hover:bottom-0.5", - "before:-bottom-1 before:transition-[bottom] before:duration-300 hover:before:-bottom-1.5", + "bottom-0 transition-[bottom,background-color,color] duration-300 hover:bottom-0.5", + "before:-bottom-1 before:transition-[bottom,background-color] before:duration-300 hover:before:-bottom-1.5", "focus-visible:outline-offset-2", "focus-visible:hover:bottom-0 focus-visible:hover:before:-bottom-1", "disabled:pointer-events-none disabled:cursor-default", @@ -21,10 +21,19 @@ export const buttonVariants = cva( variants: { variant: { default: - "bg-primary hover:bg-primary-400 dark:before:bg-primary-700 text-stone-950 transition-[bottom,background-color] selection:bg-stone-950/15", + "bg-primary hover:bg-primary-400 dark:before:bg-primary-700 text-stone-950 selection:bg-stone-950/15", outline: "bg-background dark:bg-stone-700", ghost: "text-foreground before:bg-transparent hover:bottom-0 hover:before:bottom-0 focus-visible:hover:bottom-0 focus-visible:hover:before:bottom-0", + destructive: cn( + "[--destructive-bg:oklch(0.6333_0.2162_31.5)] hover:[--destructive-bg:oklch(0.6669_0.2131_31.5)]", + "[--destructive-fg:oklch(0.2846_0.1034_31.5)]", + "[--destructive-border:oklch(0.361_0.1347_31.5)]", + "dark:[--destructive-bg:oklch(0.6169_0.2319_28.32)] dark:hover:[--destructive-bg:oklch(0.6404_0.2193_28.32)]", + "dark:[--destructive-fg:oklch(0.2934_0.1097_28.32)]", + "dark:[--destructive-border:oklch(0.4434_0.1598_28.32)]", + "bg-(--destructive-bg) text-(--destructive-fg) before:bg-(--destructive-border)", + ), }, size: { default: "px-5 py-4", diff --git a/src/app/_components/user-menu/language-submenu/index.tsx b/src/app/_components/user-menu/language-submenu/index.tsx index 3e2d3bf..9b087d6 100644 --- a/src/app/_components/user-menu/language-submenu/index.tsx +++ b/src/app/_components/user-menu/language-submenu/index.tsx @@ -13,7 +13,7 @@ import { DropdownMenuSubTrigger, } from "@/app/_components/ui/dropdown-menu"; import { routing, usePathname, useRouter } from "@/lib/i18n"; -import type { Locale } from "@/lib/i18n"; +import type { Locale } from "@/lib/i18n/types"; const LanguageSubMenu = () => { const t = useTranslations(); diff --git a/src/domain/beers/index.ts b/src/domain/beers/index.ts index 1e17211..b1a63df 100644 --- a/src/domain/beers/index.ts +++ b/src/domain/beers/index.ts @@ -32,7 +32,6 @@ import type { } from "@/domain/beers/types"; import { transformRawBeerReviewToBeerReviewWithPicture } from "@/domain/reviews/transforms"; import { getCurrentUser } from "@/lib/auth"; -import { config } from "@/lib/config"; import { checkImageForExplicitContent, createPreviews, @@ -46,6 +45,8 @@ import type { import prisma, { getPrismaTransactionClient } from "@/lib/prisma"; import { slugify } from "@/lib/prisma/utils"; import { uploadFile } from "@/lib/storage"; +import { StorageBuckets } from "@/lib/storage/constants"; +import { getBucketBaseUrl } from "@/lib/storage/utils"; export const getBeerBySlug = cache( async (beerSlug: string, brewerySlug: string): Promise => { @@ -347,28 +348,25 @@ export const reviewBeer = async ( throw new ExplicitContentError(); } - const bucketName = "review-pictures"; const fileId = nanoid(); const baseFileName = `${user.id}/${fileId}.jpg`; try { await Promise.all([ uploadFile({ - bucketName, + bucketName: StorageBuckets.REVIEW_PICTURES, fileName: baseFileName, fileBody: optimizedImage, contentType: "image/jpeg", }), - - Promise.all( - (await createPreviews(optimizedImage)).map(({ name, image }) => + ...Object.entries(await createPreviews(optimizedImage)).map( + ([name, image]) => uploadFile({ - bucketName, + bucketName: StorageBuckets.REVIEW_PICTURES, fileName: `${user.id}/${fileId}_${name}.jpg`, fileBody: image, contentType: "image/jpeg", }), - ), ), ]); } catch (error) { @@ -376,7 +374,7 @@ export const reviewBeer = async ( throw new FileUploadError(); } - pictureUrl = `${config.supabase.storageUrl}/object/public/${bucketName}/${baseFileName}`; + pictureUrl = `${getBucketBaseUrl(StorageBuckets.REVIEW_PICTURES)}${baseFileName}`; } const id = nanoid(); diff --git a/src/domain/reviews/index.ts b/src/domain/reviews/index.ts new file mode 100644 index 0000000..f28b9be --- /dev/null +++ b/src/domain/reviews/index.ts @@ -0,0 +1,37 @@ +"server only"; + +import { PreviewName } from "@/lib/images/types"; +import prisma from "@/lib/prisma"; +import { deleteFile } from "@/lib/storage"; +import { StorageBuckets } from "@/lib/storage/constants"; +import { getBucketBaseUrl } from "@/lib/storage/utils"; + +export const getReviewById = async (reviewId: string) => { + return prisma.reviews.findUnique({ where: { id: reviewId } }); +}; + +export const deleteReview = async (reviewId: string) => { + const deletedReview = await prisma.reviews.delete({ + where: { id: reviewId }, + }); + + if (deletedReview.pictureUrl) { + const baseFileName = deletedReview.pictureUrl.replace( + getBucketBaseUrl(StorageBuckets.REVIEW_PICTURES), + "", + ); + + await Promise.all([ + deleteFile({ + bucketName: StorageBuckets.REVIEW_PICTURES, + fileName: baseFileName, + }), + ...Object.values(PreviewName).map((previewName) => + deleteFile({ + bucketName: StorageBuckets.REVIEW_PICTURES, + fileName: baseFileName.replace(`.jpg`, `_${previewName}.jpg`), + }), + ), + ]); + } +}; diff --git a/src/domain/users/transforms.ts b/src/domain/users/transforms.ts index ce47eea..8bc6e46 100644 --- a/src/domain/users/transforms.ts +++ b/src/domain/users/transforms.ts @@ -92,6 +92,7 @@ export const transformRawReviewToReview = (rawReview: RawReview): Review => { acidity: rawReview.acidity ?? undefined, duration: rawReview.duration ?? undefined, user: { + id: rawReview.user.id, username: rawReview.user.username, }, beer: { diff --git a/src/domain/users/types.ts b/src/domain/users/types.ts index a6fc9c4..6619446 100644 --- a/src/domain/users/types.ts +++ b/src/domain/users/types.ts @@ -91,6 +91,7 @@ export type Review = { acidity?: Acidity; duration?: Duration; user: { + id: string; username: string; }; beer: { diff --git a/src/lib/action/types.ts b/src/lib/action/types.ts new file mode 100644 index 0000000..42f9650 --- /dev/null +++ b/src/lib/action/types.ts @@ -0,0 +1,5 @@ +import type { MessageKeys } from "@/lib/i18n/types"; + +export type ActionResult = + | { success: true; data?: T } + | { success: false; translationKey: MessageKeys }; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 3ba6755..f4ad253 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -3,14 +3,14 @@ import { createNavigation } from "next-intl/navigation"; import { defineRouting } from "next-intl/routing"; import { getRequestConfig } from "next-intl/server"; +import type { Locale } from "@/lib/i18n/types"; + export const routing = defineRouting({ locales: ["en", "fr"], defaultLocale: "en", localePrefix: "as-needed", }); -export type Locale = (typeof routing.locales)[number]; - export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing); diff --git a/src/lib/i18n/translations/en.json b/src/lib/i18n/translations/en.json index a3d5d86..40c22f5 100644 --- a/src/lib/i18n/translations/en.json +++ b/src/lib/i18n/translations/en.json @@ -58,6 +58,10 @@ }, "review": { "pictureAlt": "Photo from the review by {username}" + }, + "errors": { + "unauthorized": "You must be logged in to perform this action", + "forbidden": "You are not authorized to perform this action" } }, "form": { @@ -630,7 +634,20 @@ } }, "actions": { - "share": "Share" + "remove": { + "title": "Are you sure you want to remove this review?", + "description": "This action cannot be undone.", + "cancel": "Cancel", + "remove": "Remove review", + "pending": "Removing review...", + "success": "Review removed", + "errors": { + "removeNotFound": "Review not found", + "removeUnknown": "An error occurred while removing the review" + } + }, + "share": "Share", + "shareTitle": "Share review" } }, "createReviewPage": { diff --git a/src/lib/i18n/translations/fr.json b/src/lib/i18n/translations/fr.json index 816c00b..8e8e3c4 100644 --- a/src/lib/i18n/translations/fr.json +++ b/src/lib/i18n/translations/fr.json @@ -58,6 +58,10 @@ }, "review": { "pictureAlt": "Photo de la critique de {username}" + }, + "errors": { + "unauthorized": "Vous devez être connecté pour effectuer cette action", + "forbidden": "Vous n'êtes pas autorisé à effectuer cette action" } }, "form": { @@ -627,7 +631,20 @@ } }, "actions": { - "share": "Partager" + "remove": { + "title": "Etes-vous sûr de vouloir supprimer cette critique ?", + "description": "Cette action est irréversible.", + "cancel": "Annuler", + "remove": "Supprimer la critique", + "pending": "Suppression en cours...", + "success": "Critique supprimée", + "errors": { + "removeNotFound": "Critique inconnue", + "removeUnknown": "Une erreur est survenue lors de la suppression de la critique" + } + }, + "share": "Partager", + "shareTitle": "Partager la critique" } }, "createReviewPage": { diff --git a/src/lib/i18n/types.ts b/src/lib/i18n/types.ts new file mode 100644 index 0000000..b26225c --- /dev/null +++ b/src/lib/i18n/types.ts @@ -0,0 +1,16 @@ +import enMessages from "@/lib/i18n/translations/en.json"; + +import type { routing } from "@/lib/i18n"; + +export type Locale = (typeof routing.locales)[number]; + +type DotNotationKeys = { + [K in keyof T]: T[K] extends string + ? `${Prefix}${K & string}` + : T[K] extends object + ? DotNotationKeys + : never; +}[keyof T]; + +export type Messages = typeof enMessages; +export type MessageKeys = DotNotationKeys; diff --git a/src/lib/images/index.ts b/src/lib/images/index.ts index c0562df..0e7d24d 100644 --- a/src/lib/images/index.ts +++ b/src/lib/images/index.ts @@ -3,7 +3,7 @@ import sharp from "sharp"; import { visionClient } from "@/lib/images/gcp"; -import type { Preview } from "@/lib/images/types"; +import { PreviewName } from "@/lib/images/types"; interface OptimizeImageOptions { maxDimension?: number; @@ -47,7 +47,7 @@ export const checkImageForExplicitContent = async (imageBuffer: Buffer) => { export const createPreviews = async ( imageBuffer: Buffer, -): Promise> => { +): Promise> => { const sharpInstance = sharp(imageBuffer); const [previewImage, twitterImage] = await Promise.all([ @@ -55,8 +55,8 @@ export const createPreviews = async ( sharpInstance.resize({ width: 1200, height: 675 }).toBuffer(), ]); - return [ - { name: "preview", image: previewImage }, - { name: "twitter", image: twitterImage }, - ]; + return { + [PreviewName.PREVIEW]: previewImage, + [PreviewName.TWITTER]: twitterImage, + }; }; diff --git a/src/lib/images/types.ts b/src/lib/images/types.ts index c93a0ad..d104743 100644 --- a/src/lib/images/types.ts +++ b/src/lib/images/types.ts @@ -1,4 +1,9 @@ +export enum PreviewName { + PREVIEW = "preview", + TWITTER = "twitter", +} + export type Preview = { - name: string; + name: PreviewName; image: Buffer; }; diff --git a/src/lib/storage/constants.ts b/src/lib/storage/constants.ts new file mode 100644 index 0000000..d55efa8 --- /dev/null +++ b/src/lib/storage/constants.ts @@ -0,0 +1,3 @@ +export enum StorageBuckets { + REVIEW_PICTURES = "review-pictures", +} diff --git a/src/lib/storage/index.tsx b/src/lib/storage/index.tsx index 0938d50..fdd0721 100644 --- a/src/lib/storage/index.tsx +++ b/src/lib/storage/index.tsx @@ -1,9 +1,15 @@ "server only"; -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { + DeleteObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; import { config } from "@/lib/config"; +import type { StorageBuckets } from "@/lib/storage/constants"; + const s3 = new S3Client({ forcePathStyle: true, region: config.s3.region, @@ -15,7 +21,7 @@ const s3 = new S3Client({ }); interface UploadFileOptions { - bucketName: string; + bucketName: StorageBuckets; fileName: string; fileBody: Blob | Buffer | string; contentType: string; @@ -36,3 +42,15 @@ export const uploadFile = async ({ }), ); }; + +interface DeleteFileOptions { + bucketName: StorageBuckets; + fileName: string; +} + +export const deleteFile = async ({ + bucketName, + fileName, +}: DeleteFileOptions) => { + await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: fileName })); +}; diff --git a/src/lib/storage/utils.ts b/src/lib/storage/utils.ts new file mode 100644 index 0000000..0f197a1 --- /dev/null +++ b/src/lib/storage/utils.ts @@ -0,0 +1,8 @@ +"server only"; + +import { config } from "@/lib/config"; +import { StorageBuckets } from "@/lib/storage/constants"; + +export const getBucketBaseUrl = (bucketName: StorageBuckets) => { + return `${config.supabase.storageUrl}/object/public/${bucketName}/`; +};