diff --git a/index.html b/index.html index be7b850..841d357 100644 --- a/index.html +++ b/index.html @@ -6,9 +6,8 @@ Coop - - -
+ +
diff --git a/package.json b/package.json index 0e88455..c629b74 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,16 @@ "tauri": "tauri" }, "dependencies": { + "@phosphor-icons/react": "^2.1.7", + "@radix-ui/react-avatar": "^1.1.0", + "@tanstack/react-query": "^5.51.11", "@tanstack/react-router": "^1.45.8", "@tauri-apps/api": ">=2.0.0-beta.0", "@tauri-apps/plugin-shell": ">=2.0.0-beta.0", + "minidenticons": "^4.2.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "virtua": "^0.33.3" }, "devDependencies": { "@biomejs/biome": "1.8.3", @@ -24,9 +29,12 @@ "@types/react-dom": "^18.2.7", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", + "clsx": "^2.1.1", "postcss": "^8.4.39", + "tailwind-merge": "^2.4.0", "tailwindcss": "^3.4.6", "typescript": "^5.2.2", - "vite": "^5.3.1" + "vite": "^5.3.1", + "vite-tsconfig-paths": "^4.3.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 756307d..5073c01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@phosphor-icons/react': + specifier: ^2.1.7 + version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-avatar': + specifier: ^1.1.0 + version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.51.11 + version: 5.51.11(react@18.3.1) '@tanstack/react-router': specifier: ^1.45.8 version: 1.45.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -17,12 +26,18 @@ importers: '@tauri-apps/plugin-shell': specifier: '>=2.0.0-beta.0' version: 2.0.0-beta.8 + minidenticons: + specifier: ^4.2.1 + version: 4.2.1 react: specifier: ^18.2.0 version: 18.3.1 react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + virtua: + specifier: ^0.33.3 + version: 0.33.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@biomejs/biome': specifier: 1.8.3 @@ -45,9 +60,15 @@ importers: autoprefixer: specifier: ^10.4.19 version: 10.4.19(postcss@8.4.39) + clsx: + specifier: ^2.1.1 + version: 2.1.1 postcss: specifier: ^8.4.39 version: 8.4.39 + tailwind-merge: + specifier: ^2.4.0 + version: 2.4.0 tailwindcss: specifier: ^3.4.6 version: 3.4.6 @@ -57,6 +78,9 @@ importers: vite: specifier: ^5.3.1 version: 5.3.4 + vite-tsconfig-paths: + specifier: ^4.3.2 + version: 4.3.2(typescript@5.5.4)(vite@5.3.4) packages: @@ -408,10 +432,88 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@phosphor-icons/react@2.1.7': + resolution: {integrity: sha512-g2e2eVAn1XG2a+LI09QU3IORLhnFNAFkNbo2iwbX6NOKSLOwvEMmTa7CgOzEbgNWR47z8i8kwjdvYZ5fkGx1mQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@radix-ui/react-avatar@1.1.0': + resolution: {integrity: sha512-Q/PbuSMk/vyAd/UoIShVGZ7StHHeRFYU7wXmi5GV+8cLXflZAEpHL/F697H1klrzxKXNtZ97vWiC0q3RKUH8UA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.0': + resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.0': + resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.0.0': + resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.1.0': + resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.0': + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.0': + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@rollup/rollup-android-arm-eabi@4.19.0': resolution: {integrity: sha512-JlPfZ/C7yn5S5p0yKk7uhHTTnFlvTgLetl2VxqE518QgyM7C9bSfFTYvB/Q/ftkq0RIPY4ySxTz+/wKJ/dXC0w==} cpu: [arm] @@ -496,6 +598,14 @@ packages: resolution: {integrity: sha512-n4XXInV9irIq0obRvINIkESkGk280Q+xkIIbswmM0z9nAu2wsIRZNvlmPrtYh6bgNWtItOWWoihFUjLTW8g6Jg==} engines: {node: '>=12'} + '@tanstack/query-core@5.51.9': + resolution: {integrity: sha512-HsAwaY5J19MD18ykZDS3aVVh+bAt0i7m6uQlFC2b77DLV9djo+xEN7MWQAQQTR8IM+7r/zbozTQ7P0xr0bHuew==} + + '@tanstack/react-query@5.51.11': + resolution: {integrity: sha512-4Kq2x0XpDlpvSnaLG+8pHNH60zEc3mBvb3B2tOMDjcPCi/o+Du3p/9qpPLwJOTliVxxPJAP27fuIhLrsRdCr7A==} + peerDependencies: + react: ^18.0.0 + '@tanstack/react-router@1.45.8': resolution: {integrity: sha512-hLJOKDK5lGHteoMjpF6COQrlhsl4C6GyBCzmSJHFcoh26GBa7tv/94li0H1a3deJpzMNpSvmSXrQDpxj9h9bNA==} engines: {node: '>=12'} @@ -712,6 +822,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -830,6 +944,9 @@ packages: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -914,6 +1031,10 @@ packages: resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} engines: {node: '>=8.6'} + minidenticons@4.2.1: + resolution: {integrity: sha512-oWfFivA0lOx/V/bO/YIJbthB26lV8JXYvhnv9zM2hNd3fzsHTXQ6c6bWZPcvhD3nnOB+lQk/D9lF43BXixrN8g==} + engines: {node: '>=15.14.0'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -1118,6 +1239,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tailwind-merge@2.4.0: + resolution: {integrity: sha512-49AwoOQNKdqKPd9CViyH5wJoSKsCDjUlzL8DxuGp3P1FsGY36NJDAa18jLZcaHAUUuTj+JB8IAo8zWgBNvBF7A==} + tailwindcss@3.4.6: resolution: {integrity: sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==} engines: {node: '>=14.0.0'} @@ -1147,6 +1271,16 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsconfck@3.1.1: + resolution: {integrity: sha512-00eoI6WY57SvZEVjm13stEVE90VkEdJAFGgpFLTsZbJyW/LwFQ7uQxJHWpZ2hzSWgCPKc9AnBnNP+0X7o3hAmQ==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + typescript@5.5.4: resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} engines: {node: '>=14.17'} @@ -1170,6 +1304,34 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + virtua@0.33.3: + resolution: {integrity: sha512-Zxr2hhmTHARMHdZjs5fvd17bHH2YJ1uZZGaw4SKmynDEXtHFzJn/pL9xYJeXWZ8UfXNIBbPvGlHGpruCRbLHIg==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + solid-js: '>=1.0' + svelte: '>=4.0' + vue: '>=3.2' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + + vite-tsconfig-paths@4.3.2: + resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite@5.3.4: resolution: {integrity: sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1535,9 +1697,66 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@phosphor-icons/react@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@pkgjs/parseargs@0.11.0': optional: true + '@radix-ui/react-avatar@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + + '@radix-ui/react-context@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + + '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-slot@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + '@rollup/rollup-android-arm-eabi@4.19.0': optional: true @@ -1588,6 +1807,13 @@ snapshots: '@tanstack/history@1.45.3': {} + '@tanstack/query-core@5.51.9': {} + + '@tanstack/react-query@5.51.11(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.51.9 + react: 18.3.1 + '@tanstack/react-router@1.45.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/history': 1.45.3 @@ -1814,6 +2040,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + clsx@2.1.1: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -1935,6 +2163,8 @@ snapshots: globals@11.12.0: {} + globrex@0.1.2: {} + has-flag@3.0.0: {} hasown@2.0.2: @@ -1998,6 +2228,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + minidenticons@4.2.1: {} + minimatch@9.0.5: dependencies: brace-expansion: 2.0.1 @@ -2190,6 +2422,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tailwind-merge@2.4.0: {} + tailwindcss@3.4.6: dependencies: '@alloc/quick-lru': 5.2.0 @@ -2237,6 +2471,10 @@ snapshots: ts-interface-checker@0.1.13: {} + tsconfck@3.1.1(typescript@5.5.4): + optionalDependencies: + typescript: 5.5.4 + typescript@5.5.4: {} unplugin@1.11.0: @@ -2258,6 +2496,22 @@ snapshots: util-deprecate@1.0.2: {} + virtua@0.33.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + vite-tsconfig-paths@4.3.2(typescript@5.5.4)(vite@5.3.4): + dependencies: + debug: 4.3.5 + globrex: 0.1.2 + tsconfck: 3.1.1(typescript@5.5.4) + optionalDependencies: + vite: 5.3.4 + transitivePeerDependencies: + - supports-color + - typescript + vite@5.3.4: dependencies: esbuild: 0.21.5 diff --git a/src/App.css b/src/App.css index b5c61c9..f72aac9 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,92 @@ @tailwind base; @tailwind components; @tailwind utilities; + +html { + font-size: 14px; +} + +a { + @apply cursor-default no-underline !important; +} + +button { + @apply cursor-default focus:outline-none; +} + +input::-ms-reveal, +input::-ms-clear { + display: none; +} + +::-webkit-input-placeholder { + line-height: normal; +} + +.spinner-leaf { + position: absolute; + top: 0; + left: calc(50% - 12.5% / 2); + width: 12.5%; + height: 100%; + animation: spinner-leaf-fade 800ms linear infinite; + + &::before { + content: ""; + display: block; + width: 100%; + height: 30%; + background-color: currentColor; + @apply rounded; + } + + &:where(:nth-child(1)) { + transform: rotate(0deg); + animation-delay: -800ms; + } + + &:where(:nth-child(2)) { + transform: rotate(45deg); + animation-delay: -700ms; + } + + &:where(:nth-child(3)) { + transform: rotate(90deg); + animation-delay: -600ms; + } + + &:where(:nth-child(4)) { + transform: rotate(135deg); + animation-delay: -500ms; + } + + &:where(:nth-child(5)) { + transform: rotate(180deg); + animation-delay: -400ms; + } + + &:where(:nth-child(6)) { + transform: rotate(225deg); + animation-delay: -300ms; + } + + &:where(:nth-child(7)) { + transform: rotate(270deg); + animation-delay: -200ms; + } + + &:where(:nth-child(8)) { + transform: rotate(315deg); + animation-delay: -100ms; + } +} + +@keyframes spinner-leaf-fade { + from { + opacity: 1; + } + + to { + opacity: 0.25; + } +} diff --git a/src/commons.ts b/src/commons.ts new file mode 100644 index 0000000..ab18028 --- /dev/null +++ b/src/commons.ts @@ -0,0 +1,23 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function npub(pubkey: string, len: number) { + if (pubkey.length <= len) return pubkey; + + const separator = " ... "; + + const sepLen = separator.length; + const charsToShow = len - sepLen; + const frontChars = Math.ceil(charsToShow / 2); + const backChars = Math.floor(charsToShow / 2); + + return ( + pubkey.substring(0, frontChars) + + separator + + pubkey.substring(pubkey.length - backChars) + ); +} diff --git a/src/components/spinner.tsx b/src/components/spinner.tsx new file mode 100644 index 0000000..3342142 --- /dev/null +++ b/src/components/spinner.tsx @@ -0,0 +1,47 @@ +import { cn } from "@/commons"; +import type { ReactNode } from "react"; + +export function Spinner({ + children, + className, +}: { + children?: ReactNode; + className?: string; +}) { + const spinner = ( + + + + + + + + + + + ); + + if (children === undefined) return spinner; + + return ( +
+ + {/** + * `display: contents` removes the content from the accessibility tree in some browsers, + * so we force remove it with `aria-hidden` + */} + + {children} + +
+ {spinner} +
+
+
+ ); +} diff --git a/src/components/user/about.tsx b/src/components/user/about.tsx new file mode 100644 index 0000000..29ba682 --- /dev/null +++ b/src/components/user/about.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/commons"; +import { useUserContext } from "./provider"; + +export function UserAbout({ className }: { className?: string }) { + const user = useUserContext(); + + return ( +
+ {user.profile?.about?.trim() || "No bio"} +
+ ); +} diff --git a/src/components/user/avatar.tsx b/src/components/user/avatar.tsx new file mode 100644 index 0000000..9c4e5bd --- /dev/null +++ b/src/components/user/avatar.tsx @@ -0,0 +1,40 @@ +import { cn } from "@/commons"; +import * as Avatar from "@radix-ui/react-avatar"; +import { minidenticon } from "minidenticons"; +import { useMemo } from "react"; +import { useUserContext } from "./provider"; + +export function UserAvatar({ className }: { className?: string }) { + const user = useUserContext(); + const fallback = useMemo( + () => + `data:image/svg+xml;utf8,${encodeURIComponent( + minidenticon(user.pubkey, 60, 50), + )}`, + [user.pubkey], + ); + + return ( + + + + {user.pubkey} + + + ); +} diff --git a/src/components/user/cover.tsx b/src/components/user/cover.tsx new file mode 100644 index 0000000..0097b32 --- /dev/null +++ b/src/components/user/cover.tsx @@ -0,0 +1,36 @@ +import { cn } from "@/commons"; +import { useUserContext } from "./provider"; + +export function UserCover({ className }: { className?: string }) { + const user = useUserContext(); + + if (!user) { + return ( +
+ ); + } + + if (user && !user.profile?.banner) { + return ( +
+ ); + } + + return ( + banner + ); +} diff --git a/src/components/user/index.ts b/src/components/user/index.ts new file mode 100644 index 0000000..9bbe41d --- /dev/null +++ b/src/components/user/index.ts @@ -0,0 +1,15 @@ +import { UserAbout } from "./about"; +import { UserAvatar } from "./avatar"; +import { UserCover } from "./cover"; +import { UserName } from "./name"; +import { UserProvider } from "./provider"; +import { UserRoot } from "./root"; + +export const User = { + Provider: UserProvider, + Root: UserRoot, + Avatar: UserAvatar, + Cover: UserCover, + Name: UserName, + About: UserAbout, +}; diff --git a/src/components/user/name.tsx b/src/components/user/name.tsx new file mode 100644 index 0000000..0de6465 --- /dev/null +++ b/src/components/user/name.tsx @@ -0,0 +1,21 @@ +import { cn, npub } from "@/commons"; +import { useUserContext } from "./provider"; + +export function UserName({ + className, + prefix, +}: { + className?: string; + prefix?: string; +}) { + const user = useUserContext(); + + return ( +
+ {prefix} + {user.profile?.display_name || + user.profile?.name || + npub(user.pubkey, 16)} +
+ ); +} diff --git a/src/components/user/provider.tsx b/src/components/user/provider.tsx new file mode 100644 index 0000000..995c2c6 --- /dev/null +++ b/src/components/user/provider.tsx @@ -0,0 +1,71 @@ +import { useQuery } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { type ReactNode, createContext, useContext } from "react"; + +type Metadata = { + name?: string; + display_name?: string; + about?: string; + website?: string; + picture?: string; + banner?: string; + nip05?: string; + lud06?: string; + lud16?: string; +}; + +type UserContext = { + pubkey: string; + isLoading: boolean; + isError: boolean; + profile: Metadata | undefined; +}; + +const UserContext = createContext(null); + +export function UserProvider({ + pubkey, + children, +}: { + pubkey: string; + children: ReactNode; +}) { + const { + isLoading, + isError, + data: profile, + } = useQuery({ + queryKey: ["profile", pubkey], + queryFn: async () => { + try { + const normalizePubkey = pubkey + .replace("nostr:", "") + .replace(/[^\w\s]/gi, ""); + + const query: string = await invoke("get_profile", { + id: normalizePubkey, + }); + + return JSON.parse(query) as Metadata; + } catch (e) { + throw new Error(String(e)); + } + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: Number.POSITIVE_INFINITY, + retry: 2, + }); + + return ( + + {children} + + ); +} + +export function useUserContext() { + const context = useContext(UserContext); + return context; +} diff --git a/src/components/user/root.tsx b/src/components/user/root.tsx new file mode 100644 index 0000000..14887c1 --- /dev/null +++ b/src/components/user/root.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/commons"; +import type { ReactNode } from "react"; + +export function UserRoot({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return
{children}
; +} diff --git a/src/main.tsx b/src/main.tsx index 56e2cae..180d554 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,11 +3,18 @@ import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import "./app.css"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // Import the generated route tree import { routeTree } from "./routes.gen"; // Create a new router instance -const router = createRouter({ routeTree }); +const queryClient = new QueryClient(); +const router = createRouter({ + routeTree, + context: { + queryClient, + }, +}); // Register the router instance for type safety declare module "@tanstack/react-router" { @@ -22,7 +29,9 @@ if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - + + + , ); } diff --git a/src/routes.gen.ts b/src/routes.gen.ts index e8428b4..9331b58 100644 --- a/src/routes.gen.ts +++ b/src/routes.gen.ts @@ -13,17 +13,29 @@ import { createFileRoute } from '@tanstack/react-router' // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as IndexImport } from './routes/index' +import { Route as AccountChatsImport } from './routes/$account.chats' // Create Virtual Routes -const IndexLazyImport = createFileRoute('/')() +const NewLazyImport = createFileRoute('/new')() // Create/Update Routes -const IndexLazyRoute = IndexLazyImport.update({ +const NewLazyRoute = NewLazyImport.update({ + path: '/new', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/new.lazy').then((d) => d.Route)) + +const IndexRoute = IndexImport.update({ path: '/', getParentRoute: () => rootRoute, -} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) +} as any) + +const AccountChatsRoute = AccountChatsImport.update({ + path: '/$account/chats', + getParentRoute: () => rootRoute, +} as any) // Populate the FileRoutesByPath interface @@ -33,7 +45,21 @@ declare module '@tanstack/react-router' { id: '/' path: '/' fullPath: '/' - preLoaderRoute: typeof IndexLazyImport + preLoaderRoute: typeof IndexImport + parentRoute: typeof rootRoute + } + '/new': { + id: '/new' + path: '/new' + fullPath: '/new' + preLoaderRoute: typeof NewLazyImport + parentRoute: typeof rootRoute + } + '/$account/chats': { + id: '/$account/chats' + path: '/$account/chats' + fullPath: '/$account/chats' + preLoaderRoute: typeof AccountChatsImport parentRoute: typeof rootRoute } } @@ -41,7 +67,11 @@ declare module '@tanstack/react-router' { // Create and export the route tree -export const routeTree = rootRoute.addChildren({ IndexLazyRoute }) +export const routeTree = rootRoute.addChildren({ + IndexRoute, + NewLazyRoute, + AccountChatsRoute, +}) /* prettier-ignore-end */ @@ -51,11 +81,19 @@ export const routeTree = rootRoute.addChildren({ IndexLazyRoute }) "__root__": { "filePath": "__root.tsx", "children": [ - "/" + "/", + "/new", + "/$account/chats" ] }, "/": { - "filePath": "index.lazy.tsx" + "filePath": "index.tsx" + }, + "/new": { + "filePath": "new.lazy.tsx" + }, + "/$account/chats": { + "filePath": "$account.chats.tsx" } } } diff --git a/src/routes/$account.chats.tsx b/src/routes/$account.chats.tsx new file mode 100644 index 0000000..23f1026 --- /dev/null +++ b/src/routes/$account.chats.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$account/chats')({ + component: () =>
Hello /$account/chats!
+}) \ No newline at end of file diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 143143f..fb5f5ae 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,5 +1,10 @@ -import { Outlet, createRootRoute } from "@tanstack/react-router"; +import type { QueryClient } from "@tanstack/react-query"; +import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; -export const Route = createRootRoute({ +interface RouterContext { + queryClient: QueryClient; +} + +export const Route = createRootRouteWithContext()({ component: () => , }); diff --git a/src/routes/index.lazy.tsx b/src/routes/index.lazy.tsx deleted file mode 100644 index e9aa002..0000000 --- a/src/routes/index.lazy.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { createLazyFileRoute } from '@tanstack/react-router' - -export const Route = createLazyFileRoute('/')({ - component: () =>
Hello /!
-}) \ No newline at end of file diff --git a/src/routes/index.tsx b/src/routes/index.tsx new file mode 100644 index 0000000..61e2d30 --- /dev/null +++ b/src/routes/index.tsx @@ -0,0 +1,111 @@ +import { npub } from "@/commons"; +import { Spinner } from "@/components/spinner"; +import { User } from "@/components/user"; +import { Plus } from "@phosphor-icons/react"; +import { Link, createFileRoute, redirect } from "@tanstack/react-router"; +import { invoke } from "@tauri-apps/api/core"; +import { useMemo, useState } from "react"; + +export const Route = createFileRoute("/")({ + beforeLoad: async () => { + const accounts: string[] = await invoke("get_accounts"); + + if (!accounts.length) { + throw redirect({ + to: "/new", + replace: true, + }); + } + + return { accounts }; + }, + component: Screen, +}); + +function Screen() { + const context = Route.useRouteContext(); + const navigate = Route.useNavigate(); + + const currentDate = useMemo( + () => + new Date().toLocaleString("default", { + weekday: "long", + month: "long", + day: "numeric", + }), + [], + ); + + const [loading, setLoading] = useState({ npub: "", status: false }); + + const login = async (npub: string) => { + try { + setLoading({ npub, status: true }); + + const status = await invoke("login", { id: npub }); + + if (status) { + return navigate({ + to: "/$account/chats", + params: { account: npub }, + replace: true, + }); + } + } catch (e) { + setLoading({ npub: "", status: false }); + } + }; + + return ( +
+
+
+

+ {currentDate} +

+

Welcome back!

+
+
+ {context.accounts.map((account) => ( +
login(account)} + onKeyDown={() => login(account)} + className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5" + > + + + +
+ + + {npub(account, 16)} + +
+
+
+
+ {loading.npub === account && loading.status ? ( + + ) : null} +
+
+ ))} + +
+
+ +
+ + Add an account + +
+ +
+
+
+ ); +} diff --git a/src/routes/new.lazy.tsx b/src/routes/new.lazy.tsx new file mode 100644 index 0000000..4344d2f --- /dev/null +++ b/src/routes/new.lazy.tsx @@ -0,0 +1,5 @@ +import { createLazyFileRoute } from '@tanstack/react-router' + +export const Route = createLazyFileRoute('/new')({ + component: () =>
Hello /new!
+}) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index a7fc6fb..3b5fd27 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,10 +2,19 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], "module": "ESNext", "skipLibCheck": true, - + "baseUrl": "./", + "paths": { + "@/*": [ + "./src/*" + ] + }, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -13,13 +22,18 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": [ + "src" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] } diff --git a/vite.config.ts b/vite.config.ts index 38a164c..00a70cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,11 @@ import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; // https://vitejs.dev/config/ export default defineConfig(async () => ({ - plugins: [TanStackRouterVite(), react()], + plugins: [TanStackRouterVite(), tsconfigPaths(), react()], // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` //