implement edit bookmark dialog
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./bookmark": "./src/bookmark.ts",
|
||||
"./user": "./src/user.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 }
|
||||
|
3
packages/core/src/index.ts
Normal file
3
packages/core/src/index.ts
Normal 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
6
packages/core/src/tag.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type { Tag }
|
@@ -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 }
|
||||
|
@@ -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",
|
||||
|
@@ -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,
|
||||
}
|
||||
|
@@ -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 }
|
||||
|
@@ -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"],
|
||||
}),
|
||||
},
|
||||
|
42
packages/server/src/tag/tag.ts
Normal file
42
packages/server/src/tag/tag.ts
Normal 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 }
|
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
@@ -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"
|
||||
|
@@ -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 (
|
||||
|
@@ -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>
|
||||
))}
|
||||
|
@@ -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
|
||||
|
@@ -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() {
|
||||
|
104
packages/web/src/app/bookmarks/-dialogs/edit-bookmark-dialog.tsx
Normal file
104
packages/web/src/app/bookmarks/-dialogs/edit-bookmark-dialog.tsx
Normal 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 }
|
@@ -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 }
|
||||
|
@@ -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) {
|
||||
|
@@ -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 }
|
||||
|
229
packages/web/src/components/tags-input.tsx
Normal file
229
packages/web/src/components/tags-input.tsx
Normal 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}
|
||||
>
|
||||
#{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,
|
||||
})}
|
||||
>
|
||||
{lastTag.includes("#") ? "Tags cannot contain '#'" : `Add tag: #${lastTag}`}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TagsInput }
|
||||
export type { TagsInputRef }
|
@@ -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)
|
||||
}
|
Reference in New Issue
Block a user