implement bookmark delete

This commit is contained in:
2025-05-07 15:47:08 +01:00
parent 30cc4d3fb5
commit e87a6586b6
26 changed files with 763 additions and 149 deletions

View File

@@ -11,11 +11,18 @@
// Import Routes
import { Route as rootRoute } from "./__root"
import { Route as LoginImport } from "./login"
import { Route as BookmarksImport } from "./bookmarks"
import { Route as IndexImport } from "./index"
// Create/Update Routes
const LoginRoute = LoginImport.update({
id: "/login",
path: "/login",
getParentRoute: () => rootRoute,
} as any)
const BookmarksRoute = BookmarksImport.update({
id: "/bookmarks",
path: "/bookmarks",
@@ -46,6 +53,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof BookmarksImport
parentRoute: typeof rootRoute
}
"/login": {
id: "/login"
path: "/login"
fullPath: "/login"
preLoaderRoute: typeof LoginImport
parentRoute: typeof rootRoute
}
}
}
@@ -54,36 +68,41 @@ declare module "@tanstack/react-router" {
export interface FileRoutesByFullPath {
"/": typeof IndexRoute
"/bookmarks": typeof BookmarksRoute
"/login": typeof LoginRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute
"/bookmarks": typeof BookmarksRoute
"/login": typeof LoginRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
"/": typeof IndexRoute
"/bookmarks": typeof BookmarksRoute
"/login": typeof LoginRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: "/" | "/bookmarks"
fullPaths: "/" | "/bookmarks" | "/login"
fileRoutesByTo: FileRoutesByTo
to: "/" | "/bookmarks"
id: "__root__" | "/" | "/bookmarks"
to: "/" | "/bookmarks" | "/login"
id: "__root__" | "/" | "/bookmarks" | "/login"
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BookmarksRoute: typeof BookmarksRoute
LoginRoute: typeof LoginRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BookmarksRoute: BookmarksRoute,
LoginRoute: LoginRoute,
}
export const routeTree = rootRoute
@@ -97,7 +116,8 @@ export const routeTree = rootRoute
"filePath": "__root.tsx",
"children": [
"/",
"/bookmarks"
"/bookmarks",
"/login"
]
},
"/": {
@@ -105,6 +125,9 @@ export const routeTree = rootRoute
},
"/bookmarks": {
"filePath": "bookmarks.tsx"
},
"/login": {
"filePath": "login.tsx"
}
}
}

View File

