implement bookmark search
This commit is contained in:
@@ -3,4 +3,6 @@ interface Tag {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type { Tag }
|
||||
type TagId = Tag["id"]
|
||||
|
||||
export type { Tag, TagId }
|
||||
|
@@ -17,6 +17,7 @@
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"arktype": "^2.1.20",
|
||||
"flexsearch": "^0.8.204",
|
||||
"jsdom": "^26.1.0",
|
||||
"uid-safe": "^2.1.5",
|
||||
"ulid": "^3.0.0"
|
||||
|
@@ -2,6 +2,7 @@ import { DEMO_USER, type User } from "@markone/core/user"
|
||||
import { type } from "arktype"
|
||||
import dayjs from "dayjs"
|
||||
import { ulid } from "ulid"
|
||||
import { initializeSearchIndexForUser } from "~/bookmark/search.js"
|
||||
import { db } from "~/database.ts"
|
||||
import { HttpError } from "~/error.ts"
|
||||
import { httpHandler } from "~/http-handler.ts"
|
||||
@@ -151,7 +152,8 @@ async function login(request: Bun.BunRequest<"/api/login">) {
|
||||
const session = verifySession(request.cookies)
|
||||
if (session) {
|
||||
extendSession(session)
|
||||
return Response.json(session, { status: 200 })
|
||||
initializeSearchIndexForUser(session.user)
|
||||
return Response.json(session.user, { status: 200 })
|
||||
}
|
||||
|
||||
// then, check if there is a valid auth token
|
||||
@@ -159,6 +161,7 @@ async function login(request: Bun.BunRequest<"/api/login">) {
|
||||
const foundUser = await verifyAuthToken(request.cookies)
|
||||
if (foundUser) {
|
||||
await createSessionForUser(foundUser, request.cookies)
|
||||
initializeSearchIndexForUser(foundUser)
|
||||
return Response.json(foundUser, { status: 200 })
|
||||
}
|
||||
}
|
||||
@@ -196,6 +199,8 @@ async function login(request: Bun.BunRequest<"/api/login">) {
|
||||
rememberLoginForUser(user, request.cookies)
|
||||
}
|
||||
|
||||
initializeSearchIndexForUser(user)
|
||||
|
||||
return Response.json(user, { status: 200 })
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ import type { Bookmark, BookmarkId, Tag, TaggedBookmark } from "@markone/core"
|
||||
import type { User } from "@markone/core/user"
|
||||
import { Readability } from "@mozilla/readability"
|
||||
import { JSDOM } from "jsdom"
|
||||
import { addToSearchIndex, updateSearchIndex } from "~/bookmark/search.js"
|
||||
import { db } from "~/database.ts"
|
||||
import { findTagsByNames, insertTags } from "~/tag/tag.js"
|
||||
import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts"
|
||||
@@ -42,6 +43,7 @@ VALUES ($id, $userId, $title, $url, $mimeType, $content)
|
||||
mimeType: cachedContent?.mimeType || null,
|
||||
content: cachedContent?.data ?? null,
|
||||
})
|
||||
addToSearchIndex(bookmark, user)
|
||||
}
|
||||
|
||||
function findBookmarkCachedContent(id: string, user: User): Buffer | null {
|
||||
@@ -52,7 +54,19 @@ function findBookmarkCachedContent(id: string, user: User): Buffer | null {
|
||||
return row?.content ?? null
|
||||
}
|
||||
|
||||
function findBookmark(id: string, user: User): Bookmark | null {
|
||||
function findUserBookmarks({ limit = -1, skip = -1 }: { limit: number; skip: number }, user: User): Bookmark[] {
|
||||
let query = "SELECT id, title, url FROM bookmarks WHERE user_id = $userId ORDER BY id DESC"
|
||||
if (limit >= 0) {
|
||||
query += ` LIMIT ${limit}`
|
||||
}
|
||||
if (skip >= 0) {
|
||||
query += ` SKIP ${skip}`
|
||||
}
|
||||
|
||||
return db.query<Bookmark, { userId: string }>(query).all({ userId: user.id })
|
||||
}
|
||||
|
||||
function findBookmark(id: string, user: User): TaggedBookmark | null {
|
||||
const bookmarkQuery = db.query<TaggedBookmark, { id: string; userId: string }>(
|
||||
"SELECT id, title, url FROM bookmarks WHERE id = $id AND user_id = $userId",
|
||||
)
|
||||
@@ -66,13 +80,67 @@ function findBookmark(id: string, user: User): Bookmark | null {
|
||||
INNER JOIN bookmark_tags
|
||||
ON bookmark_tags.tag_id = tags.id AND bookmark_tags.bookmark_id = $bookmarkId
|
||||
`)
|
||||
const tags = tagsQuery.all({ bookmarkId: id })
|
||||
|
||||
bookmark.tags = tags
|
||||
bookmark.tags = tagsQuery.all({ bookmarkId: id })
|
||||
|
||||
return bookmark
|
||||
}
|
||||
|
||||
function findBookmarks(
|
||||
{ ids, tagIds, limit, skip }: { ids: BookmarkId[]; tagIds: Tag["id"][]; limit?: number; skip?: number },
|
||||
user: User,
|
||||
) {
|
||||
type ParamType = (string | number)[]
|
||||
|
||||
if (tagIds.length === 0) {
|
||||
let query = `SELECT id, title, url FROM bookmarks WHERE user_id = ?${ids.length > 0 ? `AND id IN (${Array(ids.length).fill("?").join(",")})` : ""}`
|
||||
if (limit) {
|
||||
query += " LIMIT ?"
|
||||
}
|
||||
if (skip) {
|
||||
query += " SKIP ?"
|
||||
}
|
||||
|
||||
const params: (string | number)[] = [user.id, ...ids]
|
||||
if (limit) {
|
||||
params.push(limit)
|
||||
}
|
||||
if (skip) {
|
||||
params.push(skip)
|
||||
}
|
||||
|
||||
return db.query<Bookmark, ParamType>(query).all(...params)
|
||||
}
|
||||
|
||||
const params: ParamType = [user.id, ...ids, ...tagIds]
|
||||
if (limit) {
|
||||
params.push(limit)
|
||||
}
|
||||
if (skip) {
|
||||
params.push(skip)
|
||||
}
|
||||
|
||||
return db
|
||||
.query<Bookmark, ParamType>(`
|
||||
SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks
|
||||
INNER JOIN bookmark_tags
|
||||
ON bookmark_tags.bookmark_id = bookmarks.id
|
||||
WHERE bookmarks.user_id = ?
|
||||
${ids.length > 0 ? `AND bookmarks.id IN (${Array(ids.length).fill("?").join(",")})` : ""}
|
||||
AND bookmark_tags.tag_id IN (${Array(tagIds.length).fill("?").join(",")})
|
||||
ORDER BY bookmarks.id DESC
|
||||
${limit ? "LIMIT ?" : ""} ${skip ? "OFFSET ?" : ""}
|
||||
`)
|
||||
.all(...params)
|
||||
}
|
||||
|
||||
function findBookmarksByIds(ids: BookmarkId[], user: User) {
|
||||
return db
|
||||
.query<Bookmark, string[]>(
|
||||
`SELECT id, title, url FROM bookmarks WHERE user_id = ? AND id IN (${Array(ids.length).fill("?").join(",")})`,
|
||||
)
|
||||
.all(user.id, ...ids)
|
||||
}
|
||||
|
||||
function updateBookmarkTitle(bookmark: Bookmark, newTitle: string, user: User) {
|
||||
const query = db.query("UPDATE bookmarks SET title = $title WHERE id = $id AND user_id = $userId")
|
||||
query.run({
|
||||
@@ -80,6 +148,7 @@ function updateBookmarkTitle(bookmark: Bookmark, newTitle: string, user: User) {
|
||||
id: bookmark.id,
|
||||
userId: user.id,
|
||||
})
|
||||
updateSearchIndex({ ...bookmark, title: newTitle }, user)
|
||||
}
|
||||
|
||||
function deleteBookmark(id: BookmarkId, user: User) {
|
||||
@@ -207,7 +276,10 @@ export {
|
||||
updateBookmarkTitle,
|
||||
deleteBookmark,
|
||||
findBookmark,
|
||||
findBookmarks,
|
||||
findUserBookmarks,
|
||||
findBookmarkCachedContent,
|
||||
findBookmarksByIds,
|
||||
cacheContent,
|
||||
assignTagsToBookmark,
|
||||
findBookmarkTags,
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import type { Bookmark, Tag, TaggedBookmark } from "@markone/core"
|
||||
import type { Bookmark, Tag, TagId, TaggedBookmark } from "@markone/core"
|
||||
import { DEMO_USER } from "@markone/core/user"
|
||||
import { type } from "arktype"
|
||||
import { ulid } from "ulid"
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
findBookmark,
|
||||
findBookmarkCachedContent,
|
||||
findBookmarkTags,
|
||||
findBookmarks,
|
||||
insertBookmark,
|
||||
updateBookmarkTags,
|
||||
updateBookmarkTitle,
|
||||
} from "./bookmark.ts"
|
||||
|
||||
import { fuzzySearchBookmarks } from "~/bookmark/search.js"
|
||||
import { insertTags } from "~/tag/tag.js"
|
||||
|
||||
const BOOKMARK_PAGINATION_LIMIT = 100
|
||||
@@ -27,6 +29,7 @@ const ListUserBookmarksParams = type({
|
||||
limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT],
|
||||
skip: ["number", "=", 0],
|
||||
"tags?": "string",
|
||||
"q?": "string",
|
||||
})
|
||||
|
||||
const AddBookmarkRequestBody = type({
|
||||
@@ -53,38 +56,23 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user
|
||||
}
|
||||
|
||||
let results: Bookmark[]
|
||||
if (queryParams.tags) {
|
||||
const tagNames = queryParams.tags.split(",")
|
||||
|
||||
const tagIdsQuery = db.query<{ id: string }, string[]>(
|
||||
`SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`,
|
||||
)
|
||||
|
||||
const tagIds = tagIdsQuery.all(...tagNames).map(({ id }) => id)
|
||||
|
||||
const query = db.query(`
|
||||
SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks
|
||||
INNER JOIN bookmark_tags
|
||||
ON bookmark_tags.bookmark_id = bookmarks.id
|
||||
WHERE bookmarks.user_id = ? AND bookmark_tags.tag_id IN (${Array(tagIds.length).fill("?").join(",")})
|
||||
ORDER BY bookmarks.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`)
|
||||
|
||||
results = query.all(...[user.id, ...tagIds, queryParams.limit, queryParams.skip]) as Bookmark[]
|
||||
if (queryParams.q || queryParams.tags) {
|
||||
let tagIds: TagId[] = []
|
||||
if (queryParams.tags) {
|
||||
const tagNames = queryParams.tags.split(",")
|
||||
const tagIdsQuery = db.query<{ id: string }, string[]>(
|
||||
`SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`,
|
||||
)
|
||||
tagIds = tagIdsQuery.all(...tagNames).map(({ id }) => id)
|
||||
}
|
||||
if (queryParams.q) {
|
||||
results = fuzzySearchBookmarks({ searchTerm: queryParams.q, tagIds }, user)
|
||||
} else {
|
||||
results = findBookmarks({ ids: [], tagIds }, user)
|
||||
}
|
||||
} else {
|
||||
const query = db.query(`
|
||||
SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks
|
||||
WHERE bookmarks.user_id = $userId
|
||||
ORDER BY bookmarks.id DESC
|
||||
LIMIT $limit OFFSET $skip
|
||||
`)
|
||||
|
||||
results = query.all({
|
||||
userId: user.id,
|
||||
limit: queryParams.limit,
|
||||
skip: queryParams.skip,
|
||||
}) as Bookmark[]
|
||||
results = findBookmarks({ ids: [], tagIds: [], limit: queryParams.limit, skip: queryParams.skip }, user)
|
||||
}
|
||||
|
||||
return Response.json(results, { status: 200 })
|
||||
|
39
packages/server/src/bookmark/search.ts
Normal file
39
packages/server/src/bookmark/search.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Bookmark, BookmarkId, TagId, User } from "@markone/core"
|
||||
import { Index } from "flexsearch"
|
||||
import { findBookmarks, findUserBookmarks } from "./bookmark.js"
|
||||
|
||||
const indices = new Map<string, Index>()
|
||||
|
||||
function initializeSearchIndexForUser(user: User) {
|
||||
const bookmarks = findUserBookmarks({ limit: -1, skip: -1 }, user)
|
||||
const index = new Index({ tokenize: "forward" })
|
||||
for (const bookmark of bookmarks) {
|
||||
index.add(bookmark.id, bookmark.title)
|
||||
}
|
||||
indices.set(user.id, index)
|
||||
return index
|
||||
}
|
||||
|
||||
function addToSearchIndex(bookmark: Bookmark, user: User) {
|
||||
const index = indices.get(user.id)
|
||||
index?.add(bookmark.id, bookmark.title)
|
||||
}
|
||||
|
||||
function updateSearchIndex(updatedBookmark: Bookmark, user: User) {
|
||||
const index = indices.get(user.id)
|
||||
index?.update(updatedBookmark.id, updatedBookmark.title)
|
||||
}
|
||||
|
||||
function fuzzySearchBookmarks({ searchTerm, tagIds }: { searchTerm: string; tagIds: TagId[] }, user: User): Bookmark[] {
|
||||
let index = indices.get(user.id)
|
||||
if (!index) {
|
||||
index = initializeSearchIndexForUser(user)
|
||||
}
|
||||
const bookmarkIds = index.search(searchTerm) as BookmarkId[]
|
||||
if (bookmarkIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
return findBookmarks({ ids: bookmarkIds, tagIds }, user)
|
||||
}
|
||||
|
||||
export { initializeSearchIndexForUser, addToSearchIndex, updateSearchIndex, fuzzySearchBookmarks }
|
@@ -14,6 +14,7 @@
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"@tanstack/react-query": "^5.75.2",
|
||||
"@tanstack/react-router": "^1.119.0",
|
||||
"arktype": "^2.1.20",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"jotai": "^2.12.4",
|
||||
|
@@ -1,16 +1,30 @@
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router"
|
||||
import { type } from "arktype"
|
||||
import { useEffect } from "react"
|
||||
import { AddBookmarkDialog } from "./bookmarks/-dialogs/add-bookmark-dialog"
|
||||
import { DeleteBookmarkDialog } from "./bookmarks/-dialogs/delete-bookmark-dialog"
|
||||
import { EditBookmarkDialog } from "./bookmarks/-dialogs/edit-bookmark-dialog"
|
||||
import { DialogKind, LayoutMode, useBookmarkPageStore } from "./bookmarks/-store"
|
||||
import { ActionBarContentKind, DialogKind, LayoutMode, useBookmarkPageStore } from "./bookmarks/-store"
|
||||
|
||||
const bookmarkSearchParams = type({
|
||||
"q?": "string",
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/bookmarks")({
|
||||
component: RouteComponent,
|
||||
validateSearch: bookmarkSearchParams,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { q } = Route.useSearch()
|
||||
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
|
||||
const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent)
|
||||
|
||||
useEffect(() => {
|
||||
if (q) {
|
||||
setActionBarContent({ kind: ActionBarContentKind.SearchBar })
|
||||
}
|
||||
}, [q, setActionBarContent])
|
||||
|
||||
useEffect(() => {
|
||||
function mediaQueryListener(this: MediaQueryList) {
|
||||
|
@@ -8,9 +8,9 @@ import { useBookmark } from "~/bookmark/api"
|
||||
import { Button, LinkButton } from "~/components/button"
|
||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||
import { ActionBar, BookmarkListActionBar } from "./-action-bar"
|
||||
import { ActionBar } from "./-action-bar"
|
||||
import { BookmarkList } from "./-bookmark-list"
|
||||
import { LayoutMode, useBookmarkPageStore } from "./-store"
|
||||
import { DialogKind, LayoutMode, useBookmarkPageStore } from "./-store"
|
||||
|
||||
export const Route = createFileRoute("/bookmarks/$bookmarkId")({
|
||||
component: RouteComponent,
|
||||
@@ -72,7 +72,7 @@ function BookmarkListSidebar() {
|
||||
</h1>
|
||||
</header>
|
||||
<BookmarkListContainer />
|
||||
<BookmarkListActionBar className="absolute bottom-0 left-0 right-0" />
|
||||
<BookmarkListActionBar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -116,6 +116,29 @@ function BookmarkListContainer() {
|
||||
}
|
||||
}
|
||||
|
||||
function BookmarkListActionBar() {
|
||||
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||
|
||||
function addBookmark() {
|
||||
setActiveDialog({ kind: DialogKind.AddBookmark })
|
||||
}
|
||||
|
||||
useMnemonics(
|
||||
{
|
||||
a: addBookmark,
|
||||
},
|
||||
{ ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
|
||||
)
|
||||
|
||||
return (
|
||||
<ActionBar className="absolute bottom-0 left-0 right-0">
|
||||
<Button onClick={addBookmark}>
|
||||
<span className="underline">A</span>DD
|
||||
</Button>
|
||||
</ActionBar>
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkPreview() {
|
||||
const { bookmarkId } = Route.useParams()
|
||||
const { data: previewHtml, status: previewQueryStatus } = useAuthenticatedQuery(
|
||||
|
@@ -1,10 +1,4 @@
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useCallback } from "react"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { useLogOut } from "~/auth"
|
||||
import { Button } from "~/components/button"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||
import { DialogKind, useBookmarkPageStore } from "./-store"
|
||||
|
||||
function ActionBar({
|
||||
ref,
|
||||
@@ -24,54 +18,4 @@ function ActionBar({
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkListActionBar({ className }: { className?: string }) {
|
||||
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||
const statusMessage = useBookmarkPageStore((state) => state.statusMessage)
|
||||
|
||||
useMnemonics(
|
||||
{
|
||||
a: addBookmark,
|
||||
},
|
||||
{ ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
|
||||
)
|
||||
|
||||
function addBookmark() {
|
||||
setActiveDialog({ kind: DialogKind.AddBookmark })
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionBar className={className}>
|
||||
{statusMessage ? (
|
||||
<p>{statusMessage}</p>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={addBookmark}>
|
||||
<span className="underline">A</span>DD
|
||||
</Button>
|
||||
<Button>
|
||||
<span className="underline">S</span>EARCH
|
||||
</Button>
|
||||
<LogOutButton />
|
||||
</>
|
||||
)}
|
||||
</ActionBar>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export { ActionBar, BookmarkListActionBar }
|
||||
export { ActionBar }
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { Bookmark } from "@markone/core"
|
||||
import type { Bookmark } from "@markone/core"
|
||||
import { useDeleteBookmark } from "~/bookmark/api"
|
||||
import { Button } from "~/components/button"
|
||||
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||
import { DialogKind, useBookmarkPageStore } from "../-store"
|
||||
import { useBookmarkPageStore } from "../-store"
|
||||
|
||||
function DeleteBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
|
||||
const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
|
||||
|
@@ -14,23 +14,36 @@ enum DialogKind {
|
||||
DeleteBookmark = "DeleteBookmark",
|
||||
EditBookmark = "EditBookmark",
|
||||
}
|
||||
|
||||
interface NoDialogData {
|
||||
kind: DialogKind.None | DialogKind.AddBookmark
|
||||
}
|
||||
|
||||
interface DeleteBookmarkDialogData {
|
||||
kind: DialogKind.DeleteBookmark
|
||||
data: Bookmark
|
||||
}
|
||||
|
||||
interface EditBookmarkDialogData {
|
||||
kind: DialogKind.EditBookmark
|
||||
data: Bookmark
|
||||
}
|
||||
|
||||
type DialogData = NoDialogData | DeleteBookmarkDialogData | EditBookmarkDialogData
|
||||
|
||||
enum ActionBarContentKind {
|
||||
Normal = "Normal",
|
||||
StatusMessage = "StatusMessage",
|
||||
SearchBar = "SearchBar",
|
||||
}
|
||||
interface Normal {
|
||||
kind: ActionBarContentKind.Normal
|
||||
}
|
||||
interface StatusMessage {
|
||||
kind: ActionBarContentKind.StatusMessage
|
||||
message: string
|
||||
}
|
||||
interface SearchBar {
|
||||
kind: ActionBarContentKind.SearchBar
|
||||
}
|
||||
type ActionBarContent = Normal | StatusMessage | SearchBar
|
||||
|
||||
const STATUS_MESSAGE_DURATION_MS = 2000
|
||||
const NO_DIALOG: DialogData = { kind: DialogKind.None } as const
|
||||
|
||||
@@ -38,9 +51,14 @@ interface BookmarkPageState {
|
||||
bookmarkToBeDeleted: Bookmark | null
|
||||
bookmarkToBeEdited: Bookmark | null
|
||||
layoutMode: LayoutMode
|
||||
dialog: NoDialogData | DeleteBookmarkDialogData | EditBookmarkDialogData
|
||||
dialog: DialogData
|
||||
actionBarContent: ActionBarContent
|
||||
statusMessage: string
|
||||
isSearchBarActive: boolean
|
||||
|
||||
openSearchBar: () => void
|
||||
closeSearchBar: () => void
|
||||
setActionBarContent: (content: ActionBarContent) => void
|
||||
handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
|
||||
setActiveDialog: (dialog: DialogData) => void
|
||||
closeDialog: () => void
|
||||
@@ -53,7 +71,21 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
||||
bookmarkToBeEdited: null,
|
||||
layoutMode: LayoutMode.Popup,
|
||||
dialog: NO_DIALOG,
|
||||
actionBarContent: { kind: ActionBarContentKind.Normal },
|
||||
statusMessage: "",
|
||||
isSearchBarActive: false,
|
||||
|
||||
openSearchBar() {
|
||||
set({ isSearchBarActive: true })
|
||||
},
|
||||
|
||||
closeSearchBar() {
|
||||
set({ isSearchBarActive: false })
|
||||
},
|
||||
|
||||
setActionBarContent(content: ActionBarContent) {
|
||||
set({ actionBarContent: content })
|
||||
},
|
||||
|
||||
async handleBookmarkListItemAction(bookmark: Bookmark, action: BookmarkListItemAction) {
|
||||
switch (action) {
|
||||
@@ -111,5 +143,5 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
||||
},
|
||||
}))
|
||||
|
||||
export { LayoutMode, DialogKind, useBookmarkPageStore }
|
||||
export { LayoutMode, DialogKind, ActionBarContentKind, useBookmarkPageStore }
|
||||
export type { BookmarkPageState }
|
||||
|
@@ -1,9 +1,13 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { BookmarkList } from "./-bookmark-list"
|
||||
import { useBookmarkPageStore } from "./-store"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { 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 { Button } from "~/components/button.tsx"
|
||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||
import { BookmarkListActionBar } from "./-action-bar"
|
||||
import { useMnemonics } from "~/hooks/use-mnemonics.ts"
|
||||
import { BookmarkList } from "./-bookmark-list"
|
||||
import { ActionBarContentKind, DialogKind, useBookmarkPageStore } from "./-store"
|
||||
|
||||
export const Route = createFileRoute("/bookmarks/")({
|
||||
component: RouteComponent,
|
||||
@@ -67,3 +71,177 @@ function BookmarkListContainer() {
|
||||
return <p>error loading bookmarks</p>
|
||||
}
|
||||
}
|
||||
|
||||
function BookmarkListActionBar({ className }: { className?: string }) {
|
||||
const content = useBookmarkPageStore((state) => state.actionBarContent)
|
||||
return (
|
||||
<ActionBar className={className}>
|
||||
{(() => {
|
||||
switch (content.kind) {
|
||||
case ActionBarContentKind.Normal:
|
||||
return <ActionButtons />
|
||||
case ActionBarContentKind.StatusMessage:
|
||||
return (
|
||||
<div className="border-t-1 flex flex-row justify-center py-4 space-x-4">
|
||||
<p>{content.message}</p>
|
||||
</div>
|
||||
)
|
||||
case ActionBarContentKind.SearchBar:
|
||||
return <SearchBar />
|
||||
}
|
||||
})()}
|
||||
</ActionBar>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchBar() {
|
||||
const searchInputId = useId()
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent)
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const navigate = Route.useNavigate()
|
||||
const { q } = Route.useSearch()
|
||||
|
||||
useMnemonics(
|
||||
{
|
||||
Escape: () => {
|
||||
navigate({
|
||||
search: (prevSearch: Record<string, string | undefined>) => ({
|
||||
...prevSearch,
|
||||
tags: undefined,
|
||||
q: undefined,
|
||||
}),
|
||||
})
|
||||
setActionBarContent({ kind: ActionBarContentKind.Normal })
|
||||
},
|
||||
},
|
||||
{ ignore: () => false },
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setInterval(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
function pushSearchQuery(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
const q = event.currentTarget.value
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
navigate({
|
||||
search: (prevSearch: Record<string, string | undefined>) => {
|
||||
if (!q) {
|
||||
return { ...prevSearch, tags: undefined, q: undefined }
|
||||
}
|
||||
|
||||
if (q.startsWith("#")) {
|
||||
const parts = q.split(" ")
|
||||
let searchTermBegin = -1
|
||||
for (let i = 0; i < parts.length; ++i) {
|
||||
if (!parts[i].startsWith("#")) {
|
||||
searchTermBegin = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const tags = (searchTermBegin >= 0 ? parts.slice(0, searchTermBegin) : parts)
|
||||
.map((tag) => tag.substring(1))
|
||||
.join(",")
|
||||
const query = searchTermBegin >= 0 ? parts.slice(searchTermBegin).join(" ") : ""
|
||||
|
||||
if (query) {
|
||||
return { ...prevSearch, tags, q: query }
|
||||
}
|
||||
|
||||
return { ...prevSearch, tags, q: undefined }
|
||||
}
|
||||
|
||||
return { ...prevSearch, tags: undefined, q }
|
||||
},
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function closeSearchBar() {
|
||||
setActionBarContent({ kind: ActionBarContentKind.Normal })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-my-4 w-full flex items-center justify-center">
|
||||
<label className="p-4 bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800" htmlFor={searchInputId}>
|
||||
SEARCH
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={searchInputId}
|
||||
type="text"
|
||||
className="flex-1 text-center outline-0 ring-0"
|
||||
defaultValue={q ?? ""}
|
||||
onChange={pushSearchQuery}
|
||||
/>
|
||||
<Button className="mx-2" onClick={closeSearchBar}>
|
||||
CLOSE
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButtons() {
|
||||
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||
const setActionBarContent = useBookmarkPageStore((state) => state.setActionBarContent)
|
||||
|
||||
useMnemonics(
|
||||
{
|
||||
a: addBookmark,
|
||||
s: openSearchBar,
|
||||
},
|
||||
{ ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
|
||||
)
|
||||
|
||||
function addBookmark() {
|
||||
setActiveDialog({ kind: DialogKind.AddBookmark })
|
||||
}
|
||||
|
||||
function openSearchBar() {
|
||||
setActionBarContent({ kind: ActionBarContentKind.SearchBar })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center space-x-4">
|
||||
<Button onClick={addBookmark}>
|
||||
<span className="underline">A</span>DD
|
||||
</Button>
|
||||
<Button onClick={openSearchBar}>
|
||||
<span className="underline">S</span>EARCH
|
||||
</Button>
|
||||
<LogOutButton />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export { BookmarkListActionBar }
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
function useMnemonics(
|
||||
mnemonicMap: Record<string, (event: KeyboardEvent) => void>,
|
||||
@@ -6,7 +6,7 @@ function useMnemonics(
|
||||
) {
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!ignore(event)) {
|
||||
if (event.key === "Escape" || document.activeElement?.tagName !== "INPUT") {
|
||||
mnemonicMap[event.key]?.(event)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ function useMnemonics(
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [mnemonicMap, ignore])
|
||||
}, [mnemonicMap])
|
||||
}
|
||||
|
||||
export { useMnemonics }
|
||||
|
Reference in New Issue
Block a user