From 6afb5dee9a78daf0bf685acd26e1d1fcd85f399c Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 25 May 2025 15:40:16 +0100 Subject: [PATCH] implement edit bookmark dialog --- packages/core/package.json | 1 + packages/core/src/bookmark.ts | 10 +- packages/core/src/index.ts | 3 + packages/core/src/tag.ts | 6 + packages/server/src/bookmark/bookmark.ts | 50 +++- .../server/src/bookmark/demo-bookmarks.ts | 4 +- packages/server/src/bookmark/handlers.ts | 62 ++++- packages/server/src/bookmark/tag.ts | 29 --- packages/server/src/server.ts | 10 +- packages/server/src/tag/tag.ts | 42 ++++ packages/web/src/app/bookmarks.tsx | 18 +- .../web/src/app/bookmarks/$bookmarkId.tsx | 2 +- .../web/src/app/bookmarks/-action-bar.tsx | 12 +- .../web/src/app/bookmarks/-bookmark-list.tsx | 27 ++- .../-dialogs/add-bookmark-dialog.tsx | 18 +- .../-dialogs/delete-bookmark-dialog.tsx | 15 +- .../-dialogs/edit-bookmark-dialog.tsx | 104 ++++++++ packages/web/src/app/bookmarks/-store.tsx | 59 ++++- packages/web/src/app/bookmarks/index.tsx | 14 +- packages/web/src/bookmark/api.ts | 33 ++- packages/web/src/components/tags-input.tsx | 229 ++++++++++++++++++ packages/web/src/components/with-query.tsx | 9 - 22 files changed, 630 insertions(+), 127 deletions(-) create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/tag.ts create mode 100644 packages/server/src/tag/tag.ts create mode 100644 packages/web/src/app/bookmarks/-dialogs/edit-bookmark-dialog.tsx create mode 100644 packages/web/src/components/tags-input.tsx delete mode 100644 packages/web/src/components/with-query.tsx diff --git a/packages/core/package.json b/packages/core/package.json index e08240e..e07ac36 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,6 +3,7 @@ "module": "src/index.ts", "type": "module", "exports": { + ".": "./src/index.ts", "./bookmark": "./src/bookmark.ts", "./user": "./src/user.ts" }, diff --git a/packages/core/src/bookmark.ts b/packages/core/src/bookmark.ts index a9f05b5..5090b90 100644 --- a/packages/core/src/bookmark.ts +++ b/packages/core/src/bookmark.ts @@ -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 } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..d801707 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,3 @@ +export * from "./bookmark.ts" +export * from "./tag.ts" +export * from "./user.ts" diff --git a/packages/core/src/tag.ts b/packages/core/src/tag.ts new file mode 100644 index 0000000..fd79af1 --- /dev/null +++ b/packages/core/src/tag.ts @@ -0,0 +1,6 @@ +interface Tag { + id: string + name: string +} + +export type { Tag } diff --git a/packages/server/src/bookmark/bookmark.ts b/packages/server/src/bookmark/bookmark.ts index 413e507..36d6428 100644 --- a/packages/server/src/bookmark/bookmark.ts +++ b/packages/server/src/bookmark/bookmark.ts @@ -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( + const bookmarkQuery = db.query( "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(` + const tagsQuery = db.query(` 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 { 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(` +function findBookmarkTags(bookmark: Bookmark): Tag[] { + const query = db.query(` 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() + 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 } diff --git a/packages/server/src/bookmark/demo-bookmarks.ts b/packages/server/src/bookmark/demo-bookmarks.ts index c099635..cff04be 100644 --- a/packages/server/src/bookmark/demo-bookmarks.ts +++ b/packages/server/src/bookmark/demo-bookmarks.ts @@ -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", diff --git a/packages/server/src/bookmark/handlers.ts b/packages/server/src/bookmark/handlers.ts index fb1ecab..2e0a614 100644 --- a/packages/server/src/bookmark/handlers.ts +++ b/packages/server/src/bookmark/handlers.ts @@ -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, string[]>(` + const tagQuery = db.query, 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("SELECT id, name FROM tags WHERE user_id = $id") + const query = db.query("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, } diff --git a/packages/server/src/bookmark/tag.ts b/packages/server/src/bookmark/tag.ts index 3f7c798..e69de29 100644 --- a/packages/server/src/bookmark/tag.ts +++ b/packages/server/src/bookmark/tag.ts @@ -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 = [] - 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 } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 8171e4e..0b00a6a 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -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"], }), }, diff --git a/packages/server/src/tag/tag.ts b/packages/server/src/tag/tag.ts new file mode 100644 index 0000000..b7baf99 --- /dev/null +++ b/packages/server/src/tag/tag.ts @@ -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( + `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 = [] + 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 } diff --git a/packages/web/src/app/bookmarks.tsx b/packages/web/src/app/bookmarks.tsx index e7c0c6d..c485343 100644 --- a/packages/web/src/app/bookmarks.tsx +++ b/packages/web/src/app/bookmarks.tsx @@ -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() { ) } + 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 - case ActiveDialog.DeleteBookmark: - return + case DialogKind.DeleteBookmark: + return + case DialogKind.EditBookmark: + return } } diff --git a/packages/web/src/app/bookmarks/$bookmarkId.tsx b/packages/web/src/app/bookmarks/$bookmarkId.tsx index 92bae2d..8b2d610 100644 --- a/packages/web/src/app/bookmarks/$bookmarkId.tsx +++ b/packages/web/src/app/bookmarks/$bookmarkId.tsx @@ -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" diff --git a/packages/web/src/app/bookmarks/-action-bar.tsx b/packages/web/src/app/bookmarks/-action-bar.tsx index 5633921..57ed19e 100644 --- a/packages/web/src/app/bookmarks/-action-bar.tsx +++ b/packages/web/src/app/bookmarks/-action-bar.tsx @@ -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 ( diff --git a/packages/web/src/app/bookmarks/-bookmark-list.tsx b/packages/web/src/app/bookmarks/-bookmark-list.tsx index 176b61e..7095d8d 100644 --- a/packages/web/src/app/bookmarks/-bookmark-list.tsx +++ b/packages/web/src/app/bookmarks/-bookmark-list.tsx @@ -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 (
    @@ -278,6 +285,10 @@ const BookmarkListItem = memo( onItemAction(bookmark, BookmarkListItemAction.CopyLink) } + function editItem() { + onItemAction(bookmark, BookmarkListItemAction.Edit) + } + return (
  • COPY LINK - + + + + ) +} + +function EditForm({ formId, bookmark, tags }: { formId: string; bookmark: Bookmark; tags: Tag[] }) { + const tagsInputRef = useRef(null) + const updateBookmarkMutation = useUpdateBookmark(bookmark) + const closeDialog = useBookmarkPageStore((state) => state.closeDialog) + + async function onSubmit(event: React.FormEvent) { + 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 ( +

    + Saving changes +

    + ) + case "error": + return Error updating the bookmark! + default: + return null + } + } + + return ( + <> + {message()} +
    + + tag.name).join(" ")} /> + + + ) +} + +export { EditBookmarkDialog } diff --git a/packages/web/src/app/bookmarks/-store.tsx b/packages/web/src/app/bookmarks/-store.tsx index 57d6882..68a1d46 100644 --- a/packages/web/src/app/bookmarks/-store.tsx +++ b/packages/web/src/app/bookmarks/-store.tsx @@ -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()((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()((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()((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()((set, get) => ({ }, })) -export { LayoutMode, ActiveDialog, useBookmarkPageStore } +export { LayoutMode, DialogKind, useBookmarkPageStore } export type { BookmarkPageState } diff --git a/packages/web/src/app/bookmarks/index.tsx b/packages/web/src/app/bookmarks/index.tsx index f72e15b..7db8e0b 100644 --- a/packages/web/src/app/bookmarks/index.tsx +++ b/packages/web/src/app/bookmarks/index.tsx @@ -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) { diff --git a/packages/web/src/bookmark/api.ts b/packages/web/src/bookmark/api.ts index 86214aa..708becd 100644 --- a/packages/web/src/bookmark/api.ts +++ b/packages/web/src/bookmark/api.ts @@ -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 => res.json())) + return useAuthenticatedQuery(["tags"], () => fetchApi("/tags").then((res): Promise => res.json())) } function useBookmarkTags(bookmark: Bookmark) { return useAuthenticatedQuery(["bookmarks", bookmark.id, "tags"], () => - fetchApi(`/bookmarks/${bookmark.id}/tags`).then((res): Promise => res.json()), + fetchApi(`/bookmarks/${bookmark.id}/tags`).then((res): Promise => 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 } diff --git a/packages/web/src/components/tags-input.tsx b/packages/web/src/components/tags-input.tsx new file mode 100644 index 0000000..547c1d8 --- /dev/null +++ b/packages/web/src/components/tags-input.tsx @@ -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 + lastTag: Atom +} | null>(null) + +function TagsInput({ ref, initialValue = "" }: { ref: React.Ref; 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 ( + + <_TagsInput ref={ref} /> + + ) +} + +function _TagsInput({ ref }: { ref: React.Ref }) { + // biome-ignore lint/style/noNonNullAssertion: + const { value: valueAtom, lastTag: lastTagAtom } = useContext(TagsInputContext)! + const { refs, floatingStyles } = useFloating({ + 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 ( + <> + { + setValue(event.currentTarget.value) + }} + className="flex-1" + onFocus={() => { + setIsInputFocused(true) + }} + onBlur={() => { + setIsInputFocused(false) + }} + labelClassName="bg-stone-300 dark:bg-stone-800" + /> + {isInputFocused && lastTag !== "" ? : null} + + ) +} + +function TagList({ ref, style }: { ref: React.Ref; style: React.CSSProperties }) { + const { data: tags, status } = useTags() + switch (status) { + case "pending": + return ( +

    + Loading +

    + ) + case "success": + return <_TagList ref={ref} style={style} tags={tags} /> + case "error": + return null + } +} + +function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref; style: React.CSSProperties }) { + // biome-ignore lint/style/noNonNullAssertion: + const { value: valueAtom, lastTag: lastTagAtom } = useContext(TagsInputContext)! + const [selectedTag, setSelectedTag] = useState(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( +
  • +  #{tag.name} +
  • , + ) + } + } + 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 ( +
    +
      + {listItems} + {hasExactMatch ? null : ( +
    • +  {lastTag.includes("#") ? "Tags cannot contain '#'" : `Add tag: #${lastTag}`} +
    • + )} +
    +
    + ) +} + +export { TagsInput } +export type { TagsInputRef } diff --git a/packages/web/src/components/with-query.tsx b/packages/web/src/components/with-query.tsx deleted file mode 100644 index 1b32c3d..0000000 --- a/packages/web/src/components/with-query.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useQuery, type QueryOptions } from "@tanstack/react-query" -import type { QueryKey } from "~/api" - -interface WithQueryProps - extends QueryOptions {} - -function WithQuery(options: React.PropsWithChildren>) { - useQuery(options) -}