@@ -3,22 +3,11 @@ import { createFileRoute } from "@tanstack/react-router"
import clsx from "clsx"
import { useEffect } from "react"
import { create } from "zustand"
import { fetchApi, useAuthenticatedQuery } from "~/api"
import { Button } from "~/components/button"
const testBookmarks: LinkBookmark[] = [
{
kind: "link",
id: "1",
title: "Running a Docker container as a non-root user",
url: "https://test.website.com/article/123",
},
{
kind: "link",
id: "2",
title: "Running a Docker container as a non-root user",
url: "https://test.website.com/article/123",
},
]
import { useDeleteBookmark } from "~/bookmark/api"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
const LAYOUT_MODE = {
popup: "popup",
@@ -26,25 +15,45 @@ const LAYOUT_MODE = {
} as const
type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
enum ActiveDialog {
None = "None",
AddBookmark = "AddBookmark",
DeleteBookmark = "DeleteBookmark",
}
interface BookmarkPageState {
bookmarks: LinkBookmark[]
bookmarkCount: number
selectedBookmarkId: string
selectedBookmarkIndex: number
isBookmarkItemExpanded: boolean
isBookmarkPreviewOpened: boolean
bookmarkToBeDeleted: LinkBookmark | null
layoutMode: LayoutMode
activeDialog: ActiveDialog
setActiveDialog: (dialog: ActiveDialog) => void
setBookmarkItemExpanded: (isExpanded: boolean) => void
setBookmarkPreviewOpened: (isOpened: boolean) => void
setLayoutMode: (mode: LayoutMode) => void
selectBookmarkAt: (index: number) => void
selectBookmark: (bookmark: LinkBookmark, index: number) => void
reconcileSelection: (bookmarks: LinkBookmark[]) => void
markBookmarkForDeletion: (bookmark: LinkBookmark | null) => void
}
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
bookmarks: testBookmarks,
bookmarkCount: 0,
bookmarks: [],
selectedBookmarkId: "",
selectedBookmarkIndex: 0,
isBookmarkItemExpanded: false,
isBookmarkPreviewOpened: false,
bookmarkToBeDeleted: null,
layoutMode: LAYOUT_MODE.popup,
activeDialog: ActiveDialog.None,
setActiveDialog(dialog: ActiveDialog) {
set({ activeDialog: dialog })
},
setBookmarkItemExpanded(isExpanded: boolean) {
set({ isBookmarkItemExpanded: isExpanded })
@@ -58,46 +67,34 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
set({ layoutMode: mode })
},
selectBookmarkAt(index: number) {
const bookmarks = get().bookmarks
if (index >= 0 && index < bookmarks.length) {
set({ selectedBookmarkIndex: index })
selectBookmark(bookmark: LinkBookmark, index: number) {
set({ selectedBookmarkId: bookmark.id, selectedBookmarkIndex: index })
},
reconcileSelection(bookmarks: LinkBookmark[]) {
const { selectedBookmarkId, selectedBookmarkIndex } = get()
const newIndex = bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId)
if (newIndex !== selectedBookmarkIndex) {
if (newIndex >= 0) {
set({ selectedBookmarkIndex: newIndex })
} else if (selectedBookmarkIndex >= bookmarks.length - 1) {
set({ selectedBookmarkIndex: bookmarks.length - 1 })
} else if (selectedBookmarkIndex === 0) {
set({ selectedBookmarkIndex: 0 })
} else {
set({ selectedBookmarkIndex: selectedBookmarkIndex + 1 })
}
}
},
markBookmarkForDeletion(bookmark: LinkBookmark | null) {
set({ bookmarkToBeDeleted: bookmark })
},
}))
function Page() {
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const state = useBookmarkPageStore.getState()
switch (event.key) {
case "ArrowDown":
state.selectBookmarkAt(state.selectedBookmarkIndex + 1)
break
case "ArrowUp":
state.selectBookmarkAt(state.selectedBookmarkIndex - 1)
break
case "ArrowLeft":
state.setBookmarkItemExpanded(false)
break
case "ArrowRight":
state.setBookmarkItemExpanded(true)
break
default:
break
}
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [])
useEffect(() => {
function mediaQueryListener(this: MediaQueryList) {
if (this.matches) {
@@ -118,23 +115,21 @@ function Page() {
}, [setLayoutMode])
return (
<div className="flex justify-center h-full">
<div className="relative">
<Main>
<div className="flex flex-col md:flex-row justify-center py-16 lg:py-32 ">
<div className="flex flex-col md:flex-row justify-center py-16 lg:py-32">
<header className="mb-4 md:mb-0 md:mr-16 text-start">
<h1 className="font-bold text-start">
<span className="invisible md:hidden">&gt;&nbsp;</span>
YOUR BOOKMARKS
</h1>
</header>
<div className="flex flex-col container max-w-2xl -mt-2">
{testBookmarks.map((bookmark, i) => (
<BookmarkListItem key={bookmark.id} index={i} bookmark={bookmark} />
))}
</div>
<BookmarkListSection />
</div>
<BookmarkPreview />
</Main>
<ActionBar />
<PageDialog />
</div>
)
}
@@ -155,6 +150,142 @@ function Main({ children }: React.PropsWithChildren) {
)
}
function PageDialog() {
const dialog = useBookmarkPageStore((state) => state.activeDialog)
switch (dialog) {
case ActiveDialog.None:
return null
case ActiveDialog.AddBookmark:
return null
case ActiveDialog.DeleteBookmark:
return <DeleteBookmarkDialog />
}
}
function DeleteBookmarkDialog() {
// biome-ignore lint/style/noNonNullAssertion: this cannot be null when delete bookmark dialog is visible
const bookmark = useBookmarkPageStore((state) => state.bookmarkToBeDeleted!)
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion)
const deleteBookmarkMutation = useDeleteBookmark()
useMnemonics(
{
p: proceed,
c: cancel,
},
{ active: true },
)
async function proceed() {
try {
await deleteBookmarkMutation.mutateAsync({ bookmark })
setActiveDialog(ActiveDialog.None)
markBookmarkForDeletion(null)
} catch (error) {
console.error(error)
}
}
function cancel() {
setActiveDialog(ActiveDialog.None)
markBookmarkForDeletion(null)
}
return (
<Dialog>
<DialogTitle>CONFIRM</DialogTitle>
<DialogBody>
The bookmark titled{" "}
<strong>
<em>"{bookmark.title}"</em>
</strong>{" "}
will be deleted. Proceed?
</DialogBody>
<DialogActionRow>
<Button disabled={deleteBookmarkMutation.isPending} onClick={proceed}>
<span className="underline">P</span>roceed
</Button>
<Button disabled={deleteBookmarkMutation.isPending} onClick={cancel}>
<span className="underline">C</span>ancel
</Button>
</DialogActionRow>
</Dialog>
)
}
function BookmarkListSection() {
const {
data: bookmarks,
error,
status,
} = useAuthenticatedQuery(["bookmarks"], () => fetchApi<LinkBookmark[]>("/bookmarks").then(([data]) => data))
switch (status) {
case "pending":
return <p>Loading...</p>
case "success":
return <BookmarkList bookmarks={bookmarks} />
default:
return null
}
}
function BookmarkList({ bookmarks }: { bookmarks: LinkBookmark[] }) {
const reconcileSelection = useBookmarkPageStore((state) => state.reconcileSelection)
useEffect(() => {
reconcileSelection(bookmarks)
}, [bookmarks, reconcileSelection])
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const state = useBookmarkPageStore.getState()
switch (event.key) {
case "ArrowDown": {
const nextIndex = state.selectedBookmarkIndex + 1
if (nextIndex < bookmarks.length) {
state.selectBookmark(bookmarks[nextIndex], nextIndex)
}
break
}
case "ArrowUp": {
const prevIndex = state.selectedBookmarkIndex - 1
if (prevIndex >= 0) {
state.selectBookmark(bookmarks[prevIndex], prevIndex)
}
break
}
case "ArrowLeft":
state.setBookmarkItemExpanded(false)
break
case "ArrowRight":
state.setBookmarkItemExpanded(true)
break
default:
break
}
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [bookmarks])
return (
<div className="flex flex-col container max-w-2xl -mt-2">
{bookmarks.length === 0 ? (
<p className="mt-2">You have not saved any bookmark!</p>
) : (
bookmarks.map((bookmark, i) => <BookmarkListItem key={bookmark.id} index={i} bookmark={bookmark} />)
)}
</div>
)
}
function BookmarkPreview() {
const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
@@ -179,12 +310,28 @@ function BookmarkPreview() {
function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) {
const url = new URL(bookmark.url)
const selectedBookmark = useBookmarkPageStore((state) => state.bookmarks[state.selectedBookmarkIndex])
const isSelected = selectedBookmark.id === bookmark.id
const selectedBookmarkIndex = useBookmarkPageStore((state) => state.selectedBookmarkIndex)
const isBookmarkItemExpanded = useBookmarkPageStore((state) => state.isBookmarkItemExpanded)
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
const selectBookmarkAt = useBookmarkPageStore((state) => state.selectBookmarkAt)
const selectBookmark = useBookmarkPageStore((state) => state.selectBookmark)
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion)
const isSelected = selectedBookmarkIndex === index
useMnemonics(
{
d: deleteItem,
},
{ active: isSelected },
)
function deleteItem() {
if (isSelected) {
markBookmarkForDeletion(bookmark)
setActiveDialog(ActiveDialog.DeleteBookmark)
}
}
function expandOrOpenPreview() {
setBookmarkItemExpanded(true)
@@ -202,7 +349,7 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index:
})}
onMouseEnter={() => {
if (!isBookmarkItemExpanded) {
selectBookmarkAt(index)
selectBookmark(bookmark, index)
}
}}
>
@@ -232,10 +379,10 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index:
<p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p>
<div className="flex space-x-2">
<OpenBookmarkPreviewButton />
<Button className="text-sm">
<Button variant="light" className="text-sm">
<span className="underline">E</span>dit
</Button>
<Button className="text-sm">
<Button variant="light" className="text-sm" onClick={deleteItem}>
<span className="underline">D</span>elete
</Button>
<span className="-ml-2">&nbsp;</span>
@@ -279,6 +426,7 @@ function OpenBookmarkPreviewButton() {
return (
<Button
variant="light"
className="text-sm"
onClick={() => {
if (isBookmarkPreviewOpened) {
@@ -301,6 +449,19 @@ function OpenBookmarkPreviewButton() {
)
}
function ActionBar() {
return (
<div className="fixed z-10 bottom-0 left-0 right-0 border-t-1 flex flex-row justify-center py-4 space-x-4">
<Button>
<span className="underline">A</span>DD
</Button>
<Button>
<span className="underline">S</span>EARCH
</Button>
</div>
)
}
export const Route = createFileRoute("/bookmarks")({
component: Page,
})

View File

@@ -13,12 +13,12 @@ function Index() {
</h1>
<p className="pb-4 text-2xl uppercase">BOOKMARK MANAGER</p>
<div className="flex flex-col text-lg">
<Link>LOGIN</Link>
<Link href="/login">LOGIN</Link>
<Link>SIGN UP</Link>
<DemoButton />
</div>
</div>
<div>
<div className="container max-w-2xl">
<p>
<strong>WHAT IS MARKONE?</strong>
<br />
@@ -52,11 +52,15 @@ function DemoButton() {
const navigate = useNavigate()
async function startDemo() {
await loginMutation.mutateAsync({
username: DEMO_USER.username,
password: DEMO_USER.unhashedPassword,
})
navigate({ to: "/bookmarks" })
try {
const res = await loginMutation.mutateAsync({
username: DEMO_USER.username,
password: DEMO_USER.unhashedPassword,
})
if (res.status === 200) {
navigate({ to: "/bookmarks" })
}
} catch {}
}
return (

View File

@@ -0,0 +1,80 @@
import { useNavigate } from "@tanstack/react-router"
import { useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { FormField } from "~/components/form-field"
import { Button } from "~/components/button"
import { useLogin } from "~/auth"
function Page() {
const [error, setError] = useState("")
const navigate = useNavigate()
const loginMutation = useLogin()
async function submitLoginForm(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!event.currentTarget) {
return
}
const formData = new FormData(event.currentTarget)
const username = formData.get("username")
const password = formData.get("password")
if (typeof username === "string" && typeof password === "string") {
try {
const res = await loginMutation.mutateAsync({
username,
password,
})
switch (res.status) {
case 200:
navigate({ to: "/bookmarks" })
break
case 401:
setError("Incorrect username or password!")
break
default:
setError(`Server returned an unexpected response (status ${res.status}.)`)
break
}
} catch (error) {
setError("Unable to connect to the server!")
}
}
}
return (
<main className="p-48 flex flex-row items-start justify-center space-x-24">
<div className="flex flex-col items-start">
<h1 className="text-2xl">
<span className="font-bold">MARKONE</span>
<br />
<span className="pb-4 text-2xl uppercase">BOOKMARK MANAGER</span>
</h1>
</div>
<div className="container max-w-2xl">
<h2 className="font-bold">LOG IN TO YOUR ACCOUNT</h2>
{error ? (
<div className="border border-red-500 text-red-500 p-3 my-4">
<p className="text-red-500 font-bold">{error}</p>
</div>
) : null}
<form onSubmit={submitLoginForm}>
<FormField type="text" name="username" label="USERNAME" className="mb-2" />
<FormField type="password" name="password" label="PASSWORD" className="mb-8" />
<Button className="w-full py-2" type="submit">
Log in
</Button>
</form>
</div>
</main>
)
}
export const Route = createFileRoute("/login")({
component: Page,
})