implement bookmark collections and enhance bookmark search functionality
This commit is contained in:
@@ -18,6 +18,7 @@ import { Route as BookmarksImport } from "./bookmarks"
|
||||
import { Route as IndexImport } from "./index"
|
||||
import { Route as CollectionsIndexImport } from "./collections/index"
|
||||
import { Route as BookmarksIndexImport } from "./bookmarks/index"
|
||||
import { Route as CollectionsCollectionIdImport } from "./collections/$collectionId"
|
||||
import { Route as BookmarksBookmarkIdImport } from "./bookmarks/$bookmarkId"
|
||||
|
||||
// Create/Update Routes
|
||||
@@ -64,6 +65,12 @@ const BookmarksIndexRoute = BookmarksIndexImport.update({
|
||||
getParentRoute: () => BookmarksRoute,
|
||||
} as any)
|
||||
|
||||
const CollectionsCollectionIdRoute = CollectionsCollectionIdImport.update({
|
||||
id: "/$collectionId",
|
||||
path: "/$collectionId",
|
||||
getParentRoute: () => CollectionsRoute,
|
||||
} as any)
|
||||
|
||||
const BookmarksBookmarkIdRoute = BookmarksBookmarkIdImport.update({
|
||||
id: "/$bookmarkId",
|
||||
path: "/$bookmarkId",
|
||||
@@ -116,6 +123,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof BookmarksBookmarkIdImport
|
||||
parentRoute: typeof BookmarksImport
|
||||
}
|
||||
"/collections/$collectionId": {
|
||||
id: "/collections/$collectionId"
|
||||
path: "/$collectionId"
|
||||
fullPath: "/collections/$collectionId"
|
||||
preLoaderRoute: typeof CollectionsCollectionIdImport
|
||||
parentRoute: typeof CollectionsImport
|
||||
}
|
||||
"/bookmarks/": {
|
||||
id: "/bookmarks/"
|
||||
path: "/"
|
||||
@@ -150,10 +164,12 @@ const BookmarksRouteWithChildren = BookmarksRoute._addFileChildren(
|
||||
)
|
||||
|
||||
interface CollectionsRouteChildren {
|
||||
CollectionsCollectionIdRoute: typeof CollectionsCollectionIdRoute
|
||||
CollectionsIndexRoute: typeof CollectionsIndexRoute
|
||||
}
|
||||
|
||||
const CollectionsRouteChildren: CollectionsRouteChildren = {
|
||||
CollectionsCollectionIdRoute: CollectionsCollectionIdRoute,
|
||||
CollectionsIndexRoute: CollectionsIndexRoute,
|
||||
}
|
||||
|
||||
@@ -168,6 +184,7 @@ export interface FileRoutesByFullPath {
|
||||
"/login": typeof LoginRoute
|
||||
"/signup": typeof SignupRoute
|
||||
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
||||
"/collections/$collectionId": typeof CollectionsCollectionIdRoute
|
||||
"/bookmarks/": typeof BookmarksIndexRoute
|
||||
"/collections/": typeof CollectionsIndexRoute
|
||||
}
|
||||
@@ -177,6 +194,7 @@ export interface FileRoutesByTo {
|
||||
"/login": typeof LoginRoute
|
||||
"/signup": typeof SignupRoute
|
||||
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
||||
"/collections/$collectionId": typeof CollectionsCollectionIdRoute
|
||||
"/bookmarks": typeof BookmarksIndexRoute
|
||||
"/collections": typeof CollectionsIndexRoute
|
||||
}
|
||||
@@ -189,6 +207,7 @@ export interface FileRoutesById {
|
||||
"/login": typeof LoginRoute
|
||||
"/signup": typeof SignupRoute
|
||||
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
||||
"/collections/$collectionId": typeof CollectionsCollectionIdRoute
|
||||
"/bookmarks/": typeof BookmarksIndexRoute
|
||||
"/collections/": typeof CollectionsIndexRoute
|
||||
}
|
||||
@@ -202,6 +221,7 @@ export interface FileRouteTypes {
|
||||
| "/login"
|
||||
| "/signup"
|
||||
| "/bookmarks/$bookmarkId"
|
||||
| "/collections/$collectionId"
|
||||
| "/bookmarks/"
|
||||
| "/collections/"
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -210,6 +230,7 @@ export interface FileRouteTypes {
|
||||
| "/login"
|
||||
| "/signup"
|
||||
| "/bookmarks/$bookmarkId"
|
||||
| "/collections/$collectionId"
|
||||
| "/bookmarks"
|
||||
| "/collections"
|
||||
id:
|
||||
@@ -220,6 +241,7 @@ export interface FileRouteTypes {
|
||||
| "/login"
|
||||
| "/signup"
|
||||
| "/bookmarks/$bookmarkId"
|
||||
| "/collections/$collectionId"
|
||||
| "/bookmarks/"
|
||||
| "/collections/"
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -271,6 +293,7 @@ export const routeTree = rootRoute
|
||||
"/collections": {
|
||||
"filePath": "collections.tsx",
|
||||
"children": [
|
||||
"/collections/$collectionId",
|
||||
"/collections/"
|
||||
]
|
||||
},
|
||||
@@ -284,6 +307,10 @@ export const routeTree = rootRoute
|
||||
"filePath": "bookmarks/$bookmarkId.tsx",
|
||||
"parent": "/bookmarks"
|
||||
},
|
||||
"/collections/$collectionId": {
|
||||
"filePath": "collections/$collectionId.tsx",
|
||||
"parent": "/collections"
|
||||
},
|
||||
"/bookmarks/": {
|
||||
"filePath": "bookmarks/index.tsx",
|
||||
"parent": "/bookmarks"
|
||||
|
43
packages/web/src/app/-side-nav.tsx
Normal file
43
packages/web/src/app/-side-nav.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Link, useRouterState, useNavigate } from "@tanstack/react-router"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||
|
||||
function SideNav({ enableMnemonics }: { enableMnemonics: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useMnemonics(
|
||||
{
|
||||
b: () => navigate({ to: "/bookmarks" }),
|
||||
c: () => navigate({ to: "/collections" }),
|
||||
},
|
||||
{ ignore: () => !enableMnemonics },
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className="mb-4 md:mb-0 md:mr-24 text-start">
|
||||
<nav className="mb-8">
|
||||
<ul className="flex flex-col space-y-2">
|
||||
<NavItem to="/bookmarks" label="BOOKMARKS" />
|
||||
<NavItem to="/collections" label="COLLECTIONS" />
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({ to, label }: { to: string; label: string }) {
|
||||
const currentPath = useRouterState({ select: (state) => state.location.pathname })
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link to={to}>
|
||||
<span className="font-bold">[{currentPath === to ? "•" : " "}]</span>{" "}
|
||||
<span className={currentPath === to ? "font-bold" : ""}>
|
||||
<span className="underline">{label.charAt(0)}</span>
|
||||
{label.substring(1)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export { SideNav }
|
@@ -1,12 +1,13 @@
|
||||
import type { Bookmark } from "@markone/core"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import clsx from "clsx"
|
||||
import { memo, useCallback } from "react"
|
||||
import { memo, useCallback, useRef } from "react"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { useBookmarkTags } from "~/bookmark/api"
|
||||
import { Button } from "~/components/button"
|
||||
import { List } from "~/components/list"
|
||||
import { DialogKind, useBookmarkPageStore } from "./-store"
|
||||
import { List, type ListRef } from "~/components/list"
|
||||
import { DialogKind, ActionBarContentKind, useBookmarkPageStore } from "./-store"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||
|
||||
export enum BookmarkListItemAction {
|
||||
Open = "Open",
|
||||
@@ -129,6 +130,7 @@ function BookmarkList({
|
||||
onItemAction,
|
||||
className,
|
||||
}: BookmarkListProps) {
|
||||
const listRef = useRef<ListRef<Bookmark>>(null)
|
||||
const handleSelect = useCallback(
|
||||
(bookmark: Bookmark) => {
|
||||
onSelectionChange?.(bookmark)
|
||||
@@ -136,12 +138,32 @@ function BookmarkList({
|
||||
[onSelectionChange],
|
||||
)
|
||||
|
||||
const handleExpand = useCallback((bookmark: Bookmark) => {
|
||||
// No-op since expansion is handled by the List component
|
||||
}, [])
|
||||
useMnemonics(
|
||||
{
|
||||
e: () => {
|
||||
const selectedBookmark = listRef.current?.selectedItem
|
||||
if (selectedBookmark) {
|
||||
onItemAction(selectedBookmark, BookmarkListItemAction.Edit)
|
||||
}
|
||||
},
|
||||
d: () => {
|
||||
const selectedBookmark = listRef.current?.selectedItem
|
||||
if (selectedBookmark) {
|
||||
onItemAction(selectedBookmark, BookmarkListItemAction.Delete)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
ignore: useCallback(() => {
|
||||
const state = useBookmarkPageStore.getState()
|
||||
return state.dialog.kind !== DialogKind.None || state.actionBarContent.kind === ActionBarContentKind.SearchBar
|
||||
}, []),
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<List
|
||||
ref={listRef}
|
||||
items={bookmarks}
|
||||
selectedItemId={selectedBookmarkId}
|
||||
alwaysExpandItem={alwaysExpandItem}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useId, useRef, useState } from "react"
|
||||
import { ApiErrorCode, BadRequestError } from "~/api"
|
||||
import { useCreateBookmark } from "~/bookmark/api"
|
||||
import { useCreateBookmark, useTags } from "~/bookmark/api"
|
||||
import { Button } from "~/components/button"
|
||||
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
||||
import { FormField } from "~/components/form-field"
|
||||
@@ -13,6 +13,7 @@ import { DialogKind, useBookmarkPageStore } from "../-store"
|
||||
function AddBookmarkDialog() {
|
||||
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
|
||||
const createBookmarkMutation = useCreateBookmark()
|
||||
const { data: tags, status: tagsStatus } = useTags()
|
||||
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||
const formId = useId()
|
||||
const linkInputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -21,7 +22,7 @@ function AddBookmarkDialog() {
|
||||
useMnemonics(
|
||||
{
|
||||
c: () => {
|
||||
if (linkInputRef.current !== document.activeElement && tagsInputRef.current !== document.activeElement) {
|
||||
if (linkInputRef.current !== document.activeElement && tagsInputRef.current?.input !== document.activeElement) {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
@@ -112,7 +113,13 @@ function AddBookmarkDialog() {
|
||||
className="w-full"
|
||||
labelClassName="bg-stone-300 dark:bg-stone-800"
|
||||
/>
|
||||
<TagsInput ref={tagsInputRef} />
|
||||
{tagsStatus === "success" ? (
|
||||
<TagsInput ref={tagsInputRef} tags={tags} />
|
||||
) : (
|
||||
<p>
|
||||
Loading tags <LoadingSpinner />
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</DialogBody>
|
||||
<DialogActionRow>
|
||||
|
@@ -1,78 +1,151 @@
|
||||
import type { Bookmark, Tag } from "@markone/core"
|
||||
import { useEffect, useId, useImperativeHandle, useRef } from "react"
|
||||
import { useBookmarkTags, useUpdateBookmark } from "~/bookmark/api"
|
||||
import type { Bookmark } from "@markone/core"
|
||||
import { useEffect, useId, useRef, useState } from "react"
|
||||
import { ApiErrorCode, BadRequestError } from "~/api"
|
||||
import { useBookmarkTags, useTags, useUpdateBookmark } from "~/bookmark/api"
|
||||
import { Button } from "~/components/button"
|
||||
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
||||
import { FormField } from "~/components/form-field"
|
||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||
import { Message, MessageVariant } from "~/components/message.tsx"
|
||||
import { TagsInput, type TagsInputRef } from "~/components/tags-input"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics.ts"
|
||||
import { useBookmarkPageStore } from "../-store"
|
||||
|
||||
interface EditFormRef {
|
||||
form: HTMLFormElement | null
|
||||
titleInput: HTMLInputElement | null
|
||||
tagsInput: TagsInputRef | null
|
||||
}
|
||||
import { Message, MessageVariant } from "~/components/message"
|
||||
import { TagsInput, type TagsInputRef } from "~/components/tags-input.tsx"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||
import { DialogKind, useBookmarkPageStore } from "../-store"
|
||||
|
||||
function EditBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
|
||||
const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
|
||||
const { data: tags, status } = useBookmarkTags(bookmark)
|
||||
const editFormId = useId()
|
||||
const editFormRef = useRef<EditFormRef | null>(null)
|
||||
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
|
||||
const updateBookmarkMutation = useUpdateBookmark(bookmark)
|
||||
const { data: tags, status: tagsStatus } = useTags()
|
||||
const { data: bookmarkTags, status: bookmarkTagsStatus } = useBookmarkTags(bookmark)
|
||||
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||
const formId = useId()
|
||||
const linkInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const tagsInputRef = useRef<TagsInputRef | null>(null)
|
||||
|
||||
useMnemonics(
|
||||
{
|
||||
s: () => {
|
||||
if (
|
||||
editFormRef.current &&
|
||||
editFormRef.current.titleInput !== document.activeElement &&
|
||||
editFormRef.current.tagsInput?.input !== document.activeElement
|
||||
) {
|
||||
editFormRef.current.form?.requestSubmit()
|
||||
}
|
||||
},
|
||||
c: () => {
|
||||
if (
|
||||
editFormRef.current?.titleInput !== document.activeElement &&
|
||||
editFormRef.current?.tagsInput?.input !== document.activeElement
|
||||
) {
|
||||
closeDialog()
|
||||
if (linkInputRef.current !== document.activeElement && tagsInputRef.current?.input !== document.activeElement) {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
Escape: () => {
|
||||
editFormRef.current?.titleInput?.blur()
|
||||
editFormRef.current?.tagsInput?.input?.blur()
|
||||
linkInputRef.current?.blur()
|
||||
tagsInputRef.current?.input?.blur()
|
||||
},
|
||||
},
|
||||
{ ignore: () => false },
|
||||
)
|
||||
|
||||
function content() {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return (
|
||||
<p>
|
||||
Loading <LoadingSpinner />
|
||||
</p>
|
||||
)
|
||||
case "success":
|
||||
return <EditForm ref={editFormRef} formId={editFormId} bookmark={bookmark} tags={tags} />
|
||||
case "error":
|
||||
return null
|
||||
// when using autoFocus, it also captures the "a" mnemonic
|
||||
// which appends the "a" to the input
|
||||
// this is to prevent the "a" mnemonic to be captured as input to the text box
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (linkInputRef.current) {
|
||||
linkInputRef.current.focus()
|
||||
}
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
if (tagsInputRef.current) {
|
||||
event.preventDefault()
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const title = formData.get("title")
|
||||
if (title && typeof title === "string") {
|
||||
try {
|
||||
await updateBookmarkMutation.mutateAsync({
|
||||
title,
|
||||
tags: tagsInputRef.current.tags,
|
||||
})
|
||||
setActiveDialog({ kind: DialogKind.None })
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError && error.code === ApiErrorCode.LinkUnreachable) {
|
||||
setIsWebsiteUnreachable(true)
|
||||
} else {
|
||||
setIsWebsiteUnreachable(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setActiveDialog({ kind: DialogKind.None })
|
||||
}
|
||||
|
||||
function message() {
|
||||
if (updateBookmarkMutation.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 (updateBookmarkMutation.status === "error") {
|
||||
return (
|
||||
<Message variant={MessageVariant.Error} className="px-4">
|
||||
An error occurred when saving bookmark
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function content() {
|
||||
if (tagsStatus === "pending" || bookmarkTagsStatus === "pending") {
|
||||
return (
|
||||
<p>
|
||||
Loading <LoadingSpinner />
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (tagsStatus === "error" || bookmarkTagsStatus === "error") {
|
||||
return (
|
||||
<Message variant={MessageVariant.Error} className="px-4">
|
||||
An error occurred when loading tags
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
if (tags && bookmarkTags) {
|
||||
return (
|
||||
<form id={formId} className="px-8" onSubmit={onSubmit}>
|
||||
<FormField
|
||||
ref={linkInputRef}
|
||||
type="text"
|
||||
name="title"
|
||||
label="TITLE"
|
||||
className="w-full"
|
||||
labelClassName="bg-stone-300 dark:bg-stone-800"
|
||||
defaultValue={bookmark.title}
|
||||
/>
|
||||
<TagsInput ref={tagsInputRef} tags={tags} initialSelections={bookmarkTags} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTitle>EDIT BOOKMARK</DialogTitle>
|
||||
<DialogBody>{content()}</DialogBody>
|
||||
<DialogBody>
|
||||
{message()}
|
||||
{content()}
|
||||
</DialogBody>
|
||||
<DialogActionRow>
|
||||
<Button type="submit" form={editFormId} disabled={status !== "success"}>
|
||||
<span className="underline">S</span>AVE
|
||||
<Button type="submit" disabled={updateBookmarkMutation.isPending} form={formId}>
|
||||
SAVE
|
||||
</Button>
|
||||
<Button disabled={status !== "success"} onClick={closeDialog}>
|
||||
<Button type="button" disabled={updateBookmarkMutation.isPending} onClick={cancel}>
|
||||
<span className="underline">C</span>ANCEL
|
||||
</Button>
|
||||
</DialogActionRow>
|
||||
@@ -80,83 +153,4 @@ function EditBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
|
||||
)
|
||||
}
|
||||
|
||||
function EditForm({
|
||||
ref,
|
||||
formId,
|
||||
bookmark,
|
||||
tags,
|
||||
}: { ref: React.Ref<EditFormRef>; formId: string; bookmark: Bookmark; tags: Tag[] }) {
|
||||
const formRef = useRef<HTMLFormElement | null>(null)
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const tagsInputRef = useRef<TagsInputRef>(null)
|
||||
const updateBookmarkMutation = useUpdateBookmark(bookmark)
|
||||
const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
form: formRef.current,
|
||||
titleInput: titleInputRef.current,
|
||||
tagsInput: tagsInputRef.current,
|
||||
}))
|
||||
|
||||
// when using autoFocus, it also captures the "e" mnemonic
|
||||
// which appends the "e" to the input
|
||||
// this is to prevent the "e" mnemonic to be captured as input to the text box
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
titleInputRef.current?.focus()
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
if (tagsInputRef.current) {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
const title = form.get("title")
|
||||
const tags = tagsInputRef.current.tags
|
||||
if (title && typeof title === "string") {
|
||||
try {
|
||||
await updateBookmarkMutation.mutateAsync({
|
||||
title,
|
||||
tags,
|
||||
})
|
||||
closeDialog()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function message() {
|
||||
switch (updateBookmarkMutation.status) {
|
||||
case "pending":
|
||||
return (
|
||||
<p>
|
||||
Saving changes <LoadingSpinner />
|
||||
</p>
|
||||
)
|
||||
case "error":
|
||||
return <Message variant={MessageVariant.Error}>Error updating the bookmark!</Message>
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{message()}
|
||||
<form ref={formRef} id={formId} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
name="title"
|
||||
label="TITLE"
|
||||
className="w-full"
|
||||
defaultValue={bookmark.title}
|
||||
labelClassName="bg-stone-300 dark:bg-stone-800"
|
||||
/>
|
||||
<TagsInput ref={tagsInputRef} initialValue={tags.map((tag) => tag.name).join(" ")} />
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { EditBookmarkDialog }
|
||||
|
@@ -59,7 +59,6 @@ interface BookmarkPageState {
|
||||
bookmarkToBeEdited: Bookmark | null
|
||||
layoutMode: LayoutMode
|
||||
dialog: DialogData
|
||||
hasDialog: boolean
|
||||
actionBarContent: ActionBarContent
|
||||
statusMessage: string
|
||||
activeWindow: WindowKind
|
||||
@@ -82,10 +81,6 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
||||
statusMessage: "",
|
||||
activeWindow: WindowKind.None,
|
||||
|
||||
get hasDialog(): boolean {
|
||||
return get().dialog.kind !== DialogKind.None
|
||||
},
|
||||
|
||||
setActiveWindow(window: WindowKind) {
|
||||
set({ activeWindow: window })
|
||||
},
|
||||
|
@@ -1,18 +1,19 @@
|
||||
import { autoUpdate, offset, useFloating } from "@floating-ui/react-dom"
|
||||
import type { Tag } from "@markone/core"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"
|
||||
import { memo, useCallback, useEffect, useId, useRef } from "react"
|
||||
import { fetchApi, useAuthenticatedQuery } from "~/api"
|
||||
import { ActionBar } from "~/app/bookmarks/-action-bar.tsx"
|
||||
import { useLogOut } from "~/auth.ts"
|
||||
import { useTags } from "~/bookmark/api.ts"
|
||||
import { Button, LinkButton } from "~/components/button"
|
||||
import { Button } from "~/components/button"
|
||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||
import { Message, MessageVariant } from "~/components/message.tsx"
|
||||
import { useDocumentEvent } from "~/hooks/use-document-event.ts"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics.ts"
|
||||
import { BookmarkList } from "./-bookmark-list"
|
||||
import { ActionBarContentKind, DialogKind, WindowKind, useBookmarkPageStore } from "./-store"
|
||||
import { SideNav } from "~/app/-side-nav"
|
||||
|
||||
export const Route = createFileRoute("/bookmarks/")({
|
||||
component: RouteComponent,
|
||||
@@ -30,12 +31,7 @@ function RouteComponent() {
|
||||
function BookmarkListPane() {
|
||||
return (
|
||||
<div className="flex flex-col py-16 container max-w-3xl md:flex-row 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"> > </span>
|
||||
YOUR BOOKMARKS
|
||||
</h1>
|
||||
</header>
|
||||
<_SideNav />
|
||||
<div className="flex-1">
|
||||
<BookmarkListContainer />
|
||||
</div>
|
||||
@@ -43,6 +39,11 @@ function BookmarkListPane() {
|
||||
)
|
||||
}
|
||||
|
||||
function _SideNav() {
|
||||
const hasDialog = useBookmarkPageStore((state) => state.dialog.kind !== DialogKind.None)
|
||||
return <SideNav enableMnemonics={!hasDialog} />
|
||||
}
|
||||
|
||||
function BookmarkListContainer() {
|
||||
const searchParamsString = new URLSearchParams(Route.useSearch()).toString()
|
||||
const { data: bookmarks, status } = useAuthenticatedQuery(
|
||||
@@ -360,11 +361,6 @@ function AppMenuWindow({ ref, style }: { ref: React.Ref<HTMLDivElement>; style:
|
||||
<p className="bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800 text-center">MENU</p>
|
||||
<div className="p-4">
|
||||
<ul className="space-x-4 flex justify-center">
|
||||
<li>
|
||||
<LinkButton to="/collections">
|
||||
<span className="underline">C</span>OLLECTIONS
|
||||
</LinkButton>
|
||||
</li>
|
||||
<li>
|
||||
<LogOutButton />
|
||||
</li>
|
||||
|
185
packages/web/src/app/collections/$collectionId.tsx
Normal file
185
packages/web/src/app/collections/$collectionId.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { autoUpdate, offset, useFloating } from "@floating-ui/react-dom"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useCallback } from "react"
|
||||
import { ActionBar } from "~/app/bookmarks/-action-bar"
|
||||
import { useLogOut } from "~/auth"
|
||||
import { useCollection, useCollectionBookmarks } from "~/collection/api"
|
||||
import { Button, LinkButton } from "~/components/button"
|
||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||
import { BookmarkList } from "~/app/bookmarks/-bookmark-list"
|
||||
import { DialogKind, WindowKind, useBookmarkPageStore } from "~/app/bookmarks/-store"
|
||||
import type { Collection } from "@markone/core"
|
||||
|
||||
export const Route = createFileRoute("/collections/$collectionId")({
|
||||
component: CollectionDetailPage,
|
||||
})
|
||||
|
||||
function CollectionDetailPage() {
|
||||
return (
|
||||
<main className="w-full flex justify-center">
|
||||
<CollectionDetailPane />
|
||||
<CollectionDetailActionBar className="fixed left-0 right-0 bottom-0" />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionDetailPane() {
|
||||
const { collectionId } = Route.useParams()
|
||||
const { data: collection, status } = useCollection(collectionId)
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p>
|
||||
Loading <LoadingSpinner />
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === "error" || !collection) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p>Error loading collection</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-16 container max-w-3xl md:flex-row 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"> > </span>
|
||||
{collection.name}
|
||||
</h1>
|
||||
<p className="opacity-80 text-sm">{collection.description}</p>
|
||||
</header>
|
||||
<div className="flex-1">
|
||||
<CollectionBookmarkList collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionBookmarkList({ collection }: { collection: Collection }) {
|
||||
const { data: bookmarks, status } = useCollectionBookmarks(collection)
|
||||
const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction)
|
||||
|
||||
switch (status) {
|
||||
case "success":
|
||||
return (
|
||||
<BookmarkList
|
||||
className={bookmarks.length > 0 ? "-mt-2" : ""}
|
||||
alwaysExpandItem={false}
|
||||
bookmarks={bookmarks}
|
||||
onItemAction={handleBookmarkListItemAction}
|
||||
/>
|
||||
)
|
||||
|
||||
case "pending":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<p>
|
||||
Loading <LoadingSpinner />
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||
<p>Error loading bookmarks</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function CollectionDetailActionBar({ className }: { className?: string }) {
|
||||
const activeWindow = useBookmarkPageStore((state) => state.activeWindow)
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
placement: "top",
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [offset(8)],
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionBar ref={refs.setReference} className={className}>
|
||||
<ActionButtons />
|
||||
</ActionBar>
|
||||
{activeWindow === WindowKind.AppMenu && <AppMenuWindow ref={refs.setFloating} style={floatingStyles} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButtons() {
|
||||
const setActiveWindow = useBookmarkPageStore((state) => state.setActiveWindow)
|
||||
const activeWindow = useBookmarkPageStore((state) => state.activeWindow)
|
||||
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||
|
||||
useMnemonics(
|
||||
{
|
||||
a: addBookmark,
|
||||
},
|
||||
{ ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
|
||||
)
|
||||
|
||||
function addBookmark() {
|
||||
setActiveDialog({ kind: DialogKind.AddBookmark })
|
||||
}
|
||||
|
||||
function toggleAppMenu() {
|
||||
setActiveWindow(activeWindow === WindowKind.AppMenu ? WindowKind.None : WindowKind.AppMenu)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center space-x-4">
|
||||
<Button onClick={addBookmark}>
|
||||
<span className="underline">A</span>DD BOOKMARK
|
||||
</Button>
|
||||
<Button onClick={toggleAppMenu}>⋯</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppMenuWindow({ ref, style }: { ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
|
||||
return (
|
||||
<div ref={ref} style={style} className="border w-full md:w-100">
|
||||
<p className="bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800 text-center">MENU</p>
|
||||
<div className="p-4">
|
||||
<ul className="space-x-4 space-y-2 flex flex-wrap justify-center">
|
||||
<li>
|
||||
<LinkButton to="/bookmarks">
|
||||
<span className="underline">B</span>OOKMARKS
|
||||
</LinkButton>
|
||||
</li>
|
||||
<li>
|
||||
<LinkButton to="/collections">
|
||||
<span className="underline">C</span>OLLECTIONS
|
||||
</LinkButton>
|
||||
</li>
|
||||
<li>
|
||||
<LogOutButton />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
@@ -1,8 +1,8 @@
|
||||
import type { Collection } from "@markone/core"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { clsx } from "clsx"
|
||||
import { memo } from "react"
|
||||
import { Button } from "~/components/button"
|
||||
import { Link } from "~/components/link"
|
||||
import { List } from "~/components/list"
|
||||
|
||||
export enum CollectionListItemAction {
|
||||
@@ -53,7 +53,9 @@ const CollectionListItem = memo(
|
||||
</button>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="block w-full text-start font-bold">
|
||||
<Link href="#">{collection.name}</Link>
|
||||
<Link to={`/collections/${collection.id}`} className={isSelected ? "underline" : ""}>
|
||||
{collection.name}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="opacity-80 text-sm">{collection.description}</p>
|
||||
{isExpanded ? (
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { useId, useRef, useEffect } from "react"
|
||||
import type { Collection } from "@markone/core"
|
||||
import { useUpdateCollection } from "~/collections/api"
|
||||
import { useUpdateCollection } from "~/collection/api"
|
||||
import { Button } from "~/components/button"
|
||||
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
||||
import { FormField } from "~/components/form-field"
|
||||
|
@@ -11,6 +11,7 @@ import { CollectionList } from "./-collection-list"
|
||||
import { AddCollectionDialog } from "./-dialogs/add-collection-dialog"
|
||||
import { DeleteCollectionDialog } from "./-dialogs/delete-collection-dialog"
|
||||
import { DialogKind, WindowKind, useCollectionPageStore } from "./-store"
|
||||
import { SideNav } from "~/app/-side-nav"
|
||||
|
||||
export const Route = createFileRoute("/collections/")({
|
||||
component: CollectionsPage,
|
||||
@@ -42,12 +43,7 @@ function CollectionsPage() {
|
||||
function CollectionsPane() {
|
||||
return (
|
||||
<div className="flex flex-col py-16 container max-w-3xl md:flex-row 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"> > </span>
|
||||
YOUR COLLECTIONS
|
||||
</h1>
|
||||
</header>
|
||||
<_SideNav />
|
||||
<div className="flex-1">
|
||||
<CollectionsContainer />
|
||||
</div>
|
||||
@@ -55,6 +51,11 @@ function CollectionsPane() {
|
||||
)
|
||||
}
|
||||
|
||||
function _SideNav() {
|
||||
const hasDialog = useCollectionPageStore((state) => state.dialog.kind !== DialogKind.None)
|
||||
return <SideNav enableMnemonics={!hasDialog} />
|
||||
}
|
||||
|
||||
function CollectionsContainer() {
|
||||
const { data: collections, status } = useCollections()
|
||||
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
|
||||
|
Reference in New Issue
Block a user