diff --git a/bun.lockb b/bun.lockb index 062b65f..9141797 100755 Binary files a/bun.lockb and b/bun.lockb differ 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}/`; +};