implement edit bookmark dialog
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
"./bookmark": "./src/bookmark.ts",
|
"./bookmark": "./src/bookmark.ts",
|
||||||
"./user": "./src/user.ts"
|
"./user": "./src/user.ts"
|
||||||
},
|
},
|
||||||
|
@@ -1,17 +1,15 @@
|
|||||||
type BookmarkKind = "link" | "placeholder"
|
import type { Tag } from "./tag.ts"
|
||||||
|
|
||||||
interface Bookmark {
|
interface Bookmark {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
tags: BookmarkTag[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BookmarkTag {
|
interface TaggedBookmark extends Bookmark {
|
||||||
id: string
|
tags: Tag[]
|
||||||
name: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BookmarkId = Bookmark["id"]
|
type BookmarkId = Bookmark["id"]
|
||||||
|
|
||||||
export type { Bookmark, BookmarkId, BookmarkKind, BookmarkTag }
|
export type { Bookmark, TaggedBookmark, BookmarkId, Tag }
|
||||||
|
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 type { User } from "@markone/core/user"
|
||||||
import { Readability } from "@mozilla/readability"
|
import { Readability } from "@mozilla/readability"
|
||||||
import { JSDOM } from "jsdom"
|
import { JSDOM } from "jsdom"
|
||||||
import { db } from "~/database.ts"
|
import { db } from "~/database.ts"
|
||||||
|
import { findTagsByNames, insertTags } from "~/tag/tag.js"
|
||||||
import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts"
|
import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts"
|
||||||
|
|
||||||
class LinkUnreachable {}
|
class LinkUnreachable {}
|
||||||
@@ -52,7 +53,7 @@ function findBookmarkCachedContent(id: string, user: User): Buffer | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findBookmark(id: string, user: User): Bookmark | null {
|
function findBookmark(id: string, user: User): Bookmark | null {
|
||||||
const bookmarkQuery = db.query<Bookmark, { id: string; userId: string }>(
|
const bookmarkQuery = db.query<TaggedBookmark, { id: string; userId: string }>(
|
||||||
"SELECT id, title, url FROM bookmarks WHERE id = $id AND user_id = $userId",
|
"SELECT id, title, url FROM bookmarks WHERE id = $id AND user_id = $userId",
|
||||||
)
|
)
|
||||||
const bookmark = bookmarkQuery.get({ id, userId: user.id })
|
const bookmark = bookmarkQuery.get({ id, userId: user.id })
|
||||||
@@ -60,7 +61,7 @@ function findBookmark(id: string, user: User): Bookmark | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsQuery = db.query<BookmarkTag, { bookmarkId: string }>(`
|
const tagsQuery = db.query<Tag, { bookmarkId: string }>(`
|
||||||
SELECT tags.id, tags.name FROM tags
|
SELECT tags.id, tags.name FROM tags
|
||||||
INNER JOIN bookmark_tags
|
INNER JOIN bookmark_tags
|
||||||
ON bookmark_tags.tag_id = tags.id AND bookmark_tags.bookmark_id = $bookmarkId
|
ON bookmark_tags.tag_id = tags.id AND bookmark_tags.bookmark_id = $bookmarkId
|
||||||
@@ -72,6 +73,15 @@ function findBookmark(id: string, user: User): Bookmark | null {
|
|||||||
return bookmark
|
return bookmark
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateBookmarkTitle(bookmark: Bookmark, newTitle: string, user: User) {
|
||||||
|
const query = db.query("UPDATE bookmarks SET title = $title WHERE id = $id AND user_id = $userId")
|
||||||
|
query.run({
|
||||||
|
title: newTitle,
|
||||||
|
id: bookmark.id,
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function deleteBookmark(id: BookmarkId, user: User) {
|
function deleteBookmark(id: BookmarkId, user: User) {
|
||||||
db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id").run({
|
db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id").run({
|
||||||
id,
|
id,
|
||||||
@@ -141,9 +151,9 @@ async function cacheContent(url: string): Promise<CachedContent | null> {
|
|||||||
throw new UnsupportedLink()
|
throw new UnsupportedLink()
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignTagsToBookmark(tags: BookmarkTag[], bookmark: Bookmark) {
|
function assignTagsToBookmark(tags: Tag[], bookmark: Bookmark) {
|
||||||
const query = db.query(`
|
const query = db.query(`
|
||||||
INSERT INTO bookmark_tags (tag_id, bookmark_id)
|
INSERT OR IGNORE INTO bookmark_tags (tag_id, bookmark_id)
|
||||||
VALUES ${Array(tags.length).fill("(?,?)").join(",")}
|
VALUES ${Array(tags.length).fill("(?,?)").join(",")}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
@@ -156,8 +166,8 @@ function assignTagsToBookmark(tags: BookmarkTag[], bookmark: Bookmark) {
|
|||||||
query.run(...args)
|
query.run(...args)
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBookmarkTags(bookmark: Bookmark): BookmarkTag[] {
|
function findBookmarkTags(bookmark: Bookmark): Tag[] {
|
||||||
const query = db.query<BookmarkTag, { bookmarkId: string }>(`
|
const query = db.query<Tag, { bookmarkId: string }>(`
|
||||||
SELECT tags.name as name, tags.id as id FROM bookmark_tags
|
SELECT tags.name as name, tags.id as id FROM bookmark_tags
|
||||||
INNER JOIN tags
|
INNER JOIN tags
|
||||||
ON tags.id = bookmark_tags.tag_id
|
ON tags.id = bookmark_tags.tag_id
|
||||||
@@ -167,14 +177,40 @@ function findBookmarkTags(bookmark: Bookmark): BookmarkTag[] {
|
|||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateBookmarkTags(bookmark: Bookmark, tagNames: string[], user: User) {
|
||||||
|
const tags = findTagsByNames(tagNames, user)
|
||||||
|
const existingTagNames = new Set<string>()
|
||||||
|
for (const tag of tags) {
|
||||||
|
existingTagNames.add(tag.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTagNames: string[] = []
|
||||||
|
for (const name of tagNames) {
|
||||||
|
if (!existingTagNames.has(name)) {
|
||||||
|
newTagNames.push(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTagNames.length > 0) {
|
||||||
|
const newTags = insertTags(newTagNames, user)
|
||||||
|
tags.push(...newTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignTagsToBookmark(tags, bookmark)
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
insertDemoBookmarks,
|
insertDemoBookmarks,
|
||||||
insertBookmark,
|
insertBookmark,
|
||||||
|
updateBookmarkTitle,
|
||||||
deleteBookmark,
|
deleteBookmark,
|
||||||
findBookmark,
|
findBookmark,
|
||||||
findBookmarkCachedContent,
|
findBookmarkCachedContent,
|
||||||
cacheContent,
|
cacheContent,
|
||||||
assignTagsToBookmark,
|
assignTagsToBookmark,
|
||||||
findBookmarkTags,
|
findBookmarkTags,
|
||||||
|
updateBookmarkTags,
|
||||||
}
|
}
|
||||||
export { LinkUnreachable, UnsupportedLink }
|
export { LinkUnreachable, UnsupportedLink }
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import type { Bookmark } from "@markone/core/bookmark"
|
import type { TaggedBookmark } from "@markone/core"
|
||||||
|
|
||||||
const DEMO_BOOKMARKS: Bookmark[] = [
|
const DEMO_BOOKMARKS: TaggedBookmark[] = [
|
||||||
{
|
{
|
||||||
id: "01HYN4G66K0000000000000000",
|
id: "01HYN4G66K0000000000000000",
|
||||||
title: "Google",
|
title: "Google",
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import type { Bookmark, BookmarkTag } from "@markone/core/bookmark"
|
import type { Bookmark, Tag, TaggedBookmark } from "@markone/core"
|
||||||
import { DEMO_USER } from "@markone/core/user"
|
import { DEMO_USER } from "@markone/core/user"
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
@@ -15,8 +15,11 @@ import {
|
|||||||
findBookmarkCachedContent,
|
findBookmarkCachedContent,
|
||||||
findBookmarkTags,
|
findBookmarkTags,
|
||||||
insertBookmark,
|
insertBookmark,
|
||||||
|
updateBookmarkTags,
|
||||||
|
updateBookmarkTitle,
|
||||||
} from "./bookmark.ts"
|
} from "./bookmark.ts"
|
||||||
import { insertTags } from "./tag.ts"
|
|
||||||
|
import { insertTags } from "~/tag/tag.js"
|
||||||
|
|
||||||
const BOOKMARK_PAGINATION_LIMIT = 100
|
const BOOKMARK_PAGINATION_LIMIT = 100
|
||||||
|
|
||||||
@@ -33,6 +36,11 @@ const AddBookmarkRequestBody = type({
|
|||||||
"force?": "boolean",
|
"force?": "boolean",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const UpdateBookmarkRequestBody = type({
|
||||||
|
"title?": "string",
|
||||||
|
"tags?": "string[]",
|
||||||
|
})
|
||||||
|
|
||||||
const AddTagRequestBody = type({
|
const AddTagRequestBody = type({
|
||||||
name: "string",
|
name: "string",
|
||||||
})
|
})
|
||||||
@@ -93,7 +101,6 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
|
|||||||
if (user.id !== DEMO_USER.id) {
|
if (user.id !== DEMO_USER.id) {
|
||||||
const body = AddBookmarkRequestBody(await request.json())
|
const body = AddBookmarkRequestBody(await request.json())
|
||||||
if (body instanceof type.errors) {
|
if (body instanceof type.errors) {
|
||||||
console.log(body)
|
|
||||||
throw new HttpError(400)
|
throw new HttpError(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +125,7 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookmark: Bookmark = {
|
const bookmark: TaggedBookmark = {
|
||||||
id: ulid(),
|
id: ulid(),
|
||||||
title: "",
|
title: "",
|
||||||
url: body.url,
|
url: body.url,
|
||||||
@@ -134,7 +141,7 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
|
|||||||
insertBookmark(bookmark, cachedContent, user)
|
insertBookmark(bookmark, cachedContent, user)
|
||||||
|
|
||||||
if (tagNames.size > 0) {
|
if (tagNames.size > 0) {
|
||||||
const tagQuery = db.query<Partial<BookmarkTag>, string[]>(`
|
const tagQuery = db.query<Partial<Tag>, string[]>(`
|
||||||
SELECT id, name FROM tags
|
SELECT id, name FROM tags
|
||||||
WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")})
|
WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")})
|
||||||
`)
|
`)
|
||||||
@@ -142,7 +149,7 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
|
|||||||
|
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
if (tag.id && tag.name) {
|
if (tag.id && tag.name) {
|
||||||
bookmark.tags.push(tag as BookmarkTag)
|
bookmark.tags.push(tag as Tag)
|
||||||
tagNames.delete(tag.name)
|
tagNames.delete(tag.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +197,7 @@ async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) {
|
async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) {
|
||||||
const query = db.query<BookmarkTag, { id: string }>("SELECT id, name FROM tags WHERE user_id = $id")
|
const query = db.query<Tag, { id: string }>("SELECT id, name FROM tags WHERE user_id = $id")
|
||||||
const tags = query.all({ id: user.id })
|
const tags = query.all({ id: user.id })
|
||||||
return Response.json(tags, { status: 200 })
|
return Response.json(tags, { status: 200 })
|
||||||
}
|
}
|
||||||
@@ -208,7 +215,7 @@ async function createUserTag(request: Bun.BunRequest<"/api/tags">, user: User) {
|
|||||||
throw new HttpError(400)
|
throw new HttpError(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tag: BookmarkTag = {
|
const tag: Tag = {
|
||||||
id: ulid(),
|
id: ulid(),
|
||||||
name: body.name,
|
name: body.name,
|
||||||
}
|
}
|
||||||
@@ -230,6 +237,44 @@ async function listBookmarkTags(request: Bun.BunRequest<"/api/bookmarks/:id/tags
|
|||||||
return Response.json(tags, { status: 200 })
|
return Response.json(tags, { status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
|
||||||
|
const bodyJson = await request.json().catch(() => {
|
||||||
|
throw new HttpError(400)
|
||||||
|
})
|
||||||
|
const body = UpdateBookmarkRequestBody(bodyJson)
|
||||||
|
if (body instanceof type.errors) {
|
||||||
|
throw new HttpError(400)
|
||||||
|
}
|
||||||
|
if (!body.title || !body.tags) {
|
||||||
|
return Response.json(undefined, { status: 204 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmark = findBookmark(request.params.id, user)
|
||||||
|
if (!bookmark) {
|
||||||
|
throw new HttpError(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.title) {
|
||||||
|
updateBookmarkTitle(bookmark, body.title, user)
|
||||||
|
bookmark.title = body.title
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.tags) {
|
||||||
|
const taggedBookmark = bookmark as TaggedBookmark
|
||||||
|
|
||||||
|
for (const tag of body.tags) {
|
||||||
|
if (tag.length === 0 || /[\s#]/g.test(tag)) {
|
||||||
|
throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taggedBookmark.tags = updateBookmarkTags(bookmark, body.tags, user)
|
||||||
|
|
||||||
|
return Response.json(taggedBookmark, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(bookmark, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
addBookmark,
|
addBookmark,
|
||||||
fetchBookmark,
|
fetchBookmark,
|
||||||
@@ -238,4 +283,5 @@ export {
|
|||||||
listUserTags,
|
listUserTags,
|
||||||
createUserTag,
|
createUserTag,
|
||||||
listBookmarkTags,
|
listBookmarkTags,
|
||||||
|
updateBookmark,
|
||||||
}
|
}
|
||||||
|
@@ -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 { insertDemoBookmarks } from "./bookmark/bookmark.ts"
|
||||||
import {
|
import {
|
||||||
addBookmark,
|
addBookmark,
|
||||||
listUserBookmarks,
|
createUserTag,
|
||||||
deleteUserBookmark,
|
deleteUserBookmark,
|
||||||
fetchBookmark,
|
fetchBookmark,
|
||||||
listUserTags,
|
|
||||||
createUserTag,
|
|
||||||
listBookmarkTags,
|
listBookmarkTags,
|
||||||
|
listUserBookmarks,
|
||||||
|
listUserTags,
|
||||||
|
updateBookmark,
|
||||||
} from "./bookmark/handlers.ts"
|
} from "./bookmark/handlers.ts"
|
||||||
import { migrateDatabase } from "./database.ts"
|
import { migrateDatabase } from "./database.ts"
|
||||||
import { httpHandler, preflightHandler } from "./http-handler.ts"
|
import { httpHandler, preflightHandler } from "./http-handler.ts"
|
||||||
@@ -39,8 +40,9 @@ async function main() {
|
|||||||
"/api/bookmarks/:id": {
|
"/api/bookmarks/:id": {
|
||||||
GET: authenticated(fetchBookmark),
|
GET: authenticated(fetchBookmark),
|
||||||
DELETE: authenticated(deleteUserBookmark),
|
DELETE: authenticated(deleteUserBookmark),
|
||||||
|
PATCH: authenticated(updateBookmark),
|
||||||
OPTIONS: preflightHandler({
|
OPTIONS: preflightHandler({
|
||||||
allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
allowedMethods: ["GET", "POST", "DELETE", "PATCH", "OPTIONS"],
|
||||||
allowedHeaders: ["Accept"],
|
allowedHeaders: ["Accept"],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
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 { useEffect } from "react"
|
||||||
import { AddBookmarkDialog } from "./bookmarks/-dialogs/add-bookmark-dialog"
|
import { AddBookmarkDialog } from "./bookmarks/-dialogs/add-bookmark-dialog"
|
||||||
import { DeleteBookmarkDialog } from "./bookmarks/-dialogs/delete-bookmark-dialog"
|
import { DeleteBookmarkDialog } from "./bookmarks/-dialogs/delete-bookmark-dialog"
|
||||||
import { ActiveDialog, LayoutMode, useBookmarkPageStore } from "./bookmarks/-store"
|
import { EditBookmarkDialog } from "./bookmarks/-dialogs/edit-bookmark-dialog"
|
||||||
|
import { DialogKind, LayoutMode, useBookmarkPageStore } from "./bookmarks/-store"
|
||||||
|
|
||||||
export const Route = createFileRoute("/bookmarks")({
|
export const Route = createFileRoute("/bookmarks")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -38,14 +39,17 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageDialog() {
|
function PageDialog() {
|
||||||
const dialog = useBookmarkPageStore((state) => state.activeDialog)
|
const dialog = useBookmarkPageStore((state) => state.dialog)
|
||||||
switch (dialog) {
|
switch (dialog.kind) {
|
||||||
case ActiveDialog.None:
|
case DialogKind.None:
|
||||||
return null
|
return null
|
||||||
case ActiveDialog.AddBookmark:
|
case DialogKind.AddBookmark:
|
||||||
return <AddBookmarkDialog />
|
return <AddBookmarkDialog />
|
||||||
case ActiveDialog.DeleteBookmark:
|
case DialogKind.DeleteBookmark:
|
||||||
return <DeleteBookmarkDialog />
|
return <DeleteBookmarkDialog bookmark={dialog.data} />
|
||||||
|
case DialogKind.EditBookmark:
|
||||||
|
return <EditBookmarkDialog bookmark={dialog.data} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import type { Bookmark } from "@markone/core/bookmark"
|
import type { Bookmark } from "@markone/core"
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import { atom, useAtom } from "jotai"
|
import { atom, useAtom } from "jotai"
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import { Button } from "~/components/button"
|
|
||||||
import { ActiveDialog, useBookmarkPageStore } from "./-store"
|
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
import { useCallback } from "react"
|
import { useCallback } from "react"
|
||||||
import { useLogOut } from "~/auth"
|
|
||||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import { useLogOut } from "~/auth"
|
||||||
|
import { Button } from "~/components/button"
|
||||||
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
|
import { DialogKind, useBookmarkPageStore } from "./-store"
|
||||||
|
|
||||||
function ActionBar({
|
function ActionBar({
|
||||||
ref,
|
ref,
|
||||||
@@ -32,11 +32,11 @@ function BookmarkListActionBar({ className }: { className?: string }) {
|
|||||||
{
|
{
|
||||||
a: addBookmark,
|
a: addBookmark,
|
||||||
},
|
},
|
||||||
{ ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []) },
|
{ ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
|
||||||
)
|
)
|
||||||
|
|
||||||
function addBookmark() {
|
function addBookmark() {
|
||||||
setActiveDialog(ActiveDialog.AddBookmark)
|
setActiveDialog({ kind: DialogKind.AddBookmark })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -1,15 +1,15 @@
|
|||||||
import type { Bookmark } from "@markone/core/bookmark"
|
import type { Bookmark } from "@markone/core"
|
||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
|
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { createStore, useStore } from "zustand"
|
import { createStore, useStore } from "zustand"
|
||||||
import { subscribeWithSelector } from "zustand/middleware"
|
import { subscribeWithSelector } from "zustand/middleware"
|
||||||
import { Button } from "~/components/button"
|
|
||||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
|
||||||
import { ActiveDialog, useBookmarkPageStore } from "./-store"
|
|
||||||
import { useBookmarkTags } from "~/bookmark/api"
|
import { useBookmarkTags } from "~/bookmark/api"
|
||||||
|
import { Button } from "~/components/button"
|
||||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||||
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
|
import { DialogKind, useBookmarkPageStore } from "./-store"
|
||||||
|
|
||||||
enum BookmarkListItemAction {
|
enum BookmarkListItemAction {
|
||||||
Open = "Open",
|
Open = "Open",
|
||||||
@@ -77,7 +77,7 @@ function createBookmarkListStore({
|
|||||||
onItemAction,
|
onItemAction,
|
||||||
|
|
||||||
setBookmarks(bookmarks: Bookmark[]) {
|
setBookmarks(bookmarks: Bookmark[]) {
|
||||||
set({ bookmarks })
|
set({ bookmarks, selectedBookmarkId: bookmarks.length > 0 ? bookmarks[0].id : "", selectedIndex: 0 })
|
||||||
},
|
},
|
||||||
|
|
||||||
setSelectedIndex(index: number) {
|
setSelectedIndex(index: number) {
|
||||||
@@ -186,9 +186,11 @@ const _BookmarkList = memo(({ className }: { className?: string }) => {
|
|||||||
copyBookmarkLink()
|
copyBookmarkLink()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
e: editItem,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignore: useCallback(() => useBookmarkPageStore.getState().activeDialog !== ActiveDialog.None, []),
|
ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,6 +237,11 @@ const _BookmarkList = memo(({ className }: { className?: string }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editItem() {
|
||||||
|
const { bookmarks, selectedIndex, onItemAction } = store.getState()
|
||||||
|
onItemAction(bookmarks[selectedIndex], BookmarkListItemAction.Edit)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={twMerge("flex flex-col", className)}>
|
<ul className={twMerge("flex flex-col", className)}>
|
||||||
<ListContainer />
|
<ListContainer />
|
||||||
@@ -278,6 +285,10 @@ const BookmarkListItem = memo(
|
|||||||
onItemAction(bookmark, BookmarkListItemAction.CopyLink)
|
onItemAction(bookmark, BookmarkListItemAction.CopyLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editItem() {
|
||||||
|
onItemAction(bookmark, BookmarkListItemAction.Edit)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={clsx("group flex flex-row justify-start py-2", {
|
className={clsx("group flex flex-row justify-start py-2", {
|
||||||
@@ -316,7 +327,7 @@ const BookmarkListItem = memo(
|
|||||||
<Button variant="light" className="text-sm" onClick={copyItemLink}>
|
<Button variant="light" className="text-sm" onClick={copyItemLink}>
|
||||||
<span>COPY LINK</span>
|
<span>COPY LINK</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="light" className="text-sm">
|
<Button variant="light" className="text-sm" onClick={editItem}>
|
||||||
<span className="underline">E</span>dit
|
<span className="underline">E</span>dit
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="light" className="text-sm" onClick={deleteItem}>
|
<Button variant="light" className="text-sm" onClick={deleteItem}>
|
||||||
@@ -342,7 +353,7 @@ function BookmarkTagList({ bookmark }: { bookmark: Bookmark }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-row flex-wrap space-x-2">
|
<div className="flex flex-row flex-wrap space-x-2">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<Link key={tag.id} to={`/bookmarks?tags=${tag.name}`}>
|
<Link key={tag.id} to={`/bookmarks?tags=${tag.name}`} className="underline">
|
||||||
#{tag.name}
|
#{tag.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { autoUpdate, size, useFloating } from "@floating-ui/react-dom"
|
import { autoUpdate, size, useFloating } from "@floating-ui/react-dom"
|
||||||
import type { BookmarkTag } from "@markone/core/bookmark"
|
import type { Tag } from "@markone/core"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import { atom, useAtom } from "jotai"
|
import { atom, useAtom } from "jotai"
|
||||||
import { useAtomCallback } from "jotai/utils"
|
import { useAtomCallback } from "jotai/utils"
|
||||||
@@ -12,7 +12,7 @@ import { FormField } from "~/components/form-field"
|
|||||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||||
import { Message, MessageVariant } from "~/components/message"
|
import { Message, MessageVariant } from "~/components/message"
|
||||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
import { ActiveDialog, useBookmarkPageStore } from "../-store"
|
import { DialogKind, useBookmarkPageStore } from "../-store"
|
||||||
|
|
||||||
const tagsInputValueAtom = atom("")
|
const tagsInputValueAtom = atom("")
|
||||||
const appendTagAtom = atom(null, (_, set, update: string) => {
|
const appendTagAtom = atom(null, (_, set, update: string) => {
|
||||||
@@ -75,7 +75,7 @@ function AddBookmarkDialog() {
|
|||||||
if (url && typeof url === "string") {
|
if (url && typeof url === "string") {
|
||||||
try {
|
try {
|
||||||
await createBookmarkMutation.mutateAsync({ url, tags: getTags(), force: isWebsiteUnreachable })
|
await createBookmarkMutation.mutateAsync({ url, tags: getTags(), force: isWebsiteUnreachable })
|
||||||
setActiveDialog(ActiveDialog.None)
|
setActiveDialog({ kind: DialogKind.None })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BadRequestError && error.code === ApiErrorCode.LinkUnreachable) {
|
if (error instanceof BadRequestError && error.code === ApiErrorCode.LinkUnreachable) {
|
||||||
setIsWebsiteUnreachable(true)
|
setIsWebsiteUnreachable(true)
|
||||||
@@ -87,7 +87,7 @@ function AddBookmarkDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
setActiveDialog(ActiveDialog.None)
|
setActiveDialog({ kind: DialogKind.None })
|
||||||
}
|
}
|
||||||
|
|
||||||
function message() {
|
function message() {
|
||||||
@@ -204,16 +204,12 @@ function TagList({ ref, style }: { ref: React.Ref<HTMLDivElement>; style: React.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _TagList({
|
function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
|
||||||
ref,
|
const [selectedTag, setSelectedTag] = useState<Tag | null | undefined>(undefined)
|
||||||
style,
|
|
||||||
tags,
|
|
||||||
}: { tags: BookmarkTag[]; ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
|
|
||||||
const [selectedTag, setSelectedTag] = useState<BookmarkTag | null | undefined>(undefined)
|
|
||||||
const [, appendTag] = useAtom(appendTagAtom)
|
const [, appendTag] = useAtom(appendTagAtom)
|
||||||
const [lastTag] = useAtom(lastTagAtom)
|
const [lastTag] = useAtom(lastTagAtom)
|
||||||
|
|
||||||
const filteredTags: BookmarkTag[] = []
|
const filteredTags: Tag[] = []
|
||||||
const listItems: React.ReactElement[] = []
|
const listItems: React.ReactElement[] = []
|
||||||
let hasExactMatch = false
|
let hasExactMatch = false
|
||||||
let shouldResetSelection = selectedTag !== null
|
let shouldResetSelection = selectedTag !== null
|
||||||
|
@@ -1,14 +1,13 @@
|
|||||||
|
import { Bookmark } from "@markone/core"
|
||||||
import { useDeleteBookmark } from "~/bookmark/api"
|
import { useDeleteBookmark } from "~/bookmark/api"
|
||||||
import { Button } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
import { Dialog, DialogTitle, DialogBody, DialogActionRow } from "~/components/dialog"
|
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
||||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||||
import { useMnemonics } from "~/hooks/use-mnemonics"
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
import { useBookmarkPageStore, ActiveDialog } from "../-store"
|
import { DialogKind, useBookmarkPageStore } from "../-store"
|
||||||
|
|
||||||
function DeleteBookmarkDialog() {
|
function DeleteBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
|
||||||
// biome-ignore lint/style/noNonNullAssertion: this cannot be null when delete bookmark dialog is visible
|
const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
|
||||||
const bookmark = useBookmarkPageStore((state) => state.bookmarkToBeDeleted!)
|
|
||||||
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
|
||||||
const deleteBookmarkMutation = useDeleteBookmark()
|
const deleteBookmarkMutation = useDeleteBookmark()
|
||||||
|
|
||||||
useMnemonics(
|
useMnemonics(
|
||||||
@@ -22,14 +21,14 @@ function DeleteBookmarkDialog() {
|
|||||||
async function proceed() {
|
async function proceed() {
|
||||||
try {
|
try {
|
||||||
await deleteBookmarkMutation.mutateAsync({ bookmark })
|
await deleteBookmarkMutation.mutateAsync({ bookmark })
|
||||||
setActiveDialog(ActiveDialog.None)
|
closeDialog()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
setActiveDialog(ActiveDialog.None)
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function body() {
|
function body() {
|
||||||
|
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 { create } from "zustand/react"
|
||||||
import { router } from "~/router"
|
import { router } from "~/router"
|
||||||
import { BookmarkListItemAction } from "./-bookmark-list"
|
import { BookmarkListItemAction } from "./-bookmark-list"
|
||||||
@@ -8,30 +8,51 @@ enum LayoutMode {
|
|||||||
SideBySide = "SideBySide",
|
SideBySide = "SideBySide",
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ActiveDialog {
|
enum DialogKind {
|
||||||
None = "None",
|
None = "None",
|
||||||
AddBookmark = "AddBookmark",
|
AddBookmark = "AddBookmark",
|
||||||
DeleteBookmark = "DeleteBookmark",
|
DeleteBookmark = "DeleteBookmark",
|
||||||
|
EditBookmark = "EditBookmark",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NoDialogData {
|
||||||
|
kind: DialogKind.None | DialogKind.AddBookmark
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteBookmarkDialogData {
|
||||||
|
kind: DialogKind.DeleteBookmark
|
||||||
|
data: Bookmark
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditBookmarkDialogData {
|
||||||
|
kind: DialogKind.EditBookmark
|
||||||
|
data: Bookmark
|
||||||
|
}
|
||||||
|
|
||||||
|
type DialogData = NoDialogData | DeleteBookmarkDialogData | EditBookmarkDialogData
|
||||||
|
|
||||||
const STATUS_MESSAGE_DURATION_MS = 2000
|
const STATUS_MESSAGE_DURATION_MS = 2000
|
||||||
|
const NO_DIALOG: DialogData = { kind: DialogKind.None } as const
|
||||||
|
|
||||||
interface BookmarkPageState {
|
interface BookmarkPageState {
|
||||||
bookmarkToBeDeleted: Bookmark | null
|
bookmarkToBeDeleted: Bookmark | null
|
||||||
|
bookmarkToBeEdited: Bookmark | null
|
||||||
layoutMode: LayoutMode
|
layoutMode: LayoutMode
|
||||||
activeDialog: ActiveDialog
|
dialog: NoDialogData | DeleteBookmarkDialogData | EditBookmarkDialogData
|
||||||
statusMessage: string
|
statusMessage: string
|
||||||
|
|
||||||
handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
|
handleBookmarkListItemAction: (bookmark: Bookmark, action: BookmarkListItemAction) => void
|
||||||
setActiveDialog: (dialog: ActiveDialog) => void
|
setActiveDialog: (dialog: DialogData) => void
|
||||||
|
closeDialog: () => void
|
||||||
setLayoutMode: (mode: LayoutMode) => void
|
setLayoutMode: (mode: LayoutMode) => void
|
||||||
showStatus: (message: string) => void
|
showStatus: (message: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
||||||
bookmarkToBeDeleted: null,
|
bookmarkToBeDeleted: null,
|
||||||
|
bookmarkToBeEdited: null,
|
||||||
layoutMode: LayoutMode.Popup,
|
layoutMode: LayoutMode.Popup,
|
||||||
activeDialog: ActiveDialog.None,
|
dialog: NO_DIALOG,
|
||||||
statusMessage: "",
|
statusMessage: "",
|
||||||
|
|
||||||
async handleBookmarkListItemAction(bookmark: Bookmark, action: BookmarkListItemAction) {
|
async handleBookmarkListItemAction(bookmark: Bookmark, action: BookmarkListItemAction) {
|
||||||
@@ -41,7 +62,13 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
|||||||
break
|
break
|
||||||
|
|
||||||
case BookmarkListItemAction.Delete:
|
case BookmarkListItemAction.Delete:
|
||||||
set({ bookmarkToBeDeleted: bookmark, activeDialog: ActiveDialog.DeleteBookmark })
|
set({
|
||||||
|
bookmarkToBeDeleted: bookmark,
|
||||||
|
dialog: {
|
||||||
|
kind: DialogKind.DeleteBookmark,
|
||||||
|
data: bookmark,
|
||||||
|
},
|
||||||
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
case BookmarkListItemAction.CopyLink:
|
case BookmarkListItemAction.CopyLink:
|
||||||
@@ -49,13 +76,27 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
|||||||
get().showStatus("Link copied to clipboard!")
|
get().showStatus("Link copied to clipboard!")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case BookmarkListItemAction.Edit:
|
||||||
|
set({
|
||||||
|
bookmarkToBeEdited: bookmark,
|
||||||
|
dialog: {
|
||||||
|
kind: DialogKind.EditBookmark,
|
||||||
|
data: bookmark,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setActiveDialog(dialog: ActiveDialog) {
|
setActiveDialog(dialog: DialogData) {
|
||||||
set({ activeDialog: dialog })
|
set({ dialog })
|
||||||
|
},
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
set({ dialog: NO_DIALOG })
|
||||||
},
|
},
|
||||||
|
|
||||||
setLayoutMode(mode: LayoutMode) {
|
setLayoutMode(mode: LayoutMode) {
|
||||||
@@ -70,5 +111,5 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export { LayoutMode, ActiveDialog, useBookmarkPageStore }
|
export { LayoutMode, DialogKind, useBookmarkPageStore }
|
||||||
export type { BookmarkPageState }
|
export type { BookmarkPageState }
|
||||||
|
@@ -35,12 +35,14 @@ function BookmarkListPane() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BookmarkListContainer() {
|
function BookmarkListContainer() {
|
||||||
const searchParams = Route.useSearch()
|
const searchParamsString = new URLSearchParams(Route.useSearch()).toString()
|
||||||
const { data: bookmarks, status } = useAuthenticatedQuery(["bookmarks"], () => {
|
const { data: bookmarks, status } = useAuthenticatedQuery(
|
||||||
const params = new URLSearchParams(searchParams)
|
searchParamsString ? ["bookmarks", searchParamsString] : ["bookmarks"],
|
||||||
console.log("params", params)
|
async () => {
|
||||||
return fetchApi(params.size > 0 ? `/bookmarks?${params.toString()}` : "/bookmarks").then((res) => res.json())
|
const res = await fetchApi(searchParamsString ? `/bookmarks?${searchParamsString}` : "/bookmarks")
|
||||||
})
|
return await res.json()
|
||||||
|
},
|
||||||
|
)
|
||||||
const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction)
|
const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction)
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import type { Bookmark, BookmarkTag } from "@markone/core/bookmark"
|
import type { Bookmark, Tag, TaggedBookmark } from "@markone/core"
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
import { UnauthenticatedError, fetchApi, useAuthenticatedQuery } from "~/api"
|
import { UnauthenticatedError, fetchApi, useAuthenticatedQuery } from "~/api"
|
||||||
@@ -12,12 +12,12 @@ function useBookmark(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useTags() {
|
function useTags() {
|
||||||
return useAuthenticatedQuery(["tags"], () => fetchApi("/tags").then((res): Promise<BookmarkTag[]> => res.json()))
|
return useAuthenticatedQuery(["tags"], () => fetchApi("/tags").then((res): Promise<Tag[]> => res.json()))
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBookmarkTags(bookmark: Bookmark) {
|
function useBookmarkTags(bookmark: Bookmark) {
|
||||||
return useAuthenticatedQuery(["bookmarks", bookmark.id, "tags"], () =>
|
return useAuthenticatedQuery(["bookmarks", bookmark.id, "tags"], () =>
|
||||||
fetchApi(`/bookmarks/${bookmark.id}/tags`).then((res): Promise<BookmarkTag[]> => res.json()),
|
fetchApi(`/bookmarks/${bookmark.id}/tags`).then((res): Promise<Tag[]> => res.json()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,15 +59,36 @@ function useCreateBookmark() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (bookmark: Bookmark | undefined) => {
|
onSuccess: (bookmark: Bookmark | undefined) => {
|
||||||
console.log("on success bookmark", bookmark)
|
|
||||||
if (bookmark) {
|
if (bookmark) {
|
||||||
queryClient.setQueryData(["bookmarks"], (bookmarks: Bookmark[]) =>
|
queryClient.setQueryData(["bookmarks"], (bookmarks: Bookmark[]) =>
|
||||||
bookmarks ? [bookmark, ...bookmarks] : [bookmark],
|
bookmarks ? [bookmark, ...bookmarks] : [bookmark],
|
||||||
)
|
)
|
||||||
console.log("query data updated")
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useBookmark, useDeleteBookmark, useCreateBookmark, useTags, useBookmarkTags }
|
function useUpdateBookmark(bookmark: Bookmark) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: { title?: string; tags: string[] }) =>
|
||||||
|
fetchApi(`/bookmarks/${bookmark.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then((res) => (res.status === 204 ? bookmark : res.json())),
|
||||||
|
onSuccess: (bookmark: Bookmark | TaggedBookmark | undefined) => {
|
||||||
|
if (bookmark) {
|
||||||
|
queryClient.setQueryData(["bookmarks"], (bookmarks: Bookmark[]) =>
|
||||||
|
bookmarks ? bookmarks.map((it) => (it.id === bookmark.id ? bookmark : it)) : [bookmark],
|
||||||
|
)
|
||||||
|
queryClient.setQueryData(["bookmarks", bookmark.id], bookmark)
|
||||||
|
if ("tags" in bookmark) {
|
||||||
|
queryClient.setQueryData(["bookmarks", bookmark.id, "tags"], bookmark.tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useBookmark, useUpdateBookmark, useDeleteBookmark, useCreateBookmark, useTags, useBookmarkTags }
|
||||||
|
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