2025-05-06 11:00:35 +01:00
|
|
|
import type { LinkBookmark } from "@markone/core/bookmark"
|
2025-05-07 23:40:03 +01:00
|
|
|
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
2025-05-06 11:00:35 +01:00
|
|
|
import clsx from "clsx"
|
2025-05-07 23:09:14 +01:00
|
|
|
import { useEffect, useId, useState } from "react"
|
2025-05-06 11:00:35 +01:00
|
|
|
import { create } from "zustand"
|
2025-05-07 23:09:14 +01:00
|
|
|
import { fetchApi, useAuthenticatedQuery, BadRequestError, ApiErrorCode } from "~/api"
|
|
|
|
import { useCreateBookmark, useDeleteBookmark } from "~/bookmark/api"
|
2025-05-06 11:00:35 +01:00
|
|
|
import { Button } from "~/components/button"
|
2025-05-07 23:09:14 +01:00
|
|
|
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
|
|
|
import { Message, MessageVariant } from "~/components/message"
|
|
|
|
import { FormField } from "~/components/form-field"
|
2025-05-07 16:57:09 +01:00
|
|
|
import { LoadingSpinner } from "~/components/loading-spinner"
|
2025-05-07 15:47:08 +01:00
|
|
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
2025-05-07 23:40:03 +01:00
|
|
|
import { useLogOut } from "~/auth"
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
const LAYOUT_MODE = {
|
|
|
|
popup: "popup",
|
|
|
|
sideBySide: "side-by-side",
|
2025-05-06 11:00:35 +01:00
|
|
|
} as const
|
|
|
|
type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
|
2025-05-03 23:27:36 +01:00
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
enum ActiveDialog {
|
|
|
|
None = "None",
|
|
|
|
AddBookmark = "AddBookmark",
|
|
|
|
DeleteBookmark = "DeleteBookmark",
|
|
|
|
}
|
|
|
|
|
2025-05-03 23:27:36 +01:00
|
|
|
interface BookmarkPageState {
|
2025-05-07 15:47:08 +01:00
|
|
|
bookmarkCount: number
|
|
|
|
selectedBookmarkId: string
|
2025-05-06 11:00:35 +01:00
|
|
|
selectedBookmarkIndex: number
|
|
|
|
isBookmarkItemExpanded: boolean
|
|
|
|
isBookmarkPreviewOpened: boolean
|
2025-05-07 15:47:08 +01:00
|
|
|
bookmarkToBeDeleted: LinkBookmark | null
|
2025-05-06 11:00:35 +01:00
|
|
|
layoutMode: LayoutMode
|
2025-05-07 15:47:08 +01:00
|
|
|
activeDialog: ActiveDialog
|
2025-05-06 11:00:35 +01:00
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
setActiveDialog: (dialog: ActiveDialog) => void
|
2025-05-06 11:00:35 +01:00
|
|
|
setBookmarkItemExpanded: (isExpanded: boolean) => void
|
|
|
|
setBookmarkPreviewOpened: (isOpened: boolean) => void
|
|
|
|
setLayoutMode: (mode: LayoutMode) => void
|
2025-05-07 15:47:08 +01:00
|
|
|
selectBookmark: (bookmark: LinkBookmark, index: number) => void
|
|
|
|
reconcileSelection: (bookmarks: LinkBookmark[]) => void
|
|
|
|
markBookmarkForDeletion: (bookmark: LinkBookmark | null) => void
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
2025-05-07 15:47:08 +01:00
|
|
|
bookmarkCount: 0,
|
|
|
|
bookmarks: [],
|
|
|
|
selectedBookmarkId: "",
|
2025-05-03 23:27:36 +01:00
|
|
|
selectedBookmarkIndex: 0,
|
|
|
|
isBookmarkItemExpanded: false,
|
|
|
|
isBookmarkPreviewOpened: false,
|
2025-05-07 15:47:08 +01:00
|
|
|
bookmarkToBeDeleted: null,
|
2025-05-03 23:27:36 +01:00
|
|
|
layoutMode: LAYOUT_MODE.popup,
|
2025-05-07 15:47:08 +01:00
|
|
|
activeDialog: ActiveDialog.None,
|
|
|
|
|
|
|
|
setActiveDialog(dialog: ActiveDialog) {
|
|
|
|
set({ activeDialog: dialog })
|
|
|
|
},
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
setBookmarkItemExpanded(isExpanded: boolean) {
|
2025-05-06 11:00:35 +01:00
|
|
|
set({ isBookmarkItemExpanded: isExpanded })
|
2025-05-03 23:27:36 +01:00
|
|
|
},
|
|
|
|
|
|
|
|
setBookmarkPreviewOpened(isOpened: boolean) {
|
2025-05-06 11:00:35 +01:00
|
|
|
set({ isBookmarkPreviewOpened: isOpened })
|
2025-05-03 23:27:36 +01:00
|
|
|
},
|
|
|
|
|
|
|
|
setLayoutMode(mode: LayoutMode) {
|
2025-05-06 11:00:35 +01:00
|
|
|
set({ layoutMode: mode })
|
2025-05-03 23:27:36 +01:00
|
|
|
},
|
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
selectBookmark(bookmark: LinkBookmark, index: number) {
|
|
|
|
set({ selectedBookmarkId: bookmark.id, selectedBookmarkIndex: index })
|
2025-05-03 23:27:36 +01:00
|
|
|
},
|
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
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 })
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
}
|
2025-05-07 15:47:08 +01:00
|
|
|
},
|
2025-05-03 23:27:36 +01:00
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
markBookmarkForDeletion(bookmark: LinkBookmark | null) {
|
|
|
|
set({ bookmarkToBeDeleted: bookmark })
|
|
|
|
},
|
|
|
|
}))
|
2025-05-03 23:27:36 +01:00
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
function Page() {
|
|
|
|
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
function mediaQueryListener(this: MediaQueryList) {
|
|
|
|
if (this.matches) {
|
2025-05-06 11:00:35 +01:00
|
|
|
setLayoutMode(LAYOUT_MODE.sideBySide)
|
2025-05-03 23:27:36 +01:00
|
|
|
} else {
|
2025-05-06 11:00:35 +01:00
|
|
|
setLayoutMode(LAYOUT_MODE.popup)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-05-06 11:00:35 +01:00
|
|
|
const q = window.matchMedia("(width >= 64rem)")
|
|
|
|
q.addEventListener("change", mediaQueryListener)
|
2025-05-03 23:27:36 +01:00
|
|
|
|
2025-05-06 11:00:35 +01:00
|
|
|
mediaQueryListener.call(q)
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
return () => {
|
2025-05-06 11:00:35 +01:00
|
|
|
q.removeEventListener("change", mediaQueryListener)
|
|
|
|
}
|
|
|
|
}, [setLayoutMode])
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
return (
|
2025-05-07 15:47:08 +01:00
|
|
|
<div className="relative">
|
2025-05-03 23:27:36 +01:00
|
|
|
<Main>
|
2025-05-07 15:47:08 +01:00
|
|
|
<div className="flex flex-col md:flex-row justify-center py-16 lg:py-32">
|
2025-05-03 23:27:36 +01:00
|
|
|
<header className="mb-4 md:mb-0 md:mr-16 text-start">
|
|
|
|
<h1 className="font-bold text-start">
|
|
|
|
<span className="invisible md:hidden">> </span>
|
|
|
|
YOUR BOOKMARKS
|
|
|
|
</h1>
|
|
|
|
</header>
|
2025-05-07 15:47:08 +01:00
|
|
|
<BookmarkListSection />
|
2025-05-03 23:27:36 +01:00
|
|
|
</div>
|
|
|
|
<BookmarkPreview />
|
|
|
|
</Main>
|
2025-05-07 15:47:08 +01:00
|
|
|
<ActionBar />
|
|
|
|
<PageDialog />
|
2025-05-03 23:27:36 +01:00
|
|
|
</div>
|
2025-05-06 11:00:35 +01:00
|
|
|
)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
function Main({ children }: React.PropsWithChildren) {
|
2025-05-06 11:00:35 +01:00
|
|
|
const isPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
|
|
|
|
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
return (
|
|
|
|
<main
|
|
|
|
className={clsx(
|
|
|
|
"px-4 lg:px-8 2xl:px-0 grid flex justify-center relative w-full",
|
2025-05-06 11:00:35 +01:00
|
|
|
isPreviewOpened && layoutMode === LAYOUT_MODE.sideBySide ? "grid-cols-2" : "grid-cols-1",
|
2025-05-03 23:27:36 +01:00
|
|
|
)}
|
|
|
|
>
|
|
|
|
{children}
|
|
|
|
</main>
|
2025-05-06 11:00:35 +01:00
|
|
|
)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
function PageDialog() {
|
|
|
|
const dialog = useBookmarkPageStore((state) => state.activeDialog)
|
|
|
|
switch (dialog) {
|
|
|
|
case ActiveDialog.None:
|
|
|
|
return null
|
|
|
|
case ActiveDialog.AddBookmark:
|
2025-05-07 23:09:14 +01:00
|
|
|
return <AddBookmarkDialog />
|
2025-05-07 15:47:08 +01:00
|
|
|
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(
|
|
|
|
{
|
2025-05-07 16:57:09 +01:00
|
|
|
y: proceed,
|
|
|
|
n: cancel,
|
2025-05-07 15:47:08 +01:00
|
|
|
},
|
|
|
|
{ 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)
|
|
|
|
}
|
|
|
|
|
2025-05-07 16:57:09 +01:00
|
|
|
function body() {
|
|
|
|
switch (deleteBookmarkMutation.status) {
|
|
|
|
case "pending":
|
|
|
|
return (
|
|
|
|
<p>
|
|
|
|
Deleting <LoadingSpinner />
|
|
|
|
</p>
|
|
|
|
)
|
|
|
|
case "idle":
|
|
|
|
return (
|
|
|
|
<p>
|
|
|
|
The bookmark titled{" "}
|
|
|
|
<strong>
|
|
|
|
<em>"{bookmark.title}"</em>
|
|
|
|
</strong>{" "}
|
|
|
|
will be deleted. Proceed?
|
|
|
|
</p>
|
|
|
|
)
|
|
|
|
case "error":
|
|
|
|
return <p className="text-red-500">Failed to delete the bookmark!</p>
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function title() {
|
|
|
|
switch (deleteBookmarkMutation.status) {
|
|
|
|
case "pending":
|
|
|
|
return "PLEASE WAIT"
|
|
|
|
case "idle":
|
|
|
|
return "CONFIRM"
|
|
|
|
case "error":
|
|
|
|
return "ERROR OCCURRED"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
return (
|
|
|
|
<Dialog>
|
2025-05-07 16:57:09 +01:00
|
|
|
<DialogTitle>{title()}</DialogTitle>
|
|
|
|
<DialogBody>{body()}</DialogBody>
|
2025-05-07 15:47:08 +01:00
|
|
|
<DialogActionRow>
|
|
|
|
<Button disabled={deleteBookmarkMutation.isPending} onClick={proceed}>
|
2025-05-07 16:57:09 +01:00
|
|
|
{deleteBookmarkMutation.isError ? "Retry" : "Proceed"} (y)
|
2025-05-07 15:47:08 +01:00
|
|
|
</Button>
|
|
|
|
<Button disabled={deleteBookmarkMutation.isPending} onClick={cancel}>
|
2025-05-07 16:57:09 +01:00
|
|
|
Cancel (n)
|
2025-05-07 15:47:08 +01:00
|
|
|
</Button>
|
|
|
|
</DialogActionRow>
|
|
|
|
</Dialog>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2025-05-07 23:09:14 +01:00
|
|
|
function AddBookmarkDialog() {
|
|
|
|
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
|
|
|
|
const createBookmarkMutation = useCreateBookmark()
|
|
|
|
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
|
|
|
const formId = useId()
|
|
|
|
|
|
|
|
useMnemonics(
|
|
|
|
{
|
|
|
|
c: cancel,
|
|
|
|
},
|
|
|
|
{ active: true },
|
|
|
|
)
|
|
|
|
|
|
|
|
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
|
|
const formData = new FormData(event.currentTarget)
|
|
|
|
const url = formData.get("link")
|
|
|
|
if (url && typeof url === "string") {
|
|
|
|
try {
|
|
|
|
await createBookmarkMutation.mutateAsync({ url, force: isWebsiteUnreachable })
|
|
|
|
} catch (error) {
|
|
|
|
if (error instanceof BadRequestError && error.code === ApiErrorCode.WebsiteUnreachable) {
|
|
|
|
setIsWebsiteUnreachable(true)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function cancel() {
|
|
|
|
setActiveDialog(ActiveDialog.None)
|
|
|
|
}
|
|
|
|
|
|
|
|
function message() {
|
|
|
|
if (createBookmarkMutation.isPending) {
|
|
|
|
return (
|
|
|
|
<p>
|
|
|
|
Loading <LoadingSpinner />
|
|
|
|
</p>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
if (isWebsiteUnreachable) {
|
|
|
|
return (
|
|
|
|
<Message variant={MessageVariant.Warning} className="px-4">
|
|
|
|
The link does not seem to be reachable. Click "SAVE" to save anyways.
|
|
|
|
</Message>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
if (createBookmarkMutation.status === "error") {
|
|
|
|
return (
|
|
|
|
<Message variant={MessageVariant.Error} className="px-4">
|
|
|
|
An error occurred when saving bookmark
|
|
|
|
</Message>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Dialog>
|
|
|
|
<DialogTitle>NEW BOOKMARK</DialogTitle>
|
|
|
|
<DialogBody>
|
|
|
|
{message()}
|
|
|
|
<form id={formId} className="px-8" onSubmit={onSubmit}>
|
|
|
|
<FormField
|
|
|
|
type="text"
|
|
|
|
name="link"
|
|
|
|
label="LINK"
|
|
|
|
className="w-full"
|
|
|
|
labelClassName="bg-stone-300 dark:bg-stone-800"
|
|
|
|
/>
|
|
|
|
</form>
|
|
|
|
</DialogBody>
|
|
|
|
<DialogActionRow>
|
|
|
|
<Button type="submit" disabled={createBookmarkMutation.isPending} form={formId}>
|
|
|
|
SAVE
|
|
|
|
</Button>
|
|
|
|
<Button type="button" disabled={createBookmarkMutation.isPending} onClick={cancel}>
|
|
|
|
<span className="underline">C</span>ANCEL
|
|
|
|
</Button>
|
|
|
|
</DialogActionRow>
|
|
|
|
</Dialog>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
function BookmarkListSection() {
|
2025-05-07 16:57:09 +01:00
|
|
|
const { data: bookmarks, status } = useAuthenticatedQuery(["bookmarks"], () =>
|
2025-05-07 23:09:14 +01:00
|
|
|
fetchApi("/bookmarks").then((res) => res.json()),
|
2025-05-07 16:57:09 +01:00
|
|
|
)
|
2025-05-07 15:47:08 +01:00
|
|
|
|
|
|
|
switch (status) {
|
|
|
|
case "pending":
|
2025-05-07 16:57:09 +01:00
|
|
|
return (
|
|
|
|
<p className="container max-w-2xl">
|
|
|
|
Loading
|
|
|
|
<LoadingSpinner />
|
|
|
|
</p>
|
|
|
|
)
|
2025-05-07 15:47:08 +01:00
|
|
|
case "success":
|
2025-05-07 16:57:09 +01:00
|
|
|
if (bookmarks) {
|
|
|
|
return <BookmarkList bookmarks={bookmarks} />
|
|
|
|
}
|
|
|
|
return null
|
2025-05-07 15:47:08 +01:00
|
|
|
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>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2025-05-03 23:27:36 +01:00
|
|
|
function BookmarkPreview() {
|
2025-05-06 11:00:35 +01:00
|
|
|
const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
|
|
|
|
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
if (!isVisible) {
|
2025-05-06 11:00:35 +01:00
|
|
|
return null
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
className={clsx(
|
|
|
|
"h-screen flex justify-center items-center border-l border-stone-700 dark:border-stone-300 flex dark:bg-stone-900",
|
|
|
|
{
|
|
|
|
"absolute inset-0 border-l-0": layoutMode === LAYOUT_MODE.popup,
|
|
|
|
},
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
<p>Content here</p>
|
|
|
|
</div>
|
2025-05-06 11:00:35 +01:00
|
|
|
)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
|
2025-05-06 11:00:35 +01:00
|
|
|
function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) {
|
|
|
|
const url = new URL(bookmark.url)
|
2025-05-07 15:47:08 +01:00
|
|
|
const selectedBookmarkIndex = useBookmarkPageStore((state) => state.selectedBookmarkIndex)
|
2025-05-06 11:00:35 +01:00
|
|
|
const isBookmarkItemExpanded = useBookmarkPageStore((state) => state.isBookmarkItemExpanded)
|
2025-05-07 15:47:08 +01:00
|
|
|
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
2025-05-06 11:00:35 +01:00
|
|
|
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
|
2025-05-07 15:47:08 +01:00
|
|
|
const selectBookmark = useBookmarkPageStore((state) => state.selectBookmark)
|
2025-05-06 11:00:35 +01:00
|
|
|
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
|
2025-05-07 15:47:08 +01:00
|
|
|
const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion)
|
|
|
|
const isSelected = selectedBookmarkIndex === index
|
|
|
|
|
|
|
|
useMnemonics(
|
|
|
|
{
|
|
|
|
d: deleteItem,
|
|
|
|
},
|
|
|
|
{ active: isSelected },
|
|
|
|
)
|
|
|
|
|
|
|
|
function deleteItem() {
|
|
|
|
if (isSelected) {
|
|
|
|
markBookmarkForDeletion(bookmark)
|
|
|
|
setActiveDialog(ActiveDialog.DeleteBookmark)
|
|
|
|
}
|
|
|
|
}
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
function expandOrOpenPreview() {
|
2025-05-06 11:00:35 +01:00
|
|
|
setBookmarkItemExpanded(true)
|
2025-05-03 23:27:36 +01:00
|
|
|
if (useBookmarkPageStore.getState().layoutMode === LAYOUT_MODE.sideBySide) {
|
2025-05-06 11:00:35 +01:00
|
|
|
console.log(useBookmarkPageStore.getState().layoutMode)
|
|
|
|
setBookmarkPreviewOpened(true)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
className={clsx("group flex flex-row justify-start py-2", {
|
|
|
|
"bg-teal-600 text-stone-100": isBookmarkItemExpanded && isSelected,
|
|
|
|
"text-teal-600": isSelected && !isBookmarkItemExpanded,
|
|
|
|
})}
|
|
|
|
onMouseEnter={() => {
|
|
|
|
if (!isBookmarkItemExpanded) {
|
2025-05-07 15:47:08 +01:00
|
|
|
selectBookmark(bookmark, index)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<button
|
2025-05-06 11:00:35 +01:00
|
|
|
type="button"
|
2025-05-03 23:27:36 +01:00
|
|
|
disabled={!isSelected}
|
2025-05-06 11:00:35 +01:00
|
|
|
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
|
|
|
|
invisible: !isSelected,
|
|
|
|
})}
|
2025-05-03 23:27:36 +01:00
|
|
|
onClick={() => {
|
2025-05-06 11:00:35 +01:00
|
|
|
setBookmarkItemExpanded(!isBookmarkItemExpanded)
|
|
|
|
setBookmarkPreviewOpened(false)
|
2025-05-03 23:27:36 +01:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
<span className="sr-only">Options for this bookmark</span>
|
|
|
|
<span> </span>
|
|
|
|
<span className={isBookmarkItemExpanded ? "rotate-90" : ""}>></span>
|
|
|
|
<span> </span>
|
|
|
|
</button>
|
|
|
|
<div className="flex flex-col w-full">
|
2025-05-06 11:00:35 +01:00
|
|
|
<button type="button" className="text-start font-bold" onClick={expandOrOpenPreview}>
|
2025-05-03 23:27:36 +01:00
|
|
|
{bookmark.title}
|
|
|
|
</button>
|
|
|
|
<p className="opacity-80 text-sm">{url.host}</p>
|
|
|
|
{isBookmarkItemExpanded && isSelected ? (
|
|
|
|
<div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2">
|
2025-05-06 11:00:35 +01:00
|
|
|
<p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p>
|
2025-05-03 23:27:36 +01:00
|
|
|
<div className="flex space-x-2">
|
|
|
|
<OpenBookmarkPreviewButton />
|
2025-05-07 15:47:08 +01:00
|
|
|
<Button variant="light" className="text-sm">
|
2025-05-03 23:27:36 +01:00
|
|
|
<span className="underline">E</span>dit
|
|
|
|
</Button>
|
2025-05-07 15:47:08 +01:00
|
|
|
<Button variant="light" className="text-sm" onClick={deleteItem}>
|
2025-05-03 23:27:36 +01:00
|
|
|
<span className="underline">D</span>elete
|
|
|
|
</Button>
|
|
|
|
<span className="-ml-2"> </span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
) : null}
|
|
|
|
</div>
|
|
|
|
</div>
|
2025-05-06 11:00:35 +01:00
|
|
|
)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
function OpenBookmarkPreviewButton() {
|
2025-05-06 11:00:35 +01:00
|
|
|
const isBookmarkPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
|
|
|
|
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
|
|
|
|
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
|
|
if (isBookmarkPreviewOpened && event.key === "c") {
|
2025-05-06 11:00:35 +01:00
|
|
|
closePreview()
|
2025-05-03 23:27:36 +01:00
|
|
|
} else if (!isBookmarkPreviewOpened && event.key === "o") {
|
2025-05-06 11:00:35 +01:00
|
|
|
openPreview()
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-05-06 11:00:35 +01:00
|
|
|
window.addEventListener("keydown", onKeyDown)
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
return () => {
|
2025-05-06 11:00:35 +01:00
|
|
|
window.removeEventListener("keydown", onKeyDown)
|
|
|
|
}
|
|
|
|
}, [isBookmarkPreviewOpened])
|
2025-05-03 23:27:36 +01:00
|
|
|
|
|
|
|
function closePreview() {
|
2025-05-06 11:00:35 +01:00
|
|
|
setBookmarkPreviewOpened(false)
|
|
|
|
setBookmarkItemExpanded(false)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
function openPreview() {
|
2025-05-06 11:00:35 +01:00
|
|
|
setBookmarkPreviewOpened(true)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Button
|
2025-05-07 15:47:08 +01:00
|
|
|
variant="light"
|
2025-05-03 23:27:36 +01:00
|
|
|
className="text-sm"
|
|
|
|
onClick={() => {
|
|
|
|
if (isBookmarkPreviewOpened) {
|
2025-05-06 11:00:35 +01:00
|
|
|
closePreview()
|
2025-05-03 23:27:36 +01:00
|
|
|
} else {
|
2025-05-06 11:00:35 +01:00
|
|
|
openPreview()
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{isBookmarkPreviewOpened ? (
|
|
|
|
<>
|
|
|
|
<span className="underline">C</span>lose
|
|
|
|
</>
|
|
|
|
) : (
|
|
|
|
<>
|
|
|
|
<span className="underline">O</span>pen
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
</Button>
|
2025-05-06 11:00:35 +01:00
|
|
|
)
|
2025-05-03 23:27:36 +01:00
|
|
|
}
|
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
function ActionBar() {
|
2025-05-07 23:09:14 +01:00
|
|
|
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
|
|
|
|
|
|
|
useMnemonics(
|
|
|
|
{
|
|
|
|
a: addBookmark,
|
|
|
|
},
|
|
|
|
{ active: true },
|
|
|
|
)
|
|
|
|
|
|
|
|
function addBookmark() {
|
|
|
|
setActiveDialog(ActiveDialog.AddBookmark)
|
|
|
|
}
|
|
|
|
|
2025-05-07 15:47:08 +01:00
|
|
|
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">
|
2025-05-07 23:09:14 +01:00
|
|
|
<Button onClick={addBookmark}>
|
2025-05-07 15:47:08 +01:00
|
|
|
<span className="underline">A</span>DD
|
|
|
|
</Button>
|
|
|
|
<Button>
|
|
|
|
<span className="underline">S</span>EARCH
|
|
|
|
</Button>
|
2025-05-07 23:40:03 +01:00
|
|
|
<LogOutButton />
|
2025-05-07 15:47:08 +01:00
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2025-05-07 23:40:03 +01:00
|
|
|
function LogOutButton() {
|
|
|
|
const logOutMutation = useLogOut()
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
|
|
function logOut() {
|
|
|
|
logOutMutation.mutate()
|
|
|
|
navigate({ to: "/", replace: true })
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Button disabled={logOutMutation.isPending} onClick={logOut}>
|
|
|
|
LOG OUT
|
|
|
|
</Button>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2025-05-06 11:00:35 +01:00
|
|
|
export const Route = createFileRoute("/bookmarks")({
|
2025-05-03 23:27:36 +01:00
|
|
|
component: Page,
|
2025-05-06 11:00:35 +01:00
|
|
|
})
|