diff options
| author | MohamedBassem <me@mbassem.com> | 2024-02-07 18:12:53 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-02-07 18:12:53 +0000 |
| commit | 3745443c2b27afb833be7bcc2f3b4f486a42a571 (patch) | |
| tree | 88f0a90fb100b56ad9e9f4686a1f72c50ba7d5a9 | |
| parent | 293869e1743c925519d938ebeeff033c773a1ec6 (diff) | |
| download | karakeep-3745443c2b27afb833be7bcc2f3b4f486a42a571.tar.zst | |
[feature] Add support for deleting links
| -rwxr-xr-x | bun.lockb | bin | 260504 -> 89336 bytes | |||
| -rw-r--r-- | db/index.ts | 2 | ||||
| -rw-r--r-- | web/app/api/v1/links/[linkId]/route.ts | 32 | ||||
| -rw-r--r-- | web/app/bookmarks/components/LinkCard.tsx | 36 | ||||
| -rw-r--r-- | web/app/layout.tsx | 6 | ||||
| -rw-r--r-- | web/components/ui/toast.tsx | 127 | ||||
| -rw-r--r-- | web/components/ui/toaster.tsx | 35 | ||||
| -rw-r--r-- | web/components/ui/use-toast.ts | 192 | ||||
| -rw-r--r-- | web/lib/api.ts | 10 | ||||
| -rw-r--r-- | web/lib/services/links.ts | 9 | ||||
| -rw-r--r-- | web/package.json | 1 |
11 files changed, 440 insertions, 10 deletions
| Binary files differ diff --git a/db/index.ts b/db/index.ts index b5bf6ce8..dbf925f4 100644 --- a/db/index.ts +++ b/db/index.ts @@ -2,4 +2,6 @@ import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); +export { Prisma } from "@prisma/client"; + export default prisma; diff --git a/web/app/api/v1/links/[linkId]/route.ts b/web/app/api/v1/links/[linkId]/route.ts new file mode 100644 index 00000000..39449d6d --- /dev/null +++ b/web/app/api/v1/links/[linkId]/route.ts @@ -0,0 +1,32 @@ +import { authOptions } from "@/lib/auth"; +import { unbookmarkLink } from "@/lib/services/links"; +import { Prisma } from "@remember/db"; + +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; + +export async function DELETE( + _request: NextRequest, + { params }: { params: { linkId: string } }, +) { + // TODO: We probably should be using an API key here instead of the session; + const session = await getServerSession(authOptions); + if (!session) { + return new Response(null, { status: 401 }); + } + + try { + await unbookmarkLink(params.linkId, session.user.id); + } catch (e: unknown) { + if ( + e instanceof Prisma.PrismaClientKnownRequestError && + e.code === "P2025" // RecordNotFound + ) { + return new Response(null, { status: 404 }); + } else { + throw e; + } + } + + return new Response(null, { status: 201 }); +} diff --git a/web/app/bookmarks/components/LinkCard.tsx b/web/app/bookmarks/components/LinkCard.tsx index 75973f7e..907acd19 100644 --- a/web/app/bookmarks/components/LinkCard.tsx +++ b/web/app/bookmarks/components/LinkCard.tsx @@ -13,12 +13,34 @@ import { ImageCardFooter, ImageCardTitle, } from "@/components/ui/imageCard"; +import { useToast } from "@/components/ui/use-toast"; +import APIClient from "@/lib/api"; import { ZBookmarkedLink } from "@/lib/types/api/links"; import { MoreHorizontal, Trash2 } from "lucide-react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; -export function LinkOptions() { - // TODO: Implement deletion +export function LinkOptions({ linkId }: { linkId: string }) { + const { toast } = useToast(); + const router = useRouter(); + + const unbookmarkLink = async () => { + let [_, error] = await APIClient.unbookmarkLink(linkId); + + if (error) { + toast({ + variant: "destructive", + title: "Something went wrong", + description: "There was a problem with your request.", + }); + } else { + toast({ + description: "The link has been deleted!", + }); + } + + router.refresh(); + }; return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -27,10 +49,10 @@ export function LinkOptions() { </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> - <DropdownMenuItem className="text-destructive"> - <Trash2 className="mr-2 h-4 w-4" /> - <span>Delete</span> - </DropdownMenuItem> + <DropdownMenuItem className="text-destructive" onClick={unbookmarkLink}> + <Trash2 className="mr-2 h-4 w-4" /> + <span>Delete</span> + </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); @@ -59,7 +81,7 @@ export default function LinkCard({ link }: { link: ZBookmarkedLink }) { {parsedUrl.host} </Link> </div> - <LinkOptions /> + <LinkOptions linkId={link.id} /> </div> </ImageCardFooter> </ImageCard> diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 30d918df..a6543b1c 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import React from "react"; +import { Toaster } from "@/components/ui/toaster"; const inter = Inter({ subsets: ["latin"] }); @@ -17,7 +18,10 @@ export default function RootLayout({ }>) { return ( <html lang="en"> - <body className={inter.className}>{children}</body> + <body className={inter.className}> + {children} + <Toaster /> + </body> </html> ); } diff --git a/web/components/ui/toast.tsx b/web/components/ui/toast.tsx new file mode 100644 index 00000000..a8224775 --- /dev/null +++ b/web/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Viewport>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className + )} + {...props} + /> +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & + VariantProps<typeof toastVariants> +>(({ className, variant, ...props }, ref) => { + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} + /> + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Action>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Action + ref={ref} + className={cn( + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", + className + )} + {...props} + /> +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Close>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Close + ref={ref} + className={cn( + "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", + className + )} + toast-close="" + {...props} + > + <X className="h-4 w-4" /> + </ToastPrimitives.Close> +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Title>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold", className)} + {...props} + /> +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Description>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Description + ref={ref} + className={cn("text-sm opacity-90", className)} + {...props} + /> +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> + +type ToastActionElement = React.ReactElement<typeof ToastAction> + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/web/components/ui/toaster.tsx b/web/components/ui/toaster.tsx new file mode 100644 index 00000000..e2233852 --- /dev/null +++ b/web/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" +import { useToast } from "@/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ) + })} + <ToastViewport /> + </ToastProvider> + ) +} diff --git a/web/components/ui/use-toast.ts b/web/components/ui/use-toast.ts new file mode 100644 index 00000000..16713070 --- /dev/null +++ b/web/components/ui/use-toast.ts @@ -0,0 +1,192 @@ +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial<ToasterToast> + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit<ToasterToast, "id"> + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState<State>(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/web/lib/api.ts b/web/lib/api.ts index 193d9bb7..12ce9464 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -35,8 +35,8 @@ async function doRequest<T>( opts?: RequestInit, ): Promise< | (InputSchema<T> extends ZodTypeAny - ? [z.infer<InputSchema<T>>, undefined] - : [undefined, undefined]) + ? [z.infer<InputSchema<T>>, undefined] + : [undefined, undefined]) | [undefined, FetchError] > { try { @@ -84,4 +84,10 @@ export default class APIClient { body: JSON.stringify(body), }); } + + static async unbookmarkLink(linkId: string) { + return await doRequest(`/links/${linkId}`, undefined, { + method: "DELETE", + }); + } } diff --git a/web/lib/services/links.ts b/web/lib/services/links.ts index f3ff1757..dbcbe9c4 100644 --- a/web/lib/services/links.ts +++ b/web/lib/services/links.ts @@ -1,6 +1,15 @@ import { LinkCrawlerQueue } from "@remember/shared/queues"; import prisma from "@remember/db"; +export async function unbookmarkLink(linkId: string, userId: string) { + await prisma.bookmarkedLink.delete({ + where: { + id: linkId, + userId, + }, + }); +} + export async function bookmarkLink(url: string, userId: string) { const link = await prisma.bookmarkedLink.create({ data: { diff --git a/web/package.json b/web/package.json index 09249bab..0c7ce127 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "@next/eslint-plugin-next": "^14.1.0", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "install": "^0.13.0", |
