implement edit bookmark dialog

This commit is contained in:
2025-05-25 15:40:16 +01:00
parent 255acfcb32
commit 6afb5dee9a
22 changed files with 630 additions and 127 deletions

View File

@@ -3,6 +3,7 @@
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts",
"./bookmark": "./src/bookmark.ts", "./bookmark": "./src/bookmark.ts",
"./user": "./src/user.ts" "./user": "./src/user.ts"
}, },

View File

@@ -1,17 +1,15 @@
type BookmarkKind = "link" | "placeholder" import type { Tag } from "./tag.ts"
interface Bookmark { interface Bookmark {
id: string id: string
title: string title: string
url: string url: string
tags: BookmarkTag[]
} }
interface BookmarkTag { interface TaggedBookmark extends Bookmark {
id: string tags: Tag[]
name: string
} }
type BookmarkId = Bookmark["id"] type BookmarkId = Bookmark["id"]
export type { Bookmark, BookmarkId, BookmarkKind, BookmarkTag } export type { Bookmark, TaggedBookmark, BookmarkId, Tag }

View File

@@ -0,0 +1,3 @@
export * from "./bookmark.ts"
export * from "./tag.ts"
export * from "./user.ts"

6
packages/core/src/tag.ts Normal file
View File

@@ -0,0 +1,6 @@
interface Tag {
id: string
name: string
}
export type { Tag }

View File

