feat: add login screen

This commit is contained in:
reya
2024-07-23 14:18:40 +07:00
parent 462837565e
commit 7cd5f06122
22 changed files with 837 additions and 27 deletions

View File

@@ -6,9 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Coop</title>
</head>
<body>
<div id="root"></div>
<body class="antialiased h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black dark:text-white">
<div id="root" class="size-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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"
}
}

254
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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;
}
}

23
src/commons.ts Normal file
View File

@@ -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)
);
}

View File

@@ -0,0 +1,47 @@
import { cn } from "@/commons";
import type { ReactNode } from "react";
export function Spinner({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) {
const spinner = (
<span className={cn("block relative opacity-65 size-4", className)}>
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
</span>
);
if (children === undefined) return spinner;
return (
<div className="relative flex items-center justify-center">
<span>
{/**
* `display: contents` removes the content from the accessibility tree in some browsers,
* so we force remove it with `aria-hidden`
*/}
<span
aria-hidden
style={{ display: "contents", visibility: "hidden" }}
// biome-ignore lint/correctness/noConstantCondition: Workaround to use `inert` until https://github.com/facebook/react/pull/24730 is merged.
{...{ inert: true ? "" : undefined }}
>
{children}
</span>
<div className="absolute flex items-center justify-center">
<span>{spinner}</span>
</div>
</span>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { cn } from "@/commons";
import { useUserContext } from "./provider";
export function UserAbout({ className }: { className?: string }) {
const user = useUserContext();
return (
<div className={cn("content-break select-text", className)}>
{user.profile?.about?.trim() || "No bio"}
</div>
);
}

View File

@@ -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 (
<Avatar.Root
className={cn(
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
className,
)}
>
<Avatar.Image
src={user.profile?.picture}
alt={user.pubkey}
loading="lazy"
decoding="async"
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
<Avatar.Fallback>
<img
src={fallback}
alt={user.pubkey}
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}

View File

@@ -0,0 +1,36 @@
import { cn } from "@/commons";
import { useUserContext } from "./provider";
export function UserCover({ className }: { className?: string }) {
const user = useUserContext();
if (!user) {
return (
<div
className={cn(
"animate-pulse bg-neutral-300 dark:bg-neutral-700",
className,
)}
/>
);
}
if (user && !user.profile?.banner) {
return (
<div
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
/>
);
}
return (
<img
src={user?.profile?.banner}
alt="banner"
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className={cn("object-cover", className)}
/>
);
}

View File

@@ -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,
};

View File

@@ -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 (
<div className={cn("max-w-[12rem] truncate", className)}>
{prefix}
{user.profile?.display_name ||
user.profile?.name ||
npub(user.pubkey, 16)}
</div>
);
}

View File

@@ -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<UserContext>(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 (
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
{children}
</UserContext.Provider>
);
}
export function useUserContext() {
const context = useContext(UserContext);
return context;
}

View File

@@ -0,0 +1,12 @@
import { cn } from "@/commons";
import type { ReactNode } from "react";
export function UserRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn(className)}>{children}</div>;
}

View File

@@ -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(
<StrictMode>
<RouterProvider router={router} />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
}

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/$account/chats')({
component: () => <div>Hello /$account/chats!</div>
})

View File

@@ -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<RouterContext>()({
component: () => <Outlet />,
});

View File

@@ -1,5 +0,0 @@
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/')({
component: () => <div>Hello /!</div>
})

111
src/routes/index.tsx Normal file
View File

@@ -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 (
<div className="size-full flex items-center justify-center">
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h3 className="leading-tight text-neutral-700 dark:text-neutral-300">
{currentDate}
</h3>
<h1 className="leading-tight text-xl font-semibold">Welcome back!</h1>
</div>
<div className="flex flex-col w-full bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-lg shadow-neutral-500/10 dark:shadow-none dark:bg-white/10 dark:ring-1 dark:ring-white/5">
{context.accounts.map((account) => (
<div
key={account}
onClick={() => login(account)}
onKeyDown={() => login(account)}
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2.5 p-3">
<User.Avatar className="rounded-full size-10" />
<div className="inline-flex flex-col items-start">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{npub(account, 16)}
</span>
</div>
</User.Root>
</User.Provider>
<div className="inline-flex items-center justify-center size-10">
{loading.npub === account && loading.status ? (
<Spinner />
) : null}
</div>
</div>
))}
<Link
to="/new"
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2.5 p-3">
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
<Plus className="size-5" />
</div>
<span className="max-w-[6rem] truncate text-sm font-medium leading-tight">
Add an account
</span>
</div>
</Link>
</div>
</div>
</div>
);
}

5
src/routes/new.lazy.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/new')({
component: () => <div>Hello /new!</div>
})

View File

@@ -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"
}
]
}

View File

@@ -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`
//