diff --git a/bun.lock b/bun.lock index 3db7968..034cc8f 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "version": "0.0.0", "dependencies": { "@floating-ui/react-dom": "^2.1.2", + "@headlessui/react": "^2.2.4", "@markone/core": "workspace:*", "@tailwindcss/vite": "^4.1.5", "@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/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/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/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=="], + "@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-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=="], + "@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/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-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-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/virtual-core": ["@tanstack/virtual-core@3.13.9", "", {}, "sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ=="], + "@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=="], @@ -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/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=="], @@ -581,7 +606,7 @@ "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=="], @@ -1097,6 +1122,8 @@ "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=="], "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=="], + "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=="], "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=="], + "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=="], "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=="], + "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/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], diff --git a/packages/core/src/collection.ts b/packages/core/src/collection.ts index 472f878..e0fdc41 100644 --- a/packages/core/src/collection.ts +++ b/packages/core/src/collection.ts @@ -7,4 +7,30 @@ interface Collection { 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 { findCollectionBookmarks } diff --git a/packages/server/src/collection/collection.ts b/packages/server/src/collection/collection.ts index 1de4ef0..4e4cef0 100644 --- a/packages/server/src/collection/collection.ts +++ b/packages/server/src/collection/collection.ts @@ -1,5 +1,5 @@ import type { User } from "@markone/core" -import type { Collection } from "@markone/core" +import type { Collection, Bookmark } from "@markone/core" import { db } from "~/database.js" function findCollections(user: User): Collection[] { @@ -50,10 +50,23 @@ function deleteCollection(collectionId: string, user: User): void { function findCollectionById(collectionId: string, user: User): Collection | null { return db.query(` - SELECT * + SELECT id, name, description FROM collections WHERE id = $id AND user_id = $userId `).get({ id: collectionId, userId: user.id }) } -export { findCollections, insertCollection, updateCollection, deleteCollection, findCollectionById } +function findCollectionBookmarks( + collection: Collection, + user: User +): Bookmark[] { + return db.query(` + 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 } diff --git a/packages/server/src/collection/handlers.ts b/packages/server/src/collection/handlers.ts index 809574d..3fafab6 100644 --- a/packages/server/src/collection/handlers.ts +++ b/packages/server/src/collection/handlers.ts @@ -2,7 +2,7 @@ import { type Collection, DEMO_USER, type User } from "@markone/core" import { type } from "arktype" import { ulid } from "ulid" 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({ title: "string", @@ -46,6 +46,16 @@ async function listUserCollections(request: Bun.BunRequest<"/api/collections">, 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) { if (user.id !== DEMO_USER.id) { deleteCollection(request.params.id, user) @@ -79,4 +89,14 @@ async function updateUserCollection(request: Bun.BunRequest<"/api/collections/:i 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 } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index b9a8926..7b56ca0 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -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 { startBackgroundSessionCleanup } from "./auth/session.ts" import { insertDemoBookmarks } from "./bookmark/bookmark.ts" @@ -59,12 +59,16 @@ async function main() { POST: authenticated(createCollection), }, "/api/collections/:id": { + GET: authenticated(fetchUserCollection), DELETE: authenticated(deleteUserCollection), OPTIONS: preflightHandler({ allowedMethods: ["GET", "POST", "DELETE", "PATCH", "OPTIONS"], allowedHeaders: ["Accept"], }), }, + "/api/collections/:id/bookmarks": { + GET: authenticated(getCollectionBookmarks), + }, }, port: 8080, diff --git a/packages/web/package.json b/packages/web/package.json index d054e94..daacacc 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.2", + "@headlessui/react": "^2.2.4", "@markone/core": "workspace:*", "@tailwindcss/vite": "^4.1.5", "@tanstack/react-query": "^5.75.2", diff --git a/packages/web/src/app/-route-tree.gen.ts b/packages/web/src/app/-route-tree.gen.ts index a38579e..cdb7397 100644 --- a/packages/web/src/app/-route-tree.gen.ts +++ b/packages/web/src/app/-route-tree.gen.ts @@ -18,6 +18,7 @@ import { Route as BookmarksImport } from "./bookmarks" import { Route as IndexImport } from "./index" import { Route as CollectionsIndexImport } from "./collections/index" import { Route as BookmarksIndexImport } from "./bookmarks/index" +import { Route as CollectionsCollectionIdImport } from "./collections/$collectionId" import { Route as BookmarksBookmarkIdImport } from "./bookmarks/$bookmarkId" // Create/Update Routes @@ -64,6 +65,12 @@ const BookmarksIndexRoute = BookmarksIndexImport.update({ getParentRoute: () => BookmarksRoute, } as any) +const CollectionsCollectionIdRoute = CollectionsCollectionIdImport.update({ + id: "/$collectionId", + path: "/$collectionId", + getParentRoute: () => CollectionsRoute, +} as any) + const BookmarksBookmarkIdRoute = BookmarksBookmarkIdImport.update({ id: "/$bookmarkId", path: "/$bookmarkId", @@ -116,6 +123,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof BookmarksBookmarkIdImport parentRoute: typeof BookmarksImport } + "/collections/$collectionId": { + id: "/collections/$collectionId" + path: "/$collectionId" + fullPath: "/collections/$collectionId" + preLoaderRoute: typeof CollectionsCollectionIdImport + parentRoute: typeof CollectionsImport + } "/bookmarks/": { id: "/bookmarks/" path: "/" @@ -150,10 +164,12 @@ const BookmarksRouteWithChildren = BookmarksRoute._addFileChildren( ) interface CollectionsRouteChildren { + CollectionsCollectionIdRoute: typeof CollectionsCollectionIdRoute CollectionsIndexRoute: typeof CollectionsIndexRoute } const CollectionsRouteChildren: CollectionsRouteChildren = { + CollectionsCollectionIdRoute: CollectionsCollectionIdRoute, CollectionsIndexRoute: CollectionsIndexRoute, } @@ -168,6 +184,7 @@ export interface FileRoutesByFullPath { "/login": typeof LoginRoute "/signup": typeof SignupRoute "/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute + "/collections/$collectionId": typeof CollectionsCollectionIdRoute "/bookmarks/": typeof BookmarksIndexRoute "/collections/": typeof CollectionsIndexRoute } @@ -177,6 +194,7 @@ export interface FileRoutesByTo { "/login": typeof LoginRoute "/signup": typeof SignupRoute "/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute + "/collections/$collectionId": typeof CollectionsCollectionIdRoute "/bookmarks": typeof BookmarksIndexRoute "/collections": typeof CollectionsIndexRoute } @@ -189,6 +207,7 @@ export interface FileRoutesById { "/login": typeof LoginRoute "/signup": typeof SignupRoute "/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute + "/collections/$collectionId": typeof CollectionsCollectionIdRoute "/bookmarks/": typeof BookmarksIndexRoute "/collections/": typeof CollectionsIndexRoute } @@ -202,6 +221,7 @@ export interface FileRouteTypes { | "/login" | "/signup" | "/bookmarks/$bookmarkId" + | "/collections/$collectionId" | "/bookmarks/" | "/collections/" fileRoutesByTo: FileRoutesByTo @@ -210,6 +230,7 @@ export interface FileRouteTypes { | "/login" | "/signup" | "/bookmarks/$bookmarkId" + | "/collections/$collectionId" | "/bookmarks" | "/collections" id: @@ -220,6 +241,7 @@ export interface FileRouteTypes { | "/login" | "/signup" | "/bookmarks/$bookmarkId" + | "/collections/$collectionId" | "/bookmarks/" | "/collections/" fileRoutesById: FileRoutesById @@ -271,6 +293,7 @@ export const routeTree = rootRoute "/collections": { "filePath": "collections.tsx", "children": [ + "/collections/$collectionId", "/collections/" ] }, @@ -284,6 +307,10 @@ export const routeTree = rootRoute "filePath": "bookmarks/$bookmarkId.tsx", "parent": "/bookmarks" }, + "/collections/$collectionId": { + "filePath": "collections/$collectionId.tsx", + "parent": "/collections" + }, "/bookmarks/": { "filePath": "bookmarks/index.tsx", "parent": "/bookmarks" diff --git a/packages/web/src/app/-side-nav.tsx b/packages/web/src/app/-side-nav.tsx new file mode 100644 index 0000000..2b16ab9 --- /dev/null +++ b/packages/web/src/app/-side-nav.tsx @@ -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 ( + + ) +} + +function NavItem({ to, label }: { to: string; label: string }) { + const currentPath = useRouterState({ select: (state) => state.location.pathname }) + + return ( +
  • + + [{currentPath === to ? "•" : " "}]{" "} + + {label.charAt(0)} + {label.substring(1)} + + +
  • + ) +} + +export { SideNav } diff --git a/packages/web/src/app/bookmarks/-bookmark-list.tsx b/packages/web/src/app/bookmarks/-bookmark-list.tsx index a3234b0..a0df894 100644 --- a/packages/web/src/app/bookmarks/-bookmark-list.tsx +++ b/packages/web/src/app/bookmarks/-bookmark-list.tsx @@ -1,12 +1,13 @@ import type { Bookmark } from "@markone/core" import { Link } from "@tanstack/react-router" import clsx from "clsx" -import { memo, useCallback } from "react" +import { memo, useCallback, useRef } from "react" import { twMerge } from "tailwind-merge" import { useBookmarkTags } from "~/bookmark/api" import { Button } from "~/components/button" -import { List } from "~/components/list" -import { DialogKind, useBookmarkPageStore } from "./-store" +import { List, type ListRef } from "~/components/list" +import { DialogKind, ActionBarContentKind, useBookmarkPageStore } from "./-store" +import { useMnemonics } from "~/hooks/use-mnemonics" export enum BookmarkListItemAction { Open = "Open", @@ -129,6 +130,7 @@ function BookmarkList({ onItemAction, className, }: BookmarkListProps) { + const listRef = useRef>(null) const handleSelect = useCallback( (bookmark: Bookmark) => { onSelectionChange?.(bookmark) @@ -136,12 +138,32 @@ function BookmarkList({ [onSelectionChange], ) - const handleExpand = useCallback((bookmark: Bookmark) => { - // No-op since expansion is handled by the List component - }, []) + useMnemonics( + { + 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 ( state.setActiveDialog) const formId = useId() const linkInputRef = useRef(null) @@ -21,7 +22,7 @@ function AddBookmarkDialog() { useMnemonics( { c: () => { - if (linkInputRef.current !== document.activeElement && tagsInputRef.current !== document.activeElement) { + if (linkInputRef.current !== document.activeElement && tagsInputRef.current?.input !== document.activeElement) { cancel() } }, @@ -112,7 +113,13 @@ function AddBookmarkDialog() { className="w-full" labelClassName="bg-stone-300 dark:bg-stone-800" /> - + {tagsStatus === "success" ? ( + + ) : ( +

    + Loading tags +

    + )} diff --git a/packages/web/src/app/bookmarks/-dialogs/edit-bookmark-dialog.tsx b/packages/web/src/app/bookmarks/-dialogs/edit-bookmark-dialog.tsx index 1cd763e..80474d6 100644 --- a/packages/web/src/app/bookmarks/-dialogs/edit-bookmark-dialog.tsx +++ b/packages/web/src/app/bookmarks/-dialogs/edit-bookmark-dialog.tsx @@ -1,78 +1,151 @@ -import type { Bookmark, Tag } from "@markone/core" -import { useEffect, useId, useImperativeHandle, useRef } from "react" -import { useBookmarkTags, useUpdateBookmark } from "~/bookmark/api" +import type { Bookmark } from "@markone/core" +import { useEffect, useId, useRef, useState } from "react" +import { ApiErrorCode, BadRequestError } from "~/api" +import { useBookmarkTags, useTags, useUpdateBookmark } from "~/bookmark/api" import { Button } from "~/components/button" import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog" import { FormField } from "~/components/form-field" import { LoadingSpinner } from "~/components/loading-spinner" -import { Message, MessageVariant } from "~/components/message.tsx" -import { TagsInput, type TagsInputRef } from "~/components/tags-input" -import { useMnemonics } from "~/hooks/use-mnemonics.ts" -import { useBookmarkPageStore } from "../-store" - -interface EditFormRef { - form: HTMLFormElement | null - titleInput: HTMLInputElement | null - tagsInput: TagsInputRef | null -} +import { Message, MessageVariant } from "~/components/message" +import { TagsInput, type TagsInputRef } from "~/components/tags-input.tsx" +import { useMnemonics } from "~/hooks/use-mnemonics" +import { DialogKind, useBookmarkPageStore } from "../-store" function EditBookmarkDialog({ bookmark }: { bookmark: Bookmark }) { - const closeDialog = useBookmarkPageStore((state) => state.closeDialog) - const { data: tags, status } = useBookmarkTags(bookmark) - const editFormId = useId() - const editFormRef = useRef(null) + const [isWebsiteUnreachable, setIsWebsiteUnreachable] = useState(false) + const updateBookmarkMutation = useUpdateBookmark(bookmark) + const { data: tags, status: tagsStatus } = useTags() + const { data: bookmarkTags, status: bookmarkTagsStatus } = useBookmarkTags(bookmark) + const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) + const formId = useId() + const linkInputRef = useRef(null) + const tagsInputRef = useRef(null) useMnemonics( { - s: () => { - if ( - editFormRef.current && - editFormRef.current.titleInput !== document.activeElement && - editFormRef.current.tagsInput?.input !== document.activeElement - ) { - editFormRef.current.form?.requestSubmit() - } - }, c: () => { - if ( - editFormRef.current?.titleInput !== document.activeElement && - editFormRef.current?.tagsInput?.input !== document.activeElement - ) { - closeDialog() + if (linkInputRef.current !== document.activeElement && tagsInputRef.current?.input !== document.activeElement) { + cancel() } }, Escape: () => { - editFormRef.current?.titleInput?.blur() - editFormRef.current?.tagsInput?.input?.blur() + linkInputRef.current?.blur() + tagsInputRef.current?.input?.blur() }, }, { ignore: () => false }, ) - function content() { - switch (status) { - case "pending": - return ( -

    - Loading -

    - ) - case "success": - return - case "error": - return null + // when using autoFocus, it also captures the "a" mnemonic + // which appends the "a" to the input + // this is to prevent the "a" mnemonic to be captured as input to the text box + useEffect(() => { + setTimeout(() => { + if (linkInputRef.current) { + linkInputRef.current.focus() + } + }, 0) + }, []) + + async function onSubmit(event: React.FormEvent) { + 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 ( +

    + Loading +

    + ) + } + if (isWebsiteUnreachable) { + return ( + + The link does not seem to be reachable. Click "SAVE" to save anyways. + + ) + } + if (updateBookmarkMutation.status === "error") { + return ( + + An error occurred when saving bookmark + + ) + } + return null + } + + function content() { + if (tagsStatus === "pending" || bookmarkTagsStatus === "pending") { + return ( +

    + Loading +

    + ) + } + if (tagsStatus === "error" || bookmarkTagsStatus === "error") { + return ( + + An error occurred when loading tags + + ) + } + if (tags && bookmarkTags) { + return ( +
    + + + + ) + } + return null + } + return ( EDIT BOOKMARK - {content()} + + {message()} + {content()} + - - @@ -80,83 +153,4 @@ function EditBookmarkDialog({ bookmark }: { bookmark: Bookmark }) { ) } -function EditForm({ - ref, - formId, - bookmark, - tags, -}: { ref: React.Ref; formId: string; bookmark: Bookmark; tags: Tag[] }) { - const formRef = useRef(null) - const titleInputRef = useRef(null) - const tagsInputRef = useRef(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) { - if (tagsInputRef.current) { - event.preventDefault() - const form = new FormData(event.currentTarget) - const title = form.get("title") - const tags = tagsInputRef.current.tags - if (title && typeof title === "string") { - try { - await updateBookmarkMutation.mutateAsync({ - title, - tags, - }) - closeDialog() - } catch {} - } - } - } - - function message() { - switch (updateBookmarkMutation.status) { - case "pending": - return ( -

    - Saving changes -

    - ) - case "error": - return Error updating the bookmark! - default: - return null - } - } - - return ( - <> - {message()} -
    - - tag.name).join(" ")} /> - - - ) -} - export { EditBookmarkDialog } diff --git a/packages/web/src/app/bookmarks/-store.tsx b/packages/web/src/app/bookmarks/-store.tsx index 6a037df..71ed7a9 100644 --- a/packages/web/src/app/bookmarks/-store.tsx +++ b/packages/web/src/app/bookmarks/-store.tsx @@ -59,7 +59,6 @@ interface BookmarkPageState { bookmarkToBeEdited: Bookmark | null layoutMode: LayoutMode dialog: DialogData - hasDialog: boolean actionBarContent: ActionBarContent statusMessage: string activeWindow: WindowKind @@ -82,10 +81,6 @@ const useBookmarkPageStore = create()((set, get) => ({ statusMessage: "", activeWindow: WindowKind.None, - get hasDialog(): boolean { - return get().dialog.kind !== DialogKind.None - }, - setActiveWindow(window: WindowKind) { set({ activeWindow: window }) }, diff --git a/packages/web/src/app/bookmarks/index.tsx b/packages/web/src/app/bookmarks/index.tsx index 677d6f1..4460bf0 100644 --- a/packages/web/src/app/bookmarks/index.tsx +++ b/packages/web/src/app/bookmarks/index.tsx @@ -1,18 +1,19 @@ import { autoUpdate, offset, useFloating } from "@floating-ui/react-dom" 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 { fetchApi, useAuthenticatedQuery } from "~/api" import { ActionBar } from "~/app/bookmarks/-action-bar.tsx" import { useLogOut } from "~/auth.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 { Message, MessageVariant } from "~/components/message.tsx" import { useDocumentEvent } from "~/hooks/use-document-event.ts" import { useMnemonics } from "~/hooks/use-mnemonics.ts" import { BookmarkList } from "./-bookmark-list" import { ActionBarContentKind, DialogKind, WindowKind, useBookmarkPageStore } from "./-store" +import { SideNav } from "~/app/-side-nav" export const Route = createFileRoute("/bookmarks/")({ component: RouteComponent, @@ -30,12 +31,7 @@ function RouteComponent() { function BookmarkListPane() { return (
    -
    -

    -  >  - YOUR BOOKMARKS -

    -
    + <_SideNav />
    @@ -43,6 +39,11 @@ function BookmarkListPane() { ) } +function _SideNav() { + const hasDialog = useBookmarkPageStore((state) => state.dialog.kind !== DialogKind.None) + return +} + function BookmarkListContainer() { const searchParamsString = new URLSearchParams(Route.useSearch()).toString() const { data: bookmarks, status } = useAuthenticatedQuery( @@ -360,11 +361,6 @@ function AppMenuWindow({ ref, style }: { ref: React.Ref; style:

    MENU

      -
    • - - COLLECTIONS - -
    • diff --git a/packages/web/src/app/collections/$collectionId.tsx b/packages/web/src/app/collections/$collectionId.tsx new file mode 100644 index 0000000..61cc62b --- /dev/null +++ b/packages/web/src/app/collections/$collectionId.tsx @@ -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 ( +
      + + +
      + ) +} + +function CollectionDetailPane() { + const { collectionId } = Route.useParams() + const { data: collection, status } = useCollection(collectionId) + + if (status === "pending") { + return ( +
      +

      + Loading +

      +
      + ) + } + + if (status === "error" || !collection) { + return ( +
      +

      Error loading collection

      +
      + ) + } + + return ( +
      +
      +

      +  >  + {collection.name} +

      +

      {collection.description}

      +
      +
      + +
      +
      + ) +} + +function CollectionBookmarkList({ collection }: { collection: Collection }) { + const { data: bookmarks, status } = useCollectionBookmarks(collection) + const handleBookmarkListItemAction = useBookmarkPageStore((state) => state.handleBookmarkListItemAction) + + switch (status) { + case "success": + return ( + 0 ? "-mt-2" : ""} + alwaysExpandItem={false} + bookmarks={bookmarks} + onItemAction={handleBookmarkListItemAction} + /> + ) + + case "pending": + return ( +
      +

      + Loading +

      +
      + ) + + case "error": + return ( +
      +

      Error loading bookmarks

      +
      + ) + } +} + +function CollectionDetailActionBar({ className }: { className?: string }) { + const activeWindow = useBookmarkPageStore((state) => state.activeWindow) + const { refs, floatingStyles } = useFloating({ + placement: "top", + whileElementsMounted: autoUpdate, + middleware: [offset(8)], + }) + + return ( + <> + + + + {activeWindow === WindowKind.AppMenu && } + + ) +} + +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 ( +
      + + +
      + ) +} + +function AppMenuWindow({ ref, style }: { ref: React.Ref; style: React.CSSProperties }) { + return ( +
      +

      MENU

      +
      +
        +
      • + + BOOKMARKS + +
      • +
      • + + COLLECTIONS + +
      • +
      • + +
      • +
      +
      +
      + ) +} + +function LogOutButton() { + const logOutMutation = useLogOut() + const navigate = useNavigate() + + function logOut() { + logOutMutation.mutate() + navigate({ to: "/", replace: true }) + } + + return ( + + ) +} diff --git a/packages/web/src/app/collections/-collection-list.tsx b/packages/web/src/app/collections/-collection-list.tsx index 9c04fcb..9947ef0 100644 --- a/packages/web/src/app/collections/-collection-list.tsx +++ b/packages/web/src/app/collections/-collection-list.tsx @@ -1,8 +1,8 @@ import type { Collection } from "@markone/core" +import { Link } from "@tanstack/react-router" import { clsx } from "clsx" import { memo } from "react" import { Button } from "~/components/button" -import { Link } from "~/components/link" import { List } from "~/components/list" export enum CollectionListItemAction { @@ -53,7 +53,9 @@ const CollectionListItem = memo(
      - {collection.name} + + {collection.name} +

      {collection.description}

      {isExpanded ? ( diff --git a/packages/web/src/app/collections/-dialogs/edit-collection-dialog.tsx b/packages/web/src/app/collections/-dialogs/edit-collection-dialog.tsx index b1b2b85..1e723b2 100644 --- a/packages/web/src/app/collections/-dialogs/edit-collection-dialog.tsx +++ b/packages/web/src/app/collections/-dialogs/edit-collection-dialog.tsx @@ -1,6 +1,6 @@ import { useId, useRef, useEffect } from "react" import type { Collection } from "@markone/core" -import { useUpdateCollection } from "~/collections/api" +import { useUpdateCollection } from "~/collection/api" import { Button } from "~/components/button" import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog" import { FormField } from "~/components/form-field" diff --git a/packages/web/src/app/collections/index.tsx b/packages/web/src/app/collections/index.tsx index ddcdb7b..c4dd310 100644 --- a/packages/web/src/app/collections/index.tsx +++ b/packages/web/src/app/collections/index.tsx @@ -11,6 +11,7 @@ import { CollectionList } from "./-collection-list" import { AddCollectionDialog } from "./-dialogs/add-collection-dialog" import { DeleteCollectionDialog } from "./-dialogs/delete-collection-dialog" import { DialogKind, WindowKind, useCollectionPageStore } from "./-store" +import { SideNav } from "~/app/-side-nav" export const Route = createFileRoute("/collections/")({ component: CollectionsPage, @@ -42,12 +43,7 @@ function CollectionsPage() { function CollectionsPane() { return (
      -
      -

      -  >  - YOUR COLLECTIONS -

      -
      + <_SideNav />
      @@ -55,6 +51,11 @@ function CollectionsPane() { ) } +function _SideNav() { + const hasDialog = useCollectionPageStore((state) => state.dialog.kind !== DialogKind.None) + return +} + function CollectionsContainer() { const { data: collections, status } = useCollections() const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction) diff --git a/packages/web/src/collection/api.ts b/packages/web/src/collection/api.ts index 1126f71..037dd89 100644 --- a/packages/web/src/collection/api.ts +++ b/packages/web/src/collection/api.ts @@ -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 { useNavigate } from "@tanstack/react-router" 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() { const navigate = useNavigate() 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 } diff --git a/packages/web/src/components/list.tsx b/packages/web/src/components/list.tsx index c59a2b5..f43b4f2 100644 --- a/packages/web/src/components/list.tsx +++ b/packages/web/src/components/list.tsx @@ -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 { subscribeWithSelector } from "zustand/middleware" import { clsx } from "clsx" @@ -8,6 +8,10 @@ interface ListData { id: string } +interface ListRef { + selectedItem: T +} + interface ListState { items: T[] selectedIndex: number @@ -24,6 +28,8 @@ type ListStore = ReturnType> const ListStoreContext = createContext | null>(null) +type MnemonicCallback = (event: KeyboardEvent, item: T) => void + interface ListProps { items: T[] selectedItemId?: string @@ -38,6 +44,8 @@ interface ListProps { }) => React.ReactNode className?: string emptyMessage?: string + mnemonics?: Record> + ref?: React.Ref> } function List({ @@ -48,30 +56,34 @@ function List({ renderItem, className, emptyMessage = "No items found!", + mnemonics, + ref, }: ListProps) { - const storeRef = useRef | null>(null) - if (!storeRef.current) { - storeRef.current = createListStore({ items, selectedItemId, alwaysExpandItem }) - } + const storeRef = useRef>(createListStore({ items, selectedItemId, alwaysExpandItem })) + + useImperativeHandle( + ref, + () => ({ + get selectedItem() { + const { items, selectedIndex } = storeRef.current.getState() + return items[selectedIndex] + }, + }), + [], + ) useEffect(() => { - const store = storeRef.current - if (!store) return - store.getState().setItems(items) + storeRef.current.getState().setItems(items) }, [items]) useEffect(() => { - const store = storeRef.current - if (!store) return - if (selectedItemId !== store.getState().selectedItemId && selectedItemId) { - store.getState().setSelectedItemId(selectedItemId) + if (selectedItemId !== storeRef.current.getState().selectedItemId && selectedItemId) { + storeRef.current.getState().setSelectedItemId(selectedItemId) } }, [selectedItemId]) useEffect(() => { - const store = storeRef.current - if (!store) return - const unsub = store.subscribe( + const unsub = storeRef.current.subscribe( (state) => state, ({ items, selectedIndex }) => { onSelectionChange?.(items[selectedIndex]) @@ -87,7 +99,7 @@ function List({ return ( }> - + ) } @@ -213,6 +225,7 @@ function _List({ className, renderItem, emptyMessage, + mnemonics, }: { className?: string renderItem: (props: { @@ -223,6 +236,7 @@ function _List({ onExpand: () => void }) => React.ReactNode emptyMessage: string + mnemonics?: Record> }) { const store = useListStoreContext() const items = useListStore((state) => state.items) @@ -231,20 +245,35 @@ function _List({ const setSelectedItemId = useListStore void>((state) => state.setSelectedItemId) const setIsItemExpanded = useListStore void>((state) => state.setIsItemExpanded) - const shortcuts = { - j: selectNextItem, - ArrowDown: selectNextItem, - k: selectPrevItem, - ArrowUp: selectPrevItem, - l: expandItem, - ArrowRight: expandItem, - h: collapseItem, - ArrowLeft: collapseItem, - } + const shortcuts = useMemo(() => { + const baseShortcuts: Record void> = { + j: selectNextItem, + ArrowDown: selectNextItem, + k: selectPrevItem, + ArrowUp: selectPrevItem, + l: expandItem, + ArrowRight: expandItem, + h: collapseItem, + ArrowLeft: collapseItem, + } - useMnemonics(shortcuts, { - ignore: useCallback(() => false, []), - }) + if (!mnemonics) { + 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() { const { items, selectedIndex, setSelectedItemId } = store.getState() @@ -303,4 +332,4 @@ function _List({ const MemoizedList = memo(_List) as typeof _List export { List } -export type { ListData, ListProps } +export type { ListData, ListProps, MnemonicCallback, ListRef } diff --git a/packages/web/src/components/tags-input.tsx b/packages/web/src/components/tags-input.tsx index 41efbee..fbc6f1e 100644 --- a/packages/web/src/components/tags-input.tsx +++ b/packages/web/src/components/tags-input.tsx @@ -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 { useCallback, useImperativeHandle, useRef, useState, useEffect, useId, memo } from "react" import clsx from "clsx" -import { type Atom, type PrimitiveAtom, atom, useAtom, useSetAtom } from "jotai" -import { createContext, useContext, useEffect, useImperativeHandle, useMemo, useState } from "react" -import { useTags } from "~/bookmark/api" -import { useMnemonics } from "~/hooks/use-mnemonics" -import { FormField } from "./form-field" -import { LoadingSpinner } from "./loading-spinner" interface TagsInputRef { input: HTMLInputElement | null tags: string[] + initialSelections?: Tag[] } -const TagsInputContext = createContext<{ - value: PrimitiveAtom - lastTag: Atom -} | null>(null) +const TagOption = memo(({ tag }: { tag: Tag }) => ( + + {tag.name} + + +)) -function TagsInput({ ref, initialValue = "" }: { ref: React.Ref; initialValue?: string }) { - const valueAtom = useMemo(() => atom(initialValue), [initialValue]) - const lastTagAtom = useMemo( - () => - atom((get) => { - const value = get(valueAtom) - let start = 0 - for (let i = value.length; i > 0; --i) { - if (value.charAt(i) === " ") { - start = i + 1 - break - } - } - return value.slice(start) - }), - [valueAtom], - ) +function TagsInput({ + ref, + tags, + initialSelections, +}: { + ref: React.Ref + tags: Tag[] + initialSelections?: Tag[] +}) { + const [selectedTags, setSelectedTags] = useState(initialSelections ?? []) + const [addedTags, setAddedTags] = useState([]) + const [query, setQuery] = useState("") + const [isFocused, setIsFocused] = useState(false) + const inputRef = useRef(null) + const id = useId() - return ( - - <_TagsInput ref={ref} /> - - ) -} - -function _TagsInput({ ref }: { ref: React.Ref }) { - // biome-ignore lint/style/noNonNullAssertion: - const { value: valueAtom, lastTag: lastTagAtom } = useContext(TagsInputContext)! - const { refs, floatingStyles } = useFloating({ - whileElementsMounted: autoUpdate, - middleware: [ - size({ - apply({ rects, elements }) { - Object.assign(elements.floating.style, { - minWidth: `${rects.reference.width}px`, - }) - }, - }), - ], - }) - - const [value, setValue] = useAtom(valueAtom) - const [lastTag] = useAtom(lastTagAtom) - const [isInputFocused, setIsInputFocused] = useState(false) + useEffect(() => { + setSelectedTags(initialSelections ?? []) + }, [initialSelections]) useImperativeHandle(ref, () => ({ get tags() { - if (value === "") { - return [] - } - return value.trim().split(" ") + return selectedTags.map((tag) => tag.name) }, - input: refs.reference.current, + input: inputRef.current, })) - return ( - <> - { - setValue(event.currentTarget.value) - }} - className="flex-1" - onFocus={() => { - setIsInputFocused(true) - }} - onBlur={() => { - setIsInputFocused(false) - }} - labelClassName="bg-stone-300 dark:bg-stone-800" - /> - {isInputFocused && lastTag !== "" ? : null} - - ) -} - -function TagList({ ref, style }: { ref: React.Ref; style: React.CSSProperties }) { - const { data: tags, status } = useTags() - switch (status) { - case "pending": - return ( -

      - Loading -

      - ) - case "success": - return <_TagList ref={ref} style={style} tags={tags} /> - case "error": - return null - } -} - -function _TagList({ ref, style, tags }: { tags: Tag[]; ref: React.Ref; style: React.CSSProperties }) { - // biome-ignore lint/style/noNonNullAssertion: - const { value: valueAtom, lastTag: lastTagAtom } = useContext(TagsInputContext)! - const [selectedTag, setSelectedTag] = useState(undefined) - const [lastTag] = useAtom(lastTagAtom) - const setValue = useSetAtom(valueAtom) - - const filteredTags: Tag[] = [] - const listItems: React.ReactElement[] = [] - let hasExactMatch = false - let shouldResetSelection = selectedTag !== null - for (const tag of tags) { - if (tag.name.startsWith(lastTag)) { - if (tag.name.length === lastTag.length) { - hasExactMatch = true - } - if (tag.id === selectedTag?.id) { - shouldResetSelection = false - } - filteredTags.push(tag) - listItems.push( -
    • - -
    • , - ) - } - } - 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) - } - }, + const filteredTags = tags.filter((tag) => { + if (query === "") return true + return tag.name.toLowerCase().includes(query.toLowerCase()) }) - function addTag(selectedTag: Tag | null | undefined) { - if (selectedTag) { - setValue((value) => `${value}${selectedTag.name.slice(lastTag.length)} `) - } else { - // biome-ignore lint/style/useTemplate: this is more readable than using template literal - setValue((value) => value + " ") - } - } + const filteredAddedTags = addedTags.filter((tag) => { + if (query === "") return true + return tag.name.toLowerCase().includes(query.toLowerCase()) + }) - if (lastTag === "") { - return null - } + const removeTag = useCallback( + (tagToRemove: string) => { + setSelectedTags(selectedTags.filter((tag) => tag.name !== tagToRemove)) + }, + [selectedTags], + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + 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 ( -
      -
        - {listItems} - {hasExactMatch ? null : ( -
      • - {lastTag.includes("#") ? ( - <> Tags cannot contain '#' - ) : ( - +
        +
        + +
        + setQuery(event.target.value.split(" ").at(-1) ?? "")} + onKeyDown={handleKeyDown} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + displayValue={() => { + const tagsString = selectedTags.map((tag) => `${tag.name}`).join(" ") + return query ? `${tagsString}${query}` : tagsString + }} + /> + + ▼ + +
        +
      + > + TAGS + + + {filteredTags.length === 0 && filteredAddedTags.length === 0 ? ( + + Add tag: {query} + + + ) : ( + <> + {filteredTags.map((tag) => ( + + ))} + {filteredAddedTags.length > 0 && filteredAddedTags.map((tag) => )} + + )} + + +
      ) }