implement bookmark search

This commit is contained in:
2025-05-29 00:11:17 +01:00
parent 347451dbbc
commit d5a3266870
16 changed files with 425 additions and 119 deletions

7
.run/server.ts.run.xml Normal file
View File

@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="server.ts" type="BunRunConfiguration" nameIsGenerated="true">
<option name="program" value="src/server.ts" />
<option name="workingDirectory" value="$PROJECT_DIR$/packages/server" />
<method v="2" />
</configuration>
</component>

View File

@@ -21,6 +21,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",
@@ -43,6 +44,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",
@@ -491,7 +493,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
"@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
@@ -579,7 +581,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
@@ -709,6 +711,8 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"flexsearch": ["flexsearch@0.8.204", "", {}, "sha512-Vh+WUZfUHsVP6w4o5uAkYle8Gz/oEuztSWvpSY3h71AE8ox+goTQ2X5YG4x6VlKKfubkMwhewk8kBTOVKMObHA=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
@@ -1291,8 +1295,6 @@
"regjsparser/jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="],
"server/@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="],
"sharp/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"source-map/whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
@@ -1309,8 +1311,6 @@
"prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"server/@types/bun/bun-types": ["bun-types@1.2.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-tvWMx5vPqbRXgE8WUZI94iS1xAYs8bkqESR9cxBB1Wi+urvfTrF1uzuDgBHFAdO0+d2lmsbG3HmeKMvUyj6pWA=="],
"source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],

View File

@@ -3,4 +3,6 @@ interface Tag {
name: string
}
export type { Tag }
type TagId = Tag["id"]
export type { Tag, TagId }

View File

@@ -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"

View File

@@ -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 })
}

View File

@@ -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,

View File

@@ -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 })

View 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 }

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }