implement bookmark collections and enhance bookmark search functionality
This commit is contained in:
37
bun.lock
37
bun.lock
@@ -40,6 +40,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react-dom": "^2.1.2",
|
"@floating-ui/react-dom": "^2.1.2",
|
||||||
|
"@headlessui/react": "^2.2.4",
|
||||||
"@markone/core": "workspace:*",
|
"@markone/core": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"@tanstack/react-query": "^5.75.2",
|
"@tanstack/react-query": "^5.75.2",
|
||||||
@@ -355,10 +356,14 @@
|
|||||||
|
|
||||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="],
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="],
|
||||||
|
|
||||||
|
"@floating-ui/react": ["@floating-ui/react@0.26.28", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.8", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw=="],
|
||||||
|
|
||||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="],
|
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="],
|
||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
||||||
|
|
||||||
|
"@headlessui/react": ["@headlessui/react@2.2.4", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.20.2", "@react-aria/interactions": "^3.25.0", "@tanstack/react-virtual": "^3.13.9", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-lz+OGcAH1dK93rgSMzXmm1qKOJkBUqZf1L4M8TWLNplftQD3IkoEDdUFNfAn4ylsN6WOTVtWaLmvmaHOUk1dTA=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||||
|
|
||||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
@@ -377,6 +382,20 @@
|
|||||||
|
|
||||||
"@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="],
|
"@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="],
|
||||||
|
|
||||||
|
"@react-aria/focus": ["@react-aria/focus@3.20.3", "", { "dependencies": { "@react-aria/interactions": "^3.25.1", "@react-aria/utils": "^3.29.0", "@react-types/shared": "^3.29.1", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-rR5uZUMSY4xLHmpK/I8bP1V6vUNHFo33gTvrvNUsAKKqvMfa7R2nu5A6v97dr5g6tVH6xzpdkPsOJCWh90H2cw=="],
|
||||||
|
|
||||||
|
"@react-aria/interactions": ["@react-aria/interactions@3.25.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.8", "@react-aria/utils": "^3.29.0", "@react-stately/flags": "^3.1.1", "@react-types/shared": "^3.29.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ntLrlgqkmZupbbjekz3fE/n3eQH2vhncx8gUp0+N+GttKWevx7jos11JUBjnJwb1RSOPgRUFcrluOqBp0VgcfQ=="],
|
||||||
|
|
||||||
|
"@react-aria/ssr": ["@react-aria/ssr@3.9.8", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw=="],
|
||||||
|
|
||||||
|
"@react-aria/utils": ["@react-aria/utils@3.29.0", "", { "dependencies": { "@react-aria/ssr": "^3.9.8", "@react-stately/flags": "^3.1.1", "@react-stately/utils": "^3.10.6", "@react-types/shared": "^3.29.1", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-jSOrZimCuT1iKNVlhjIxDkAhgF7HSp3pqyT6qjg/ZoA0wfqCi/okmrMPiWSAKBnkgX93N8GYTLT3CIEO6WZe9Q=="],
|
||||||
|
|
||||||
|
"@react-stately/flags": ["@react-stately/flags@3.1.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg=="],
|
||||||
|
|
||||||
|
"@react-stately/utils": ["@react-stately/utils@3.10.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-O76ip4InfTTzAJrg8OaZxKU4vvjMDOpfA/PGNOytiXwBbkct2ZeZwaimJ8Bt9W1bj5VsZ81/o/tW4BacbdDOMA=="],
|
||||||
|
|
||||||
|
"@react-types/shared": ["@react-types/shared@3.29.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-KtM+cDf2CXoUX439rfEhbnEdAgFZX20UP2A35ypNIawR7/PFFPjQDWyA2EnClCcW/dLWJDEPX2U8+EJff8xqmQ=="],
|
||||||
|
|
||||||
"@rollup/plugin-babel": ["@rollup/plugin-babel@5.3.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.10.4", "@rollup/pluginutils": "^3.1.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "@types/babel__core": "^7.1.9", "rollup": "^1.20.0||^2.0.0" }, "optionalPeers": ["@types/babel__core"] }, "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q=="],
|
"@rollup/plugin-babel": ["@rollup/plugin-babel@5.3.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.10.4", "@rollup/pluginutils": "^3.1.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "@types/babel__core": "^7.1.9", "rollup": "^1.20.0||^2.0.0" }, "optionalPeers": ["@types/babel__core"] }, "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q=="],
|
||||||
|
|
||||||
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@15.3.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA=="],
|
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@15.3.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA=="],
|
||||||
@@ -429,6 +448,8 @@
|
|||||||
|
|
||||||
"@surma/rollup-plugin-off-main-thread": ["@surma/rollup-plugin-off-main-thread@2.2.3", "", { "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" } }, "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ=="],
|
"@surma/rollup-plugin-off-main-thread": ["@surma/rollup-plugin-off-main-thread@2.2.3", "", { "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" } }, "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ=="],
|
||||||
|
|
||||||
|
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.5", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.5" } }, "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.5", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.5" } }, "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.5", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.5", "@tailwindcss/oxide-darwin-arm64": "4.1.5", "@tailwindcss/oxide-darwin-x64": "4.1.5", "@tailwindcss/oxide-freebsd-x64": "4.1.5", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.5", "@tailwindcss/oxide-linux-arm64-musl": "4.1.5", "@tailwindcss/oxide-linux-x64-gnu": "4.1.5", "@tailwindcss/oxide-linux-x64-musl": "4.1.5", "@tailwindcss/oxide-wasm32-wasi": "4.1.5", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.5", "@tailwindcss/oxide-win32-x64-msvc": "4.1.5" } }, "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA=="],
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.5", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.5", "@tailwindcss/oxide-darwin-arm64": "4.1.5", "@tailwindcss/oxide-darwin-x64": "4.1.5", "@tailwindcss/oxide-freebsd-x64": "4.1.5", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.5", "@tailwindcss/oxide-linux-arm64-musl": "4.1.5", "@tailwindcss/oxide-linux-x64-gnu": "4.1.5", "@tailwindcss/oxide-linux-x64-musl": "4.1.5", "@tailwindcss/oxide-wasm32-wasi": "4.1.5", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.5", "@tailwindcss/oxide-win32-x64-msvc": "4.1.5" } }, "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA=="],
|
||||||
@@ -471,6 +492,8 @@
|
|||||||
|
|
||||||
"@tanstack/react-store": ["@tanstack/react-store@0.7.0", "", { "dependencies": { "@tanstack/store": "0.7.0", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-S/Rq17HaGOk+tQHV/yrePMnG1xbsKZIl/VsNWnNXt4XW+tTY8dTlvpJH2ZQ3GRALsusG5K6Q3unAGJ2pd9W/Ng=="],
|
"@tanstack/react-store": ["@tanstack/react-store@0.7.0", "", { "dependencies": { "@tanstack/store": "0.7.0", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-S/Rq17HaGOk+tQHV/yrePMnG1xbsKZIl/VsNWnNXt4XW+tTY8dTlvpJH2ZQ3GRALsusG5K6Q3unAGJ2pd9W/Ng=="],
|
||||||
|
|
||||||
|
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.9", "", { "dependencies": { "@tanstack/virtual-core": "3.13.9" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA=="],
|
||||||
|
|
||||||
"@tanstack/router-core": ["@tanstack/router-core@1.119.0", "", { "dependencies": { "@tanstack/history": "1.115.0", "@tanstack/store": "^0.7.0", "tiny-invariant": "^1.3.3" } }, "sha512-3dZYP5cCq3jJYgnRDzKR3w4sYzrXP5sw1st303ye87VV26r31I8UaIuUEs7kiJaxgWBvqHglWCiygBWQODZXVw=="],
|
"@tanstack/router-core": ["@tanstack/router-core@1.119.0", "", { "dependencies": { "@tanstack/history": "1.115.0", "@tanstack/store": "^0.7.0", "tiny-invariant": "^1.3.3" } }, "sha512-3dZYP5cCq3jJYgnRDzKR3w4sYzrXP5sw1st303ye87VV26r31I8UaIuUEs7kiJaxgWBvqHglWCiygBWQODZXVw=="],
|
||||||
|
|
||||||
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.119.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.119.0", "csstype": "^3.0.10", "solid-js": ">=1.9.5", "tiny-invariant": "^1.3.3" }, "optionalPeers": ["csstype"] }, "sha512-CH2Hx4J2UOigFtKR0anQfNiWQfidV2S7AZafkeo/S885IxwoFK7xXWzYxNbUhCDJC2tsBJ+XKjgxeBv5wGi62Q=="],
|
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.119.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.119.0", "csstype": "^3.0.10", "solid-js": ">=1.9.5", "tiny-invariant": "^1.3.3" }, "optionalPeers": ["csstype"] }, "sha512-CH2Hx4J2UOigFtKR0anQfNiWQfidV2S7AZafkeo/S885IxwoFK7xXWzYxNbUhCDJC2tsBJ+XKjgxeBv5wGi62Q=="],
|
||||||
@@ -483,6 +506,8 @@
|
|||||||
|
|
||||||
"@tanstack/store": ["@tanstack/store@0.7.0", "", {}, "sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg=="],
|
"@tanstack/store": ["@tanstack/store@0.7.0", "", {}, "sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg=="],
|
||||||
|
|
||||||
|
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.9", "", {}, "sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ=="],
|
||||||
|
|
||||||
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.115.0", "", {}, "sha512-XLUh1Py3AftcERrxkxC5Y5m5mfllRH3YR6YVlyjFgI2Tc2Ssy2NKmQFQIafoxfW459UJ8Dn81nWKETEIJifE4g=="],
|
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.115.0", "", {}, "sha512-XLUh1Py3AftcERrxkxC5Y5m5mfllRH3YR6YVlyjFgI2Tc2Ssy2NKmQFQIafoxfW459UJ8Dn81nWKETEIJifE4g=="],
|
||||||
|
|
||||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
@@ -493,7 +518,7 @@
|
|||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="],
|
"@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||||
|
|
||||||
@@ -581,7 +606,7 @@
|
|||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
|
"bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="],
|
||||||
|
|
||||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||||
|
|
||||||
@@ -1097,6 +1122,8 @@
|
|||||||
|
|
||||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||||
|
|
||||||
|
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.2.0", "", {}, "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA=="],
|
"tailwind-merge": ["tailwind-merge@3.2.0", "", {}, "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="],
|
"tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="],
|
||||||
@@ -1133,6 +1160,8 @@
|
|||||||
|
|
||||||
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"tsx": ["tsx@4.19.4", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q=="],
|
"tsx": ["tsx@4.19.4", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q=="],
|
||||||
|
|
||||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||||
@@ -1295,6 +1324,8 @@
|
|||||||
|
|
||||||
"regjsparser/jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="],
|
"regjsparser/jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="],
|
||||||
|
|
||||||
|
"server/@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="],
|
||||||
|
|
||||||
"sharp/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
|
"sharp/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
|
||||||
|
|
||||||
"source-map/whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
|
"source-map/whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
|
||||||
@@ -1311,6 +1342,8 @@
|
|||||||
|
|
||||||
"prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
"prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||||
|
|
||||||
|
"server/@types/bun/bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
|
||||||
|
|
||||||
"source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
|
"source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
|
||||||
|
|
||||||
"source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
|
"source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
|
||||||
|
@@ -7,4 +7,30 @@ interface Collection {
|
|||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds bookmarks in a collection that match the given criteria
|
||||||
|
* @param collection The collection to search in
|
||||||
|
* @param options Optional search criteria
|
||||||
|
* @returns Array of matching bookmarks
|
||||||
|
*/
|
||||||
|
function findCollectionBookmarks(
|
||||||
|
collection: Collection,
|
||||||
|
options?: {
|
||||||
|
searchTerm?: string
|
||||||
|
}
|
||||||
|
): Bookmark[] {
|
||||||
|
if (!options?.searchTerm) {
|
||||||
|
return collection.bookmarks
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = options.searchTerm.toLowerCase()
|
||||||
|
return collection.bookmarks.filter((bookmark) => {
|
||||||
|
return (
|
||||||
|
bookmark.title.toLowerCase().includes(searchTerm) ||
|
||||||
|
bookmark.url.toLowerCase().includes(searchTerm)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type { Collection }
|
export type { Collection }
|
||||||
|
export { findCollectionBookmarks }
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import type { User } from "@markone/core"
|
import type { User } from "@markone/core"
|
||||||
import type { Collection } from "@markone/core"
|
import type { Collection, Bookmark } from "@markone/core"
|
||||||
import { db } from "~/database.js"
|
import { db } from "~/database.js"
|
||||||
|
|
||||||
function findCollections(user: User): Collection[] {
|
function findCollections(user: User): Collection[] {
|
||||||
@@ -50,10 +50,23 @@ function deleteCollection(collectionId: string, user: User): void {
|
|||||||
|
|
||||||
function findCollectionById(collectionId: string, user: User): Collection | null {
|
function findCollectionById(collectionId: string, user: User): Collection | null {
|
||||||
return db.query<Collection, { id: string; userId: string }>(`
|
return db.query<Collection, { id: string; userId: string }>(`
|
||||||
SELECT *
|
SELECT id, name, description
|
||||||
FROM collections
|
FROM collections
|
||||||
WHERE id = $id AND user_id = $userId
|
WHERE id = $id AND user_id = $userId
|
||||||
`).get({ id: collectionId, userId: user.id })
|
`).get({ id: collectionId, userId: user.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
export { findCollections, insertCollection, updateCollection, deleteCollection, findCollectionById }
|
function findCollectionBookmarks(
|
||||||
|
collection: Collection,
|
||||||
|
user: User
|
||||||
|
): Bookmark[] {
|
||||||
|
return db.query<Bookmark, { collectionId: string; userId: string }>(`
|
||||||
|
SELECT b.id, b.title, b.url
|
||||||
|
FROM bookmarks b
|
||||||
|
INNER JOIN bookmark_collections bc ON b.id = bc.bookmark_id
|
||||||
|
WHERE b.user_id = $userId
|
||||||
|
AND bc.collection_id = $collectionId
|
||||||
|
`).all({ collectionId: collection.id, userId: user.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export { findCollections, insertCollection, updateCollection, deleteCollection, findCollectionById, findCollectionBookmarks }
|
||||||
|
@@ -2,7 +2,7 @@ import { type Collection, DEMO_USER, type User } from "@markone/core"
|
|||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
import { HttpError } from "~/error.ts"
|
import { HttpError } from "~/error.ts"
|
||||||
import { findCollections, insertCollection, deleteCollection, updateCollection, findCollectionById } from "./collection.ts"
|
import { findCollections, insertCollection, deleteCollection, updateCollection, findCollectionById, findCollectionBookmarks } from "./collection.ts"
|
||||||
|
|
||||||
const AddCollectionRequestBody = type({
|
const AddCollectionRequestBody = type({
|
||||||
title: "string",
|
title: "string",
|
||||||
@@ -46,6 +46,16 @@ async function listUserCollections(request: Bun.BunRequest<"/api/collections">,
|
|||||||
return Response.json(collections, { status: 200 })
|
return Response.json(collections, { status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchUserCollection(request: Bun.BunRequest<"/api/collections/:id">, user: User) {
|
||||||
|
const collection = findCollectionById(request.params.id, user)
|
||||||
|
if (!collection) {
|
||||||
|
throw new HttpError(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmarks = findCollectionBookmarks(collection, user)
|
||||||
|
return Response.json({ ...collection, bookmarks }, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteUserCollection(request: Bun.BunRequest<"/api/collections/:id">, user: User) {
|
async function deleteUserCollection(request: Bun.BunRequest<"/api/collections/:id">, user: User) {
|
||||||
if (user.id !== DEMO_USER.id) {
|
if (user.id !== DEMO_USER.id) {
|
||||||
deleteCollection(request.params.id, user)
|
deleteCollection(request.params.id, user)
|
||||||
@@ -79,4 +89,14 @@ async function updateUserCollection(request: Bun.BunRequest<"/api/collections/:i
|
|||||||
return Response.json(existingCollection, { status: 200 })
|
return Response.json(existingCollection, { status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
export { createCollection, listUserCollections, deleteUserCollection, updateUserCollection }
|
async function getCollectionBookmarks(request: Bun.BunRequest<"/api/collections/:id/bookmarks">, user: User) {
|
||||||
|
const collection = findCollectionById(request.params.id, user)
|
||||||
|
if (!collection) {
|
||||||
|
throw new HttpError(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmarks = findCollectionBookmarks(collection, user)
|
||||||
|
return Response.json(bookmarks, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createCollection, listUserCollections, deleteUserCollection, updateUserCollection, getCollectionBookmarks, fetchUserCollection }
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { createCollection, deleteUserCollection, listUserCollections } from "~/collection/handlers.js"
|
import { createCollection, deleteUserCollection, getCollectionBookmarks, fetchUserCollection, listUserCollections } from "~/collection/handlers.js"
|
||||||
import { authenticated, login, logout, signUp, startBackgroundAuthTokenCleanup } from "./auth/auth.ts"
|
import { authenticated, login, logout, signUp, startBackgroundAuthTokenCleanup } from "./auth/auth.ts"
|
||||||
import { startBackgroundSessionCleanup } from "./auth/session.ts"
|
import { startBackgroundSessionCleanup } from "./auth/session.ts"
|
||||||
import { insertDemoBookmarks } from "./bookmark/bookmark.ts"
|
import { insertDemoBookmarks } from "./bookmark/bookmark.ts"
|
||||||
@@ -59,12 +59,16 @@ async function main() {
|
|||||||
POST: authenticated(createCollection),
|
POST: authenticated(createCollection),
|
||||||
},
|
},
|
||||||
"/api/collections/:id": {
|
"/api/collections/:id": {
|
||||||
|
GET: authenticated(fetchUserCollection),
|
||||||
DELETE: authenticated(deleteUserCollection),
|
DELETE: authenticated(deleteUserCollection),
|
||||||
OPTIONS: preflightHandler({
|
OPTIONS: preflightHandler({
|
||||||
allowedMethods: ["GET", "POST", "DELETE", "PATCH", "OPTIONS"],
|
allowedMethods: ["GET", "POST", "DELETE", "PATCH", "OPTIONS"],
|
||||||
allowedHeaders: ["Accept"],
|
allowedHeaders: ["Accept"],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
"/api/collections/:id/bookmarks": {
|
||||||
|
GET: authenticated(getCollectionBookmarks),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
port: 8080,
|
port: 8080,
|
||||||
|
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react-dom": "^2.1.2",
|
"@floating-ui/react-dom": "^2.1.2",
|
||||||
|
"@headlessui/react": "^2.2.4",
|
||||||
"@markone/core": "workspace:*",
|
"@markone/core": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"@tanstack/react-query": "^5.75.2",
|
"@tanstack/react-query": "^5.75.2",
|
||||||
|
@@ -18,6 +18,7 @@ import { Route as BookmarksImport } from "./bookmarks"
|
|||||||
import { Route as IndexImport } from "./index"
|
import { Route as IndexImport } from "./index"
|
||||||
import { Route as CollectionsIndexImport } from "./collections/index"
|
import { Route as CollectionsIndexImport } from "./collections/index"
|
||||||
import { Route as BookmarksIndexImport } from "./bookmarks/index"
|
import { Route as BookmarksIndexImport } from "./bookmarks/index"
|
||||||
|
import { Route as CollectionsCollectionIdImport } from "./collections/$collectionId"
|
||||||
import { Route as BookmarksBookmarkIdImport } from "./bookmarks/$bookmarkId"
|
import { Route as BookmarksBookmarkIdImport } from "./bookmarks/$bookmarkId"
|
||||||
|
|
||||||
// Create/Update Routes
|
// Create/Update Routes
|
||||||
@@ -64,6 +65,12 @@ const BookmarksIndexRoute = BookmarksIndexImport.update({
|
|||||||
getParentRoute: () => BookmarksRoute,
|
getParentRoute: () => BookmarksRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const CollectionsCollectionIdRoute = CollectionsCollectionIdImport.update({
|
||||||
|
id: "/$collectionId",
|
||||||
|
path: "/$collectionId",
|
||||||
|
getParentRoute: () => CollectionsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const BookmarksBookmarkIdRoute = BookmarksBookmarkIdImport.update({
|
const BookmarksBookmarkIdRoute = BookmarksBookmarkIdImport.update({
|
||||||
id: "/$bookmarkId",
|
id: "/$bookmarkId",
|
||||||
path: "/$bookmarkId",
|
path: "/$bookmarkId",
|
||||||
@@ -116,6 +123,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof BookmarksBookmarkIdImport
|
preLoaderRoute: typeof BookmarksBookmarkIdImport
|
||||||
parentRoute: typeof BookmarksImport
|
parentRoute: typeof BookmarksImport
|
||||||
}
|
}
|
||||||
|
"/collections/$collectionId": {
|
||||||
|
id: "/collections/$collectionId"
|
||||||
|
path: "/$collectionId"
|
||||||
|
fullPath: "/collections/$collectionId"
|
||||||
|
preLoaderRoute: typeof CollectionsCollectionIdImport
|
||||||
|
parentRoute: typeof CollectionsImport
|
||||||
|
}
|
||||||
"/bookmarks/": {
|
"/bookmarks/": {
|
||||||
id: "/bookmarks/"
|
id: "/bookmarks/"
|
||||||
path: "/"
|
path: "/"
|
||||||
@@ -150,10 +164,12 @@ const BookmarksRouteWithChildren = BookmarksRoute._addFileChildren(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface CollectionsRouteChildren {
|
interface CollectionsRouteChildren {
|
||||||
|
CollectionsCollectionIdRoute: typeof CollectionsCollectionIdRoute
|
||||||
CollectionsIndexRoute: typeof CollectionsIndexRoute
|
CollectionsIndexRoute: typeof CollectionsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const CollectionsRouteChildren: CollectionsRouteChildren = {
|
const CollectionsRouteChildren: CollectionsRouteChildren = {
|
||||||
|
CollectionsCollectionIdRoute: CollectionsCollectionIdRoute,
|
||||||
CollectionsIndexRoute: CollectionsIndexRoute,
|
CollectionsIndexRoute: CollectionsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +184,7 @@ export interface FileRoutesByFullPath {
|
|||||||
"/login": typeof LoginRoute
|
"/login": typeof LoginRoute
|
||||||
"/signup": typeof SignupRoute
|
"/signup": typeof SignupRoute
|
||||||
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
||||||
|
"/collections/$collectionId": typeof CollectionsCollectionIdRoute
|
||||||
"/bookmarks/": typeof BookmarksIndexRoute
|
"/bookmarks/": typeof BookmarksIndexRoute
|
||||||
"/collections/": typeof CollectionsIndexRoute
|
"/collections/": typeof CollectionsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -177,6 +194,7 @@ export interface FileRoutesByTo {
|
|||||||
"/login": typeof LoginRoute
|
"/login": typeof LoginRoute
|
||||||
"/signup": typeof SignupRoute
|
"/signup": typeof SignupRoute
|
||||||
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
||||||
|
"/collections/$collectionId": typeof CollectionsCollectionIdRoute
|
||||||
"/bookmarks": typeof BookmarksIndexRoute
|
"/bookmarks": typeof BookmarksIndexRoute
|
||||||
"/collections": typeof CollectionsIndexRoute
|
"/collections": typeof CollectionsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -189,6 +207,7 @@ export interface FileRoutesById {
|
|||||||
"/login": typeof LoginRoute
|
"/login": typeof LoginRoute
|
||||||
"/signup": typeof SignupRoute
|
"/signup": typeof SignupRoute
|
||||||
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
||||||
|
"/collections/$collectionId": typeof CollectionsCollectionIdRoute
|
||||||
"/bookmarks/": typeof BookmarksIndexRoute
|
"/bookmarks/": typeof BookmarksIndexRoute
|
||||||
"/collections/": typeof CollectionsIndexRoute
|
"/collections/": typeof CollectionsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -202,6 +221,7 @@ export interface FileRouteTypes {
|
|||||||
| "/login"
|
| "/login"
|
||||||
| "/signup"
|
| "/signup"
|
||||||
| "/bookmarks/$bookmarkId"
|
| "/bookmarks/$bookmarkId"
|
||||||
|
| "/collections/$collectionId"
|
||||||
| "/bookmarks/"
|
| "/bookmarks/"
|
||||||
| "/collections/"
|
| "/collections/"
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -210,6 +230,7 @@ export interface FileRouteTypes {
|
|||||||
| "/login"
|
| "/login"
|
||||||
| "/signup"
|
| "/signup"
|
||||||
| "/bookmarks/$bookmarkId"
|
| "/bookmarks/$bookmarkId"
|
||||||
|
| "/collections/$collectionId"
|
||||||
| "/bookmarks"
|
| "/bookmarks"
|
||||||
| "/collections"
|
| "/collections"
|
||||||
id:
|
id:
|
||||||
@@ -220,6 +241,7 @@ export interface FileRouteTypes {
|
|||||||
| "/login"
|
| "/login"
|
||||||
| "/signup"
|
| "/signup"
|
||||||
| "/bookmarks/$bookmarkId"
|
| "/bookmarks/$bookmarkId"
|
||||||
|
| "/collections/$collectionId"
|
||||||
| "/bookmarks/"
|
| "/bookmarks/"
|
||||||
| "/collections/"
|
| "/collections/"
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -271,6 +293,7 @@ export const routeTree = rootRoute
|
|||||||
"/collections": {
|
"/collections": {
|
||||||
"filePath": "collections.tsx",
|
"filePath": "collections.tsx",
|
||||||
"children": [
|
"children": [
|
||||||
|
"/collections/$collectionId",
|
||||||
"/collections/"
|
"/collections/"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -284,6 +307,10 @@ export const routeTree = rootRoute
|
|||||||
"filePath": "bookmarks/$bookmarkId.tsx",
|
"filePath": "bookmarks/$bookmarkId.tsx",
|
||||||
"parent": "/bookmarks"
|
"parent": "/bookmarks"
|
||||||
},
|
},
|
||||||
|
"/collections/$collectionId": {
|
||||||
|
"filePath": "collections/$collectionId.tsx",
|
||||||
|
"parent": "/collections"
|
||||||
|
},
|
||||||
"/bookmarks/": {
|
"/bookmarks/": {
|
||||||
"filePath": "bookmarks/index.tsx",
|
"filePath": "bookmarks/index.tsx",
|
||||||
"parent": "/bookmarks"
|
"parent": "/bookmarks"
|
||||||
|
43
packages/web/src/app/-side-nav.tsx
Normal file
43
packages/web/src/app/-side-nav.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Link, useRouterState, useNavigate } from "@tanstack/react-router"
|
||||||
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
|
|
||||||
|
function SideNav({ enableMnemonics }: { enableMnemonics: boolean }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useMnemonics(
|
||||||
|
{
|
||||||
|
b: () => navigate({ to: "/bookmarks" }),
|
||||||
|
c: () => navigate({ to: "/collections" }),
|
||||||
|
},
|
||||||
|
{ ignore: () => !enableMnemonics },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="mb-4 md:mb-0 md:mr-24 text-start">
|
||||||
|
<nav className="mb-8">
|
||||||
|
<ul className="flex flex-col space-y-2">
|
||||||
|
<NavItem to="/bookmarks" label="BOOKMARKS" />
|
||||||
|
<NavItem to="/collections" label="COLLECTIONS" />
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({ to, label }: { to: string; label: string }) {
|
||||||
|
const currentPath = useRouterState({ select: (state) => state.location.pathname })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link to={to}>
|
||||||
|
<span className="font-bold">[{currentPath === to ? "•" : " "}]</span>{" "}
|
||||||
|
<span className={currentPath === to ? "font-bold" : ""}>
|
||||||
|
<span className="underline">{label.charAt(0)}</span>
|
||||||
|
{label.substring(1)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SideNav }
|
@@ -1,12 +1,13 @@
|
|||||||
import type { Bookmark } from "@markone/core"
|
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 { memo, useCallback } from "react"
|
import { memo, useCallback, useRef } from "react"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { useBookmarkTags } from "~/bookmark/api"
|
import { useBookmarkTags } from "~/bookmark/api"
|
||||||
import { Button } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
import { List } from "~/components/list"
|
import { List, type ListRef } from "~/components/list"
|
||||||
import { DialogKind, useBookmarkPageStore } from "./-store"
|
import { DialogKind, ActionBarContentKind, useBookmarkPageStore } from "./-store"
|
||||||
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
|
|
||||||
export enum BookmarkListItemAction {
|
export enum BookmarkListItemAction {
|
||||||
Open = "Open",
|
Open = "Open",
|
||||||
@@ -129,6 +130,7 @@ function BookmarkList({
|
|||||||
onItemAction,
|
onItemAction,
|
||||||
className,
|
className,
|
||||||
}: BookmarkListProps) {
|
}: BookmarkListProps) {
|
||||||
|
const listRef = useRef<ListRef<Bookmark>>(null)
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(bookmark: Bookmark) => {
|
(bookmark: Bookmark) => {
|
||||||
onSelectionChange?.(bookmark)
|
onSelectionChange?.(bookmark)
|
||||||
@@ -136,12 +138,32 @@ function BookmarkList({
|
|||||||
[onSelectionChange],
|
[onSelectionChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleExpand = useCallback((bookmark: Bookmark) => {
|
useMnemonics(
|
||||||
// No-op since expansion is handled by the List component
|
{
|
||||||
}, [])
|
e: () => {
|
||||||
|
const selectedBookmark = listRef.current?.selectedItem
|
||||||
|
if (selectedBookmark) {
|
||||||
|
onItemAction(selectedBookmark, BookmarkListItemAction.Edit)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
d: () => {
|
||||||
|
const selectedBookmark = listRef.current?.selectedItem
|
||||||
|
if (selectedBookmark) {
|
||||||
|
onItemAction(selectedBookmark, BookmarkListItemAction.Delete)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignore: useCallback(() => {
|
||||||
|
const state = useBookmarkPageStore.getState()
|
||||||
|
return state.dialog.kind !== DialogKind.None || state.actionBarContent.kind === ActionBarContentKind.SearchBar
|
||||||
|
}, []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
|
ref={listRef}
|
||||||
items={bookmarks}
|
items={bookmarks}
|
||||||
selectedItemId={selectedBookmarkId}
|
selectedItemId={selectedBookmarkId}
|
||||||
alwaysExpandItem={alwaysExpandItem}
|
alwaysExpandItem={alwaysExpandItem}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useId, useRef, useState } from "react"
|
import { useEffect, useId, useRef, useState } from "react"
|
||||||
import { ApiErrorCode, BadRequestError } from "~/api"
|
import { ApiErrorCode, BadRequestError } from "~/api"
|
||||||
import { useCreateBookmark } from "~/bookmark/api"
|
import { useCreateBookmark, useTags } from "~/bookmark/api"
|
||||||
import { Button } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
||||||
import { FormField } from "~/components/form-field"
|
import { FormField } from "~/components/form-field"
|
||||||
@@ -13,6 +13,7 @@ import { DialogKind, useBookmarkPageStore } from "../-store"
|
|||||||
function AddBookmarkDialog() {
|
function AddBookmarkDialog() {
|
||||||
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
|
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
|
||||||
const createBookmarkMutation = useCreateBookmark()
|
const createBookmarkMutation = useCreateBookmark()
|
||||||
|
const { data: tags, status: tagsStatus } = useTags()
|
||||||
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||||
const formId = useId()
|
const formId = useId()
|
||||||
const linkInputRef = useRef<HTMLInputElement | null>(null)
|
const linkInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
@@ -21,7 +22,7 @@ function AddBookmarkDialog() {
|
|||||||
useMnemonics(
|
useMnemonics(
|
||||||
{
|
{
|
||||||
c: () => {
|
c: () => {
|
||||||
if (linkInputRef.current !== document.activeElement && tagsInputRef.current !== document.activeElement) {
|
if (linkInputRef.current !== document.activeElement && tagsInputRef.current?.input !== document.activeElement) {
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -112,7 +113,13 @@ function AddBookmarkDialog() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
labelClassName="bg-stone-300 dark:bg-stone-800"
|
labelClassName="bg-stone-300 dark:bg-stone-800"
|
||||||
/>
|
/>
|
||||||
<TagsInput ref={tagsInputRef} />
|
{tagsStatus === "success" ? (
|
||||||
|
<TagsInput ref={tagsInputRef} tags={tags} />
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Loading tags <LoadingSpinner />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
<DialogActionRow>
|
<DialogActionRow>
|
||||||
|
@@ -1,78 +1,151 @@
|
|||||||
import type { Bookmark, Tag } from "@markone/core"
|
import type { Bookmark } from "@markone/core"
|
||||||
import { useEffect, useId, useImperativeHandle, useRef } from "react"
|
import { useEffect, useId, useRef, useState } from "react"
|
||||||
import { useBookmarkTags, useUpdateBookmark } from "~/bookmark/api"
|
import { ApiErrorCode, BadRequestError } from "~/api"
|
||||||
|
import { useBookmarkTags, useTags, useUpdateBookmark } from "~/bookmark/api"
|
||||||
import { Button } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
||||||
import { FormField } from "~/components/form-field"
|
import { FormField } from "~/components/form-field"
|
||||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||||
import { Message, MessageVariant } from "~/components/message.tsx"
|
import { Message, MessageVariant } from "~/components/message"
|
||||||
import { TagsInput, type TagsInputRef } from "~/components/tags-input"
|
import { TagsInput, type TagsInputRef } from "~/components/tags-input.tsx"
|
||||||
import { useMnemonics } from "~/hooks/use-mnemonics.ts"
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
import { useBookmarkPageStore } from "../-store"
|
import { DialogKind, useBookmarkPageStore } from "../-store"
|
||||||
|
|
||||||
interface EditFormRef {
|
|
||||||
form: HTMLFormElement | null
|
|
||||||
titleInput: HTMLInputElement | null
|
|
||||||
tagsInput: TagsInputRef | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
|
function EditBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
|
||||||
const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
|
const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false)
|
||||||
const { data: tags, status } = useBookmarkTags(bookmark)
|
const updateBookmarkMutation = useUpdateBookmark(bookmark)
|
||||||
const editFormId = useId()
|
const { data: tags, status: tagsStatus } = useTags()
|
||||||
const editFormRef = useRef<EditFormRef | null>(null)
|
const { data: bookmarkTags, status: bookmarkTagsStatus } = useBookmarkTags(bookmark)
|
||||||
|
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||||
|
const formId = useId()
|
||||||
|
const linkInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const tagsInputRef = useRef<TagsInputRef | null>(null)
|
||||||
|
|
||||||
useMnemonics(
|
useMnemonics(
|
||||||
{
|
{
|
||||||
s: () => {
|
|
||||||
if (
|
|
||||||
editFormRef.current &&
|
|
||||||
editFormRef.current.titleInput !== document.activeElement &&
|
|
||||||
editFormRef.current.tagsInput?.input !== document.activeElement
|
|
||||||
) {
|
|
||||||
editFormRef.current.form?.requestSubmit()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
c: () => {
|
c: () => {
|
||||||
if (
|
if (linkInputRef.current !== document.activeElement && tagsInputRef.current?.input !== document.activeElement) {
|
||||||
editFormRef.current?.titleInput !== document.activeElement &&
|
cancel()
|
||||||
editFormRef.current?.tagsInput?.input !== document.activeElement
|
|
||||||
) {
|
|
||||||
closeDialog()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Escape: () => {
|
Escape: () => {
|
||||||
editFormRef.current?.titleInput?.blur()
|
linkInputRef.current?.blur()
|
||||||
editFormRef.current?.tagsInput?.input?.blur()
|
tagsInputRef.current?.input?.blur()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ ignore: () => false },
|
{ ignore: () => false },
|
||||||
)
|
)
|
||||||
|
|
||||||
function content() {
|
// when using autoFocus, it also captures the "a" mnemonic
|
||||||
switch (status) {
|
// which appends the "a" to the input
|
||||||
case "pending":
|
// this is to prevent the "a" mnemonic to be captured as input to the text box
|
||||||
return (
|
useEffect(() => {
|
||||||
<p>
|
setTimeout(() => {
|
||||||
Loading <LoadingSpinner />
|
if (linkInputRef.current) {
|
||||||
</p>
|
linkInputRef.current.focus()
|
||||||
)
|
}
|
||||||
case "success":
|
}, 0)
|
||||||
return <EditForm ref={editFormRef} formId={editFormId} bookmark={bookmark} tags={tags} />
|
}, [])
|
||||||
case "error":
|
|
||||||
return null
|
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
if (tagsInputRef.current) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget)
|
||||||
|
const title = formData.get("title")
|
||||||
|
if (title && typeof title === "string") {
|
||||||
|
try {
|
||||||
|
await updateBookmarkMutation.mutateAsync({
|
||||||
|
title,
|
||||||
|
tags: tagsInputRef.current.tags,
|
||||||
|
})
|
||||||
|
setActiveDialog({ kind: DialogKind.None })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BadRequestError && error.code === ApiErrorCode.LinkUnreachable) {
|
||||||
|
setIsWebsiteUnreachable(true)
|
||||||
|
} else {
|
||||||
|
setIsWebsiteUnreachable(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setActiveDialog({ kind: DialogKind.None })
|
||||||
|
}
|
||||||
|
|
||||||
|
function message() {
|
||||||
|
if (updateBookmarkMutation.isPending) {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Loading <LoadingSpinner />
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isWebsiteUnreachable) {
|
||||||
|
return (
|
||||||
|
<Message variant={MessageVariant.Warning} className="px-4">
|
||||||
|
The link does not seem to be reachable. Click "SAVE" to save anyways.
|
||||||
|
</Message>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (updateBookmarkMutation.status === "error") {
|
||||||
|
return (
|
||||||
|
<Message variant={MessageVariant.Error} className="px-4">
|
||||||
|
An error occurred when saving bookmark
|
||||||
|
</Message>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function content() {
|
||||||
|
if (tagsStatus === "pending" || bookmarkTagsStatus === "pending") {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Loading <LoadingSpinner />
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tagsStatus === "error" || bookmarkTagsStatus === "error") {
|
||||||
|
return (
|
||||||
|
<Message variant={MessageVariant.Error} className="px-4">
|
||||||
|
An error occurred when loading tags
|
||||||
|
</Message>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tags && bookmarkTags) {
|
||||||
|
return (
|
||||||
|
<form id={formId} className="px-8" onSubmit={onSubmit}>
|
||||||
|
<FormField
|
||||||
|
ref={linkInputRef}
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
label="TITLE"
|
||||||
|
className="w-full"
|
||||||
|
labelClassName="bg-stone-300 dark:bg-stone-800"
|
||||||
|
defaultValue={bookmark.title}
|
||||||
|
/>
|
||||||
|
<TagsInput ref={tagsInputRef} tags={tags} initialSelections={bookmarkTags} />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTitle>EDIT BOOKMARK</DialogTitle>
|
<DialogTitle>EDIT BOOKMARK</DialogTitle>
|
||||||
<DialogBody>{content()}</DialogBody>
|
<DialogBody>
|
||||||
|
{message()}
|
||||||
|
{content()}
|
||||||
|
</DialogBody>
|
||||||
<DialogActionRow>
|
<DialogActionRow>
|
||||||
<Button type="submit" form={editFormId} disabled={status !== "success"}>
|
<Button type="submit" disabled={updateBookmarkMutation.isPending} form={formId}>
|
||||||
<span className="underline">S</span>AVE
|
SAVE
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={status !== "success"} onClick={closeDialog}>
|
<Button type="button" disabled={updateBookmarkMutation.isPending} onClick={cancel}>
|
||||||
<span className="underline">C</span>ANCEL
|
<span className="underline">C</span>ANCEL
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActionRow>
|
</DialogActionRow>
|
||||||
@@ -80,83 +153,4 @@ function EditBookmarkDialog({ bookmark }: { bookmark: Bookmark }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditForm({
|
|
||||||
ref,
|
|
||||||
formId,
|
|
||||||
bookmark,
|
|
||||||
tags,
|
|
||||||
}: { ref: React.Ref<EditFormRef>; formId: string; bookmark: Bookmark; tags: Tag[] }) {
|
|
||||||
const formRef = useRef<HTMLFormElement | null>(null)
|
|
||||||
const titleInputRef = useRef<HTMLInputElement | null>(null)
|
|
||||||
const tagsInputRef = useRef<TagsInputRef>(null)
|
|
||||||
const updateBookmarkMutation = useUpdateBookmark(bookmark)
|
|
||||||
const closeDialog = useBookmarkPageStore((state) => state.closeDialog)
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
form: formRef.current,
|
|
||||||
titleInput: titleInputRef.current,
|
|
||||||
tagsInput: tagsInputRef.current,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// when using autoFocus, it also captures the "e" mnemonic
|
|
||||||
// which appends the "e" to the input
|
|
||||||
// this is to prevent the "e" mnemonic to be captured as input to the text box
|
|
||||||
useEffect(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
titleInputRef.current?.focus()
|
|
||||||
}, 0)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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 ref={formRef} id={formId} onSubmit={onSubmit}>
|
|
||||||
<FormField
|
|
||||||
ref={titleInputRef}
|
|
||||||
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 }
|
export { EditBookmarkDialog }
|
||||||
|
@@ -59,7 +59,6 @@ interface BookmarkPageState {
|
|||||||
bookmarkToBeEdited: Bookmark | null
|
bookmarkToBeEdited: Bookmark | null
|
||||||
layoutMode: LayoutMode
|
layoutMode: LayoutMode
|
||||||
dialog: DialogData
|
dialog: DialogData
|
||||||
hasDialog: boolean
|
|
||||||
actionBarContent: ActionBarContent
|
actionBarContent: ActionBarContent
|
||||||
statusMessage: string
|
statusMessage: string
|
||||||
activeWindow: WindowKind
|
activeWindow: WindowKind
|
||||||
@@ -82,10 +81,6 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
|||||||
statusMessage: "",
|
statusMessage: "",
|
||||||
activeWindow: WindowKind.None,
|
activeWindow: WindowKind.None,
|
||||||
|
|
||||||
get hasDialog(): boolean {
|
|
||||||
return get().dialog.kind !== DialogKind.None
|
|
||||||
},
|
|
||||||
|
|
||||||
setActiveWindow(window: WindowKind) {
|
setActiveWindow(window: WindowKind) {
|
||||||
set({ activeWindow: window })
|
set({ activeWindow: window })
|
||||||
},
|
},
|
||||||
|
@@ -1,18 +1,19 @@
|
|||||||
import { autoUpdate, offset, useFloating } from "@floating-ui/react-dom"
|
import { autoUpdate, offset, useFloating } from "@floating-ui/react-dom"
|
||||||
import type { Tag } from "@markone/core"
|
import type { Tag } from "@markone/core"
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"
|
||||||
import { memo, useCallback, useEffect, useId, useRef } from "react"
|
import { memo, useCallback, useEffect, useId, useRef } from "react"
|
||||||
import { fetchApi, useAuthenticatedQuery } from "~/api"
|
import { fetchApi, useAuthenticatedQuery } from "~/api"
|
||||||
import { ActionBar } from "~/app/bookmarks/-action-bar.tsx"
|
import { ActionBar } from "~/app/bookmarks/-action-bar.tsx"
|
||||||
import { useLogOut } from "~/auth.ts"
|
import { useLogOut } from "~/auth.ts"
|
||||||
import { useTags } from "~/bookmark/api.ts"
|
import { useTags } from "~/bookmark/api.ts"
|
||||||
import { Button, LinkButton } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
import { LoadingSpinner } from "~/components/loading-spinner"
|
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||||
import { Message, MessageVariant } from "~/components/message.tsx"
|
import { Message, MessageVariant } from "~/components/message.tsx"
|
||||||
import { useDocumentEvent } from "~/hooks/use-document-event.ts"
|
import { useDocumentEvent } from "~/hooks/use-document-event.ts"
|
||||||
import { useMnemonics } from "~/hooks/use-mnemonics.ts"
|
import { useMnemonics } from "~/hooks/use-mnemonics.ts"
|
||||||
import { BookmarkList } from "./-bookmark-list"
|
import { BookmarkList } from "./-bookmark-list"
|
||||||
import { ActionBarContentKind, DialogKind, WindowKind, useBookmarkPageStore } from "./-store"
|
import { ActionBarContentKind, DialogKind, WindowKind, useBookmarkPageStore } from "./-store"
|
||||||
|
import { SideNav } from "~/app/-side-nav"
|
||||||
|
|
||||||
export const Route = createFileRoute("/bookmarks/")({
|
export const Route = createFileRoute("/bookmarks/")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -30,12 +31,7 @@ function RouteComponent() {
|
|||||||
function BookmarkListPane() {
|
function BookmarkListPane() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-16 container max-w-3xl md:flex-row lg:py-32">
|
<div className="flex flex-col py-16 container max-w-3xl md:flex-row lg:py-32">
|
||||||
<header className="mb-4 md:mb-0 md:mr-16 text-start">
|
<_SideNav />
|
||||||
<h1 className="font-bold text-start">
|
|
||||||
<span className="invisible md:hidden"> > </span>
|
|
||||||
YOUR BOOKMARKS
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<BookmarkListContainer />
|
<BookmarkListContainer />
|
||||||
</div>
|
</div>
|
||||||
@@ -43,6 +39,11 @@ function BookmarkListPane() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _SideNav() {
|
||||||
|
const hasDialog = useBookmarkPageStore((state) => state.dialog.kind !== DialogKind.None)
|
||||||
|
return <SideNav enableMnemonics={!hasDialog} />
|
||||||
|
}
|
||||||
|
|
||||||
function BookmarkListContainer() {
|
function BookmarkListContainer() {
|
||||||
const searchParamsString = new URLSearchParams(Route.useSearch()).toString()
|
const searchParamsString = new URLSearchParams(Route.useSearch()).toString()
|
||||||
const { data: bookmarks, status } = useAuthenticatedQuery(
|
const { data: bookmarks, status } = useAuthenticatedQuery(
|
||||||
@@ -360,11 +361,6 @@ function AppMenuWindow({ ref, style }: { ref: React.Ref<HTMLDivElement>; style:
|
|||||||
<p className="bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800 text-center">MENU</p>
|
<p className="bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800 text-center">MENU</p>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<ul className="space-x-4 flex justify-center">
|
<ul className="space-x-4 flex justify-center">
|
||||||
<li>
|
|
||||||
<LinkButton to="/collections">
|
|
||||||
<span className="underline">C</span>OLLECTIONS
|
|
||||||
</LinkButton>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<LogOutButton />
|
<LogOutButton />
|
||||||
</li>
|
</li>
|
||||||
|
185
packages/web/src/app/collections/$collectionId.tsx
Normal file
185
packages/web/src/app/collections/$collectionId.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { autoUpdate, offset, useFloating } from "@floating-ui/react-dom"
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||||
|
import { useCallback } from "react"
|
||||||
|
import { ActionBar } from "~/app/bookmarks/-action-bar"
|
||||||
|
import { useLogOut } from "~/auth"
|
||||||
|
import { useCollection, useCollectionBookmarks } from "~/collection/api"
|
||||||
|
import { Button, LinkButton } from "~/components/button"
|
||||||
|
import { LoadingSpinner } from "~/components/loading-spinner"
|
||||||
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
|
import { BookmarkList } from "~/app/bookmarks/-bookmark-list"
|
||||||
|
import { DialogKind, WindowKind, useBookmarkPageStore } from "~/app/bookmarks/-store"
|
||||||
|
import type { Collection } from "@markone/core"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/collections/$collectionId")({
|
||||||
|
component: CollectionDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CollectionDetailPage() {
|
||||||
|
return (
|
||||||
|
<main className="w-full flex justify-center">
|
||||||
|
<CollectionDetailPane />
|
||||||
|
<CollectionDetailActionBar className="fixed left-0 right-0 bottom-0" />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionDetailPane() {
|
||||||
|
const { collectionId } = Route.useParams()
|
||||||
|
const { data: collection, status } = useCollection(collectionId)
|
||||||
|
|
||||||
|
if (status === "pending") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
<p>
|
||||||
|
Loading <LoadingSpinner />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error" || !collection) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
<p>Error loading collection</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col py-16 container max-w-3xl md:flex-row lg:py-32">
|
||||||
|
<header className="mb-4 md:mb-0 md:mr-16 text-start">
|
||||||
|
<h1 className="font-bold text-start">
|
||||||
|
<span className="invisible md:hidden"> > </span>
|
||||||
|
{collection.name}
|
||||||
|
</h1>
|
||||||
|
<p className="opacity-80 text-sm">{collection.description}</p>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1">
|
||||||
|
<CollectionBookmarkList collection={collection} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionBookmarkList({ collection }: { collection: Collection }) {
|
||||||
|
const { data: bookmarks, status } = useCollectionBookmarks(collection)
|
||||||
|
const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction)
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "success":
|
||||||
|
return (
|
||||||
|
<BookmarkList
|
||||||
|
className={bookmarks.length > 0 ? "-mt-2" : ""}
|
||||||
|
alwaysExpandItem={false}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
onItemAction={handleBookmarkListItemAction}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "pending":
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||||
|
<p>
|
||||||
|
Loading <LoadingSpinner />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh]">
|
||||||
|
<p>Error loading bookmarks</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionDetailActionBar({ className }: { className?: string }) {
|
||||||
|
const activeWindow = useBookmarkPageStore((state) => state.activeWindow)
|
||||||
|
const { refs, floatingStyles } = useFloating({
|
||||||
|
placement: "top",
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
middleware: [offset(8)],
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ActionBar ref={refs.setReference} className={className}>
|
||||||
|
<ActionButtons />
|
||||||
|
</ActionBar>
|
||||||
|
{activeWindow === WindowKind.AppMenu && <AppMenuWindow ref={refs.setFloating} style={floatingStyles} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButtons() {
|
||||||
|
const setActiveWindow = useBookmarkPageStore((state) => state.setActiveWindow)
|
||||||
|
const activeWindow = useBookmarkPageStore((state) => state.activeWindow)
|
||||||
|
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||||
|
|
||||||
|
useMnemonics(
|
||||||
|
{
|
||||||
|
a: addBookmark,
|
||||||
|
},
|
||||||
|
{ ignore: useCallback(() => useBookmarkPageStore.getState().dialog.kind !== DialogKind.None, []) },
|
||||||
|
)
|
||||||
|
|
||||||
|
function addBookmark() {
|
||||||
|
setActiveDialog({ kind: DialogKind.AddBookmark })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAppMenu() {
|
||||||
|
setActiveWindow(activeWindow === WindowKind.AppMenu ? WindowKind.None : WindowKind.AppMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row justify-center space-x-4">
|
||||||
|
<Button onClick={addBookmark}>
|
||||||
|
<span className="underline">A</span>DD BOOKMARK
|
||||||
|
</Button>
|
||||||
|
<Button onClick={toggleAppMenu}>⋯</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppMenuWindow({ ref, style }: { ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={style} className="border w-full md:w-100">
|
||||||
|
<p className="bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800 text-center">MENU</p>
|
||||||
|
<div className="p-4">
|
||||||
|
<ul className="space-x-4 space-y-2 flex flex-wrap justify-center">
|
||||||
|
<li>
|
||||||
|
<LinkButton to="/bookmarks">
|
||||||
|
<span className="underline">B</span>OOKMARKS
|
||||||
|
</LinkButton>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<LinkButton to="/collections">
|
||||||
|
<span className="underline">C</span>OLLECTIONS
|
||||||
|
</LinkButton>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<LogOutButton />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogOutButton() {
|
||||||
|
const logOutMutation = useLogOut()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
function logOut() {
|
||||||
|
logOutMutation.mutate()
|
||||||
|
navigate({ to: "/", replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button disabled={logOutMutation.isPending} onClick={logOut}>
|
||||||
|
LOG OUT
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
@@ -1,8 +1,8 @@
|
|||||||
import type { Collection } from "@markone/core"
|
import type { Collection } from "@markone/core"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
import { clsx } from "clsx"
|
import { clsx } from "clsx"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
import { Button } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
import { Link } from "~/components/link"
|
|
||||||
import { List } from "~/components/list"
|
import { List } from "~/components/list"
|
||||||
|
|
||||||
export enum CollectionListItemAction {
|
export enum CollectionListItemAction {
|
||||||
@@ -53,7 +53,9 @@ const CollectionListItem = memo(
|
|||||||
</button>
|
</button>
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
<div className="block w-full text-start font-bold">
|
<div className="block w-full text-start font-bold">
|
||||||
<Link href="#">{collection.name}</Link>
|
<Link to={`/collections/${collection.id}`} className={isSelected ? "underline" : ""}>
|
||||||
|
{collection.name}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="opacity-80 text-sm">{collection.description}</p>
|
<p className="opacity-80 text-sm">{collection.description}</p>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { useId, useRef, useEffect } from "react"
|
import { useId, useRef, useEffect } from "react"
|
||||||
import type { Collection } from "@markone/core"
|
import type { Collection } from "@markone/core"
|
||||||
import { useUpdateCollection } from "~/collections/api"
|
import { useUpdateCollection } from "~/collection/api"
|
||||||
import { Button } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
||||||
import { FormField } from "~/components/form-field"
|
import { FormField } from "~/components/form-field"
|
||||||
|
@@ -11,6 +11,7 @@ import { CollectionList } from "./-collection-list"
|
|||||||
import { AddCollectionDialog } from "./-dialogs/add-collection-dialog"
|
import { AddCollectionDialog } from "./-dialogs/add-collection-dialog"
|
||||||
import { DeleteCollectionDialog } from "./-dialogs/delete-collection-dialog"
|
import { DeleteCollectionDialog } from "./-dialogs/delete-collection-dialog"
|
||||||
import { DialogKind, WindowKind, useCollectionPageStore } from "./-store"
|
import { DialogKind, WindowKind, useCollectionPageStore } from "./-store"
|
||||||
|
import { SideNav } from "~/app/-side-nav"
|
||||||
|
|
||||||
export const Route = createFileRoute("/collections/")({
|
export const Route = createFileRoute("/collections/")({
|
||||||
component: CollectionsPage,
|
component: CollectionsPage,
|
||||||
@@ -42,12 +43,7 @@ function CollectionsPage() {
|
|||||||
function CollectionsPane() {
|
function CollectionsPane() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-16 container max-w-3xl md:flex-row lg:py-32">
|
<div className="flex flex-col py-16 container max-w-3xl md:flex-row lg:py-32">
|
||||||
<header className="mb-4 md:mb-0 md:mr-16 text-start">
|
<_SideNav />
|
||||||
<h1 className="font-bold text-start">
|
|
||||||
<span className="invisible md:hidden"> > </span>
|
|
||||||
YOUR COLLECTIONS
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<CollectionsContainer />
|
<CollectionsContainer />
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +51,11 @@ function CollectionsPane() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _SideNav() {
|
||||||
|
const hasDialog = useCollectionPageStore((state) => state.dialog.kind !== DialogKind.None)
|
||||||
|
return <SideNav enableMnemonics={!hasDialog} />
|
||||||
|
}
|
||||||
|
|
||||||
function CollectionsContainer() {
|
function CollectionsContainer() {
|
||||||
const { data: collections, status } = useCollections()
|
const { data: collections, status } = useCollections()
|
||||||
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
|
const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction)
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import type { Collection } from "@markone/core"
|
import type { Collection, Bookmark } 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, useAuthenticatedQuery } from "~/api"
|
import { UnauthenticatedError, useAuthenticatedQuery } from "~/api"
|
||||||
@@ -11,6 +11,13 @@ function useCollections() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useCollection(id: string) {
|
||||||
|
return useAuthenticatedQuery(["collections", id], async () => {
|
||||||
|
const res = await fetchApi(`/collections/${id}`)
|
||||||
|
return (await res.json()) as Collection
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function useCreateCollection() {
|
function useCreateCollection() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -58,4 +65,37 @@ function useDeleteCollection() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useCollections, useCreateCollection, useDeleteCollection }
|
function useUpdateCollection(collection: Collection) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: { title: string; description: string }) =>
|
||||||
|
fetchApi(`/collections/${collection.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then((res) => (res.status === 204 ? collection : res.json())),
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof UnauthenticatedError) {
|
||||||
|
navigate({ to: "/login", replace: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (updatedCollection: Collection | undefined) => {
|
||||||
|
if (updatedCollection) {
|
||||||
|
queryClient.setQueryData(["collections"], (collections: Collection[]) =>
|
||||||
|
collections ? collections.map((it) => (it.id === updatedCollection.id ? updatedCollection : it)) : [updatedCollection],
|
||||||
|
)
|
||||||
|
queryClient.setQueryData(["collections", updatedCollection.id], updatedCollection)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCollectionBookmarks(collection: Collection) {
|
||||||
|
return useAuthenticatedQuery(["collections", collection.id, "bookmarks"], async () => {
|
||||||
|
const res = await fetchApi(`/collections/${collection.id}/bookmarks`)
|
||||||
|
return (await res.json()) as Bookmark[]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useCollections, useCreateCollection, useDeleteCollection, useUpdateCollection, useCollectionBookmarks, useCollection }
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react"
|
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useImperativeHandle } from "react"
|
||||||
import { createStore, useStore } from "zustand"
|
import { createStore, useStore } from "zustand"
|
||||||
import { subscribeWithSelector } from "zustand/middleware"
|
import { subscribeWithSelector } from "zustand/middleware"
|
||||||
import { clsx } from "clsx"
|
import { clsx } from "clsx"
|
||||||
@@ -8,6 +8,10 @@ interface ListData {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ListRef<T extends ListData> {
|
||||||
|
selectedItem: T
|
||||||
|
}
|
||||||
|
|
||||||
interface ListState<T extends ListData> {
|
interface ListState<T extends ListData> {
|
||||||
items: T[]
|
items: T[]
|
||||||
selectedIndex: number
|
selectedIndex: number
|
||||||
@@ -24,6 +28,8 @@ type ListStore<T extends ListData> = ReturnType<typeof createListStore<T>>
|
|||||||
|
|
||||||
const ListStoreContext = createContext<ListStore<ListData> | null>(null)
|
const ListStoreContext = createContext<ListStore<ListData> | null>(null)
|
||||||
|
|
||||||
|
type MnemonicCallback<T> = (event: KeyboardEvent, item: T) => void
|
||||||
|
|
||||||
interface ListProps<T extends ListData> {
|
interface ListProps<T extends ListData> {
|
||||||
items: T[]
|
items: T[]
|
||||||
selectedItemId?: string
|
selectedItemId?: string
|
||||||
@@ -38,6 +44,8 @@ interface ListProps<T extends ListData> {
|
|||||||
}) => React.ReactNode
|
}) => React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
emptyMessage?: string
|
emptyMessage?: string
|
||||||
|
mnemonics?: Record<string, MnemonicCallback<T>>
|
||||||
|
ref?: React.Ref<ListRef<T>>
|
||||||
}
|
}
|
||||||
|
|
||||||
function List<T extends ListData>({
|
function List<T extends ListData>({
|
||||||
@@ -48,30 +56,34 @@ function List<T extends ListData>({
|
|||||||
renderItem,
|
renderItem,
|
||||||
className,
|
className,
|
||||||
emptyMessage = "No items found!",
|
emptyMessage = "No items found!",
|
||||||
|
mnemonics,
|
||||||
|
ref,
|
||||||
}: ListProps<T>) {
|
}: ListProps<T>) {
|
||||||
const storeRef = useRef<ListStore<T> | null>(null)
|
const storeRef = useRef<ListStore<T>>(createListStore({ items, selectedItemId, alwaysExpandItem }))
|
||||||
if (!storeRef.current) {
|
|
||||||
storeRef.current = createListStore({ items, selectedItemId, alwaysExpandItem })
|
useImperativeHandle(
|
||||||
}
|
ref,
|
||||||
|
() => ({
|
||||||
|
get selectedItem() {
|
||||||
|
const { items, selectedIndex } = storeRef.current.getState()
|
||||||
|
return items[selectedIndex]
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const store = storeRef.current
|
storeRef.current.getState().setItems(items)
|
||||||
if (!store) return
|
|
||||||
store.getState().setItems(items)
|
|
||||||
}, [items])
|
}, [items])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const store = storeRef.current
|
if (selectedItemId !== storeRef.current.getState().selectedItemId && selectedItemId) {
|
||||||
if (!store) return
|
storeRef.current.getState().setSelectedItemId(selectedItemId)
|
||||||
if (selectedItemId !== store.getState().selectedItemId && selectedItemId) {
|
|
||||||
store.getState().setSelectedItemId(selectedItemId)
|
|
||||||
}
|
}
|
||||||
}, [selectedItemId])
|
}, [selectedItemId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const store = storeRef.current
|
const unsub = storeRef.current.subscribe(
|
||||||
if (!store) return
|
|
||||||
const unsub = store.subscribe(
|
|
||||||
(state) => state,
|
(state) => state,
|
||||||
({ items, selectedIndex }) => {
|
({ items, selectedIndex }) => {
|
||||||
onSelectionChange?.(items[selectedIndex])
|
onSelectionChange?.(items[selectedIndex])
|
||||||
@@ -87,7 +99,7 @@ function List<T extends ListData>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ListStoreContext.Provider value={storeRef.current as unknown as ListStore<ListData>}>
|
<ListStoreContext.Provider value={storeRef.current as unknown as ListStore<ListData>}>
|
||||||
<MemoizedList className={className} renderItem={renderItem} emptyMessage={emptyMessage} />
|
<MemoizedList className={className} renderItem={renderItem} emptyMessage={emptyMessage} mnemonics={mnemonics} />
|
||||||
</ListStoreContext.Provider>
|
</ListStoreContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -213,6 +225,7 @@ function _List<T extends ListData>({
|
|||||||
className,
|
className,
|
||||||
renderItem,
|
renderItem,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
mnemonics,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
renderItem: (props: {
|
renderItem: (props: {
|
||||||
@@ -223,6 +236,7 @@ function _List<T extends ListData>({
|
|||||||
onExpand: () => void
|
onExpand: () => void
|
||||||
}) => React.ReactNode
|
}) => React.ReactNode
|
||||||
emptyMessage: string
|
emptyMessage: string
|
||||||
|
mnemonics?: Record<string, MnemonicCallback<T>>
|
||||||
}) {
|
}) {
|
||||||
const store = useListStoreContext<T>()
|
const store = useListStoreContext<T>()
|
||||||
const items = useListStore<T, T[]>((state) => state.items)
|
const items = useListStore<T, T[]>((state) => state.items)
|
||||||
@@ -231,20 +245,35 @@ function _List<T extends ListData>({
|
|||||||
const setSelectedItemId = useListStore<T, (id: string) => void>((state) => state.setSelectedItemId)
|
const setSelectedItemId = useListStore<T, (id: string) => void>((state) => state.setSelectedItemId)
|
||||||
const setIsItemExpanded = useListStore<T, (expanded: boolean) => void>((state) => state.setIsItemExpanded)
|
const setIsItemExpanded = useListStore<T, (expanded: boolean) => void>((state) => state.setIsItemExpanded)
|
||||||
|
|
||||||
const shortcuts = {
|
const shortcuts = useMemo(() => {
|
||||||
j: selectNextItem,
|
const baseShortcuts: Record<string, (event: KeyboardEvent) => void> = {
|
||||||
ArrowDown: selectNextItem,
|
j: selectNextItem,
|
||||||
k: selectPrevItem,
|
ArrowDown: selectNextItem,
|
||||||
ArrowUp: selectPrevItem,
|
k: selectPrevItem,
|
||||||
l: expandItem,
|
ArrowUp: selectPrevItem,
|
||||||
ArrowRight: expandItem,
|
l: expandItem,
|
||||||
h: collapseItem,
|
ArrowRight: expandItem,
|
||||||
ArrowLeft: collapseItem,
|
h: collapseItem,
|
||||||
}
|
ArrowLeft: collapseItem,
|
||||||
|
}
|
||||||
|
|
||||||
useMnemonics(shortcuts, {
|
if (!mnemonics) {
|
||||||
ignore: useCallback(() => false, []),
|
return baseShortcuts
|
||||||
})
|
}
|
||||||
|
|
||||||
|
for (const [key, callback] of Object.entries(mnemonics)) {
|
||||||
|
baseShortcuts[key] = (event: KeyboardEvent) => {
|
||||||
|
const { items, selectedIndex } = store.getState()
|
||||||
|
const selectedItem = items[selectedIndex]
|
||||||
|
if (selectedItem) {
|
||||||
|
callback(event, selectedItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baseShortcuts
|
||||||
|
}, [mnemonics, store])
|
||||||
|
|
||||||
|
useMnemonics(shortcuts)
|
||||||
|
|
||||||
function selectNextItem() {
|
function selectNextItem() {
|
||||||
const { items, selectedIndex, setSelectedItemId } = store.getState()
|
const { items, selectedIndex, setSelectedItemId } = store.getState()
|
||||||
@@ -303,4 +332,4 @@ function _List<T extends ListData>({
|
|||||||
const MemoizedList = memo(_List) as typeof _List
|
const MemoizedList = memo(_List) as typeof _List
|
||||||
|
|
||||||
export { List }
|
export { List }
|
||||||
export type { ListData, ListProps }
|
export type { ListData, ListProps, MnemonicCallback, ListRef }
|
||||||
|
@@ -1,248 +1,141 @@
|
|||||||
import { autoUpdate, size, useFloating } from "@floating-ui/react-dom"
|
import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"
|
||||||
import type { Tag } from "@markone/core"
|
import type { Tag } from "@markone/core"
|
||||||
|
import { useCallback, useImperativeHandle, useRef, useState, useEffect, useId, memo } from "react"
|
||||||
import clsx from "clsx"
|
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 {
|
interface TagsInputRef {
|
||||||
input: HTMLInputElement | null
|
input: HTMLInputElement | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
initialSelections?: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagsInputContext = createContext<{
|
const TagOption = memo(({ tag }: { tag: Tag }) => (
|
||||||
value: PrimitiveAtom<string>
|
<ComboboxOption
|
||||||
lastTag: Atom<string>
|
key={tag.id}
|
||||||
} | null>(null)
|
value={tag}
|
||||||
|
className="group pl-3 pr-4 py-1 cursor-pointer data-focus:bg-stone-800 data-focus:dark:bg-stone-300 data-focus:text-stone-300 data-focus:dark:text-stone-800 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<span>{tag.name}</span>
|
||||||
|
<span className="hidden group-data-selected:block font-bold">✓</span>
|
||||||
|
</ComboboxOption>
|
||||||
|
))
|
||||||
|
|
||||||
function TagsInput({ ref, initialValue = "" }: { ref: React.Ref<TagsInputRef>; initialValue?: string }) {
|
function TagsInput({
|
||||||
const valueAtom = useMemo(() => atom(initialValue), [initialValue])
|
ref,
|
||||||
const lastTagAtom = useMemo(
|
tags,
|
||||||
() =>
|
initialSelections,
|
||||||
atom((get) => {
|
}: {
|
||||||
const value = get(valueAtom)
|
ref: React.Ref<TagsInputRef>
|
||||||
let start = 0
|
tags: Tag[]
|
||||||
for (let i = value.length; i > 0; --i) {
|
initialSelections?: Tag[]
|
||||||
if (value.charAt(i) === " ") {
|
}) {
|
||||||
start = i + 1
|
const [selectedTags, setSelectedTags] = useState<Tag[]>(initialSelections ?? [])
|
||||||
break
|
const [addedTags, setAddedTags] = useState<Tag[]>([])
|
||||||
}
|
const [query, setQuery] = useState("")
|
||||||
}
|
const [isFocused, setIsFocused] = useState(false)
|
||||||
return value.slice(start)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
}),
|
const id = useId()
|
||||||
[valueAtom],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<TagsInputContext value={{ value: valueAtom, lastTag: lastTagAtom }}>
|
setSelectedTags(initialSelections ?? [])
|
||||||
<_TagsInput ref={ref} />
|
}, [initialSelections])
|
||||||
</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, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
get tags() {
|
get tags() {
|
||||||
if (value === "") {
|
return selectedTags.map((tag) => tag.name)
|
||||||
return []
|
|
||||||
}
|
|
||||||
return value.trim().split(" ")
|
|
||||||
},
|
},
|
||||||
input: refs.reference.current,
|
input: inputRef.current,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
const filteredTags = tags.filter((tag) => {
|
||||||
<>
|
if (query === "") return true
|
||||||
<FormField
|
return tag.name.toLowerCase().includes(query.toLowerCase())
|
||||||
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}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
addTag(tag)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
#{tag.name}
|
|
||||||
</button>
|
|
||||||
</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) {
|
|
||||||
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()
|
|
||||||
addTag(selectedTag)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function addTag(selectedTag: Tag | null | undefined) {
|
const filteredAddedTags = addedTags.filter((tag) => {
|
||||||
if (selectedTag) {
|
if (query === "") return true
|
||||||
setValue((value) => `${value}${selectedTag.name.slice(lastTag.length)} `)
|
return tag.name.toLowerCase().includes(query.toLowerCase())
|
||||||
} else {
|
})
|
||||||
// biome-ignore lint/style/useTemplate: this is more readable than using template literal
|
|
||||||
setValue((value) => value + " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastTag === "") {
|
const removeTag = useCallback(
|
||||||
return null
|
(tagToRemove: string) => {
|
||||||
}
|
setSelectedTags(selectedTags.filter((tag) => tag.name !== tagToRemove))
|
||||||
|
},
|
||||||
|
[selectedTags],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "Backspace" && query === "" && selectedTags.length > 0) {
|
||||||
|
removeTag(selectedTags[selectedTags.length - 1].name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[query, selectedTags, removeTag],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateSelectedTags = useCallback((selectedTags: Tag[]) => {
|
||||||
|
const newTags = selectedTags.filter((tag) => tag.id.startsWith("new-"))
|
||||||
|
setAddedTags(newTags)
|
||||||
|
setSelectedTags(selectedTags)
|
||||||
|
setQuery("")
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={style} className="bg-stone-300 dark:bg-stone-800 border-2 mt-1">
|
<div className="relative">
|
||||||
<ul className="py-2">
|
<div className="flex flex-col-reverse focus:text-teal-600">
|
||||||
{listItems}
|
<Combobox multiple value={selectedTags} onChange={updateSelectedTags}>
|
||||||
{hasExactMatch ? null : (
|
<div className="relative flex">
|
||||||
<li
|
<ComboboxInput
|
||||||
className={clsx("text-start py-1", {
|
ref={inputRef}
|
||||||
"bg-stone-800 dark:bg-stone-300 text-stone-300 dark:text-stone-800": selectedTag === null,
|
id={id}
|
||||||
})}
|
className="peer w-full px-3 pb-2 pt-3 bg-stone-300 dark:bg-stone-800 border focus:border-2 border-stone-800 dark:border-stone-200 focus:border-teal-600 focus:ring-0 focus:outline-none"
|
||||||
>
|
onChange={(event) => setQuery(event.target.value.split(" ").at(-1) ?? "")}
|
||||||
{lastTag.includes("#") ? (
|
onKeyDown={handleKeyDown}
|
||||||
<> Tags cannot contain '#'</>
|
onFocus={() => setIsFocused(true)}
|
||||||
) : (
|
onBlur={() => setIsFocused(false)}
|
||||||
<button
|
displayValue={() => {
|
||||||
type="button"
|
const tagsString = selectedTags.map((tag) => `${tag.name}`).join(" ")
|
||||||
onClick={() => {
|
return query ? `${tagsString}${query}` : tagsString
|
||||||
addTag(null)
|
}}
|
||||||
}}
|
/>
|
||||||
>
|
<ComboboxButton className="absolute right-0 top-0 h-full px-4 flex items-center bg-stone-300 dark:bg-stone-800 border border-l-0 border-stone-800 dark:border-stone-200 peer-focus:border-2 peer-focus:border-teal-600 peer-focus:text-teal-600 focus:ring-0 focus:outline-none active:bg-teal-600 active:text-stone-200 aria-expanded:bg-teal-600 aria-expanded:text-stone-200">
|
||||||
Add tag: #{lastTag}
|
▼
|
||||||
</button>
|
</ComboboxButton>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={clsx(
|
||||||
|
"select-none border-x-2 border-transparent w-min text-nowrap translate-y-[55%] bg-stone-300 dark:bg-stone-800 mx-2 px-1",
|
||||||
|
{
|
||||||
|
"text-teal-600 border-teal-600 font-bold": isFocused,
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
</li>
|
>
|
||||||
)}
|
TAGS
|
||||||
</ul>
|
</label>
|
||||||
|
<ComboboxOptions
|
||||||
|
anchor="bottom"
|
||||||
|
className="z-20 mt-1 w-(--input-width) bg-stone-300 dark:bg-stone-800 border-2 overflow-auto py-2"
|
||||||
|
>
|
||||||
|
{filteredTags.length === 0 && filteredAddedTags.length === 0 ? (
|
||||||
|
<ComboboxOption
|
||||||
|
value={{ id: `new-${query}`, name: query }}
|
||||||
|
className="group pl-3 pr-4 py-1 cursor-pointer data-focus:bg-stone-800 data-focus:dark:bg-stone-300 data-focus:text-stone-300 data-focus:dark:text-stone-800 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<span>Add tag: {query}</span>
|
||||||
|
<span className="hidden group-data-selected:block font-bold">✓</span>
|
||||||
|
</ComboboxOption>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredTags.map((tag) => (
|
||||||
|
<TagOption key={tag.id} tag={tag} />
|
||||||
|
))}
|
||||||
|
{filteredAddedTags.length > 0 && filteredAddedTags.map((tag) => <TagOption key={tag.id} tag={tag} />)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ComboboxOptions>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user