@@ -1,8 +1,9 @@
import type { Bookmark, BookmarkId, BookmarkTag } from "@markone/core/bookmark" import type { Bookmark, BookmarkId, Tag, TaggedBookmark } from "@markone/core"
import type { User } from "@markone/core/user" import type { User } from "@markone/core/user"
import { Readability } from "@mozilla/readability" import { Readability } from "@mozilla/readability"
import { JSDOM } from "jsdom" import { JSDOM } from "jsdom"
import { db } from "~/database.ts" import { db } from "~/database.ts"
import { findTagsByNames, insertTags } from "~/tag/tag.js"
import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts" import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts"
class LinkUnreachable {} class LinkUnreachable {}
@@ -52,7 +53,7 @@ function findBookmarkCachedContent(id: string, user: User): Buffer | null {
} }
function findBookmark(id: string, user: User): Bookmark | null { function findBookmark(id: string, user: User): Bookmark | null {
const bookmarkQuery = db.query<Bookmark, { id: string; userId: string }>( const bookmarkQuery = db.query<TaggedBookmark, { id: string; userId: string }>(
"SELECT id, title, url FROM bookmarks WHERE id = $id AND user_id = $userId", "SELECT id, title, url FROM bookmarks WHERE id = $id AND user_id = $userId",
) )
const bookmark = bookmarkQuery.get({ id, userId: user.id }) const bookmark = bookmarkQuery.get({ id, userId: user.id })
@@ -60,7 +61,7 @@ function findBookmark(id: string, user: User): Bookmark | null {
return null return null
} }
const tagsQuery = db.query<BookmarkTag, { bookmarkId: string }>(` const tagsQuery = db.query<Tag, { bookmarkId: string }>(`
SELECT tags.id, tags.name FROM tags SELECT tags.id, tags.name FROM tags
INNER JOIN bookmark_tags INNER JOIN bookmark_tags
ON bookmark_tags.tag_id = tags.id AND bookmark_tags.bookmark_id = $bookmarkId ON bookmark_tags.tag_id = tags.id AND bookmark_tags.bookmark_id = $bookmarkId
@@ -72,6 +73,15 @@ function findBookmark(id: string, user: User): Bookmark | null {
return bookmark return bookmark
} }
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({
title: newTitle,
id: bookmark.id,
userId: user.id,
})
}
function deleteBookmark(id: BookmarkId, user: User) { function deleteBookmark(id: BookmarkId, user: User) {
db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id").run({ db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id").run({
id, id,
@@ -141,9 +151,9 @@ async function cacheContent(url: string): Promise<CachedContent | null> {
throw new UnsupportedLink() throw new UnsupportedLink()
} }
function assignTagsToBookmark(tags: BookmarkTag[], bookmark: Bookmark) { function assignTagsToBookmark(tags: Tag[], bookmark: Bookmark) {
const query = db.query(` const query = db.query(`
INSERT INTO bookmark_tags (tag_id, bookmark_id) INSERT OR IGNORE INTO bookmark_tags (tag_id, bookmark_id)
VALUES ${Array(tags.length).fill("(?,?)").join(",")} VALUES ${Array(tags.length).fill("(?,?)").join(",")}
`) `)
@@ -156,8 +166,8 @@ function assignTagsToBookmark(tags: BookmarkTag[], bookmark: Bookmark) {
query.run(...args) query.run(...args)
} }
function findBookmarkTags(bookmark: Bookmark): BookmarkTag[] { function findBookmarkTags(bookmark: Bookmark): Tag[] {
const query = db.query<BookmarkTag, { bookmarkId: string }>(` const query = db.query<Tag, { bookmarkId: string }>(`
SELECT tags.name as name, tags.id as id FROM bookmark_tags SELECT tags.name as name, tags.id as id FROM bookmark_tags
INNER JOIN tags INNER JOIN tags
ON tags.id = bookmark_tags.tag_id ON tags.id = bookmark_tags.tag_id
@@ -167,14 +177,40 @@ function findBookmarkTags(bookmark: Bookmark): BookmarkTag[] {
return tags return tags
} }
function updateBookmarkTags(bookmark: Bookmark, tagNames: string[], user: User) {
const tags = findTagsByNames(tagNames, user)
const existingTagNames = new Set<string>()
for (const tag of tags) {
existingTagNames.add(tag.name)
}
const newTagNames: string[] = []
for (const name of tagNames) {
if (!existingTagNames.has(name)) {
newTagNames.push(name)
}
}
if (newTagNames.length > 0) {
const newTags = insertTags(newTagNames, user)
tags.push(...newTags)
}
assignTagsToBookmark(tags, bookmark)
return tags
}
export { export {
insertDemoBookmarks, insertDemoBookmarks,
insertBookmark, insertBookmark,
updateBookmarkTitle,
deleteBookmark, deleteBookmark,
findBookmark, findBookmark,
findBookmarkCachedContent, findBookmarkCachedContent,
cacheContent, cacheContent,
assignTagsToBookmark, assignTagsToBookmark,
findBookmarkTags, findBookmarkTags,
updateBookmarkTags,
} }
export { LinkUnreachable, UnsupportedLink } export { LinkUnreachable, UnsupportedLink }

View File

@@ -1,6 +1,6 @@
import type { Bookmark } from "@markone/core/bookmark" import type { TaggedBookmark } from "@markone/core"
const DEMO_BOOKMARKS: Bookmark[] = [ const DEMO_BOOKMARKS: TaggedBookmark[] = [
{ {
id: "01HYN4G66K0000000000000000", id: "01HYN4G66K0000000000000000",
title: "Google", title: "Google",

View File

@@ -1,4 +1,4 @@
import type { Bookmark, BookmarkTag } from "@markone/core/bookmark" import type { Bookmark, Tag, TaggedBookmark } from "@markone/core"
import { DEMO_USER } from "@markone/core/user" import { DEMO_USER } from "@markone/core/user"
import { type } from "arktype" import { type } from "arktype"
import { ulid } from "ulid" import { ulid } from "ulid"
@@ -15,8 +15,11 @@ import {
findBookmarkCachedContent, findBookmarkCachedContent,
findBookmarkTags, findBookmarkTags,
insertBookmark, insertBookmark,
updateBookmarkTags,
updateBookmarkTitle,
} from "./bookmark.ts" } from "./bookmark.ts"
import { insertTags } from "./tag.ts"
import { insertTags } from "~/tag/tag.js"
const BOOKMARK_PAGINATION_LIMIT = 100 const BOOKMARK_PAGINATION_LIMIT = 100
@@ -33,6 +36,11 @@ const AddBookmarkRequestBody = type({
"force?": "boolean", "force?": "boolean",
}) })
const UpdateBookmarkRequestBody = type({
"title?": "string",
"tags?": "string[]",
})
const AddTagRequestBody = type({ const AddTagRequestBody = type({
name: "string", name: "string",
}) })
@@ -93,7 +101,6 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
if (user.id !== DEMO_USER.id) { if (user.id !== DEMO_USER.id) {
const body = AddBookmarkRequestBody(await request.json()) const body = AddBookmarkRequestBody(await request.json())
if (body instanceof type.errors) { if (body instanceof type.errors) {
console.log(body)
throw new HttpError(400) throw new HttpError(400)
} }
@@ -118,7 +125,7 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
} }
} }
const bookmark: Bookmark = { const bookmark: TaggedBookmark = {
id: ulid(), id: ulid(),
title: "", title: "",
url: body.url, url: body.url,
@@ -134,7 +141,7 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
insertBookmark(bookmark, cachedContent, user) insertBookmark(bookmark, cachedContent, user)
if (tagNames.size > 0) { if (tagNames.size > 0) {
const tagQuery = db.query<Partial<BookmarkTag>, string[]>(` const tagQuery = db.query<Partial<Tag>, string[]>(`
SELECT id, name FROM tags SELECT id, name FROM tags
WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")}) WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")})
`) `)
@@ -142,7 +149,7 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
for (const tag of tags) { for (const tag of tags) {
if (tag.id && tag.name) { if (tag.id && tag.name) {
bookmark.tags.push(tag as BookmarkTag) bookmark.tags.push(tag as Tag)
tagNames.delete(tag.name) tagNames.delete(tag.name)
} }
} }
@@ -190,7 +197,7 @@ async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user
} }
async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) { async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) {
const query = db.query<BookmarkTag, { id: string }>("SELECT id, name FROM tags WHERE user_id = $id") const query = db.query<Tag, { id: string }>("SELECT id, name FROM tags WHERE user_id = $id")
const tags = query.all({ id: user.id }) const tags = query.all({ id: user.id })
return Response.json(tags, { status: 200 }) return Response.json(tags, { status: 200 })
} }
@@ -208,7 +215,7 @@ async function createUserTag(request: Bun.BunRequest<"/api/tags">, user: User) {
throw new HttpError(400) throw new HttpError(400)
} }
const tag: BookmarkTag = { const tag: Tag = {
id: ulid(), id: ulid(),
name: body.name, name: body.name,
} }
@@ -230,6 +237,44 @@ async function listBookmarkTags(request: Bun.BunRequest<"/api/bookmarks/:id/tags
return Response.json(tags, { status: 200 }) return Response.json(tags, { status: 200 })
} }
async function updateBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
const bodyJson = await request.json().catch(() => {
throw new HttpError(400)
})
const body = UpdateBookmarkRequestBody(bodyJson)
if (body instanceof type.errors) {
throw new HttpError(400)
}
if (!body.title || !body.tags) {
return Response.json(undefined, { status: 204 })
}
const bookmark = findBookmark(request.params.id, user)
if (!bookmark) {
throw new HttpError(404)
}
if (body.title) {
updateBookmarkTitle(bookmark, body.title, user)
bookmark.title = body.title
}
if (body.tags) {
const taggedBookmark = bookmark as TaggedBookmark
for (const tag of body.tags) {
if (tag.length === 0 || /[\s#]/g.test(tag)) {
throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces")
}
}
taggedBookmark.tags = updateBookmarkTags(bookmark, body.tags, user)
return Response.json(taggedBookmark, { status: 200 })
}
return Response.json(bookmark, { status: 200 })
}
export { export {
addBookmark, addBookmark,
fetchBookmark, fetchBookmark,
@@ -238,4 +283,5 @@ export {
listUserTags, listUserTags,
createUserTag, createUserTag,
listBookmarkTags, listBookmarkTags,
updateBookmark,
} }

View File

@@ -1,29 +0,0 @@
import type { BookmarkTag } from "@markone/core/bookmark"
import type { User } from "@markone/core/user"
import { ulid } from "ulid"
import { db } from "~/database.ts"
function insertTags(names: string[], user: User): BookmarkTag[] {
console.log("======== insert tags", names)
const insertTags = db.query(`
INSERT INTO tags (id, name, user_id)
VALUES ${Array(names.length).fill("(?,?,?)").join(",")}
`)
const args: Parameters<typeof insertTags.run> = []
const tags: BookmarkTag[] = []
for (const name of names) {
const tag: BookmarkTag = {
id: ulid(),
name,
}
args.push(tag.id, tag.name, user.id)
tags.push(tag)
}
insertTags.run(...args)
return tags
}
export { insertTags }

View File

@@ -3,12 +3,13 @@ import { startBackgroundSessionCleanup } from "./auth/session.ts"
import { insertDemoBookmarks } from "./bookmark/bookmark.ts" import { insertDemoBookmarks } from "./bookmark/bookmark.ts"
import { import {
addBookmark, addBookmark,
listUserBookmarks, createUserTag,
deleteUserBookmark, deleteUserBookmark,
fetchBookmark, fetchBookmark,
listUserTags,
createUserTag,
listBookmarkTags, listBookmarkTags,
listUserBookmarks,
listUserTags,
updateBookmark,
} from "./bookmark/handlers.ts" } from "./bookmark/handlers.ts"
import { migrateDatabase } from "./database.ts" import { migrateDatabase } from "./database.ts"
import { httpHandler, preflightHandler } from "./http-handler.ts" import { httpHandler, preflightHandler } from "./http-handler.ts"
@@ -39,8 +40,9 @@ async function main() {
"/api/bookmarks/:id": { "/api/bookmarks/:id": {
GET: authenticated(fetchBookmark), GET: authenticated(fetchBookmark),
DELETE: authenticated(deleteUserBookmark), DELETE: authenticated(deleteUserBookmark),
PATCH: authenticated(updateBookmark),
OPTIONS: preflightHandler({ OPTIONS: preflightHandler({
allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"], allowedMethods: ["GET", "POST", "DELETE", "PATCH", "OPTIONS"],
allowedHeaders: ["Accept"], allowedHeaders: ["Accept"],
}), }),
}, },

View File

@@ -0,0 +1,42 @@
import type { Tag, User } from "@markone/core"
import { ulid } from "ulid"
import { db } from "~/database.ts"
function findTagsByNames(names: string[], user: User): Tag[] {
return db
.query<Tag, string[]>(
`SELECT id, name FROM tags WHERE user_id = ? AND name IN (${Array(names.length).fill("?").join(",")})`,
)
.all(user.id, ...names)
}
function insertTags(names: string[], user: User): Tag[] {
const insertTags = db.query(`
INSERT INTO tags (id, name, user_id)
VALUES ${Array(names.length).fill("(?,?,?)").join(",")}
`)
const args: Parameters<typeof insertTags.run> = []
const tags: Tag[] = []
for (const name of names) {
const tag: Tag = {
id: ulid(),
name,
}
args.push(tag.id, tag.name, user.id)
tags.push(tag)
}
insertTags.run(...args)
return tags
}
function deleteTags(tags: Tag[], user: User) {
const tagIds = tags.map((tag) => tag.id)
const placeholder = Array(tags.length).fill("?").join(",")
db.query(`DELETE FROM tags WHERE user_id = ? AND id IN (${placeholder})`).run(user.id, ...tagIds)
db.query(`DELETE FROM bookmark_tags WHERE tag_id IN (${placeholder})`).run(...tagIds)
}
export { findTagsByNames, deleteTags, insertTags }

View File

@@ -2,7 +2,8 @@ import { Outlet, createFileRoute } from "@tanstack/react-router"
import { useEffect } from "react" import { useEffect } from "react"
import { AddBookmarkDialog } from "./bookmarks/-dialogs/add-bookmark-dialog" import { AddBookmarkDialog } from "./bookmarks/-dialogs/add-bookmark-dialog"
import { DeleteBookmarkDialog } from "./bookmarks/-dialogs/delete-bookmark-dialog" import { DeleteBookmarkDialog } from "./bookmarks/-dialogs/delete-bookmark-dialog"
import { ActiveDialog, LayoutMode, useBookmarkPageStore } from "./bookmarks/-store" import { EditBookmarkDialog } from "./bookmarks/-dialogs/edit-bookmark-dialog"
import { DialogKind, LayoutMode, useBookmarkPageStore } from "./bookmarks/-store"
export const Route = createFileRoute("/bookmarks")({ export const Route = createFileRoute("/bookmarks")({
component: RouteComponent, component: RouteComponent,
@@ -38,14 +39,17 @@ function RouteComponent() {
</div> </div>
) )
} }
function PageDialog() { function PageDialog() {
const dialog = useBookmarkPageStore((state) => state.activeDialog) const dialog = useBookmarkPageStore((state) => state.dialog)
switch (dialog) { switch (dialog.kind) {
case ActiveDialog.None: case DialogKind.None:
return null return null
case ActiveDialog.AddBookmark: case DialogKind.AddBookmark:
return <AddBookmarkDialog /> return <AddBookmarkDialog />
case ActiveDialog.DeleteBookmark: case DialogKind.DeleteBookmark:
return <DeleteBookmarkDialog /> return <DeleteBookmarkDialog bookmark={dialog.data} />
case DialogKind.EditBookmark:
return <EditBookmarkDialog bookmark={dialog.data} />
} }
} }

View File

@@ -1,4 +1,4 @@
import type { Bookmark } from "@markone/core/bookmark" import type { Bookmark } from "@markone/core"
import { createFileRoute, useNavigate } from "@tanstack/react-router" import { createFileRoute, useNavigate } from "@tanstack/react-router"
import clsx from "clsx" import clsx from "clsx"
import { atom, useAtom } from "jotai" import { atom, useAtom } from "jotai"

View File

@@ -1,10 +1,10 @@
import { Button } from "~/components/button"
import { ActiveDialog, useBookmarkPageStore } from "./-store"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { useCallback } from "react" import { useCallback } from "react"
import { useLogOut } from "~/auth"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { twMerge } from "tailwind-merge" 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({ function ActionBar({
ref, ref,
@@ -32,11 +32,11 @@ function BookmarkListActionBar({ className }: { className?: string }) {
{ {
a: addBookmark, a: addBookmark,
}, },
{ ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []) }, { ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
) )
function addBookmark() { function addBookmark() {
setActiveDialog(ActiveDialog.AddBookmark) setActiveDialog({ kind: DialogKind.AddBookmark })
} }
return ( return (

View File

@@ -1,15 +1,15 @@
import type { Bookmark } from "@markone/core/bookmark" import type { Bookmark } from "@markone/core"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import clsx from "clsx" import clsx from "clsx"
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react" import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { createStore, useStore } from "zustand" import { createStore, useStore } from "zustand"
import { subscribeWithSelector } from "zustand/middleware" import { subscribeWithSelector } from "zustand/middleware"
import { Button } from "~/components/button"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { ActiveDialog, useBookmarkPageStore } from "./-store"
import { useBookmarkTags } from "~/bookmark/api" import { useBookmarkTags } from "~/bookmark/api"
import { Button } from "~/components/button"
import { LoadingSpinner } from "~/components/loading-spinner" import { LoadingSpinner } from "~/components/loading-spinner"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { DialogKind, useBookmarkPageStore } from "./-store"
enum BookmarkListItemAction { enum BookmarkListItemAction {
Open = "Open", Open = "Open",
@@ -77,7 +77,7 @@ function createBookmarkListStore({
onItemAction, onItemAction,
setBookmarks(bookmarks: Bookmark[]) { setBookmarks(bookmarks: Bookmark[]) {
set({ bookmarks }) set({ bookmarks, selectedBookmarkId: bookmarks.length > 0 ? bookmarks[0].id : "", selectedIndex: 0 })
}, },
setSelectedIndex(index: number) { setSelectedIndex(index: number) {
@@ -186,9 +186,11 @@ const _BookmarkList = memo(({ className }: { className?: string }) => {
copyBookmarkLink() copyBookmarkLink()
} }
}, },
e: editItem,
}, },
{ {
ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []), ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []),
}, },
) )
@@ -235,6 +237,11 @@ const _BookmarkList = memo(({ className }: { className?: string }) => {
} }
} }
function editItem() {
const { bookmarks, selectedIndex, onItemAction } = store.getState()
onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.Edit)
}
return ( return (
<ul className={twMerge("flex flex-col", className)}> <ul className={twMerge("flex flex-col", className)}>
<ListContainer /> <ListContainer />
@@ -278,6 +285,10 @@ const BookmarkListItem = memo(
onItemAction(bookmark, BookmarkListItemAction.CopyLink) onItemAction(bookmark, BookmarkListItemAction.CopyLink)
} }
function editItem() {
onItemAction(bookmark, BookmarkListItemAction.Edit)
}
return ( return (
<li <li
className={clsx("group flex flex-row justify-start py-2", { className={clsx("group flex flex-row justify-start py-2", {
@@ -316,7 +327,7 @@ const BookmarkListItem = memo(
<Button variant="light" className="text-sm" onClick={copyItemLink}> <Button variant="light" className="text-sm" onClick={copyItemLink}>
<span>COPY LINK</span> <span>COPY LINK</span>
</Button> </Button>
<Button variant="light" className="text-sm"> <Button variant="light" className="text-sm" onClick={editItem}>
<span className="underline">E</span>dit <span className="underline">E</span>dit
</Button> </Button>
<Button variant="light" className="text-sm" onClick={deleteItem}> <Button variant="light" className="text-sm" onClick={deleteItem}>
@@ -342,7 +353,7 @@ function BookmarkTagList({ bookmark }: { bookmark: Bookmark }) {
return ( return (
<div className="flex flex-row flex-wrap space-x-2"> <div className="flex flex-row flex-wrap space-x-2">
{tags.map((tag) => ( {tags.map((tag) => (
<Link key={tag.id} to={`/bookmarks?tags=${tag.name}`}> <Link key={tag.id} to={`/bookmarks?tags=${tag.name}`} className="underline">
#{tag.name} #{tag.name}
</Link> </Link>
))} ))}

View File

@@ -1,5 +1,5 @@
import { autoUpdate, size, useFloating } from "@floating-ui/react-dom" import { autoUpdate, size, useFloating } from "@floating-ui/react-dom"
import type { BookmarkTag } from "@markone/core/bookmark" import type { Tag } from "@markone/core"
import clsx from "clsx" import clsx from "clsx"
import { atom, useAtom } from "jotai" import { atom, useAtom } from "jotai"
import { useAtomCallback } from "jotai/utils" import { useAtomCallback } from "jotai/utils"
@@ -12,7 +12,7 @@ import { FormField } from "~/components/form-field"
import { LoadingSpinner } from "~/components/loading-spinner" import { LoadingSpinner } from "~/components/loading-spinner"
import { Message, MessageVariant } from "~/components/message" import { Message, MessageVariant } from "~/components/message"
import { useMnemonics } from "~/hooks/use-mnemonics" import { useMnemonics } from "~/hooks/use-mnemonics"
import { ActiveDialog, useBookmarkPageStore } from "../-store" import { DialogKind, useBookmarkPageStore } from "../-store"
const tagsInputValueAtom = atom("") const tagsInputValueAtom = atom("")
const appendTagAtom = atom(null, (_, set, update: string) => { const appendTagAtom = atom(null, (_, set, update: string) => {
@@ -75,7 +75,7 @@ function AddBookmarkDialog() {
if (url && typeof url === "string") { if (url && typeof url === "string") {
try { try {
await createBookmarkMutation.mutateAsync({ url, tags: getTags(), force: isWebsiteUnreachable }) await createBookmarkMutation.mutateAsync({ url, tags: getTags(), force: isWebsiteUnreachable })
setActiveDialog(ActiveDialog.None) setActiveDialog({ kind: DialogKind.None })
} catch (error) { } catch (error) {
if (error instanceof BadRequestError && error.code === ApiErrorCode.LinkUnreachable) { if (error instanceof BadRequestError && error.code === ApiErrorCode.LinkUnreachable) {
setIsWebsiteUnreachable(true) setIsWebsiteUnreachable(true)
@@ -87,7 +87,7 @@ function AddBookmarkDialog() {
} }
function cancel() { function cancel() {
setActiveDialog(ActiveDialog.None) setActiveDialog({ kind: DialogKind.None })
} }
function message() { function message() {
@@ -204,16 +204,12 @@ function TagList({ ref, style }: { ref: React.Ref<HTMLDivElement>; style: React.
} }
} }
function _TagList({ function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
ref, const [selectedTag, setSelectedTag] = useState<Tag | null | undefined>(undefined)
style,
tags,
}: { tags: BookmarkTag[]; ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
const [selectedTag, setSelectedTag] = useState<BookmarkTag | null | undefined>(undefined)
const [, appendTag] = useAtom(appendTagAtom) const [, appendTag] = useAtom(appendTagAtom)
const [lastTag] = useAtom(lastTagAtom) const [lastTag] = useAtom(lastTagAtom)
const filteredTags: BookmarkTag[] = [] const filteredTags: Tag[] = []
const listItems: React.ReactElement[] = [] const listItems: React.ReactElement[] = []
let hasExactMatch = false let hasExactMatch = false
let shouldResetSelection = selectedTag !== null let shouldResetSelection = selectedTag !== null

View File

@@ -1,14 +1,13 @@
import { Bookmark } from "@markone/core"
import { useDeleteBookmark } from "~/bookmark/api" import { useDeleteBookmark } from "~/bookmark/api"
import { Button } from "~/components/button" import { Button } from "~/components/button"
import { Dialog, DialogTitle, DialogBody, DialogActionRow } from "~/components/dialog" import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
import { LoadingSpinner } from "~/components/loading-spinner" import { LoadingSpinner } from "~/components/loading-spinner"
import { useMnemonics } from "~/hooks/use-mnemonics" import { useMnemonics } from "~/hooks/use-mnemonics"
import { useBookmarkPageStore, ActiveDialog } from "../-store" import { DialogKind, useBookmarkPageStore } from "../-store"
function DeleteBookmarkDialog() { function DeleteBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
// biome-ignore lint/style/noNonNullAssertion: this cannot be null when delete bookmark dialog is visible const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
const bookmark = useBookmarkPageStore((state) => state.bookmarkToBeDeleted!)
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
const deleteBookmarkMutation = useDeleteBookmark() const deleteBookmarkMutation = useDeleteBookmark()
useMnemonics( useMnemonics(
@@ -22,14 +21,14 @@ function DeleteBookmarkDialog() {
async function proceed() { async function proceed() {
try { try {
await deleteBookmarkMutation.mutateAsync({ bookmark }) await deleteBookmarkMutation.mutateAsync({ bookmark })
setActiveDialog(ActiveDialog.None) closeDialog()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
} }
function cancel() { function cancel() {
setActiveDialog(ActiveDialog.None) closeDialog()
} }
function body() { function body() {

View File

@@ -0,0 +1,104 @@
import type { Bookmark, Tag } from "@markone/core"
import { useId, useRef } from "react"
import { useBookmarkTags, 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 { useBookmarkPageStore } from "../-store"
function EditBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
const { data: tags, status } = useBookmarkTags(bookmark)
const editFormId = useId()
function content() {
switch (status) {
case "pending":
return (
<p>
Loading <LoadingSpinner />
</p>
)
case "success":
return <EditForm formId={editFormId} bookmark={bookmark} tags={tags} />
case "error":
return null
}
}
return (
<Dialog>
<DialogTitle>EDIT BOOKMARK</DialogTitle>
<DialogBody>{content()}</DialogBody>
<DialogActionRow>
<Button type="submit" form={editFormId} disabled={status !== "success"}>
<span className="underline">S</span>AVE
</Button>
<Button disabled={status !== "success"} onClick={closeDialog}>
<span className="underline">C</span>ANCEL
</Button>
</DialogActionRow>
</Dialog>
)
}
function EditForm({ formId, bookmark, tags }: { formId: string; bookmark: Bookmark; tags: Tag[] }) {
const tagsInputRef = useRef<TagsInputRef>(null)
const updateBookmarkMutation = useUpdateBookmark(bookmark)
const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
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 id={formId} onSubmit={onSubmit}>
<FormField
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 }

View File

@@ -1,4 +1,4 @@
import type { Bookmark } from "@markone/core/bookmark" import type { Bookmark } from "@markone/core"
import { create } from "zustand/react" import { create } from "zustand/react"
import { router } from "~/router" import { router } from "~/router"
import { BookmarkListItemAction } from "./-bookmark-list" import { BookmarkListItemAction } from "./-bookmark-list"
@@ -8,30 +8,51 @@ enum LayoutMode {
SideBySide = "SideBySide", SideBySide = "SideBySide",
} }
enum ActiveDialog { enum DialogKind {
None = "None", None = "None",
AddBookmark = "AddBookmark", AddBookmark = "AddBookmark",
DeleteBookmark = "DeleteBookmark", 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
const STATUS_MESSAGE_DURATION_MS = 2000 const STATUS_MESSAGE_DURATION_MS = 2000
const NO_DIALOG: DialogData = { kind: DialogKind.None } as const
interface BookmarkPageState { interface BookmarkPageState {
bookmarkToBeDeleted: Bookmark | null bookmarkToBeDeleted: Bookmark | null
bookmarkToBeEdited: Bookmark | null
layoutMode: LayoutMode layoutMode: LayoutMode
activeDialog: ActiveDialog dialog: NoDialogData | DeleteBookmarkDialogData | EditBookmarkDialogData
statusMessage: string statusMessage: string
handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
setActiveDialog: (dialog: ActiveDialog) => void setActiveDialog: (dialog: DialogData) => void
closeDialog: () => void
setLayoutMode: (mode: LayoutMode) => void setLayoutMode: (mode: LayoutMode) => void
showStatus: (message: string) => void showStatus: (message: string) => void
} }
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
bookmarkToBeDeleted: null, bookmarkToBeDeleted: null,
bookmarkToBeEdited: null,
layoutMode: LayoutMode.Popup, layoutMode: LayoutMode.Popup,
activeDialog: ActiveDialog.None, dialog: NO_DIALOG,
statusMessage: "", statusMessage: "",
async handleBookmarkListItemAction(bookmark: Bookmark, action: BookmarkListItemAction) { async handleBookmarkListItemAction(bookmark: Bookmark, action: BookmarkListItemAction) {
@@ -41,7 +62,13 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
break break
case BookmarkListItemAction.Delete: case BookmarkListItemAction.Delete:
set({ bookmarkToBeDeleted: bookmark, activeDialog: ActiveDialog.DeleteBookmark }) set({
bookmarkToBeDeleted: bookmark,
dialog: {
kind: DialogKind.DeleteBookmark,
data: bookmark,
},
})
break break
case BookmarkListItemAction.CopyLink: case BookmarkListItemAction.CopyLink:
@@ -49,13 +76,27 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
get().showStatus("Link copied to clipboard!") get().showStatus("Link copied to clipboard!")
break break
case BookmarkListItemAction.Edit:
set({
bookmarkToBeEdited: bookmark,
dialog: {
kind: DialogKind.EditBookmark,
data: bookmark,
},
})
break
default: default:
break break
} }
}, },
setActiveDialog(dialog: ActiveDialog) { setActiveDialog(dialog: DialogData) {
set({ activeDialog: dialog }) set({ dialog })
},
closeDialog() {
set({ dialog: NO_DIALOG })
}, },
setLayoutMode(mode: LayoutMode) { setLayoutMode(mode: LayoutMode) {
@@ -70,5 +111,5 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
}, },
})) }))
export { LayoutMode, ActiveDialog, useBookmarkPageStore } export { LayoutMode, DialogKind, useBookmarkPageStore }
export type { BookmarkPageState } export type { BookmarkPageState }

View File

@@ -35,12 +35,14 @@ function BookmarkListPane() {
} }
function BookmarkListContainer() { function BookmarkListContainer() {
const searchParams = Route.useSearch() const searchParamsString = new URLSearchParams(Route.useSearch()).toString()
const { data: bookmarks, status } = useAuthenticatedQuery(["bookmarks"], () => { const { data: bookmarks, status } = useAuthenticatedQuery(
const params = new URLSearchParams(searchParams) searchParamsString ? ["bookmarks", searchParamsString] : ["bookmarks"],
console.log("params", params) async () => {
return fetchApi(params.size > 0 ? `/bookmarks?${params.toString()}` : "/bookmarks").then((res) => res.json()) const res = await fetchApi(searchParamsString ? `/bookmarks?${searchParamsString}` : "/bookmarks")
}) return await res.json()
},
)
const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction) const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction)
switch (status) { switch (status) {

View File

@@ -1,4 +1,4 @@
import type { Bookmark, BookmarkTag } from "@markone/core/bookmark" import type { Bookmark, Tag, TaggedBookmark } from "@markone/core"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { UnauthenticatedError, fetchApi, useAuthenticatedQuery } from "~/api" import { UnauthenticatedError, fetchApi, useAuthenticatedQuery } from "~/api"
@@ -12,12 +12,12 @@ function useBookmark(id: string) {
} }
function useTags() { function useTags() {
return useAuthenticatedQuery(["tags"], () => fetchApi("/tags").then((res): Promise<BookmarkTag[]> => res.json())) return useAuthenticatedQuery(["tags"], () => fetchApi("/tags").then((res): Promise<Tag[]> => res.json()))
} }
function useBookmarkTags(bookmark: Bookmark) { function useBookmarkTags(bookmark: Bookmark) {
return useAuthenticatedQuery(["bookmarks", bookmark.id, "tags"], () => return useAuthenticatedQuery(["bookmarks", bookmark.id, "tags"], () =>
fetchApi(`/bookmarks/${bookmark.id}/tags`).then((res): Promise<BookmarkTag[]> => res.json()), fetchApi(`/bookmarks/${bookmark.id}/tags`).then((res): Promise<Tag[]> => res.json()),
) )
} }
@@ -59,15 +59,36 @@ function useCreateBookmark() {
} }
}, },
onSuccess: (bookmark: Bookmark | undefined) => { onSuccess: (bookmark: Bookmark | undefined) => {
console.log("on success bookmark", bookmark)
if (bookmark) { if (bookmark) {
queryClient.setQueryData(["bookmarks"], (bookmarks: Bookmark[]) => queryClient.setQueryData(["bookmarks"], (bookmarks: Bookmark[]) =>
bookmarks ? [bookmark, ...bookmarks] : [bookmark], bookmarks ? [bookmark, ...bookmarks] : [bookmark],
) )
console.log("query data updated")
} }
}, },
}) })
} }
export { useBookmark, useDeleteBookmark, useCreateBookmark, useTags, useBookmarkTags } function useUpdateBookmark(bookmark: Bookmark) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: { title?: string; tags: string[] }) =>
fetchApi(`/bookmarks/${bookmark.id}`, {
method: "PATCH",
body: JSON.stringify(body),
}).then((res) => (res.status === 204 ? bookmark : res.json())),
onSuccess: (bookmark: Bookmark | TaggedBookmark | undefined) => {
if (bookmark) {
queryClient.setQueryData(["bookmarks"], (bookmarks: Bookmark[]) =>
bookmarks ? bookmarks.map((it) => (it.id === bookmark.id ? bookmark : it)) : [bookmark],
)
queryClient.setQueryData(["bookmarks", bookmark.id], bookmark)
if ("tags" in bookmark) {
queryClient.setQueryData(["bookmarks", bookmark.id, "tags"], bookmark.tags)
}
}
},
})
}
export { useBookmark, useUpdateBookmark, useDeleteBookmark, useCreateBookmark, useTags, useBookmarkTags }

View File

@@ -0,0 +1,229 @@
import { autoUpdate, size, useFloating } from "@floating-ui/react-dom"
import type { Tag } from "@markone/core"
import clsx from "clsx"
import { type Atom, type PrimitiveAtom, atom, useAtom, useSetAtom } from "jotai"
import { createContext, useContext, useEffect, useImperativeHandle, useMemo, useState } from "react"
import { useTags } from "~/bookmark/api"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { FormField } from "./form-field"
import { LoadingSpinner } from "./loading-spinner"
interface TagsInputRef {
input: HTMLInputElement | null
tags: string[]
}
const TagsInputContext = createContext<{
value: PrimitiveAtom<string>
lastTag: Atom<string>
} | null>(null)
function TagsInput({ ref, initialValue = "" }: { ref: React.Ref<TagsInputRef>; initialValue?: string }) {
const valueAtom = useMemo(() => atom(initialValue), [initialValue])
const lastTagAtom = useMemo(
() =>
atom((get) => {
const value = get(valueAtom)
let start = 0
for (let i = value.length; i > 0; --i) {
if (value.charAt(i) === " ") {
start = i + 1
break
}
}
return value.slice(start)
}),
[valueAtom],
)
return (
<TagsInputContext value={{ value: valueAtom, lastTag: lastTagAtom }}>
<_TagsInput ref={ref} />
</TagsInputContext>
)
}
function _TagsInput({ ref }: { ref: React.Ref<TagsInputRef> }) {
// biome-ignore lint/style/noNonNullAssertion: <explanation>
const { value: valueAtom, lastTag: lastTagAtom } = useContext(TagsInputContext)!
const { refs, floatingStyles } = useFloating<HTMLInputElement>({
whileElementsMounted: autoUpdate,
middleware: [
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
})
},
}),
],
})
const [value, setValue] = useAtom(valueAtom)
const [lastTag] = useAtom(lastTagAtom)
const [isInputFocused, setIsInputFocused] = useState(false)
useImperativeHandle(ref, () => ({
get tags() {
return value.trim().split(" ")
},
input: refs.reference.current,
}))
return (
<>
<FormField
ref={refs.setReference}
type="text"
name="tags"
label="TAGS"
value={value}
onChange={(event) => {
setValue(event.currentTarget.value)
}}
className="flex-1"
onFocus={() => {
setIsInputFocused(true)
}}
onBlur={() => {
setIsInputFocused(false)
}}
labelClassName="bg-stone-300 dark:bg-stone-800"
/>
{isInputFocused && lastTag !== "" ? <TagList ref={refs.setFloating} style={floatingStyles} /> : null}
</>
)
}
function TagList({ ref, style }: { ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
const { data: tags, status } = useTags()
switch (status) {
case "pending":
return (
<p>
Loading <LoadingSpinner />
</p>
)
case "success":
return <_TagList ref={ref} style={style} tags={tags} />
case "error":
return null
}
}
function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
// biome-ignore lint/style/noNonNullAssertion: <explanation>
const { value: valueAtom, lastTag: lastTagAtom } = useContext(TagsInputContext)!
const [selectedTag, setSelectedTag] = useState<Tag | null | undefined>(undefined)
const [lastTag] = useAtom(lastTagAtom)
const setValue = useSetAtom(valueAtom)
const filteredTags: Tag[] = []
const listItems: React.ReactElement[] = []
let hasExactMatch = false
let shouldResetSelection = selectedTag !== null
for (const tag of tags) {
if (tag.name.startsWith(lastTag)) {
if (tag.name.length === lastTag.length) {
hasExactMatch = true
}
if (tag.id === selectedTag?.id) {
shouldResetSelection = false
}
filteredTags.push(tag)
listItems.push(
<li
className={clsx("text-start py-1", {
"bg-stone-800 dark:bg-stone-300 text-stone-300 dark:text-stone-800": selectedTag?.id === tag.id,
})}
key={tag.id}
>
&nbsp;#{tag.name}
</li>,
)
}
}
if (hasExactMatch && selectedTag === null) {
shouldResetSelection = true
}
useEffect(() => {
if (shouldResetSelection) {
if (listItems.length === 0) {
setSelectedTag(null)
} else {
setSelectedTag(filteredTags[0])
}
}
}, [shouldResetSelection])
useMnemonics(
{
ArrowUp: (event) => {
event.preventDefault()
if (selectedTag) {
const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id)
if (i === 0 || i === filteredTags.length - 1) {
setSelectedTag(null)
} else if (i === -1) {
setSelectedTag(filteredTags[0])
} else {
setSelectedTag(filteredTags[i + 1])
}
} else {
setSelectedTag(filteredTags.at(-1) ?? null)
}
},
ArrowDown: (event) => {
event.preventDefault()
if (selectedTag) {
const i = filteredTags.findIndex((tag) => tag.id === selectedTag.id)
if (i === filteredTags.length - 1) {
setSelectedTag(null)
} else {
setSelectedTag(filteredTags[i + 1])
}
} else {
setSelectedTag(filteredTags[0])
}
},
Enter: (event) => {
if (lastTag) {
event.preventDefault()
event.stopPropagation()
if (selectedTag) {
setValue((value) => `${value}${selectedTag.name.slice(lastTag.length)} `)
} else {
// biome-ignore lint/style/useTemplate: this is more readable than using template literal
setValue((value) => value + " ")
}
}
},
},
{ ignore: () => false },
)
if (lastTag === "") {
return null
}
return (
<div ref={ref} style={style} className="bg-stone-300 dark:bg-stone-800 border-2 mt-1">
<ul className="py-2">
{listItems}
{hasExactMatch ? null : (
<li
className={clsx("text-start py-1", {
"bg-stone-800 dark:bg-stone-300 text-stone-300 dark:text-stone-800": selectedTag === null,
})}
>
&nbsp;{lastTag.includes("#") ? "Tags cannot contain '#'" : `Add tag: #${lastTag}`}
</li>
)}
</ul>
</div>
)
}
export { TagsInput }
export type { TagsInputRef }

View File

@@ -1,9 +0,0 @@
import { useQuery, type QueryOptions } from "@tanstack/react-query"
import type { QueryKey } from "~/api"
interface WithQueryProps<TQueryFnData, TError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>
extends QueryOptions<TQueryFnData, TError, TQueryKey> {}
function WithQuery<TQueryFnData, TError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(options: React.PropsWithChildren<QueryOptions<TQueryFnData, TData, TQueryKey>>) {
useQuery(options)
}