diff --git a/bun.lockb b/bun.lockb
index f4f09e8a..641526b8 100755
Binary files a/bun.lockb and b/bun.lockb 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 (
@@ -27,10 +49,10 @@ export function LinkOptions() {
-
-
- Delete
-
+
+
+ Delete
+
);
@@ -59,7 +81,7 @@ export default function LinkCard({ link }: { link: ZBookmarkedLink }) {
{parsedUrl.host}
-
+
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 (
-
{children}
+
+ {children}
+
+
);
}
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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+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 (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
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
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+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
+
+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(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(
opts?: RequestInit,
): Promise<
| (InputSchema extends ZodTypeAny
- ? [z.infer>, undefined]
- : [undefined, undefined])
+ ? [z.infer>, 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",