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",
"type": "module",
"exports": {
".": "./src/index.ts",
"./bookmark": "./src/bookmark.ts",
"./user": "./src/user.ts"
},

View File

@@ -1,17 +1,15 @@
type BookmarkKind = "link" | "placeholder"
import type { Tag } from "./tag.ts"
interface Bookmark {
id: string
title: string
url: string
tags: BookmarkTag[]
}
interface BookmarkTag {
id: string
name: string
interface TaggedBookmark extends Bookmark {
tags: Tag[]
}
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 { Readability } from "@mozilla/readability"
import { JSDOM } from "jsdom"
import { db } from "~/database.ts"
import { findTagsByNames, insertTags } from "~/tag/tag.js"
import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts"
class LinkUnreachable {}
@@ -52,7 +53,7 @@ function findBookmarkCachedContent(id: string, user: User): Buffer | 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",
)
const bookmark = bookmarkQuery.get({ id, userId: user.id })
@@ -60,7 +61,7 @@ function findBookmark(id: string, user: User): Bookmark | null {
return null
}
const tagsQuery = db.query<BookmarkTag, { bookmarkId: string }>(`
const tagsQuery = db.query<Tag, { bookmarkId: string }>(`
SELECT tags.id, tags.name FROM tags
INNER JOIN bookmark_tags
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
}
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) {
db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id").run({
id,
@@ -141,9 +151,9 @@ async function cacheContent(url: string): Promise<CachedContent | null> {
throw new UnsupportedLink()
}
function assignTagsToBookmark(tags: BookmarkTag[], bookmark: Bookmark) {
function assignTagsToBookmark(tags: Tag[], bookmark: Bookmark) {
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(",")}
`)
@@ -156,8 +166,8 @@ function assignTagsToBookmark(tags: BookmarkTag[], bookmark: Bookmark) {
query.run(...args)
}
function findBookmarkTags(bookmark: Bookmark): BookmarkTag[] {
const query = db.query<BookmarkTag, { bookmarkId: string }>(`
function findBookmarkTags(bookmark: Bookmark): Tag[] {
const query = db.query<Tag, { bookmarkId: string }>(`
SELECT tags.name as name, tags.id as id FROM bookmark_tags
INNER JOIN tags
ON tags.id = bookmark_tags.tag_id
@@ -167,14 +177,40 @@ function findBookmarkTags(bookmark: Bookmark): BookmarkTag[] {
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 {
insertDemoBookmarks,
insertBookmark,
updateBookmarkTitle,
deleteBookmark,
findBookmark,
findBookmarkCachedContent,
cacheContent,
assignTagsToBookmark,
findBookmarkTags,
updateBookmarkTags,
}
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",
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 { type } from "arktype"
import { ulid } from "ulid"
@@ -15,8 +15,11 @@ import {
findBookmarkCachedContent,
findBookmarkTags,
insertBookmark,
updateBookmarkTags,
updateBookmarkTitle,
} from "./bookmark.ts"
import { insertTags } from "./tag.ts"
import { insertTags } from "~/tag/tag.js"
const BOOKMARK_PAGINATION_LIMIT = 100
@@ -33,6 +36,11 @@ const AddBookmarkRequestBody = type({
"force?": "boolean",
})
const UpdateBookmarkRequestBody = type({
"title?": "string",
"tags?": "string[]",
})
const AddTagRequestBody = type({
name: "string",
})
@@ -93,7 +101,6 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
if (user.id !== DEMO_USER.id) {
const body = AddBookmarkRequestBody(await request.json())
if (body instanceof type.errors) {
console.log(body)
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(),
title: "",
url: body.url,
@@ -134,7 +141,7 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
insertBookmark(bookmark, cachedContent, user)
if (tagNames.size > 0) {
const tagQuery = db.query<Partial<BookmarkTag>, string[]>(`
const tagQuery = db.query<Partial<Tag>, string[]>(`
SELECT id, name FROM tags
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) {
if (tag.id && tag.name) {
bookmark.tags.push(tag as BookmarkTag)
bookmark.tags.push(tag as Tag)
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) {
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 })
return Response.json(tags, { status: 200 })
}
@@ -208,7 +215,7 @@ async function createUserTag(request: Bun.BunRequest<"/api/tags">, user: User) {
throw new HttpError(400)
}
const tag: BookmarkTag = {
const tag: Tag = {
id: ulid(),
name: body.name,
}
@@ -230,6 +237,44 @@ async function listBookmarkTags(request: Bun.BunRequest<"/api/bookmarks/:id/tags
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 {
addBookmark,
fetchBookmark,
@@ -238,4 +283,5 @@ export {
listUserTags,
createUserTag,
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 {
addBookmark,
listUserBookmarks,
createUserTag,
deleteUserBookmark,
fetchBookmark,
listUserTags,
createUserTag,
listBookmarkTags,
listUserBookmarks,
listUserTags,
updateBookmark,
} from "./bookmark/handlers.ts"
import { migrateDatabase } from "./database.ts"
import { httpHandler, preflightHandler } from "./http-handler.ts"
@@ -39,8 +40,9 @@ async function main() {
"/api/bookmarks/:id": {
GET: authenticated(fetchBookmark),
DELETE: authenticated(deleteUserBookmark),
PATCH: authenticated(updateBookmark),
OPTIONS: preflightHandler({
allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"],
allowedMethods: ["GET", "POST", "DELETE", "PATCH", "OPTIONS"],
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 { AddBookmarkDialog } from "./bookmarks/-dialogs/add-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")({
component: RouteComponent,
@@ -38,14 +39,17 @@ function RouteComponent() {
</div>
)
}
function PageDialog() {
const dialog = useBookmarkPageStore((state) => state.activeDialog)
switch (dialog) {
case ActiveDialog.None:
const dialog = useBookmarkPageStore((state) => state.dialog)
switch (dialog.kind) {
case DialogKind.None:
return null
case ActiveDialog.AddBookmark:
case DialogKind.AddBookmark:
return <AddBookmarkDialog />
case ActiveDialog.DeleteBookmark:
return <DeleteBookmarkDialog />
case DialogKind.DeleteBookmark:
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 clsx from "clsx"
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 { useCallback } from "react"
import { useLogOut } from "~/auth"
import { useMnemonics } from "~/hooks/use-mnemonics"
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,
@@ -32,11 +32,11 @@ function BookmarkListActionBar({ className }: { className?: string }) {
{
a: addBookmark,
},
{ ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []) },
{ ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
)
function addBookmark() {
setActiveDialog(ActiveDialog.AddBookmark)
setActiveDialog({ kind: DialogKind.AddBookmark })
}
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 clsx from "clsx"
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
import { twMerge } from "tailwind-merge"
import { createStore, useStore } from "zustand"
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 { Button } from "~/components/button"
import { LoadingSpinner } from "~/components/loading-spinner"
import { useMnemonics } from "~/hooks/use-mnemonics"
import { DialogKind, useBookmarkPageStore } from "./-store"
enum BookmarkListItemAction {
Open = "Open",
@@ -77,7 +77,7 @@ function createBookmarkListStore({
onItemAction,
setBookmarks(bookmarks: Bookmark[]) {
set({ bookmarks })
set({ bookmarks, selectedBookmarkId: bookmarks.length > 0 ? bookmarks[0].id : "", selectedIndex: 0 })
},
setSelectedIndex(index: number) {
@@ -186,9 +186,11 @@ const _BookmarkList = memo(({ className }: { className?: string }) => {
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 (
<ul className={twMerge("flex flex-col", className)}>
<ListContainer />
@@ -278,6 +285,10 @@ const BookmarkListItem = memo(
onItemAction(bookmark, BookmarkListItemAction.CopyLink)
}
function editItem() {
onItemAction(bookmark, BookmarkListItemAction.Edit)
}
return (
<li
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}>
<span>COPY LINK</span>
</Button>
<Button variant="light" className="text-sm">
<Button variant="light" className="text-sm" onClick={editItem}>
<span className="underline">E</span>dit
</Button>
<Button variant="light" className="text-sm" onClick={deleteItem}>
@@ -342,7 +353,7 @@ function BookmarkTagList({ bookmark }: { bookmark: Bookmark }) {
return (
<div className="flex flex-row flex-wrap space-x-2">
{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}
</Link>
))}

View File

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

View File

@@ -1,14 +1,13 @@
import { Bookmark } from "@markone/core"
import { useDeleteBookmark } from "~/bookmark/api"
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 { useMnemonics } from "~/hooks/use-mnemonics"
import { useBookmarkPageStore, ActiveDialog } from "../-store"
import { DialogKind, useBookmarkPageStore } from "../-store"
function DeleteBookmarkDialog() {
// biome-ignore lint/style/noNonNullAssertion: this cannot be null when delete bookmark dialog is visible
const bookmark = useBookmarkPageStore((state) => state.bookmarkToBeDeleted!)
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
function DeleteBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
const deleteBookmarkMutation = useDeleteBookmark()
useMnemonics(
@@ -22,14 +21,14 @@ function DeleteBookmarkDialog() {
async function proceed() {
try {
await deleteBookmarkMutation.mutateAsync({ bookmark })
setActiveDialog(ActiveDialog.None)
closeDialog()
} catch (error) {
console.error(error)
}
}
function cancel() {
setActiveDialog(ActiveDialog.None)
closeDialog()
}
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 { router } from "~/router"
import { BookmarkListItemAction } from "./-bookmark-list"
@@ -8,30 +8,51 @@ enum LayoutMode {
SideBySide = "SideBySide",
}
enum ActiveDialog {
enum DialogKind {
None = "None",
AddBookmark = "AddBookmark",
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 NO_DIALOG: DialogData = { kind: DialogKind.None } as const
interface BookmarkPageState {
bookmarkToBeDeleted: Bookmark | null
bookmarkToBeEdited: Bookmark | null
layoutMode: LayoutMode
activeDialog: ActiveDialog
dialog: NoDialogData | DeleteBookmarkDialogData | EditBookmarkDialogData
statusMessage: string
handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
setActiveDialog: (dialog: ActiveDialog) => void
setActiveDialog: (dialog: DialogData) => void
closeDialog: () => void
setLayoutMode: (mode: LayoutMode) => void
showStatus: (message: string) => void
}
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
bookmarkToBeDeleted: null,
bookmarkToBeEdited: null,
layoutMode: LayoutMode.Popup,
activeDialog: ActiveDialog.None,
dialog: NO_DIALOG,
statusMessage: "",
async handleBookmarkListItemAction(bookmark: Bookmark, action: BookmarkListItemAction) {
@@ -41,7 +62,13 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
break
case BookmarkListItemAction.Delete:
set({ bookmarkToBeDeleted: bookmark, activeDialog: ActiveDialog.DeleteBookmark })
set({
bookmarkToBeDeleted: bookmark,
dialog: {
kind: DialogKind.DeleteBookmark,
data: bookmark,
},
})
break
case BookmarkListItemAction.CopyLink:
@@ -49,13 +76,27 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
get().showStatus("Link copied to clipboard!")
break
case BookmarkListItemAction.Edit:
set({
bookmarkToBeEdited: bookmark,
dialog: {
kind: DialogKind.EditBookmark,
data: bookmark,
},
})
break
default:
break
}
},
setActiveDialog(dialog: ActiveDialog) {
set({ activeDialog: dialog })
setActiveDialog(dialog: DialogData) {
set({ dialog })
},
closeDialog() {
set({ dialog: NO_DIALOG })
},
setLayoutMode(mode: LayoutMode) {
@@ -70,5 +111,5 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
},
}))
export { LayoutMode, ActiveDialog, useBookmarkPageStore }
export { LayoutMode, DialogKind, useBookmarkPageStore }
export type { BookmarkPageState }

View File

@@ -35,12 +35,14 @@ function BookmarkListPane() {
}
function BookmarkListContainer() {
const searchParams = Route.useSearch()
const { data: bookmarks, status } = useAuthenticatedQuery(["bookmarks"], () => {
const params = new URLSearchParams(searchParams)
console.log("params", params)
return fetchApi(params.size > 0 ? `/bookmarks?${params.toString()}` : "/bookmarks").then((res) => res.json())
})
const searchParamsString = new URLSearchParams(Route.useSearch()).toString()
const { data: bookmarks, status } = useAuthenticatedQuery(
searchParamsString ? ["bookmarks", searchParamsString] : ["bookmarks"],
async () => {
const res = await fetchApi(searchParamsString ? `/bookmarks?${searchParamsString}` : "/bookmarks")
return await res.json()
},
)
const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction)
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 { useNavigate } from "@tanstack/react-router"
import { UnauthenticatedError, fetchApi, useAuthenticatedQuery } from "~/api"
@@ -12,12 +12,12 @@ function useBookmark(id: string) {
}
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) {
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) => {
console.log("on success bookmark", bookmark)
if (bookmark) {
queryClient.setQueryData(["bookmarks"], (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)
}