🛂 Migrate frontend to Shadcn (#2010)

* 🔧 Add Tailwind, update dependencies and config files

*  Introduce new Shadcn components and remove old ones

* 🔧 Update dependencies

* Add new components.json file

* 🔥 Remove Chakra UI files

* 🔧 Add ThemeProvider component and integrate it into main

* 🔥 Remove common components

* Update primary color

*  Add new components

*  Add AuthLayout component

* 🔧 Add utility function cn

* 🔧 Refactor devtools integration and update dependencies

*  Add Footer and Error components

* ♻️ Update Footer

* 🔥 Remove utils

* ♻️ Refactor error handling in useAuth

* ♻️ Refactor useCustomToast

* ♻️ Refactor Login component and form handling

* ♻️ Refactor SignUp component and form handling

* 🔧 Update dependencies

* ♻️ Refactor RecoverPassword component and form handling

* ♻️ Refactor ResetPassword and form handling

* ♻️ Add error component to root route

* ♻️ Refactor error handling in utils

* ♻️ Update buttons

* 🍱 Add icons and logos assets

* ♻️ Refactor Sidebar component

* 🎨 Format

* ♻️ Refactor ThemeProvider

* ♻️ Refactor Common components

* 🔥 Remove old Appearance component

*  Add Sidebar components

* ♻️ Refactor DeleteAccount components

* ♻️ Refactor ChangePassword component

* ♻️ Refactor UserSettings

*  Add TanStack table

* ♻️ Update SignUp

*  Add Select component

* 🎨 Format

* ♻️ Update Footer

*  Add useCopyToClipboard hook

* 🎨 Tweak table styles

* 🎨 Tweak styling

* ♻️ Refactor AddUser and AddItem components

* ♻️ Update DeleteConfirmation

*  Update tests

*  Update tests

*  Fix tests

*  Add DataTable for item and admin management

* ♻️ Refactor DeleteUser and DeleteItem components

*  Fix tests

* ♻️ Refactor EditUser and EditItem components

* ♻️ Refactor UserInformation component

* 🎨 Format

* ♻️ Refactor pending components

* 🎨 Format

*  Update tests

*  Update tests

*  Fix test

* ♻️ Minor tweaks

* ♻️ Update social media links
This commit is contained in:
Alejandra
2025-12-07 13:21:13 +01:00
committed by GitHub
parent 61b7cd673a
commit 8c2532a5c3
104 changed files with 8891 additions and 3287 deletions

22
frontend/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,17 +13,40 @@
"dependencies": { "dependencies": {
"@chakra-ui/react": "^3.30.0", "@chakra-ui/react": "^3.30.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.7", "@tanstack/react-query": "^5.90.7",
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router": "^1.131.50", "@tanstack/react-router": "^1.131.50",
"@tanstack/react-router-devtools": "^1.139.12",
"@tanstack/react-table": "^8.21.3",
"axios": "1.12.2", "axios": "1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"form-data": "4.0.5", "form-data": "4.0.5",
"lucide-react": "^0.555.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
"react-hook-form": "7.66.1", "react-hook-form": "^7.67.0",
"react-icons": "^5.5.0" "react-icons": "^5.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "^2.3.8",
@@ -36,6 +59,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2", "@vitejs/plugin-react-swc": "^4.2.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6" "vite": "^7.2.6"
} }

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="500"
viewBox="0 0 132.29167 132.29166"
version="1.1"
id="svg8"
sodipodi:docname="icon-white-nomargin-transparent.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-filename="icon-teal-nomargin-transparent-500.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="448.01877"
inkscape:cy="-91.640203"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149">
<g
id="g2141">
<g
id="g1">
<path
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.28098;stop-color:#000000"
d="M 66.145833 0 A 66.145836 65.931923 0 0 0 0 65.931893 A 66.145836 65.931923 0 0 0 66.145833 131.86379 A 66.145836 65.931923 0 0 0 132.29167 65.931893 A 66.145836 65.931923 0 0 0 66.145833 0 z M 61.581771 29.853992 L 103.20042 29.853992 L 61.410205 59.222742 L 89.976937 59.222742 L 29.091248 102.00979 L 42.315763 72.641044 L 48.358289 59.222742 L 61.581771 29.853992 z " />
</g>
</g>
</g>
<rect
y="-49.422424"
x="-51.908718"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="500"
viewBox="0 0 132.29167 132.29166"
version="1.1"
id="svg8"
sodipodi:docname="icon-teal-nomargin-transparent.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-filename="icon-teal-nomargin-192.png"
inkscape:export-xdpi="36.863998"
inkscape:export-ydpi="36.863998"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="448.01877"
inkscape:cy="-91.640203"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149">
<g
id="g2141">
<g
id="g1">
<path
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
style="fill:#009688;fill-opacity:0.980392;stroke:none;stroke-width:0.28098;stop-color:#000000"
d="M 66.145833 0 A 66.145836 65.931923 0 0 0 0 65.931893 A 66.145836 65.931923 0 0 0 66.145833 131.86379 A 66.145836 65.931923 0 0 0 132.29167 65.931893 A 66.145836 65.931923 0 0 0 66.145833 0 z M 61.581771 29.853992 L 103.20042 29.853992 L 61.410205 59.222742 L 89.976937 59.222742 L 29.091248 102.00979 L 42.315763 72.641044 L 48.358289 59.222742 L 61.581771 29.853992 z " />
</g>
</g>
</g>
<rect
y="-49.422424"
x="-51.908718"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="341.26324mm"
height="63.977489mm"
viewBox="0 0 341.26324 63.977485"
version="1.1"
id="svg8"
sodipodi:docname="logo-white-nomargin-vector.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-filename="logo-white-margin-vector.png"
inkscape:export-xdpi="57.604134"
inkscape:export-ydpi="57.604134"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="644.8755"
inkscape:cy="95.713101"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g2141"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs
id="defs2" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="g2149"
transform="translate(2.5752783e-4,1.1668595e-4)">
<g
id="g2141">
<g
id="g1">
<path
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.136325;stop-color:#000000"
d="M 32.092357,-1.166849e-4 A 32.092354,31.988569 0 0 0 -2.5752734e-4,31.988628 32.092354,31.988569 0 0 0 32.092357,63.977374 32.092354,31.988569 0 0 0 64.184455,31.988628 32.092354,31.988569 0 0 0 32.092357,-1.166849e-4 Z M 29.878022,14.484271 H 50.070588 L 29.794823,28.73353 H 43.654442 L 14.114126,49.49247 20.530789,35.243727 23.462393,28.73353 Z" />
<path
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#ffffff;stroke-width:1.99288"
d="M 89.762163,59.410606 V 4.1680399 H 121.88735 V 9.5089518 H 95.979941 V 28.082571 h 22.957949 v 5.340912 H 95.979941 v 25.987123 z m 51.017587,0.876867 q -4.46405,0 -7.97152,-1.275442 -3.50746,-1.275442 -5.50034,-4.145185 -1.99288,-2.869744 -1.99288,-7.572935 0,-4.543761 2.23203,-7.33379 2.23202,-2.869743 6.13806,-4.145185 3.98576,-1.275442 8.92809,-1.275442 2.23203,0 4.70319,0.398576 2.47117,0.398575 3.10889,0.717436 v -2.391453 q 0,-2.710314 -0.71743,-5.181482 -0.63772,-2.550883 -2.71032,-4.145186 -2.07259,-1.594302 -6.29749,-1.594302 -4.38433,0 -6.69607,0.637721 -2.31174,0.637721 -3.42775,1.036297 l -0.79715,-5.101767 q 1.43487,-0.637721 4.38433,-1.195727 2.94946,-0.558005 6.93522,-0.558005 5.65977,0 8.92809,1.992877 3.34803,1.992878 4.7829,5.500342 1.51459,3.42775 1.51459,7.891796 v 25.907408 q -1.67402,0.398576 -5.97863,1.116012 -4.30462,0.717436 -9.56581,0.717436 z m 0.87686,-5.101767 q 2.79003,0 5.02205,-0.15943 2.23203,-0.239146 3.74661,-0.558006 V 40.597842 q -0.79715,-0.398575 -2.71031,-0.797151 -1.91316,-0.398576 -4.94234,-0.398576 -2.55088,0 -5.18148,0.558006 -2.6306,0.558006 -4.46404,2.232023 -1.75374,1.674017 -1.75374,5.022052 0,4.464045 2.79003,6.217778 2.79003,1.753732 7.49322,1.753732 z m 36.4298,5.181482 q -5.34092,0 -8.37009,-0.956582 -2.94946,-0.876866 -3.98575,-1.355156 l 1.43487,-5.261197 q 0.87686,0.31886 3.58718,1.355157 2.71031,1.036296 7.33379,1.036296 4.38433,0 7.09464,-1.355157 2.71031,-1.355157 2.71031,-4.703191 0,-2.152308 -0.95658,-3.427749 -0.95658,-1.355157 -3.10889,-2.471169 -2.07259,-1.116011 -5.73948,-2.550883 -3.10889,-1.275442 -5.73949,-2.710313 -2.6306,-1.434872 -4.30462,-3.666895 -1.5943,-2.232023 -1.5943,-5.739488 0,-3.427749 1.75373,-5.978632 1.75374,-2.550884 4.94234,-3.985755 3.26832,-1.434872 7.73237,-1.434872 4.14518,0 7.01492,0.717436 2.86975,0.717436 4.06547,1.275441 l -1.35515,5.181482 q -1.0363,-0.558006 -3.34804,-1.275442 -2.31173,-0.797151 -6.53663,-0.797151 -3.34804,0 -5.81921,1.434872 -2.47117,1.355157 -2.47117,4.384331 0,2.152308 1.0363,3.507464 1.0363,1.275442 3.10889,2.311738 2.07259,1.036297 5.10177,2.232023 3.42775,1.355157 6.13806,2.869744 2.79003,1.434872 4.46405,3.74661 1.67401,2.311738 1.67401,6.138063 0,3.74661 -1.91316,6.377208 -1.91316,2.550883 -5.50034,3.826325 -3.50747,1.275442 -8.4498,1.275442 z m 39.21967,-0.07972 q -5.2612,0 -8.29037,-1.833448 -3.02917,-1.833447 -4.30461,-5.500342 -1.19573,-3.666895 -1.19573,-9.167237 V 6.1609175 L 209.494,5.1246211 V 18.118183 h 16.18217 v 5.022051 H 209.494 v 21.124503 q 0,4.38433 0.95658,6.696068 1.0363,2.311738 2.86975,3.188605 1.91316,0.797151 4.46404,0.797151 3.02918,0 5.02205,-0.717436 2.0726,-0.717436 3.26832,-1.275442 l 1.27544,4.862621 q -1.19572,0.717436 -3.98575,1.594302 -2.71031,0.876867 -6.05835,0.876867 z m 11.95723,-0.876867 q 4.30462,-11.55869 7.8918,-21.044787 3.58718,-9.486097 7.01493,-17.776468 3.50746,-8.370086 7.4135,-16.4213111 h 5.58006 q 2.86974,6.0583481 5.50034,12.2761261 2.71032,6.138063 5.34091,12.754416 2.6306,6.616354 5.42063,14.109574 2.86975,7.413504 6.05835,16.10245 h -6.77578 q -1.43488,-3.985755 -2.79003,-7.572934 -1.35516,-3.666895 -2.55089,-7.17436 h -26.30598 q -1.27544,3.507465 -2.6306,7.17436 -1.35516,3.587179 -2.71031,7.572934 z M 242.8946,39.402115 h 22.63909 q -1.51459,-3.985755 -2.94946,-7.732365 -1.43487,-3.746609 -2.86975,-7.254074 -1.35515,-3.507464 -2.79002,-6.775784 -1.35516,-3.348034 -2.79003,-6.536638 -1.35516,3.188604 -2.79003,6.536638 -1.35516,3.26832 -2.79003,6.775784 -1.35516,3.507465 -2.79003,7.254074 -1.43487,3.74661 -2.86974,7.732365 z m 44.481,20.008491 V 5.2043362 q 3.02917,-0.797151 6.93521,-1.1160114 3.98576,-0.3985755 7.33379,-0.3985755 11.71812,0 17.53732,4.5437609 5.89892,4.4640458 5.89892,12.7544168 0,6.297493 -2.94946,10.123818 -2.86974,3.826325 -8.37008,5.580057 -5.42063,1.674017 -13.07328,1.674017 h -7.09465 v 21.044787 z m 6.21777,-26.385699 h 6.53664 q 5.65978,0 9.80496,-0.956581 4.14519,-1.036296 6.37721,-3.666895 2.31174,-2.630598 2.31174,-7.493219 0,-4.703192 -2.39146,-7.254075 -2.39145,-2.550883 -6.21777,-3.587179 -3.82633,-1.0362968 -8.13094,-1.0362968 -2.79003,0 -4.86263,0.2391453 -1.99287,0.1594302 -3.42775,0.3188604 z M 335.0452,59.410606 V 4.1680399 h 6.21778 V 59.410606 Z"
id="text979-3"
aria-label="FastAPI" />
</g>
</g>
</g>
<rect
y="-49.422306"
x="-51.908459"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -1,15 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg <svg
id="svg8" width="341.26297mm"
version="1.1"
viewBox="0 0 346.52395 63.977134"
height="63.977139mm" height="63.977139mm"
width="346.52396mm" viewBox="0 0 341.26297 63.977134"
version="1.1"
id="svg8"
sodipodi:docname="logo-teal-nomargin-vector.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#" xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"> xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1720"
inkscape:window-height="1371"
id="namedview10"
showgrid="false"
inkscape:zoom="0.73657628"
inkscape:cx="448.01877"
inkscape:cy="-91.640203"
inkscape:window-x="1720"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="g1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm" />
<defs <defs
id="defs2" /> id="defs2" />
<metadata <metadata
@@ -27,6 +55,8 @@
id="g2149"> id="g2149">
<g <g
id="g2141"> id="g2141">
<g
id="g1">
<g <g
id="g2106" id="g2106"
transform="matrix(0.96564264,0,0,0.96251987,-899.3295,194.86874)"> transform="matrix(0.96564264,0,0,0.96251987,-899.3295,194.86874)">
@@ -35,7 +65,9 @@
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7" id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
cx="964.56165" cx="964.56165"
cy="-169.22266" cy="-169.22266"
r="33.234192" /> r="33.234192"
inkscape:export-xdpi="1543.8315"
inkscape:export-ydpi="1543.8315" />
<path <path
id="rect1249-6-3-4-4-3-6-6-1-2" id="rect1249-6-3-4-4-3-6-6-1-2"
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.146895;stop-color:#000000" style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.146895;stop-color:#000000"
@@ -43,9 +75,17 @@
</g> </g>
<path <path
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#009688;stroke-width:1.99288" style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#009688;stroke-width:1.99288"
d="M 89.523017,59.410606 V 4.1680399 H 122.84393 V 10.784393 H 97.255382 V 27.44485 h 22.718808 v 6.536638 H 97.255382 v 25.429118 z m 52.292963,-5.340912 q 2.6306,0 4.62348,-0.07972 2.07259,-0.15943 3.42774,-0.47829 V 41.155848 q -0.79715,-0.398576 -2.63059,-0.637721 -1.75374,-0.31886 -4.30462,-0.31886 -1.67402,0 -3.58718,0.239145 -1.83345,0.239145 -3.42775,1.036296 -1.51459,0.717436 -2.55088,2.072593 -1.0363,1.275442 -1.0363,3.427749 0,3.985755 2.55089,5.580058 2.55088,1.514586 6.93521,1.514586 z m -0.63772,-37.147238 q 4.46404,0 7.49322,1.195727 3.10889,1.116011 4.94233,3.268319 1.91317,2.072593 2.71032,5.022052 0.79715,2.869743 0.79715,6.377208 V 58.69317 q -0.95658,0.159431 -2.71031,0.478291 -1.67402,0.239145 -3.82633,0.478291 -2.15231,0.239145 -4.70319,0.398575 -2.47117,0.239146 -4.94234,0.239146 -3.50746,0 -6.45692,-0.717436 -2.94946,-0.717436 -5.10177,-2.232023 -2.1523,-1.594302 -3.34803,-4.145186 -1.19573,-2.550883 -1.19573,-6.138063 0,-3.427749 1.35516,-5.898917 1.43487,-2.471168 3.82632,-3.985755 2.39146,-1.514587 5.58006,-2.232023 3.18861,-0.717436 6.69607,-0.717436 1.11601,0 2.31174,0.15943 1.19572,0.07972 2.23202,0.31886 1.11601,0.159431 1.91316,0.318861 0.79715,0.15943 1.11601,0.239145 v -2.072593 q 0,-1.833447 -0.39857,-3.587179 -0.39858,-1.833448 -1.43487,-3.188604 -1.0363,-1.434872 -2.86975,-2.232023 -1.75373,-0.876866 -4.62347,-0.876866 -3.6669,0 -6.45693,0.558005 -2.71031,0.478291 -4.06547,1.036297 l -0.87686,-6.138063 q 1.43487,-0.637721 4.7829,-1.195727 3.34804,-0.637721 7.25408,-0.637721 z m 37.86462,37.147238 q 4.54377,0 6.69607,-1.195726 2.23203,-1.195727 2.23203,-3.826325 0,-2.710314 -2.15231,-4.304616 -2.15231,-1.594302 -7.09465,-3.587179 -2.39145,-0.956581 -4.62347,-1.913163 -2.15231,-1.036296 -3.74661,-2.391453 -1.5943,-1.355157 -2.55088,-3.268319 -0.95659,-1.913163 -0.95659,-4.703191 0,-5.500342 4.06547,-8.688946 4.06547,-3.26832 11.0804,-3.26832 1.75374,0 3.50747,0.239146 1.75373,0.15943 3.26832,0.47829 1.51458,0.239146 2.6306,0.558006 1.19572,0.31886 1.83344,0.558006 l -1.35515,6.377208 q -1.19573,-0.637721 -3.74661,-1.275442 -2.55089,-0.717436 -6.13807,-0.717436 -3.10889,0 -5.42062,1.275442 -2.31174,1.195727 -2.31174,3.826325 0,1.355157 0.47829,2.391453 0.55801,1.036296 1.5943,1.913163 1.11601,0.797151 2.71031,1.514587 1.59431,0.717436 3.82633,1.514587 2.94946,1.116011 5.2612,2.232022 2.31173,1.036297 3.90604,2.471169 1.67401,1.434871 2.55088,3.507464 0.87687,1.992878 0.87687,4.942337 0,5.739487 -4.30462,8.688946 -4.2249,2.949459 -12.1167,2.949459 -5.50034,0 -8.60923,-0.956582 -3.10889,-0.876866 -4.2249,-1.355156 l 1.35516,-6.377209 q 1.27544,0.478291 4.06547,1.434872 2.79003,0.956581 7.4135,0.956581 z m 32.84256,-36.110941 h 15.70387 v 6.217778 h -15.70387 v 19.131625 q 0,3.108889 0.47829,5.181481 0.47829,1.992878 1.43487,3.188604 0.95658,1.116012 2.39145,1.594302 1.43487,0.478291 3.34804,0.478291 3.34803,0 5.34091,-0.717436 2.07259,-0.797151 2.86974,-1.116011 l 1.43487,6.138063 q -1.11601,0.558005 -3.90604,1.355156 -2.79003,0.876867 -6.37721,0.876867 -4.2249,0 -7.01492,-1.036297 -2.71032,-1.116011 -4.38434,-3.268319 -1.67401,-2.152308 -2.39145,-5.261197 -0.63772,-3.188604 -0.63772,-7.333789 V 6.4000628 l 7.41351,-1.2754417 z m 62.49652,41.451853 q -1.35516,-3.587179 -2.55088,-7.014929 -1.19573,-3.507464 -2.47117,-7.094644 h -25.03054 l -5.02205,14.109573 h -8.05123 q 3.18861,-8.768661 5.97863,-16.182166 2.79003,-7.493219 5.42063,-14.189288 2.71031,-6.696069 5.34091,-12.754416 2.6306,-6.138063 5.50034,-12.1166961 h 7.09465 q 2.86974,5.9786331 5.50034,12.1166961 2.6306,6.058347 5.2612,12.754416 2.71031,6.696069 5.50034,14.189288 2.79003,7.413505 5.97863,16.182166 z m -7.25407,-20.486781 q -2.55089,-6.935214 -5.10177,-13.392137 -2.47117,-6.536639 -5.18148,-12.515272 -2.79003,5.978633 -5.34091,12.515272 -2.47117,6.456923 -4.94234,13.392137 z M 304.99242,3.6100342 q 11.6384,0 17.85618,4.4640458 6.29749,4.384331 6.29749,13.152992 0,4.782906 -1.75373,8.210656 -1.67402,3.348034 -4.94234,5.500342 -3.1886,2.072592 -7.81208,3.029174 -4.62347,0.956581 -10.44268,0.956581 h -6.13806 v 20.486781 h -7.73236 V 4.9651909 q 3.26832,-0.797151 7.25407,-1.0362963 4.06547,-0.3188604 7.41351,-0.3188604 z m 0.63772,6.7757838 q -4.94234,0 -7.57294,0.239145 v 21.682508 h 5.8192 q 3.98576,0 7.17436,-0.47829 3.18861,-0.558006 5.34092,-1.753733 2.23202,-1.275441 3.42774,-3.427749 1.19573,-2.152308 1.19573,-5.500342 0,-3.188604 -1.27544,-5.261197 -1.19573,-2.072593 -3.34803,-3.268319 -2.0726,-1.275442 -4.86263,-1.753732 -2.79002,-0.478291 -5.89891,-0.478291 z M 338.7916,4.1680399 h 7.73237 V 59.410606 h -7.73237 z" d="M 89.762163,59.410606 V 4.1680399 H 121.88735 V 9.5089518 H 95.979941 V 28.082571 h 22.957949 v 5.340912 H 95.979941 v 25.987123 z m 51.017587,0.876867 q -4.46405,0 -7.97152,-1.275442 -3.50746,-1.275442 -5.50034,-4.145185 -1.99288,-2.869744 -1.99288,-7.572935 0,-4.543761 2.23203,-7.33379 2.23202,-2.869743 6.13806,-4.145185 3.98576,-1.275442 8.92809,-1.275442 2.23203,0 4.70319,0.398576 2.47117,0.398575 3.10889,0.717436 v -2.391453 q 0,-2.710314 -0.71743,-5.181482 -0.63772,-2.550883 -2.71032,-4.145186 -2.07259,-1.594302 -6.29749,-1.594302 -4.38433,0 -6.69607,0.637721 -2.31174,0.637721 -3.42775,1.036297 l -0.79715,-5.101767 q 1.43487,-0.637721 4.38433,-1.195727 2.94946,-0.558005 6.93522,-0.558005 5.65977,0 8.92809,1.992877 3.34803,1.992878 4.7829,5.500342 1.51459,3.42775 1.51459,7.891796 v 25.907408 q -1.67402,0.398576 -5.97863,1.116012 -4.30462,0.717436 -9.56581,0.717436 z m 0.87686,-5.101767 q 2.79003,0 5.02205,-0.15943 2.23203,-0.239146 3.74661,-0.558006 V 40.597842 q -0.79715,-0.398575 -2.71031,-0.797151 -1.91316,-0.398576 -4.94234,-0.398576 -2.55088,0 -5.18148,0.558006 -2.6306,0.558006 -4.46404,2.232023 -1.75374,1.674017 -1.75374,5.022052 0,4.464045 2.79003,6.217778 2.79003,1.753732 7.49322,1.753732 z m 36.4298,5.181482 q -5.34092,0 -8.37009,-0.956582 -2.94946,-0.876866 -3.98575,-1.355156 l 1.43487,-5.261197 q 0.87686,0.31886 3.58718,1.355157 2.71031,1.036296 7.33379,1.036296 4.38433,0 7.09464,-1.355157 2.71031,-1.355157 2.71031,-4.703191 0,-2.152308 -0.95658,-3.427749 -0.95658,-1.355157 -3.10889,-2.471169 -2.07259,-1.116011 -5.73948,-2.550883 -3.10889,-1.275442 -5.73949,-2.710313 -2.6306,-1.434872 -4.30462,-3.666895 -1.5943,-2.232023 -1.5943,-5.739488 0,-3.427749 1.75373,-5.978632 1.75374,-2.550884 4.94234,-3.985755 3.26832,-1.434872 7.73237,-1.434872 4.14518,0 7.01492,0.717436 2.86975,0.717436 4.06547,1.275441 l -1.35515,5.181482 q -1.0363,-0.558006 -3.34804,-1.275442 -2.31173,-0.797151 -6.53663,-0.797151 -3.34804,0 -5.81921,1.434872 -2.47117,1.355157 -2.47117,4.384331 0,2.152308 1.0363,3.507464 1.0363,1.275442 3.10889,2.311738 2.07259,1.036297 5.10177,2.232023 3.42775,1.355157 6.13806,2.869744 2.79003,1.434872 4.46405,3.74661 1.67401,2.311738 1.67401,6.138063 0,3.74661 -1.91316,6.377208 -1.91316,2.550883 -5.50034,3.826325 -3.50747,1.275442 -8.4498,1.275442 z m 39.21967,-0.07972 q -5.2612,0 -8.29037,-1.833448 -3.02917,-1.833447 -4.30461,-5.500342 -1.19573,-3.666895 -1.19573,-9.167237 V 6.1609175 L 209.494,5.1246211 V 18.118183 h 16.18217 v 5.022051 H 209.494 v 21.124503 q 0,4.38433 0.95658,6.696068 1.0363,2.311738 2.86975,3.188605 1.91316,0.797151 4.46404,0.797151 3.02918,0 5.02205,-0.717436 2.0726,-0.717436 3.26832,-1.275442 l 1.27544,4.862621 q -1.19572,0.717436 -3.98575,1.594302 -2.71031,0.876867 -6.05835,0.876867 z m 11.95723,-0.876867 q 4.30462,-11.55869 7.8918,-21.044787 3.58718,-9.486097 7.01493,-17.776468 3.50746,-8.370086 7.4135,-16.4213111 h 5.58006 q 2.86974,6.0583481 5.50034,12.2761261 2.71032,6.138063 5.34091,12.754416 2.6306,6.616354 5.42063,14.109574 2.86975,7.413504 6.05835,16.10245 h -6.77578 q -1.43488,-3.985755 -2.79003,-7.572934 -1.35516,-3.666895 -2.55089,-7.17436 h -26.30598 q -1.27544,3.507465 -2.6306,7.17436 -1.35516,3.587179 -2.71031,7.572934 z M 242.8946,39.402115 h 22.63909 q -1.51459,-3.985755 -2.94946,-7.732365 -1.43487,-3.746609 -2.86975,-7.254074 -1.35515,-3.507464 -2.79002,-6.775784 -1.35516,-3.348034 -2.79003,-6.536638 -1.35516,3.188604 -2.79003,6.536638 -1.35516,3.26832 -2.79003,6.775784 -1.35516,3.507465 -2.79003,7.254074 -1.43487,3.74661 -2.86974,7.732365 z m 44.481,20.008491 V 5.2043362 q 3.02917,-0.797151 6.93521,-1.1160114 3.98576,-0.3985755 7.33379,-0.3985755 11.71812,0 17.53732,4.5437609 5.89892,4.4640458 5.89892,12.7544168 0,6.297493 -2.94946,10.123818 -2.86974,3.826325 -8.37008,5.580057 -5.42063,1.674017 -13.07328,1.674017 h -7.09465 v 21.044787 z m 6.21777,-26.385699 h 6.53664 q 5.65978,0 9.80496,-0.956581 4.14519,-1.036296 6.37721,-3.666895 2.31174,-2.630598 2.31174,-7.493219 0,-4.703192 -2.39146,-7.254075 -2.39145,-2.550883 -6.21777,-3.587179 -3.82633,-1.0362968 -8.13094,-1.0362968 -2.79003,0 -4.86263,0.2391453 -1.99287,0.1594302 -3.42775,0.3188604 z M 335.0452,59.410606 V 4.1680399 h 6.21778 V 59.410606 Z"
id="text979" id="text979-3"
aria-label="FastAPI" /> aria-label="FastAPI" />
</g> </g>
</g> </g>
</g>
<rect
y="-49.422424"
x="-51.908718"
height="162.82199"
width="451.52316"
id="rect824"
style="opacity:0.98;fill:none;fill-opacity:1;stroke-width:0.311037" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -1,48 +1,64 @@
import { import { zodResolver } from "@hookform/resolvers/zod"
Button,
DialogActionTrigger,
DialogTitle,
Flex,
Input,
Text,
VStack,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Plus } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { Controller, type SubmitHandler, useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { FaPlus } from "react-icons/fa" import { z } from "zod"
import { type UserCreate, UsersService } from "@/client" import { type UserCreate, UsersService } from "@/client"
import type { ApiError } from "@/client/core/ApiError" import { Button } from "@/components/ui/button"
import useCustomToast from "@/hooks/useCustomToast" import { Checkbox } from "@/components/ui/checkbox"
import { emailPattern, handleError } from "@/utils"
import { Checkbox } from "../ui/checkbox"
import { import {
DialogBody, Dialog,
DialogCloseTrigger, DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogRoot, DialogTitle,
DialogTrigger, DialogTrigger,
} from "../ui/dialog" } from "@/components/ui/dialog"
import { Field } from "../ui/field" import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
interface UserCreateForm extends UserCreate { const formSchema = z
confirm_password: string .object({
} email: z.email({ message: "Invalid email address" }),
full_name: z.string().optional(),
password: z
.string()
.min(1, { message: "Password is required" })
.min(8, { message: "Password must be at least 8 characters" }),
confirm_password: z
.string()
.min(1, { message: "Please confirm your password" }),
is_superuser: z.boolean(),
is_active: z.boolean(),
})
.refine((data) => data.password === data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
})
type FormData = z.infer<typeof formSchema>
const AddUser = () => { const AddUser = () => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const {
control, const form = useForm<FormData>({
register, resolver: zodResolver(formSchema),
handleSubmit,
reset,
getValues,
formState: { errors, isValid, isSubmitting },
} = useForm<UserCreateForm>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: { defaultValues: {
@@ -59,166 +75,163 @@ const AddUser = () => {
mutationFn: (data: UserCreate) => mutationFn: (data: UserCreate) =>
UsersService.createUser({ requestBody: data }), UsersService.createUser({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showSuccessToast("User created successfully.") showSuccessToast("User created successfully")
reset() form.reset()
setIsOpen(false) setIsOpen(false)
}, },
onError: (err: ApiError) => { onError: handleError.bind(showErrorToast),
handleError(err)
},
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }) queryClient.invalidateQueries({ queryKey: ["users"] })
}, },
}) })
const onSubmit: SubmitHandler<UserCreateForm> = (data) => { const onSubmit = (data: FormData) => {
mutation.mutate(data) mutation.mutate(data)
} }
return ( return (
<DialogRoot <Dialog open={isOpen} onOpenChange={setIsOpen}>
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button value="add-user" my={4}> <Button className="my-4">
<FaPlus fontSize="16px" /> <Plus className="mr-2" />
Add User Add User
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader> <DialogHeader>
<DialogTitle>Add User</DialogTitle> <DialogTitle>Add User</DialogTitle>
</DialogHeader> <DialogDescription>
<DialogBody>
<Text mb={4}>
Fill in the form below to add a new user to the system. Fill in the form below to add a new user to the system.
</Text> </DialogDescription>
<VStack gap={4}> </DialogHeader>
<Field <Form {...form}>
required <form onSubmit={form.handleSubmit(onSubmit)}>
invalid={!!errors.email} <div className="grid gap-4 py-4">
errorText={errors.email?.message} <FormField
label="Email" control={form.control}
> name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input <Input
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email" placeholder="Email"
type="email" type="email"
/> {...field}
</Field>
<Field
invalid={!!errors.full_name}
errorText={errors.full_name?.message}
label="Full Name"
>
<Input
{...register("full_name")}
placeholder="Full name"
type="text"
/>
</Field>
<Field
required required
invalid={!!errors.password} />
errorText={errors.password?.message} </FormControl>
label="Set Password" <FormMessage />
> </FormItem>
)}
/>
<FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Full name" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
Set Password <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input <Input
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password" placeholder="Password"
type="password" type="password"
/> {...field}
</Field>
<Field
required required
invalid={!!errors.confirm_password} />
errorText={errors.confirm_password?.message} </FormControl>
label="Confirm Password" <FormMessage />
> </FormItem>
)}
/>
<FormField
control={form.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel>
Confirm Password{" "}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input <Input
{...register("confirm_password", {
required: "Please confirm your password",
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password" placeholder="Password"
type="password" type="password"
{...field}
required
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
</Field>
</VStack>
<Flex mt={4} direction="column" gap={4}> <FormField
<Controller control={form.control}
control={control}
name="is_superuser" name="is_superuser"
render={({ field }) => ( render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal"> <FormItem className="flex items-center gap-3 space-y-0">
<FormControl>
<Checkbox <Checkbox
checked={field.value} checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)} onCheckedChange={field.onChange}
> />
Is superuser? </FormControl>
</Checkbox> <FormLabel className="font-normal">Is superuser?</FormLabel>
</Field> </FormItem>
)} )}
/> />
<Controller
control={control} <FormField
control={form.control}
name="is_active" name="is_active"
render={({ field }) => ( render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal"> <FormItem className="flex items-center gap-3 space-y-0">
<FormControl>
<Checkbox <Checkbox
checked={field.value} checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)} onCheckedChange={field.onChange}
> />
Is active? </FormControl>
</Checkbox> <FormLabel className="font-normal">Is active?</FormLabel>
</Field> </FormItem>
)} )}
/> />
</Flex> </div>
</DialogBody>
<DialogFooter gap={2}> <DialogFooter>
<DialogActionTrigger asChild> <DialogClose asChild>
<Button <Button variant="outline" disabled={mutation.isPending}>
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
</DialogActionTrigger> </DialogClose>
<Button <LoadingButton type="submit" loading={mutation.isPending}>
variant="solid"
type="submit"
disabled={!isValid}
loading={isSubmitting}
>
Save Save
</Button> </LoadingButton>
</DialogFooter> </DialogFooter>
</form> </form>
<DialogCloseTrigger /> </Form>
</DialogContent> </DialogContent>
</DialogRoot> </Dialog>
) )
} }

View File

@@ -1,30 +1,34 @@
import { Button, DialogTitle, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Trash2 } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { FiTrash2 } from "react-icons/fi"
import { UsersService } from "@/client" import { UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import { import {
DialogActionTrigger, Dialog,
DialogBody, DialogClose,
DialogCloseTrigger,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogRoot, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const DeleteUser = ({ id }: { id: string }) => { interface DeleteUserProps {
id: string
onSuccess: () => void
}
const DeleteUser = ({ id, onSuccess }: DeleteUserProps) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const { const { handleSubmit } = useForm()
handleSubmit,
formState: { isSubmitting },
} = useForm()
const deleteUser = async (id: string) => { const deleteUser = async (id: string) => {
await UsersService.deleteUser({ userId: id }) await UsersService.deleteUser({ userId: id })
@@ -35,10 +39,9 @@ const DeleteUser = ({ id }: { id: string }) => {
onSuccess: () => { onSuccess: () => {
showSuccessToast("The user was deleted successfully") showSuccessToast("The user was deleted successfully")
setIsOpen(false) setIsOpen(false)
onSuccess()
}, },
onError: () => { onError: handleError.bind(showErrorToast),
showErrorToast("An error occurred while deleting the user")
},
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries() queryClient.invalidateQueries()
}, },
@@ -49,55 +52,43 @@ const DeleteUser = ({ id }: { id: string }) => {
} }
return ( return (
<DialogRoot <Dialog open={isOpen} onOpenChange={setIsOpen}>
size={{ base: "xs", md: "md" }} <DropdownMenuItem
placement="center" variant="destructive"
role="alertdialog" onSelect={(e) => e.preventDefault()}
open={isOpen} onClick={() => setIsOpen(true)}
onOpenChange={({ open }) => setIsOpen(open)}
> >
<DialogTrigger asChild> <Trash2 />
<Button variant="ghost" size="sm" colorPalette="red">
<FiTrash2 fontSize="16px" />
Delete User Delete User
</Button> </DropdownMenuItem>
</DialogTrigger> <DialogContent className="sm:max-w-md">
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete User</DialogTitle> <DialogTitle>Delete User</DialogTitle>
</DialogHeader> <DialogDescription>
<DialogBody>
<Text mb={4}>
All items associated with this user will also be{" "} All items associated with this user will also be{" "}
<strong>permanently deleted.</strong> Are you sure? You will not <strong>permanently deleted.</strong> Are you sure? You will not
be able to undo this action. be able to undo this action.
</Text> </DialogDescription>
</DialogBody> </DialogHeader>
<DialogFooter gap={2}> <DialogFooter className="mt-4">
<DialogActionTrigger asChild> <DialogClose asChild>
<Button <Button variant="outline" disabled={mutation.isPending}>
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
</DialogActionTrigger> </DialogClose>
<Button <LoadingButton
variant="solid" variant="destructive"
colorPalette="red"
type="submit" type="submit"
loading={isSubmitting} loading={mutation.isPending}
> >
Delete Delete
</Button> </LoadingButton>
</DialogFooter> </DialogFooter>
<DialogCloseTrigger />
</form> </form>
</DialogContent> </DialogContent>
</DialogRoot> </Dialog>
) )
} }

View File

@@ -1,214 +1,238 @@
import { import { zodResolver } from "@hookform/resolvers/zod"
Button,
DialogActionTrigger,
DialogRoot,
DialogTrigger,
Flex,
Input,
Text,
VStack,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Pencil } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { Controller, type SubmitHandler, useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { FaExchangeAlt } from "react-icons/fa" import { z } from "zod"
import { type UserPublic, UsersService, type UserUpdate } from "@/client" import { type UserPublic, UsersService } from "@/client"
import type { ApiError } from "@/client/core/ApiError" import { Button } from "@/components/ui/button"
import useCustomToast from "@/hooks/useCustomToast" import { Checkbox } from "@/components/ui/checkbox"
import { emailPattern, handleError } from "@/utils"
import { Checkbox } from "../ui/checkbox"
import { import {
DialogBody, Dialog,
DialogCloseTrigger, DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../ui/dialog" } from "@/components/ui/dialog"
import { Field } from "../ui/field" import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const formSchema = z
.object({
email: z.email({ message: "Invalid email address" }),
full_name: z.string().optional(),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters" })
.optional()
.or(z.literal("")),
confirm_password: z.string().optional(),
is_superuser: z.boolean().optional(),
is_active: z.boolean().optional(),
})
.refine((data) => !data.password || data.password === data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
})
type FormData = z.infer<typeof formSchema>
interface EditUserProps { interface EditUserProps {
user: UserPublic user: UserPublic
onSuccess: () => void
} }
interface UserUpdateForm extends UserUpdate { const EditUser = ({ user, onSuccess }: EditUserProps) => {
confirm_password?: string
}
const EditUser = ({ user }: EditUserProps) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const {
control, const form = useForm<FormData>({
register, resolver: zodResolver(formSchema),
handleSubmit,
reset,
getValues,
formState: { errors, isSubmitting },
} = useForm<UserUpdateForm>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: user, defaultValues: {
email: user.email,
full_name: user.full_name ?? undefined,
is_superuser: user.is_superuser,
is_active: user.is_active,
},
}) })
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: UserUpdateForm) => mutationFn: (data: FormData) =>
UsersService.updateUser({ userId: user.id, requestBody: data }), UsersService.updateUser({ userId: user.id, requestBody: data }),
onSuccess: () => { onSuccess: () => {
showSuccessToast("User updated successfully.") showSuccessToast("User updated successfully")
reset()
setIsOpen(false) setIsOpen(false)
onSuccess()
}, },
onError: (err: ApiError) => { onError: handleError.bind(showErrorToast),
handleError(err)
},
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }) queryClient.invalidateQueries({ queryKey: ["users"] })
}, },
}) })
const onSubmit: SubmitHandler<UserUpdateForm> = async (data) => { const onSubmit = (data: FormData) => {
if (data.password === "") { // exclude confirm_password from submission data and remove password if empty
data.password = undefined const { confirm_password: _, ...submitData } = data
if (!submitData.password) {
delete submitData.password
} }
mutation.mutate(data) mutation.mutate(submitData)
} }
return ( return (
<DialogRoot <Dialog open={isOpen} onOpenChange={setIsOpen}>
size={{ base: "xs", md: "md" }} <DropdownMenuItem
placement="center" onSelect={(e) => e.preventDefault()}
open={isOpen} onClick={() => setIsOpen(true)}
onOpenChange={({ open }) => setIsOpen(open)}
> >
<DialogTrigger asChild> <Pencil />
<Button variant="ghost" size="sm">
<FaExchangeAlt fontSize="16px" />
Edit User Edit User
</Button> </DropdownMenuItem>
</DialogTrigger> <DialogContent className="sm:max-w-md">
<DialogContent> <Form {...form}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit User</DialogTitle> <DialogTitle>Edit User</DialogTitle>
<DialogDescription>
Update the user details below.
</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogBody> <div className="grid gap-4 py-4">
<Text mb={4}>Update the user details below.</Text> <FormField
<VStack gap={4}> control={form.control}
<Field name="email"
required render={({ field }) => (
invalid={!!errors.email} <FormItem>
errorText={errors.email?.message} <FormLabel>
label="Email" Email <span className="text-destructive">*</span>
> </FormLabel>
<FormControl>
<Input <Input
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email" placeholder="Email"
type="email" type="email"
{...field}
required
/> />
</Field> </FormControl>
<FormMessage />
<Field </FormItem>
invalid={!!errors.full_name} )}
errorText={errors.full_name?.message}
label="Full Name"
>
<Input
{...register("full_name")}
placeholder="Full name"
type="text"
/> />
</Field>
<Field <FormField
invalid={!!errors.password} control={form.control}
errorText={errors.password?.message} name="full_name"
label="Set Password" render={({ field }) => (
> <FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Full name" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Set Password</FormLabel>
<FormControl>
<Input <Input
{...register("password", {
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password" placeholder="Password"
type="password" type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
</Field>
<Field <FormField
invalid={!!errors.confirm_password} control={form.control}
errorText={errors.confirm_password?.message} name="confirm_password"
label="Confirm Password" render={({ field }) => (
> <FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input <Input
{...register("confirm_password", {
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password" placeholder="Password"
type="password" type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
</Field>
</VStack>
<Flex mt={4} direction="column" gap={4}> <FormField
<Controller control={form.control}
control={control}
name="is_superuser" name="is_superuser"
render={({ field }) => ( render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal"> <FormItem className="flex items-center gap-3 space-y-0">
<FormControl>
<Checkbox <Checkbox
checked={field.value} checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)} onCheckedChange={field.onChange}
> />
Is superuser? </FormControl>
</Checkbox> <FormLabel className="font-normal">Is superuser?</FormLabel>
</Field> </FormItem>
)} )}
/> />
<Controller
control={control} <FormField
control={form.control}
name="is_active" name="is_active"
render={({ field }) => ( render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal"> <FormItem className="flex items-center gap-3 space-y-0">
<FormControl>
<Checkbox <Checkbox
checked={field.value} checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)} onCheckedChange={field.onChange}
> />
Is active? </FormControl>
</Checkbox> <FormLabel className="font-normal">Is active?</FormLabel>
</Field> </FormItem>
)} )}
/> />
</Flex> </div>
</DialogBody>
<DialogFooter gap={2}> <DialogFooter>
<DialogActionTrigger asChild> <DialogClose asChild>
<Button <Button variant="outline" disabled={mutation.isPending}>
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
</DialogActionTrigger> </DialogClose>
<Button variant="solid" type="submit" loading={isSubmitting}> <LoadingButton type="submit" loading={mutation.isPending}>
Save Save
</Button> </LoadingButton>
</DialogFooter> </DialogFooter>
<DialogCloseTrigger />
</form> </form>
</Form>
</DialogContent> </DialogContent>
</DialogRoot> </Dialog>
) )
} }

View File

@@ -0,0 +1,40 @@
import { EllipsisVertical } from "lucide-react"
import { useState } from "react"
import type { UserPublic } from "@/client"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import useAuth from "@/hooks/useAuth"
import DeleteUser from "./DeleteUser"
import EditUser from "./EditUser"
interface UserActionsMenuProps {
user: UserPublic
}
export const UserActionsMenu = ({ user }: UserActionsMenuProps) => {
const [open, setOpen] = useState(false)
const { user: currentUser } = useAuth()
if (user.id === currentUser?.id) {
return null
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<EditUser user={user} onSuccess={() => setOpen(false)} />
<DeleteUser id={user.id} onSuccess={() => setOpen(false)} />
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,76 @@
import type { ColumnDef } from "@tanstack/react-table"
import type { UserPublic } from "@/client"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { UserActionsMenu } from "./UserActionsMenu"
export type UserTableData = UserPublic & {
isCurrentUser: boolean
}
export const columns: ColumnDef<UserTableData>[] = [
{
accessorKey: "full_name",
header: "Full Name",
cell: ({ row }) => {
const fullName = row.original.full_name
return (
<div className="flex items-center gap-2">
<span
className={cn("font-medium", !fullName && "text-muted-foreground")}
>
{fullName || "N/A"}
</span>
{row.original.isCurrentUser && (
<Badge variant="outline" className="text-xs">
You
</Badge>
)}
</div>
)
},
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.email}</span>
),
},
{
accessorKey: "is_superuser",
header: "Role",
cell: ({ row }) => (
<Badge variant={row.original.is_superuser ? "default" : "secondary"}>
{row.original.is_superuser ? "Superuser" : "User"}
</Badge>
),
},
{
accessorKey: "is_active",
header: "Status",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span
className={cn(
"size-2 rounded-full",
row.original.is_active ? "bg-green-500" : "bg-gray-400",
)}
/>
<span className={row.original.is_active ? "" : "text-muted-foreground"}>
{row.original.is_active ? "Active" : "Inactive"}
</span>
</div>
),
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end">
<UserActionsMenu user={row.original} />
</div>
),
},
]

View File

@@ -0,0 +1,105 @@
import { Monitor, Moon, Sun } from "lucide-react"
import { type Theme, useTheme } from "@/components/theme-provider"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
type LucideIcon = React.FC<React.SVGProps<SVGSVGElement>>
const ICON_MAP: Record<Theme, LucideIcon> = {
system: Monitor,
light: Sun,
dark: Moon,
}
export const SidebarAppearance = () => {
const { isMobile } = useSidebar()
const { setTheme, theme } = useTheme()
const Icon = ICON_MAP[theme]
return (
<SidebarMenuItem>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip="Appearance" data-testid="theme-button">
<Icon className="size-4 text-muted-foreground" />
<span>Appearance</span>
<span className="sr-only">Toggle theme</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side={isMobile ? "top" : "right"}
align="end"
className="w-(--radix-dropdown-menu-trigger-width) min-w-56"
>
<DropdownMenuItem
data-testid="light-mode"
onClick={() => setTheme("light")}
>
<Sun className="mr-2 h-4 w-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem
data-testid="dark-mode"
onClick={() => setTheme("dark")}
>
<Moon className="mr-2 h-4 w-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
export const Appearance = () => {
const { setTheme } = useTheme()
return (
<div className="flex items-center justify-center">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button data-testid="theme-button" variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="light-mode"
onClick={() => setTheme("light")}
>
<Sun className="mr-2 h-4 w-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem
data-testid="dark-mode"
onClick={() => setTheme("dark")}
>
<Moon className="mr-2 h-4 w-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { Appearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo"
import { Footer } from "./Footer"
interface AuthLayoutProps {
children: React.ReactNode
}
export function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="bg-muted dark:bg-zinc-900 relative hidden lg:flex lg:items-center lg:justify-center">
<Logo variant="full" className="h-16" asLink={false} />
</div>
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex justify-end">
<Appearance />
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs">{children}</div>
</div>
<Footer />
</div>
</div>
)
}

View File

@@ -0,0 +1,194 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<div className="flex flex-col gap-4">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-32 text-center text-muted-foreground"
>
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{table.getPageCount() > 1 && (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 border-t bg-muted/20">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="text-sm text-muted-foreground">
Showing{" "}
{table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1}{" "}
to{" "}
{Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
data.length,
)}{" "}
of{" "}
<span className="font-medium text-foreground">{data.length}</span>{" "}
entries
</div>
<div className="flex items-center gap-x-2">
<p className="text-sm text-muted-foreground">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[5, 10, 25, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-x-6">
<div className="flex items-center gap-x-1 text-sm text-muted-foreground">
<span>Page</span>
<span className="font-medium text-foreground">
{table.getState().pagination.pageIndex + 1}
</span>
<span>of</span>
<span className="font-medium text-foreground">
{table.getPageCount()}
</span>
</div>
<div className="flex items-center gap-x-1">
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { Link } from "@tanstack/react-router"
import { Button } from "@/components/ui/button"
const ErrorComponent = () => {
return (
<div
className="flex min-h-screen items-center justify-center flex-col p-4"
data-testid="error-component"
>
<div className="flex items-center z-10">
<div className="flex flex-col ml-4 items-center justify-center p-4">
<span className="text-6xl md:text-8xl font-bold leading-none mb-4">
Error
</span>
<span className="text-2xl font-bold mb-2">Oops!</span>
</div>
</div>
<p className="text-lg text-muted-foreground mb-4 text-center z-10">
Something went wrong. Please try again.
</p>
<Link to="/">
<Button>Go Home</Button>
</Link>
</div>
)
}
export default ErrorComponent

View File

@@ -0,0 +1,36 @@
import { FaGithub, FaLinkedinIn } from "react-icons/fa"
import { FaXTwitter } from "react-icons/fa6"
const socialLinks = [
{ icon: FaGithub, href: "https://github.com/fastapi/fastapi", label: "GitHub" },
{ icon: FaXTwitter, href: "https://x.com/fastapi", label: "X" },
{ icon: FaLinkedinIn, href: "https://linkedin.com/company/fastapi", label: "LinkedIn" },
]
export function Footer() {
const currentYear = new Date().getFullYear()
return (
<footer className="border-t py-4 px-6">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-muted-foreground text-sm">
Full Stack FastAPI Template - {currentYear}
</p>
<div className="flex items-center gap-4">
{socialLinks.map(({ icon: Icon, href, label }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Icon className="h-5 w-5" />
</a>
))}
</div>
</div>
</footer>
)
}

View File

@@ -1,26 +0,0 @@
import { IconButton } from "@chakra-ui/react"
import { BsThreeDotsVertical } from "react-icons/bs"
import type { ItemPublic } from "@/client"
import DeleteItem from "../Items/DeleteItem"
import EditItem from "../Items/EditItem"
import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu"
interface ItemActionsMenuProps {
item: ItemPublic
}
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
return (
<MenuRoot>
<MenuTrigger asChild>
<IconButton variant="ghost" color="inherit">
<BsThreeDotsVertical />
</IconButton>
</MenuTrigger>
<MenuContent>
<EditItem item={item} />
<DeleteItem id={item.id} />
</MenuContent>
</MenuRoot>
)
}

View File

@@ -0,0 +1,60 @@
import { Link } from "@tanstack/react-router"
import { useTheme } from "@/components/theme-provider"
import { cn } from "@/lib/utils"
import icon from "/assets/images/fastapi-icon.svg"
import iconLight from "/assets/images/fastapi-icon-light.svg"
import logo from "/assets/images/fastapi-logo.svg"
import logoLight from "/assets/images/fastapi-logo-light.svg"
interface LogoProps {
variant?: "full" | "icon" | "responsive"
className?: string
asLink?: boolean
}
export function Logo({
variant = "full",
className,
asLink = true,
}: LogoProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === "dark"
const fullLogo = isDark ? logoLight : logo
const iconLogo = isDark ? iconLight : icon
const content =
variant === "responsive" ? (
<>
<img
src={fullLogo}
alt="FastAPI"
className={cn(
"h-6 w-auto group-data-[collapsible=icon]:hidden",
className,
)}
/>
<img
src={iconLogo}
alt="FastAPI"
className={cn(
"size-5 hidden group-data-[collapsible=icon]:block",
className,
)}
/>
</>
) : (
<img
src={variant === "full" ? fullLogo : iconLogo}
alt="FastAPI"
className={cn(variant === "full" ? "h-6 w-auto" : "size-5", className)}
/>
)
if (!asLink) {
return content
}
return <Link to="/">{content}</Link>
}

View File

@@ -1,32 +0,0 @@
import { Flex, Image, useBreakpointValue } from "@chakra-ui/react"
import { Link } from "@tanstack/react-router"
import Logo from "/assets/images/fastapi-logo.svg"
import UserMenu from "./UserMenu"
function Navbar() {
const display = useBreakpointValue({ base: "none", md: "flex" })
return (
<Flex
display={display}
justify="space-between"
position="sticky"
color="white"
align="center"
bg="bg.muted"
w="100%"
top={0}
p={4}
>
<Link to="/">
<Image src={Logo} alt="Logo" maxW="3xs" p={2} />
</Link>
<Flex gap={2} alignItems="center">
<UserMenu />
</Flex>
</Flex>
)
}
export default Navbar

View File

@@ -1,43 +1,30 @@
import { Button, Center, Flex, Text } from "@chakra-ui/react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { Button } from "@/components/ui/button"
const NotFound = () => { const NotFound = () => {
return ( return (
<Flex <div
height="100vh" className="flex min-h-screen items-center justify-center flex-col p-4"
align="center"
justify="center"
flexDir="column"
data-testid="not-found" data-testid="not-found"
p={4}
>
<Flex alignItems="center" zIndex={1}>
<Flex flexDir="column" ml={4} align="center" justify="center" p={4}>
<Text
fontSize={{ base: "6xl", md: "8xl" }}
fontWeight="bold"
lineHeight="1"
mb={4}
> >
<div className="flex items-center z-10">
<div className="flex flex-col ml-4 items-center justify-center p-4">
<span className="text-6xl md:text-8xl font-bold leading-none mb-4">
404 404
</Text> </span>
<Text fontSize="2xl" fontWeight="bold" mb={2}> <span className="text-2xl font-bold mb-2">Oops!</span>
Oops! </div>
</Text> </div>
</Flex>
</Flex>
<Text fontSize="lg" color="gray.600" mb={4} textAlign="center" zIndex={1}> <p className="text-lg text-muted-foreground mb-4 text-center z-10">
The page you are looking for was not found. The page you are looking for was not found.
</Text> </p>
<Center zIndex={1}> <div className="z-10">
<Link to="/"> <Link to="/">
<Button variant="solid" colorScheme="teal" mt={4} alignSelf="center"> <Button className="mt-4">Go Back</Button>
Go Back
</Button>
</Link> </Link>
</Center> </div>
</Flex> </div>
) )
} }

View File

@@ -1,97 +0,0 @@
import { Box, Flex, IconButton, Text } from "@chakra-ui/react"
import { useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { FaBars } from "react-icons/fa"
import { FiLogOut } from "react-icons/fi"
import type { UserPublic } from "@/client"
import useAuth from "@/hooks/useAuth"
import {
DrawerBackdrop,
DrawerBody,
DrawerCloseTrigger,
DrawerContent,
DrawerRoot,
DrawerTrigger,
} from "../ui/drawer"
import SidebarItems from "./SidebarItems"
const Sidebar = () => {
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const { logout } = useAuth()
const [open, setOpen] = useState(false)
return (
<>
{/* Mobile */}
<DrawerRoot
placement="start"
open={open}
onOpenChange={(e) => setOpen(e.open)}
>
<DrawerBackdrop />
<DrawerTrigger asChild>
<IconButton
variant="ghost"
color="inherit"
display={{ base: "flex", md: "none" }}
aria-label="Open Menu"
position="absolute"
zIndex="100"
m={4}
>
<FaBars />
</IconButton>
</DrawerTrigger>
<DrawerContent maxW="xs">
<DrawerCloseTrigger />
<DrawerBody>
<Flex flexDir="column" justify="space-between">
<Box>
<SidebarItems onClose={() => setOpen(false)} />
<Flex
as="button"
onClick={() => {
logout()
}}
alignItems="center"
gap={4}
px={4}
py={2}
>
<FiLogOut />
<Text>Log Out</Text>
</Flex>
</Box>
{currentUser?.email && (
<Text fontSize="sm" p={2} truncate maxW="sm">
Logged in as: {currentUser.email}
</Text>
)}
</Flex>
</DrawerBody>
<DrawerCloseTrigger />
</DrawerContent>
</DrawerRoot>
{/* Desktop */}
<Box
display={{ base: "none", md: "flex" }}
position="sticky"
bg="bg.subtle"
top={0}
minW="xs"
h="100vh"
p={4}
>
<Box w="100%">
<SidebarItems />
</Box>
</Box>
</>
)
}
export default Sidebar

View File

@@ -1,61 +0,0 @@
import { Box, Flex, Icon, Text } from "@chakra-ui/react"
import { useQueryClient } from "@tanstack/react-query"
import { Link as RouterLink } from "@tanstack/react-router"
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
import type { IconType } from "react-icons/lib"
import type { UserPublic } from "@/client"
const items = [
{ icon: FiHome, title: "Dashboard", path: "/" },
{ icon: FiBriefcase, title: "Items", path: "/items" },
{ icon: FiSettings, title: "User Settings", path: "/settings" },
]
interface SidebarItemsProps {
onClose?: () => void
}
interface Item {
icon: IconType
title: string
path: string
}
const SidebarItems = ({ onClose }: SidebarItemsProps) => {
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const finalItems: Item[] = currentUser?.is_superuser
? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }]
: items
const listItems = finalItems.map(({ icon, title, path }) => (
<RouterLink key={title} to={path} onClick={onClose}>
<Flex
gap={4}
px={4}
py={2}
_hover={{
background: "gray.subtle",
}}
alignItems="center"
fontSize="sm"
>
<Icon as={icon} alignSelf="center" />
<Text ml={2}>{title}</Text>
</Flex>
</RouterLink>
))
return (
<>
<Text fontSize="xs" px={4} py={2} fontWeight="bold">
Menu
</Text>
<Box>{listItems}</Box>
</>
)
}
export default SidebarItems

View File

@@ -1,27 +0,0 @@
import { IconButton } from "@chakra-ui/react"
import { BsThreeDotsVertical } from "react-icons/bs"
import type { UserPublic } from "@/client"
import DeleteUser from "../Admin/DeleteUser"
import EditUser from "../Admin/EditUser"
import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu"
interface UserActionsMenuProps {
user: UserPublic
disabled?: boolean
}
export const UserActionsMenu = ({ user, disabled }: UserActionsMenuProps) => {
return (
<MenuRoot>
<MenuTrigger asChild>
<IconButton variant="ghost" color="inherit" disabled={disabled}>
<BsThreeDotsVertical />
</IconButton>
</MenuTrigger>
<MenuContent>
<EditUser user={user} />
<DeleteUser id={user.id} />
</MenuContent>
</MenuRoot>
)
}

View File

@@ -1,59 +0,0 @@
import { Box, Button, Flex, Text } from "@chakra-ui/react"
import { Link } from "@tanstack/react-router"
import { FaUserAstronaut } from "react-icons/fa"
import { FiLogOut, FiUser } from "react-icons/fi"
import useAuth from "@/hooks/useAuth"
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "../ui/menu"
const UserMenu = () => {
const { user, logout } = useAuth()
const handleLogout = async () => {
logout()
}
return (
<>
{/* Desktop */}
<Flex>
<MenuRoot>
<MenuTrigger asChild p={2}>
<Button data-testid="user-menu" variant="solid" maxW="sm" truncate>
<FaUserAstronaut fontSize="18" />
<Text>{user?.full_name || "User"}</Text>
</Button>
</MenuTrigger>
<MenuContent>
<Link to="/settings">
<MenuItem
closeOnSelect
value="user-settings"
gap={2}
py={2}
style={{ cursor: "pointer" }}
>
<FiUser fontSize="18px" />
<Box flex="1">My Profile</Box>
</MenuItem>
</Link>
<MenuItem
value="logout"
gap={2}
py={2}
onClick={handleLogout}
style={{ cursor: "pointer" }}
>
<FiLogOut />
Log Out
</MenuItem>
</MenuContent>
</MenuRoot>
</Flex>
</>
)
}
export default UserMenu

View File

@@ -1,41 +1,49 @@
import { import { zodResolver } from "@hookform/resolvers/zod"
Button,
DialogActionTrigger,
DialogTitle,
Input,
Text,
VStack,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Plus } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { FaPlus } from "react-icons/fa" import { z } from "zod"
import { type ItemCreate, ItemsService } from "@/client" import { type ItemCreate, ItemsService } from "@/client"
import type { ApiError } from "@/client/core/ApiError" import { Button } from "@/components/ui/button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
import { import {
DialogBody, Dialog,
DialogCloseTrigger, DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogRoot, DialogTitle,
DialogTrigger, DialogTrigger,
} from "../ui/dialog" } from "@/components/ui/dialog"
import { Field } from "../ui/field" import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const formSchema = z.object({
title: z.string().min(1, { message: "Title is required" }),
description: z.string().optional(),
})
type FormData = z.infer<typeof formSchema>
const AddItem = () => { const AddItem = () => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const {
register, const form = useForm<FormData>({
handleSubmit, resolver: zodResolver(formSchema),
reset,
formState: { errors, isValid, isSubmitting },
} = useForm<ItemCreate>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: { defaultValues: {
@@ -48,95 +56,88 @@ const AddItem = () => {
mutationFn: (data: ItemCreate) => mutationFn: (data: ItemCreate) =>
ItemsService.createItem({ requestBody: data }), ItemsService.createItem({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showSuccessToast("Item created successfully.") showSuccessToast("Item created successfully")
reset() form.reset()
setIsOpen(false) setIsOpen(false)
}, },
onError: (err: ApiError) => { onError: handleError.bind(showErrorToast),
handleError(err)
},
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }) queryClient.invalidateQueries({ queryKey: ["items"] })
}, },
}) })
const onSubmit: SubmitHandler<ItemCreate> = (data) => { const onSubmit = (data: FormData) => {
mutation.mutate(data) mutation.mutate(data)
} }
return ( return (
<DialogRoot <Dialog open={isOpen} onOpenChange={setIsOpen}>
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button value="add-item" my={4}> <Button className="my-4">
<FaPlus fontSize="16px" /> <Plus className="mr-2" />
Add Item Add Item
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader> <DialogHeader>
<DialogTitle>Add Item</DialogTitle> <DialogTitle>Add Item</DialogTitle>
<DialogDescription>
Fill in the details to add a new item.
</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogBody> <Form {...form}>
<Text mb={4}>Fill in the details to add a new item.</Text> <form onSubmit={form.handleSubmit(onSubmit)}>
<VStack gap={4}> <div className="grid gap-4 py-4">
<Field <FormField
required control={form.control}
invalid={!!errors.title} name="title"
errorText={errors.title?.message} render={({ field }) => (
label="Title" <FormItem>
> <FormLabel>
Title <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input <Input
{...register("title", {
required: "Title is required.",
})}
placeholder="Title" placeholder="Title"
type="text" type="text"
{...field}
required
/> />
</Field> </FormControl>
<FormMessage />
<Field </FormItem>
invalid={!!errors.description} )}
errorText={errors.description?.message}
label="Description"
>
<Input
{...register("description")}
placeholder="Description"
type="text"
/> />
</Field>
</VStack>
</DialogBody>
<DialogFooter gap={2}> <FormField
<DialogActionTrigger asChild> control={form.control}
<Button name="description"
variant="subtle" render={({ field }) => (
colorPalette="gray" <FormItem>
disabled={isSubmitting} <FormLabel>Description</FormLabel>
> <FormControl>
<Input placeholder="Description" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel Cancel
</Button> </Button>
</DialogActionTrigger> </DialogClose>
<Button <LoadingButton type="submit" loading={mutation.isPending}>
variant="solid"
type="submit"
disabled={!isValid}
loading={isSubmitting}
>
Save Save
</Button> </LoadingButton>
</DialogFooter> </DialogFooter>
</form> </form>
<DialogCloseTrigger /> </Form>
</DialogContent> </DialogContent>
</DialogRoot> </Dialog>
) )
} }

View File

@@ -1,30 +1,34 @@
import { Button, DialogTitle, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Trash2 } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { FiTrash2 } from "react-icons/fi"
import { ItemsService } from "@/client" import { ItemsService } from "@/client"
import { Button } from "@/components/ui/button"
import { import {
DialogActionTrigger, Dialog,
DialogBody, DialogClose,
DialogCloseTrigger,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogRoot, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const DeleteItem = ({ id }: { id: string }) => { interface DeleteItemProps {
id: string
onSuccess: () => void
}
const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const { const { handleSubmit } = useForm()
handleSubmit,
formState: { isSubmitting },
} = useForm()
const deleteItem = async (id: string) => { const deleteItem = async (id: string) => {
await ItemsService.deleteItem({ id: id }) await ItemsService.deleteItem({ id: id })
@@ -35,10 +39,9 @@ const DeleteItem = ({ id }: { id: string }) => {
onSuccess: () => { onSuccess: () => {
showSuccessToast("The item was deleted successfully") showSuccessToast("The item was deleted successfully")
setIsOpen(false) setIsOpen(false)
onSuccess()
}, },
onError: () => { onError: handleError.bind(showErrorToast),
showErrorToast("An error occurred while deleting the item")
},
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries() queryClient.invalidateQueries()
}, },
@@ -49,55 +52,42 @@ const DeleteItem = ({ id }: { id: string }) => {
} }
return ( return (
<DialogRoot <Dialog open={isOpen} onOpenChange={setIsOpen}>
size={{ base: "xs", md: "md" }} <DropdownMenuItem
placement="center" variant="destructive"
role="alertdialog" onSelect={(e) => e.preventDefault()}
open={isOpen} onClick={() => setIsOpen(true)}
onOpenChange={({ open }) => setIsOpen(open)}
> >
<DialogTrigger asChild> <Trash2 />
<Button variant="ghost" size="sm" colorPalette="red">
<FiTrash2 fontSize="16px" />
Delete Item Delete Item
</Button> </DropdownMenuItem>
</DialogTrigger> <DialogContent className="sm:max-w-md">
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<DialogCloseTrigger />
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Item</DialogTitle> <DialogTitle>Delete Item</DialogTitle>
</DialogHeader> <DialogDescription>
<DialogBody>
<Text mb={4}>
This item will be permanently deleted. Are you sure? You will not This item will be permanently deleted. Are you sure? You will not
be able to undo this action. be able to undo this action.
</Text> </DialogDescription>
</DialogBody> </DialogHeader>
<DialogFooter gap={2}> <DialogFooter className="mt-4">
<DialogActionTrigger asChild> <DialogClose asChild>
<Button <Button variant="outline" disabled={mutation.isPending}>
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
</DialogActionTrigger> </DialogClose>
<Button <LoadingButton
variant="solid" variant="destructive"
colorPalette="red"
type="submit" type="submit"
loading={isSubmitting} loading={mutation.isPending}
> >
Delete Delete
</Button> </LoadingButton>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</DialogRoot> </Dialog>
) )
} }

View File

@@ -1,148 +1,144 @@
import { import { zodResolver } from "@hookform/resolvers/zod"
Button,
ButtonGroup,
DialogActionTrigger,
Input,
Text,
VStack,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Pencil } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { FaExchangeAlt } from "react-icons/fa" import { z } from "zod"
import { type ApiError, type ItemPublic, ItemsService } from "@/client" import { type ItemPublic, ItemsService } from "@/client"
import useCustomToast from "@/hooks/useCustomToast" import { Button } from "@/components/ui/button"
import { handleError } from "@/utils"
import { import {
DialogBody, Dialog,
DialogCloseTrigger, DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogRoot,
DialogTitle, DialogTitle,
DialogTrigger, } from "@/components/ui/dialog"
} from "../ui/dialog" import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { Field } from "../ui/field" import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const formSchema = z.object({
title: z.string().min(1, { message: "Title is required" }),
description: z.string().optional(),
})
type FormData = z.infer<typeof formSchema>
interface EditItemProps { interface EditItemProps {
item: ItemPublic item: ItemPublic
onSuccess: () => void
} }
interface ItemUpdateForm { const EditItem = ({ item, onSuccess }: EditItemProps) => {
title: string
description?: string
}
const EditItem = ({ item }: EditItemProps) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const {
register, const form = useForm<FormData>({
handleSubmit, resolver: zodResolver(formSchema),
reset,
formState: { errors, isSubmitting },
} = useForm<ItemUpdateForm>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: { defaultValues: {
...item, title: item.title,
description: item.description ?? undefined, description: item.description ?? undefined,
}, },
}) })
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: ItemUpdateForm) => mutationFn: (data: FormData) =>
ItemsService.updateItem({ id: item.id, requestBody: data }), ItemsService.updateItem({ id: item.id, requestBody: data }),
onSuccess: () => { onSuccess: () => {
showSuccessToast("Item updated successfully.") showSuccessToast("Item updated successfully")
reset()
setIsOpen(false) setIsOpen(false)
onSuccess()
}, },
onError: (err: ApiError) => { onError: handleError.bind(showErrorToast),
handleError(err)
},
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }) queryClient.invalidateQueries({ queryKey: ["items"] })
}, },
}) })
const onSubmit: SubmitHandler<ItemUpdateForm> = async (data) => { const onSubmit = (data: FormData) => {
mutation.mutate(data) mutation.mutate(data)
} }
return ( return (
<DialogRoot <Dialog open={isOpen} onOpenChange={setIsOpen}>
size={{ base: "xs", md: "md" }} <DropdownMenuItem
placement="center" onSelect={(e) => e.preventDefault()}
open={isOpen} onClick={() => setIsOpen(true)}
onOpenChange={({ open }) => setIsOpen(open)}
> >
<DialogTrigger asChild> <Pencil />
<Button variant="ghost">
<FaExchangeAlt fontSize="16px" />
Edit Item Edit Item
</Button> </DropdownMenuItem>
</DialogTrigger> <DialogContent className="sm:max-w-md">
<DialogContent> <Form {...form}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Item</DialogTitle> <DialogTitle>Edit Item</DialogTitle>
<DialogDescription>
Update the item details below.
</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogBody> <div className="grid gap-4 py-4">
<Text mb={4}>Update the item details below.</Text> <FormField
<VStack gap={4}> control={form.control}
<Field name="title"
required render={({ field }) => (
invalid={!!errors.title} <FormItem>
errorText={errors.title?.message} <FormLabel>
label="Title" Title <span className="text-destructive">*</span>
> </FormLabel>
<Input <FormControl>
{...register("title", { <Input placeholder="Title" type="text" {...field} />
required: "Title is required", </FormControl>
})} <FormMessage />
placeholder="Title" </FormItem>
type="text" )}
/> />
</Field>
<Field <FormField
invalid={!!errors.description} control={form.control}
errorText={errors.description?.message} name="description"
label="Description" render={({ field }) => (
> <FormItem>
<Input <FormLabel>Description</FormLabel>
{...register("description")} <FormControl>
placeholder="Description" <Input placeholder="Description" type="text" {...field} />
type="text" </FormControl>
<FormMessage />
</FormItem>
)}
/> />
</Field> </div>
</VStack>
</DialogBody>
<DialogFooter gap={2}> <DialogFooter>
<ButtonGroup> <DialogClose asChild>
<DialogActionTrigger asChild> <Button variant="outline" disabled={mutation.isPending}>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
</DialogActionTrigger> </DialogClose>
<Button variant="solid" type="submit" loading={isSubmitting}> <LoadingButton type="submit" loading={mutation.isPending}>
Save Save
</Button> </LoadingButton>
</ButtonGroup>
</DialogFooter> </DialogFooter>
</form> </form>
<DialogCloseTrigger /> </Form>
</DialogContent> </DialogContent>
</DialogRoot> </Dialog>
) )
} }

View File

@@ -0,0 +1,34 @@
import { EllipsisVertical } from "lucide-react"
import { useState } from "react"
import type { ItemPublic } from "@/client"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import DeleteItem from "../Items/DeleteItem"
import EditItem from "../Items/EditItem"
interface ItemActionsMenuProps {
item: ItemPublic
}
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
const [open, setOpen] = useState(false)
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<EditItem item={item} onSuccess={() => setOpen(false)} />
<DeleteItem id={item.id} onSuccess={() => setOpen(false)} />
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,73 @@
import type { ColumnDef } from "@tanstack/react-table"
import { Check, Copy } from "lucide-react"
import type { ItemPublic } from "@/client"
import { Button } from "@/components/ui/button"
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
import { cn } from "@/lib/utils"
import { ItemActionsMenu } from "./ItemActionsMenu"
function CopyId({ id }: { id: string }) {
const [copiedText, copy] = useCopyToClipboard()
const isCopied = copiedText === id
return (
<div className="flex items-center gap-1.5 group">
<span className="font-mono text-xs text-muted-foreground">{id}</span>
<Button
variant="ghost"
size="icon"
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => copy(id)}
>
{isCopied ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
<span className="sr-only">Copy ID</span>
</Button>
</div>
)
}
export const columns: ColumnDef<ItemPublic>[] = [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <CopyId id={row.original.id} />,
},
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => (
<span className="font-medium">{row.original.title}</span>
),
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
const description = row.original.description
return (
<span
className={cn(
"max-w-xs truncate block text-muted-foreground",
!description && "italic",
)}
>
{description || "No description"}
</span>
)
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end">
<ItemActionsMenu item={row.original} />
</div>
),
},
]

View File

@@ -1,35 +1,46 @@
import { Table } from "@chakra-ui/react" import { Skeleton } from "@/components/ui/skeleton"
import { SkeletonText } from "../ui/skeleton" import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const PendingItems = () => ( const PendingItems = () => (
<Table.Root size={{ base: "sm", md: "md" }}> <Table>
<Table.Header> <TableHeader>
<Table.Row> <TableRow>
<Table.ColumnHeader w="sm">ID</Table.ColumnHeader> <TableHead>ID</TableHead>
<Table.ColumnHeader w="sm">Title</Table.ColumnHeader> <TableHead>Title</TableHead>
<Table.ColumnHeader w="sm">Description</Table.ColumnHeader> <TableHead>Description</TableHead>
<Table.ColumnHeader w="sm">Actions</Table.ColumnHeader> <TableHead>
</Table.Row> <span className="sr-only">Actions</span>
</Table.Header> </TableHead>
<Table.Body> </TableRow>
{[...Array(5)].map((_, index) => ( </TableHeader>
<Table.Row key={index}> <TableBody>
<Table.Cell> {Array.from({ length: 5 }).map((_, index) => (
<SkeletonText noOfLines={1} /> <TableRow key={index}>
</Table.Cell> <TableCell>
<Table.Cell> <Skeleton className="h-4 w-64 font-mono" />
<SkeletonText noOfLines={1} /> </TableCell>
</Table.Cell> <TableCell>
<Table.Cell> <Skeleton className="h-4 w-32" />
<SkeletonText noOfLines={1} /> </TableCell>
</Table.Cell> <TableCell>
<Table.Cell> <Skeleton className="h-4 w-48" />
<SkeletonText noOfLines={1} /> </TableCell>
</Table.Cell> <TableCell>
</Table.Row> <div className="flex justify-end">
<Skeleton className="size-8 rounded-md" />
</div>
</TableCell>
</TableRow>
))} ))}
</Table.Body> </TableBody>
</Table.Root> </Table>
) )
export default PendingItems export default PendingItems

View File

@@ -1,39 +1,53 @@
import { Table } from "@chakra-ui/react" import { Skeleton } from "@/components/ui/skeleton"
import { SkeletonText } from "../ui/skeleton" import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const PendingUsers = () => ( const PendingUsers = () => (
<Table.Root size={{ base: "sm", md: "md" }}> <Table>
<Table.Header> <TableHeader>
<Table.Row> <TableRow>
<Table.ColumnHeader w="sm">Full name</Table.ColumnHeader> <TableHead>Full Name</TableHead>
<Table.ColumnHeader w="sm">Email</Table.ColumnHeader> <TableHead>Email</TableHead>
<Table.ColumnHeader w="sm">Role</Table.ColumnHeader> <TableHead>Role</TableHead>
<Table.ColumnHeader w="sm">Status</Table.ColumnHeader> <TableHead>Status</TableHead>
<Table.ColumnHeader w="sm">Actions</Table.ColumnHeader> <TableHead>
</Table.Row> <span className="sr-only">Actions</span>
</Table.Header> </TableHead>
<Table.Body> </TableRow>
{[...Array(5)].map((_, index) => ( </TableHeader>
<Table.Row key={index}> <TableBody>
<Table.Cell> {Array.from({ length: 5 }).map((_, index) => (
<SkeletonText noOfLines={1} /> <TableRow key={index}>
</Table.Cell> <TableCell>
<Table.Cell> <Skeleton className="h-4 w-32" />
<SkeletonText noOfLines={1} /> </TableCell>
</Table.Cell> <TableCell>
<Table.Cell> <Skeleton className="h-4 w-40" />
<SkeletonText noOfLines={1} /> </TableCell>
</Table.Cell> <TableCell>
<Table.Cell> <Skeleton className="h-5 w-20 rounded-full" />
<SkeletonText noOfLines={1} /> </TableCell>
</Table.Cell> <TableCell>
<Table.Cell> <div className="flex items-center gap-2">
<SkeletonText noOfLines={1} /> <Skeleton className="size-2 rounded-full" />
</Table.Cell> <Skeleton className="h-4 w-12" />
</Table.Row> </div>
</TableCell>
<TableCell>
<div className="flex justify-end">
<Skeleton className="size-8 rounded-md" />
</div>
</TableCell>
</TableRow>
))} ))}
</Table.Body> </TableBody>
</Table.Root> </Table>
) )
export default PendingUsers export default PendingUsers

View File

@@ -0,0 +1,43 @@
import { Briefcase, Home, Users } from "lucide-react"
import { SidebarAppearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
} from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth"
import { type Item, Main } from "./Main"
import { User } from "./User"
const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" },
{ icon: Briefcase, title: "Items", path: "/items" },
]
export function AppSidebar() {
const { user: currentUser } = useAuth()
const items = currentUser?.is_superuser
? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }]
: baseItems
return (
<Sidebar collapsible="icon">
<SidebarHeader className="px-4 py-6 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center">
<Logo variant="responsive" />
</SidebarHeader>
<SidebarContent>
<Main items={items} />
</SidebarContent>
<SidebarFooter>
<SidebarAppearance />
<User user={currentUser} />
</SidebarFooter>
</Sidebar>
)
}
export default AppSidebar

View File

@@ -0,0 +1,60 @@
import { Link as RouterLink, useRouterState } from "@tanstack/react-router"
import type { LucideIcon } from "lucide-react"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
export type Item = {
icon: LucideIcon
title: string
path: string
}
interface MainProps {
items: Item[]
}
export function Main({ items }: MainProps) {
const { isMobile, setOpenMobile } = useSidebar()
const router = useRouterState()
const currentPath = router.location.pathname
const handleMenuClick = () => {
if (isMobile) {
setOpenMobile(false)
}
}
return (
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const isActive = currentPath === item.path
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={isActive}
asChild
>
<RouterLink to={item.path} onClick={handleMenuClick}>
<item.icon />
<span>{item.title}</span>
</RouterLink>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,97 @@
import { Link as RouterLink } from "@tanstack/react-router"
import { ChevronsUpDown, LogOut, Settings } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth"
import { getInitials } from "@/utils"
interface UserInfoProps {
fullName?: string
email?: string
}
function UserInfo({ fullName, email }: UserInfoProps) {
return (
<div className="flex items-center gap-2.5 w-full min-w-0">
<Avatar className="size-8">
<AvatarFallback className="bg-zinc-600 text-white">
{getInitials(fullName || "User")}
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start min-w-0">
<p className="text-sm font-medium truncate w-full">{fullName}</p>
<p className="text-xs text-muted-foreground truncate w-full">{email}</p>
</div>
</div>
)
}
export function User({ user }: { user: any }) {
const { logout } = useAuth()
const { isMobile, setOpenMobile } = useSidebar()
if (!user) return null
const handleMenuClick = () => {
if (isMobile) {
setOpenMobile(false)
}
}
const handleLogout = async () => {
logout()
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-testid="user-menu"
>
<UserInfo fullName={user?.full_name} email={user?.email} />
<ChevronsUpDown className="ml-auto size-4 text-muted-foreground" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<UserInfo fullName={user?.full_name} email={user?.email} />
</DropdownMenuLabel>
<DropdownMenuSeparator />
<RouterLink to="/settings" onClick={handleMenuClick}>
<DropdownMenuItem>
<Settings />
User Settings
</DropdownMenuItem>
</RouterLink>
<DropdownMenuItem onClick={handleLogout}>
<LogOut />
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View File

@@ -1,29 +0,0 @@
import { Container, Heading, Stack } from "@chakra-ui/react"
import { useTheme } from "next-themes"
import { Radio, RadioGroup } from "@/components/ui/radio"
const Appearance = () => {
const { theme, setTheme } = useTheme()
return (
<Container maxW="full">
<Heading size="sm" py={4}>
Appearance
</Heading>
<RadioGroup
onValueChange={(e) => setTheme(e.value ?? "system")}
value={theme}
colorPalette="teal"
>
<Stack>
<Radio value="system">System</Radio>
<Radio value="light">Light Mode</Radio>
<Radio value="dark">Dark Mode</Radio>
</Stack>
</RadioGroup>
</Container>
)
}
export default Appearance

View File

@@ -1,80 +1,146 @@
import { Box, Button, Container, Heading, VStack } from "@chakra-ui/react" import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { type SubmitHandler, useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { FiLock } from "react-icons/fi" import { z } from "zod"
import { type ApiError, type UpdatePassword, UsersService } from "@/client" import { type UpdatePassword, UsersService } from "@/client"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { LoadingButton } from "@/components/ui/loading-button"
import { PasswordInput } from "@/components/ui/password-input"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { confirmPasswordRules, handleError, passwordRules } from "@/utils" import { handleError } from "@/utils"
import { PasswordInput } from "../ui/password-input"
interface UpdatePasswordForm extends UpdatePassword { const formSchema = z
confirm_password: string .object({
} current_password: z
.string()
.min(1, { message: "Password is required" })
.min(8, { message: "Password must be at least 8 characters" }),
new_password: z
.string()
.min(1, { message: "Password is required" })
.min(8, { message: "Password must be at least 8 characters" }),
confirm_password: z
.string()
.min(1, { message: "Password confirmation is required" }),
})
.refine((data) => data.new_password === data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
})
type FormData = z.infer<typeof formSchema>
const ChangePassword = () => { const ChangePassword = () => {
const { showSuccessToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const { const form = useForm<FormData>({
register, resolver: zodResolver(formSchema),
handleSubmit, mode: "onSubmit",
reset,
getValues,
formState: { errors, isSubmitting },
} = useForm<UpdatePasswordForm>({
mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: {
current_password: "",
new_password: "",
confirm_password: "",
},
}) })
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: UpdatePassword) => mutationFn: (data: UpdatePassword) =>
UsersService.updatePasswordMe({ requestBody: data }), UsersService.updatePasswordMe({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showSuccessToast("Password updated successfully.") showSuccessToast("Password updated successfully")
reset() form.reset()
},
onError: (err: ApiError) => {
handleError(err)
}, },
onError: handleError.bind(showErrorToast),
}) })
const onSubmit: SubmitHandler<UpdatePasswordForm> = async (data) => { const onSubmit = async (data: FormData) => {
mutation.mutate(data) mutation.mutate(data)
} }
return ( return (
<Container maxW="full"> <div className="max-w-md">
<Heading size="sm" py={4}> <h3 className="text-lg font-semibold py-4">Change Password</h3>
Change Password <Form {...form}>
</Heading> <form
<Box as="form" onSubmit={handleSubmit(onSubmit)}> onSubmit={form.handleSubmit(onSubmit)}
<VStack gap={4} w={{ base: "100%", md: "sm" }}> className="flex flex-col gap-4"
>
<FormField
control={form.control}
name="current_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<PasswordInput <PasswordInput
type="current_password" data-testid="current-password-input"
startElement={<FiLock />} placeholder="••••••••"
{...register("current_password", passwordRules())} aria-invalid={fieldState.invalid}
placeholder="Current Password" {...field}
errors={errors}
/> />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="new_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<PasswordInput <PasswordInput
type="new_password" data-testid="new-password-input"
startElement={<FiLock />} placeholder="••••••••"
{...register("new_password", passwordRules())} aria-invalid={fieldState.invalid}
placeholder="New Password" {...field}
errors={errors}
/> />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm_password"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput <PasswordInput
type="confirm_password" data-testid="confirm-password-input"
startElement={<FiLock />} placeholder="••••••••"
{...register("confirm_password", confirmPasswordRules(getValues))} aria-invalid={fieldState.invalid}
placeholder="Confirm Password" {...field}
errors={errors}
/> />
</VStack> </FormControl>
<Button variant="solid" mt={4} type="submit" loading={isSubmitting}> <FormMessage />
Save </FormItem>
</Button> )}
</Box> />
</Container>
<LoadingButton
type="submit"
loading={mutation.isPending}
className="self-start"
>
Update Password
</LoadingButton>
</form>
</Form>
</div>
) )
} }
export default ChangePassword export default ChangePassword

View File

@@ -1,19 +1,15 @@
import { Container, Heading, Text } from "@chakra-ui/react"
import DeleteConfirmation from "./DeleteConfirmation" import DeleteConfirmation from "./DeleteConfirmation"
const DeleteAccount = () => { const DeleteAccount = () => {
return ( return (
<Container maxW="full"> <div className="max-w-md mt-4 rounded-lg border border-destructive/50 p-4">
<Heading size="sm" py={4}> <h3 className="font-semibold text-destructive">Delete Account</h3>
Delete Account <p className="mt-1 text-sm text-muted-foreground">
</Heading> Permanently delete your account and all associated data.
<Text> </p>
Permanently delete your data and everything associated with your
account.
</Text>
<DeleteConfirmation /> <DeleteConfirmation />
</Container> </div>
) )
} }
export default DeleteAccount export default DeleteAccount

View File

@@ -1,44 +1,36 @@
import { Button, ButtonGroup, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { type ApiError, UsersService } from "@/client" import { UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import { import {
DialogActionTrigger, Dialog,
DialogBody, DialogClose,
DialogCloseTrigger,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogRoot,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { LoadingButton } from "@/components/ui/loading-button"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils" import { handleError } from "@/utils"
const DeleteConfirmation = () => { const DeleteConfirmation = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const { const { handleSubmit } = useForm()
handleSubmit,
formState: { isSubmitting },
} = useForm()
const { logout } = useAuth() const { logout } = useAuth()
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => UsersService.deleteUserMe(), mutationFn: () => UsersService.deleteUserMe(),
onSuccess: () => { onSuccess: () => {
showSuccessToast("Your account has been successfully deleted") showSuccessToast("Your account has been successfully deleted")
setIsOpen(false)
logout() logout()
}, },
onError: (err: ApiError) => { onError: handleError.bind(showErrorToast),
handleError(err)
},
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["currentUser"] }) queryClient.invalidateQueries({ queryKey: ["currentUser"] })
}, },
@@ -49,58 +41,41 @@ const DeleteConfirmation = () => {
} }
return ( return (
<DialogRoot <Dialog>
size={{ base: "xs", md: "md" }}
role="alertdialog"
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="solid" colorPalette="red" mt={4}> <Button variant="destructive" className="mt-3">
Delete Delete Account
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<DialogCloseTrigger />
<DialogHeader> <DialogHeader>
<DialogTitle>Confirmation Required</DialogTitle> <DialogTitle>Confirmation Required</DialogTitle>
</DialogHeader> <DialogDescription>
<DialogBody>
<Text mb={4}>
All your account data will be{" "} All your account data will be{" "}
<strong>permanently deleted.</strong> If you are sure, please <strong>permanently deleted.</strong> If you are sure, please
click <strong>"Confirm"</strong> to proceed. This action cannot be click <strong>"Confirm"</strong> to proceed. This action cannot be
undone. undone.
</Text> </DialogDescription>
</DialogBody> </DialogHeader>
<DialogFooter gap={2}> <DialogFooter className="mt-4">
<ButtonGroup> <DialogClose asChild>
<DialogActionTrigger asChild> <Button variant="outline" disabled={mutation.isPending}>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
</DialogActionTrigger> </DialogClose>
<Button <LoadingButton
variant="solid" variant="destructive"
colorPalette="red"
type="submit" type="submit"
loading={isSubmitting} loading={mutation.isPending}
> >
Delete Delete
</Button> </LoadingButton>
</ButtonGroup>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</DialogRoot> </Dialog>
) )
} }

View File

@@ -1,43 +1,45 @@
import { import { zodResolver } from "@hookform/resolvers/zod"
Box,
Button,
Container,
Flex,
Heading,
Input,
Text,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react" import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod"
import { UsersService, type UserUpdateMe } from "@/client"
import { Button } from "@/components/ui/button"
import { import {
type ApiError, Form,
type UserPublic, FormControl,
UsersService, FormField,
type UserUpdateMe, FormItem,
} from "@/client" FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { emailPattern, handleError } from "@/utils" import { cn } from "@/lib/utils"
import { Field } from "../ui/field" import { handleError } from "@/utils"
const formSchema = z.object({
full_name: z.string().max(30).optional(),
email: z.email({ message: "Invalid email address" }),
})
type FormData = z.infer<typeof formSchema>
const UserInformation = () => { const UserInformation = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const [editMode, setEditMode] = useState(false) const [editMode, setEditMode] = useState(false)
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
const {
register, const form = useForm<FormData>({
handleSubmit, resolver: zodResolver(formSchema),
reset,
getValues,
formState: { isSubmitting, errors, isDirty },
} = useForm<UserPublic>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: { defaultValues: {
full_name: currentUser?.full_name, full_name: currentUser?.full_name ?? undefined,
email: currentUser?.email, email: currentUser?.email,
}, },
}) })
@@ -50,98 +52,119 @@ const UserInformation = () => {
mutationFn: (data: UserUpdateMe) => mutationFn: (data: UserUpdateMe) =>
UsersService.updateUserMe({ requestBody: data }), UsersService.updateUserMe({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showSuccessToast("User updated successfully.") showSuccessToast("User updated successfully")
}, toggleEditMode()
onError: (err: ApiError) => {
handleError(err)
}, },
onError: handleError.bind(showErrorToast),
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries() queryClient.invalidateQueries()
}, },
}) })
const onSubmit: SubmitHandler<UserUpdateMe> = async (data) => { const onSubmit = (data: FormData) => {
mutation.mutate(data) const updateData: UserUpdateMe = {}
// only include fields that have changed
if (data.full_name !== currentUser?.full_name) {
updateData.full_name = data.full_name
}
if (data.email !== currentUser?.email) {
updateData.email = data.email
}
mutation.mutate(updateData)
} }
const onCancel = () => { const onCancel = () => {
reset() form.reset()
toggleEditMode() toggleEditMode()
} }
return ( return (
<Container maxW="full"> <div className="max-w-md">
<Heading size="sm" py={4}> <h3 className="text-lg font-semibold py-4">User Information</h3>
User Information <Form {...form}>
</Heading> <form
<Box onSubmit={form.handleSubmit(onSubmit)}
w={{ sm: "full", md: "sm" }} className="flex flex-col gap-4"
as="form"
onSubmit={handleSubmit(onSubmit)}
> >
<Field label="Full name"> <FormField
{editMode ? ( control={form.control}
<Input name="full_name"
{...register("full_name", { maxLength: 30 })} render={({ field }) =>
type="text" editMode ? (
size="md" <FormItem>
/> <FormLabel>Full name</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
) : ( ) : (
<Text <FormItem>
fontSize="md" <FormLabel>Full name</FormLabel>
py={2} <p
color={!currentUser?.full_name ? "gray" : "inherit"} className={cn(
truncate "py-2 truncate max-w-sm",
maxW="sm" !field.value && "text-muted-foreground",
>
{currentUser?.full_name || "N/A"}
</Text>
)} )}
</Field>
<Field
mt={4}
label="Email"
invalid={!!errors.email}
errorText={errors.email?.message}
> >
{editMode ? ( {field.value || "N/A"}
<Input </p>
{...register("email", { </FormItem>
required: "Email is required", )
pattern: emailPattern, }
})}
type="email"
size="md"
/> />
<FormField
control={form.control}
name="email"
render={({ field }) =>
editMode ? (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
) : ( ) : (
<Text fontSize="md" py={2} truncate maxW="sm"> <FormItem>
{currentUser?.email} <FormLabel>Email</FormLabel>
</Text> <p className="py-2 truncate max-w-sm">{field.value}</p>
)} </FormItem>
</Field> )
<Flex mt={4} gap={3}> }
<Button />
variant="solid"
onClick={toggleEditMode} <div className="flex gap-3">
type={editMode ? "button" : "submit"} {editMode ? (
loading={editMode ? isSubmitting : false} <>
disabled={editMode ? !isDirty || !getValues("email") : false} <LoadingButton
type="submit"
loading={mutation.isPending}
disabled={!form.formState.isDirty}
> >
{editMode ? "Save" : "Edit"} Save
</Button> </LoadingButton>
{editMode && (
<Button <Button
variant="subtle" type="button"
colorPalette="gray" variant="outline"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} disabled={mutation.isPending}
> >
Cancel Cancel
</Button> </Button>
</>
) : (
<Button type="button" onClick={toggleEditMode}>
Edit
</Button>
)} )}
</Flex> </div>
</Box> </form>
</Container> </Form>
</div>
) )
} }

View File

@@ -0,0 +1,115 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react"
export type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
resolvedTheme: "dark" | "light"
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
resolvedTheme: "light",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
)
const getResolvedTheme = useCallback((theme: Theme): "dark" | "light" => {
if (theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
}
return theme
}, [])
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() =>
getResolvedTheme(theme),
)
const updateTheme = useCallback((newTheme: Theme) => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (newTheme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(newTheme)
}, [])
useEffect(() => {
updateTheme(theme)
setResolvedTheme(getResolvedTheme(theme))
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const handleChange = () => {
if (theme === "system") {
updateTheme("system")
setResolvedTheme(getResolvedTheme("system"))
}
}
mediaQuery.addEventListener("change", handleChange)
return () => {
mediaQuery.removeEventListener("change", handleChange)
}
}, [theme, updateTheme, getResolvedTheme])
const value = {
theme,
resolvedTheme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@@ -1,40 +1,60 @@
import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react"
import {
AbsoluteCenter,
Button as ChakraButton,
Span,
Spinner,
} from "@chakra-ui/react"
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
interface ButtonLoadingProps { import { cn } from "@/lib/utils"
loading?: boolean
loadingText?: React.ReactNode const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
} }
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {} export { Button, buttonVariants }
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button(props, ref) {
const { loading, disabled, loadingText, children, ...rest } = props
return (
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
{loading && !loadingText ? (
<>
<AbsoluteCenter display="inline-flex">
<Spinner size="inherit" color="inherit" />
</AbsoluteCenter>
<Span opacity={0}>{children}</Span>
</>
) : loading && loadingText ? (
<>
<Spinner size="inherit" color="inherit" />
{loadingText}
</>
) : (
children
)}
</ChakraButton>
)
},
)

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,25 +1,30 @@
import { Checkbox as ChakraCheckbox } from "@chakra-ui/react"
import * as React from "react" import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
export interface CheckboxProps extends ChakraCheckbox.RootProps { import { cn } from "@/lib/utils"
icon?: React.ReactNode
inputProps?: React.InputHTMLAttributes<HTMLInputElement> function Checkbox({
rootRef?: React.Ref<HTMLLabelElement> className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
} }
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>( export { Checkbox }
function Checkbox(props, ref) {
const { icon, children, inputProps, rootRef, ...rest } = props
return (
<ChakraCheckbox.Root ref={rootRef} {...rest}>
<ChakraCheckbox.HiddenInput ref={ref} {...inputProps} />
<ChakraCheckbox.Control>
{icon || <ChakraCheckbox.Indicator />}
</ChakraCheckbox.Control>
{children != null && (
<ChakraCheckbox.Label>{children}</ChakraCheckbox.Label>
)}
</ChakraCheckbox.Root>
)
},
)

View File

@@ -1,17 +0,0 @@
import type { ButtonProps } from "@chakra-ui/react"
import { IconButton as ChakraIconButton } from "@chakra-ui/react"
import * as React from "react"
import { LuX } from "react-icons/lu"
export type CloseButtonProps = ButtonProps
export const CloseButton = React.forwardRef<
HTMLButtonElement,
CloseButtonProps
>(function CloseButton(props, ref) {
return (
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
{props.children ?? <LuX />}
</ChakraIconButton>
)
})

View File

@@ -1,107 +0,0 @@
"use client"
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
import { ThemeProvider, useTheme } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
import * as React from "react"
import { LuMoon, LuSun } from "react-icons/lu"
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return (
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
)
}
export type ColorMode = "light" | "dark"
export interface UseColorModeReturn {
colorMode: ColorMode
setColorMode: (colorMode: ColorMode) => void
toggleColorMode: () => void
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme } = useTheme()
const toggleColorMode = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
return {
colorMode: resolvedTheme as ColorMode,
setColorMode: setTheme,
toggleColorMode,
}
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode()
return colorMode === "dark" ? dark : light
}
export function ColorModeIcon() {
const { colorMode } = useColorMode()
return colorMode === "dark" ? <LuMoon /> : <LuSun />
}
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
export const ColorModeButton = React.forwardRef<
HTMLButtonElement,
ColorModeButtonProps
>(function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode()
return (
<ClientOnly fallback={<Skeleton boxSize="8" />}>
<IconButton
onClick={toggleColorMode}
variant="ghost"
aria-label="Toggle color mode"
size="sm"
ref={ref}
{...props}
css={{
_icon: {
width: "5",
height: "5",
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
)
})
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function LightMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme light"
colorPalette="gray"
colorScheme="light"
ref={ref}
{...props}
/>
)
},
)
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function DarkMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme dark"
colorPalette="gray"
colorScheme="dark"
ref={ref}
{...props}
/>
)
},
)

View File

@@ -1,62 +1,141 @@
import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react"
import * as React from "react" import * as React from "react"
import { CloseButton } from "./close-button" import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
interface DialogContentProps extends ChakraDialog.ContentProps { import { cn } from "@/lib/utils"
portalled?: boolean
portalRef?: React.RefObject<HTMLElement> function Dialog({
backdrop?: boolean ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
export const DialogContent = React.forwardRef< function DialogTrigger({
HTMLDivElement, ...props
DialogContentProps }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
>(function DialogContent(props, ref) { return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
const { }
children,
portalled = true,
portalRef,
backdrop = true,
...rest
} = props
return ( function DialogPortal({
<Portal disabled={!portalled} container={portalRef}> ...props
{backdrop && <ChakraDialog.Backdrop />} }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
<ChakraDialog.Positioner> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
<ChakraDialog.Content ref={ref} {...rest} asChild={false}> }
{children}
</ChakraDialog.Content>
</ChakraDialog.Positioner>
</Portal>
)
})
export const DialogCloseTrigger = React.forwardRef< function DialogClose({
HTMLButtonElement, ...props
ChakraDialog.CloseTriggerProps }: React.ComponentProps<typeof DialogPrimitive.Close>) {
>(function DialogCloseTrigger(props, ref) { return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<ChakraDialog.CloseTrigger <DialogPrimitive.Overlay
position="absolute" data-slot="dialog-overlay"
top="2" className={cn(
insetEnd="2" "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props} {...props}
asChild />
>
<CloseButton size="sm" ref={ref}>
{props.children}
</CloseButton>
</ChakraDialog.CloseTrigger>
) )
}) }
export const DialogRoot = ChakraDialog.Root function DialogContent({
export const DialogFooter = ChakraDialog.Footer className,
export const DialogHeader = ChakraDialog.Header children,
export const DialogBody = ChakraDialog.Body showCloseButton = true,
export const DialogBackdrop = ChakraDialog.Backdrop ...props
export const DialogTitle = ChakraDialog.Title }: React.ComponentProps<typeof DialogPrimitive.Content> & {
export const DialogDescription = ChakraDialog.Description showCloseButton?: boolean
export const DialogTrigger = ChakraDialog.Trigger }) {
export const DialogActionTrigger = ChakraDialog.ActionTrigger return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -1,52 +0,0 @@
import { Drawer as ChakraDrawer, Portal } from "@chakra-ui/react"
import * as React from "react"
import { CloseButton } from "./close-button"
interface DrawerContentProps extends ChakraDrawer.ContentProps {
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
offset?: ChakraDrawer.ContentProps["padding"]
}
export const DrawerContent = React.forwardRef<
HTMLDivElement,
DrawerContentProps
>(function DrawerContent(props, ref) {
const { children, portalled = true, portalRef, offset, ...rest } = props
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraDrawer.Positioner padding={offset}>
<ChakraDrawer.Content ref={ref} {...rest} asChild={false}>
{children}
</ChakraDrawer.Content>
</ChakraDrawer.Positioner>
</Portal>
)
})
export const DrawerCloseTrigger = React.forwardRef<
HTMLButtonElement,
ChakraDrawer.CloseTriggerProps
>(function DrawerCloseTrigger(props, ref) {
return (
<ChakraDrawer.CloseTrigger
position="absolute"
top="2"
insetEnd="2"
{...props}
asChild
>
<CloseButton size="sm" ref={ref} />
</ChakraDrawer.CloseTrigger>
)
})
export const DrawerTrigger = ChakraDrawer.Trigger
export const DrawerRoot = ChakraDrawer.Root
export const DrawerFooter = ChakraDrawer.Footer
export const DrawerHeader = ChakraDrawer.Header
export const DrawerBody = ChakraDrawer.Body
export const DrawerBackdrop = ChakraDrawer.Backdrop
export const DrawerDescription = ChakraDrawer.Description
export const DrawerTitle = ChakraDrawer.Title
export const DrawerActionTrigger = ChakraDrawer.ActionTrigger

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,33 +0,0 @@
import { Field as ChakraField } from "@chakra-ui/react"
import * as React from "react"
export interface FieldProps extends Omit<ChakraField.RootProps, "label"> {
label?: React.ReactNode
helperText?: React.ReactNode
errorText?: React.ReactNode
optionalText?: React.ReactNode
}
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
function Field(props, ref) {
const { label, children, helperText, errorText, optionalText, ...rest } =
props
return (
<ChakraField.Root ref={ref} {...rest}>
{label && (
<ChakraField.Label>
{label}
<ChakraField.RequiredIndicator fallback={optionalText} />
</ChakraField.Label>
)}
{children}
{helperText && (
<ChakraField.HelperText>{helperText}</ChakraField.HelperText>
)}
{errorText && (
<ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>
)}
</ChakraField.Root>
)
},
)

View File

@@ -0,0 +1,165 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -1,53 +0,0 @@
import type { BoxProps, InputElementProps } from "@chakra-ui/react"
import { Group, InputElement } from "@chakra-ui/react"
import * as React from "react"
export interface InputGroupProps extends BoxProps {
startElementProps?: InputElementProps
endElementProps?: InputElementProps
startElement?: React.ReactNode
endElement?: React.ReactNode
children: React.ReactElement<InputElementProps>
startOffset?: InputElementProps["paddingStart"]
endOffset?: InputElementProps["paddingEnd"]
}
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
function InputGroup(props, ref) {
const {
startElement,
startElementProps,
endElement,
endElementProps,
children,
startOffset = "6px",
endOffset = "6px",
...rest
} = props
const child =
React.Children.only<React.ReactElement<InputElementProps>>(children)
return (
<Group ref={ref} {...rest}>
{startElement && (
<InputElement pointerEvents="none" {...startElementProps}>
{startElement}
</InputElement>
)}
{React.cloneElement(child, {
...(startElement && {
ps: `calc(var(--input-height) - ${startOffset})`,
}),
...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
...children.props,
})}
{endElement && (
<InputElement placement="end" {...endElementProps}>
{endElement}
</InputElement>
)}
</Group>
)
},
)

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -1,12 +0,0 @@
"use client"
import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react"
import { createRecipeContext } from "@chakra-ui/react"
export interface LinkButtonProps
extends HTMLChakraProps<"a", RecipeProps<"button">> {}
const { withContext } = createRecipeContext({ key: "button" })
// Replace "a" with your framework's link component
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>("a")

View File

@@ -0,0 +1,68 @@
import { Slot, Slottable } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
}
function LoadingButton({
className,
loading = false,
children,
disabled,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
disabled={loading || disabled}
{...props}
>
{loading && <Loader2 className="mr-2 h-5 w-5 animate-spin" />}
<Slottable>{children}</Slottable>
</Comp>
)
}
export { buttonVariants, LoadingButton }

View File

@@ -1,112 +0,0 @@
"use client"
import { AbsoluteCenter, Menu as ChakraMenu, Portal } from "@chakra-ui/react"
import * as React from "react"
import { LuCheck, LuChevronRight } from "react-icons/lu"
interface MenuContentProps extends ChakraMenu.ContentProps {
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
}
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>(
function MenuContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraMenu.Positioner>
<ChakraMenu.Content ref={ref} {...rest} />
</ChakraMenu.Positioner>
</Portal>
)
},
)
export const MenuArrow = React.forwardRef<
HTMLDivElement,
ChakraMenu.ArrowProps
>(function MenuArrow(props, ref) {
return (
<ChakraMenu.Arrow ref={ref} {...props}>
<ChakraMenu.ArrowTip />
</ChakraMenu.Arrow>
)
})
export const MenuCheckboxItem = React.forwardRef<
HTMLDivElement,
ChakraMenu.CheckboxItemProps
>(function MenuCheckboxItem(props, ref) {
return (
<ChakraMenu.CheckboxItem ps="8" ref={ref} {...props}>
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
<ChakraMenu.ItemIndicator>
<LuCheck />
</ChakraMenu.ItemIndicator>
</AbsoluteCenter>
{props.children}
</ChakraMenu.CheckboxItem>
)
})
export const MenuRadioItem = React.forwardRef<
HTMLDivElement,
ChakraMenu.RadioItemProps
>(function MenuRadioItem(props, ref) {
const { children, ...rest } = props
return (
<ChakraMenu.RadioItem ps="8" ref={ref} {...rest}>
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
<ChakraMenu.ItemIndicator>
<LuCheck />
</ChakraMenu.ItemIndicator>
</AbsoluteCenter>
<ChakraMenu.ItemText>{children}</ChakraMenu.ItemText>
</ChakraMenu.RadioItem>
)
})
export const MenuItemGroup = React.forwardRef<
HTMLDivElement,
ChakraMenu.ItemGroupProps
>(function MenuItemGroup(props, ref) {
const { title, children, ...rest } = props
return (
<ChakraMenu.ItemGroup ref={ref} {...rest}>
{title && (
<ChakraMenu.ItemGroupLabel userSelect="none">
{title}
</ChakraMenu.ItemGroupLabel>
)}
{children}
</ChakraMenu.ItemGroup>
)
})
export interface MenuTriggerItemProps extends ChakraMenu.ItemProps {
startIcon?: React.ReactNode
}
export const MenuTriggerItem = React.forwardRef<
HTMLDivElement,
MenuTriggerItemProps
>(function MenuTriggerItem(props, ref) {
const { startIcon, children, ...rest } = props
return (
<ChakraMenu.TriggerItem ref={ref} {...rest}>
{startIcon}
{children}
<LuChevronRight />
</ChakraMenu.TriggerItem>
)
})
export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup
export const MenuContextTrigger = ChakraMenu.ContextTrigger
export const MenuRoot = ChakraMenu.Root
export const MenuSeparator = ChakraMenu.Separator
export const MenuItem = ChakraMenu.Item
export const MenuItemText = ChakraMenu.ItemText
export const MenuItemCommand = ChakraMenu.ItemCommand
export const MenuTrigger = ChakraMenu.Trigger

View File

@@ -1,211 +1,127 @@
"use client"
import type { ButtonProps, TextProps } from "@chakra-ui/react"
import {
Button,
Pagination as ChakraPagination,
IconButton,
Text,
createContext,
usePaginationContext,
} from "@chakra-ui/react"
import * as React from "react" import * as React from "react"
import { import {
HiChevronLeft, ChevronLeftIcon,
HiChevronRight, ChevronRightIcon,
HiMiniEllipsisHorizontal, MoreHorizontalIcon,
} from "react-icons/hi2" } from "lucide-react"
import { LinkButton } from "./link-button"
interface ButtonVariantMap { import { cn } from "@/lib/utils"
current: ButtonProps["variant"] import { Button, buttonVariants } from "@/components/ui/button"
default: ButtonProps["variant"]
ellipsis: ButtonProps["variant"]
}
type PaginationVariant = "outline" | "solid" | "subtle" function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
interface ButtonVariantContext {
size: ButtonProps["size"]
variantMap: ButtonVariantMap
getHref?: (page: number) => string
}
const [RootPropsProvider, useRootProps] = createContext<ButtonVariantContext>({
name: "RootPropsProvider",
})
export interface PaginationRootProps
extends Omit<ChakraPagination.RootProps, "type"> {
size?: ButtonProps["size"]
variant?: PaginationVariant
getHref?: (page: number) => string
}
const variantMap: Record<PaginationVariant, ButtonVariantMap> = {
outline: { default: "ghost", ellipsis: "plain", current: "outline" },
solid: { default: "outline", ellipsis: "outline", current: "solid" },
subtle: { default: "ghost", ellipsis: "plain", current: "subtle" },
}
export const PaginationRoot = React.forwardRef<
HTMLDivElement,
PaginationRootProps
>(function PaginationRoot(props, ref) {
const { size = "sm", variant = "outline", getHref, ...rest } = props
return ( return (
<RootPropsProvider <nav
value={{ size, variantMap: variantMap[variant], getHref }} role="navigation"
> aria-label="pagination"
<ChakraPagination.Root data-slot="pagination"
ref={ref} className={cn("mx-auto flex w-full justify-center", className)}
type={getHref ? "link" : "button"}
{...rest}
/>
</RootPropsProvider>
)
})
export const PaginationEllipsis = React.forwardRef<
HTMLDivElement,
ChakraPagination.EllipsisProps
>(function PaginationEllipsis(props, ref) {
const { size, variantMap } = useRootProps()
return (
<ChakraPagination.Ellipsis ref={ref} {...props} asChild>
<Button as="span" variant={variantMap.ellipsis} size={size}>
<HiMiniEllipsisHorizontal />
</Button>
</ChakraPagination.Ellipsis>
)
})
export const PaginationItem = React.forwardRef<
HTMLButtonElement,
ChakraPagination.ItemProps
>(function PaginationItem(props, ref) {
const { page } = usePaginationContext()
const { size, variantMap, getHref } = useRootProps()
const current = page === props.value
const variant = current ? variantMap.current : variantMap.default
if (getHref) {
return (
<LinkButton href={getHref(props.value)} variant={variant} size={size}>
{props.value}
</LinkButton>
)
}
return (
<ChakraPagination.Item ref={ref} {...props} asChild>
<Button variant={variant} size={size}>
{props.value}
</Button>
</ChakraPagination.Item>
)
})
export const PaginationPrevTrigger = React.forwardRef<
HTMLButtonElement,
ChakraPagination.PrevTriggerProps
>(function PaginationPrevTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps()
const { previousPage } = usePaginationContext()
if (getHref) {
return (
<LinkButton
href={previousPage != null ? getHref(previousPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronLeft />
</LinkButton>
)
}
return (
<ChakraPagination.PrevTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronLeft />
</IconButton>
</ChakraPagination.PrevTrigger>
)
})
export const PaginationNextTrigger = React.forwardRef<
HTMLButtonElement,
ChakraPagination.NextTriggerProps
>(function PaginationNextTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps()
const { nextPage } = usePaginationContext()
if (getHref) {
return (
<LinkButton
href={nextPage != null ? getHref(nextPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronRight />
</LinkButton>
)
}
return (
<ChakraPagination.NextTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronRight />
</IconButton>
</ChakraPagination.NextTrigger>
)
})
export const PaginationItems = (props: React.HTMLAttributes<HTMLElement>) => {
return (
<ChakraPagination.Context>
{({ pages }) =>
pages.map((page, index) => {
return page.type === "ellipsis" ? (
<PaginationEllipsis key={index} index={index} {...props} />
) : (
<PaginationItem
key={index}
type="page"
value={page.value}
{...props} {...props}
/> />
) )
})
}
</ChakraPagination.Context>
)
} }
interface PageTextProps extends TextProps { function PaginationContent({
format?: "short" | "compact" | "long" className,
} ...props
}: React.ComponentProps<"ul">) {
export const PaginationPageText = React.forwardRef<
HTMLParagraphElement,
PageTextProps
>(function PaginationPageText(props, ref) {
const { format = "compact", ...rest } = props
const { page, totalPages, pageRange, count } = usePaginationContext()
const content = React.useMemo(() => {
if (format === "short") return `${page} / ${totalPages}`
if (format === "compact") return `${page} of ${totalPages}`
return `${pageRange.start + 1} - ${Math.min(
pageRange.end,
count,
)} of ${count}`
}, [format, page, totalPages, pageRange, count])
return ( return (
<Text fontWeight="medium" ref={ref} {...rest}> <ul
{content} data-slot="pagination-content"
</Text> className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
) )
}) }
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -1,162 +1,51 @@
"use client" import * as React from "react"
import { Eye, EyeOff } from "lucide-react"
import type { import { cn } from "@/lib/utils"
ButtonProps, import { Button } from "./button"
GroupProps,
InputProps,
StackProps,
} from "@chakra-ui/react"
import {
Box,
HStack,
IconButton,
Input,
Stack,
mergeRefs,
useControllableState,
} from "@chakra-ui/react"
import { forwardRef, useRef } from "react"
import { FiEye, FiEyeOff } from "react-icons/fi"
import { Field } from "./field"
import { InputGroup } from "./input-group"
export interface PasswordVisibilityProps { interface PasswordInputProps extends React.ComponentProps<"input"> {
defaultVisible?: boolean error?: string
visible?: boolean
onVisibleChange?: (visible: boolean) => void
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
} }
export interface PasswordInputProps const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
extends InputProps, ({ className, error, ...props }, ref) => {
PasswordVisibilityProps { const [showPassword, setShowPassword] = React.useState(false)
rootProps?: GroupProps
startElement?: React.ReactNode
type: string
errors: any
}
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
visible: visibleProp,
onVisibleChange,
visibilityIcon = { on: <FiEye />, off: <FiEyeOff /> },
startElement,
type,
errors,
...rest
} = props
const [visible, setVisible] = useControllableState({
value: visibleProp,
defaultValue: defaultVisible || false,
onChange: onVisibleChange,
})
const inputRef = useRef<HTMLInputElement>(null)
return ( return (
<Field <div className="relative">
invalid={!!errors[type]} <input
errorText={errors[type]?.message} type={showPassword ? "text" : "password"}
alignSelf="start" data-slot="input"
> className={cn(
<InputGroup "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 pr-10 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
width="100%" "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
startElement={startElement} "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
endElement={ className
<VisibilityTrigger )}
disabled={rest.disabled}
onPointerDown={(e) => {
if (rest.disabled) return
if (e.button !== 0) return
e.preventDefault()
setVisible(!visible)
}}
>
{visible ? visibilityIcon.off : visibilityIcon.on}
</VisibilityTrigger>
}
{...rootProps}
>
<Input
{...rest}
ref={mergeRefs(ref, inputRef)}
type={visible ? "text" : "password"}
/>
</InputGroup>
</Field>
)
},
)
const VisibilityTrigger = forwardRef<HTMLButtonElement, ButtonProps>(
function VisibilityTrigger(props, ref) {
return (
<IconButton
tabIndex={-1}
ref={ref} ref={ref}
me="-2" aria-invalid={!!error}
aspectRatio="square"
size="sm"
variant="ghost"
height="calc(100% - {spacing.2})"
aria-label="Toggle password visibility"
color="inherit"
{...props} {...props}
/> />
<Button
type="button"
variant="ghost"
size="icon-sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
) )
}, }
) )
interface PasswordStrengthMeterProps extends StackProps { PasswordInput.displayName = "PasswordInput"
max?: number
value: number
}
export const PasswordStrengthMeter = forwardRef< export { PasswordInput }
HTMLDivElement,
PasswordStrengthMeterProps
>(function PasswordStrengthMeter(props, ref) {
const { max = 4, value, ...rest } = props
const percent = (value / max) * 100
const { label, colorPalette } = getColorPalette(percent)
return (
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
<HStack width="full" ref={ref} {...rest}>
{Array.from({ length: max }).map((_, index) => (
<Box
key={index}
height="1"
flex="1"
rounded="sm"
data-selected={index < value ? "" : undefined}
layerStyle="fill.subtle"
colorPalette="gray"
_selected={{
colorPalette,
layerStyle: "fill.solid",
}}
/>
))}
</HStack>
{label && <HStack textStyle="xs">{label}</HStack>}
</Stack>
)
})
function getColorPalette(percent: number) {
switch (true) {
case percent < 33:
return { label: "Low", colorPalette: "red" }
case percent < 66:
return { label: "Medium", colorPalette: "orange" }
default:
return { label: "High", colorPalette: "green" }
}
}

View File

@@ -1,18 +0,0 @@
"use client"
import { ChakraProvider } from "@chakra-ui/react"
import { type PropsWithChildren } from "react"
import { system } from "../../theme"
import { ColorModeProvider } from "./color-mode"
import { Toaster } from "./toaster"
export function CustomProvider(props: PropsWithChildren) {
return (
<ChakraProvider value={system}>
<ColorModeProvider defaultTheme="light">
{props.children}
</ColorModeProvider>
<Toaster />
</ChakraProvider>
)
}

View File

@@ -1,24 +0,0 @@
import { RadioGroup as ChakraRadioGroup } from "@chakra-ui/react"
import * as React from "react"
export interface RadioProps extends ChakraRadioGroup.ItemProps {
rootRef?: React.Ref<HTMLDivElement>
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
}
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
function Radio(props, ref) {
const { children, inputProps, rootRef, ...rest } = props
return (
<ChakraRadioGroup.Item ref={rootRef} {...rest}>
<ChakraRadioGroup.ItemHiddenInput ref={ref} {...inputProps} />
<ChakraRadioGroup.ItemIndicator />
{children && (
<ChakraRadioGroup.ItemText>{children}</ChakraRadioGroup.ItemText>
)}
</ChakraRadioGroup.Item>
)
},
)
export const RadioGroup = ChakraRadioGroup.Root

View File

@@ -0,0 +1,185 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,737 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/useMobile"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const getInitialOpen = () => {
if (typeof document === "undefined") return defaultOpen
const cookie = document.cookie
.split("; ")
.find((c) => c.startsWith(`${SIDEBAR_COOKIE_NAME}=`))
if (!cookie) return defaultOpen
return cookie.split("=")[1] === "true"
}
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(getInitialOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open],
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar, open } = useSidebar()
const sidebarCopy = open ? "Collapse Sidebar" : "Open Sidebar"
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">{sidebarCopy}</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-transparent relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2 pb-3", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-3 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col px-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 data-[state=open]:bg-sidebar-accent",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm rounded-lg",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0! rounded-xl data-[state=closed]:rounded-lg",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -1,47 +1,13 @@
import type { import { cn } from "@/lib/utils"
SkeletonProps as ChakraSkeletonProps,
CircleProps,
} from "@chakra-ui/react"
import { Skeleton as ChakraSkeleton, Circle, Stack } from "@chakra-ui/react"
import * as React from "react"
export interface SkeletonCircleProps extends ChakraSkeletonProps { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
size?: CircleProps["size"]
}
export const SkeletonCircle = React.forwardRef<
HTMLDivElement,
SkeletonCircleProps
>(function SkeletonCircle(props, ref) {
const { size, ...rest } = props
return ( return (
<Circle size={size} asChild ref={ref}> <div
<ChakraSkeleton {...rest} /> data-slot="skeleton"
</Circle> className={cn("bg-accent animate-pulse rounded-md", className)}
)
})
export interface SkeletonTextProps extends ChakraSkeletonProps {
noOfLines?: number
}
export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(
function SkeletonText(props, ref) {
const { noOfLines = 3, gap, ...rest } = props
return (
<Stack gap={gap} width="full" ref={ref}>
{Array.from({ length: noOfLines }).map((_, index) => (
<ChakraSkeleton
height="4"
key={index}
{...props} {...props}
_last={{ maxW: "80%" }}
{...rest}
/> />
))}
</Stack>
)
},
) )
}
export const Skeleton = ChakraSkeleton export { Skeleton }

View File

@@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto rounded-lg border"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("bg-muted/50 [&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-muted-foreground h-11 px-4 text-left align-middle text-xs font-semibold uppercase tracking-wider whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"px-4 py-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -1,43 +0,0 @@
"use client"
import {
Toaster as ChakraToaster,
Portal,
Spinner,
Stack,
Toast,
createToaster,
} from "@chakra-ui/react"
export const toaster = createToaster({
placement: "top-end",
pauseOnPageIdle: true,
})
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
{(toast) => (
<Toast.Root width={{ md: "sm" }} color={toast.meta?.color}>
{toast.type === "loading" ? (
<Spinner size="sm" color="blue.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}
</Stack>
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.meta?.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
)
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,25 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { useState } from "react"
import { import {
type Body_login_login_access_token as AccessToken, type Body_login_login_access_token as AccessToken,
type ApiError,
LoginService, LoginService,
type UserPublic, type UserPublic,
type UserRegister, type UserRegister,
UsersService, UsersService,
} from "@/client" } from "@/client"
import { handleError } from "@/utils" import { handleError } from "@/utils"
import useCustomToast from "./useCustomToast"
const isLoggedIn = () => { const isLoggedIn = () => {
return localStorage.getItem("access_token") !== null return localStorage.getItem("access_token") !== null
} }
const useAuth = () => { const useAuth = () => {
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const { data: user } = useQuery<UserPublic | null, Error>({ const { data: user } = useQuery<UserPublic | null, Error>({
queryKey: ["currentUser"], queryKey: ["currentUser"],
queryFn: UsersService.readUserMe, queryFn: UsersService.readUserMe,
@@ -29,13 +29,10 @@ const useAuth = () => {
const signUpMutation = useMutation({ const signUpMutation = useMutation({
mutationFn: (data: UserRegister) => mutationFn: (data: UserRegister) =>
UsersService.registerUser({ requestBody: data }), UsersService.registerUser({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
navigate({ to: "/login" }) navigate({ to: "/login" })
}, },
onError: (err: ApiError) => { onError: handleError.bind(showErrorToast),
handleError(err)
},
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }) queryClient.invalidateQueries({ queryKey: ["users"] })
}, },
@@ -53,9 +50,7 @@ const useAuth = () => {
onSuccess: () => { onSuccess: () => {
navigate({ to: "/" }) navigate({ to: "/" })
}, },
onError: (err: ApiError) => { onError: handleError.bind(showErrorToast),
handleError(err)
},
}) })
const logout = () => { const logout = () => {
@@ -68,8 +63,6 @@ const useAuth = () => {
loginMutation, loginMutation,
logout, logout,
user, user,
error,
resetError: () => setError(null),
} }
} }

View File

@@ -0,0 +1,32 @@
// source: https://usehooks-ts.com/react-hook/use-copy-to-clipboard
import { useCallback, useState } from "react"
type CopiedValue = string | null
type CopyFn = (text: string) => Promise<boolean>
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
const [copiedText, setCopiedText] = useState<CopiedValue>(null)
const copy: CopyFn = useCallback(async (text) => {
if (!navigator?.clipboard) {
console.warn("Clipboard not supported")
return false
}
try {
await navigator.clipboard.writeText(text)
setCopiedText(text)
setTimeout(() => setCopiedText(null), 2000)
return true
} catch (error) {
console.warn("Copy failed", error)
setCopiedText(null)
return false
}
}, [])
return [copiedText, copy]
}

View File

@@ -1,21 +1,15 @@
"use client" import { toast } from "sonner"
import { toaster } from "@/components/ui/toaster"
const useCustomToast = () => { const useCustomToast = () => {
const showSuccessToast = (description: string) => { const showSuccessToast = (description: string) => {
toaster.create({ toast.success("Success!", {
title: "Success!",
description, description,
type: "success",
}) })
} }
const showErrorToast = (description: string) => { const showErrorToast = (description: string) => {
toaster.create({ toast.error("Something went wrong!", {
title: "Something went wrong!",
description, description,
type: "error",
}) })
} }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

124
frontend/src/index.css Normal file
View File

@@ -0,0 +1,124 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.5982 0.10687 182.4689);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.5982 0.10687 182.4689);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.65 0.10687 182.4689);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.65 0.10687 182.4689);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
button,
[role="button"] {
cursor: pointer;
}
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -8,7 +8,9 @@ import { createRouter, RouterProvider } from "@tanstack/react-router"
import { StrictMode } from "react" import { StrictMode } from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { ApiError, OpenAPI } from "./client" import { ApiError, OpenAPI } from "./client"
import { CustomProvider } from "./components/ui/provider" import { ThemeProvider } from "./components/theme-provider"
import { Toaster } from "./components/ui/sonner"
import "./index.css"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
OpenAPI.BASE = import.meta.env.VITE_API_URL OpenAPI.BASE = import.meta.env.VITE_API_URL
@@ -40,10 +42,11 @@ declare module "@tanstack/react-router" {
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<CustomProvider> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster richColors closeButton />
</QueryClientProvider> </QueryClientProvider>
</CustomProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
) )

View File

@@ -1,34 +1,17 @@
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { createRootRoute, Outlet } from "@tanstack/react-router" import { createRootRoute, Outlet } from "@tanstack/react-router"
import React, { Suspense } from "react" import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import ErrorComponent from "@/components/Common/ErrorComponent"
import NotFound from "@/components/Common/NotFound" import NotFound from "@/components/Common/NotFound"
const loadDevtools = () =>
Promise.all([
import("@tanstack/router-devtools"),
import("@tanstack/react-query-devtools"),
]).then(([routerDevtools, reactQueryDevtools]) => {
return {
default: () => (
<>
<routerDevtools.TanStackRouterDevtools />
<reactQueryDevtools.ReactQueryDevtools />
</>
),
}
})
const TanStackDevtools =
process.env.NODE_ENV === "production" ? () => null : React.lazy(loadDevtools)
export const Route = createRootRoute({ export const Route = createRootRoute({
component: () => ( component: () => (
<> <>
<Outlet /> <Outlet />
<Suspense> <TanStackRouterDevtools position="bottom-right" />
<TanStackDevtools /> <ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</> </>
), ),
notFoundComponent: () => <NotFound />, notFoundComponent: () => <NotFound />,
errorComponent: () => <ErrorComponent />,
}) })

View File

@@ -1,8 +1,12 @@
import { Flex } from "@chakra-ui/react"
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
import Navbar from "@/components/Common/Navbar" import { Footer } from "@/components/Common/Footer"
import Sidebar from "@/components/Common/Sidebar" import AppSidebar from "@/components/Sidebar/AppSidebar"
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
import { isLoggedIn } from "@/hooks/useAuth" import { isLoggedIn } from "@/hooks/useAuth"
export const Route = createFileRoute("/_layout")({ export const Route = createFileRoute("/_layout")({
@@ -18,15 +22,20 @@ export const Route = createFileRoute("/_layout")({
function Layout() { function Layout() {
return ( return (
<Flex direction="column" h="100vh"> <SidebarProvider>
<Navbar /> <AppSidebar />
<Flex flex="1" overflow="hidden"> <SidebarInset>
<Sidebar /> <header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
<Flex flex="1" direction="column" p={4} overflowY="auto"> <SidebarTrigger className="-ml-1 text-muted-foreground" />
</header>
<main className="flex-1 p-6 md:p-8">
<div className="mx-auto max-w-7xl">
<Outlet /> <Outlet />
</Flex> </div>
</Flex> </main>
</Flex> <Footer />
</SidebarInset>
</SidebarProvider>
) )
} }

View File

@@ -1,129 +1,58 @@
import { Badge, Container, Flex, Heading, Table } from "@chakra-ui/react" import { useSuspenseQuery } from "@tanstack/react-query"
import { useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router"
import { createFileRoute, useNavigate } from "@tanstack/react-router" import { Suspense } from "react"
import { z } from "zod"
import { type UserPublic, UsersService } from "@/client" import { type UserPublic, UsersService } from "@/client"
import AddUser from "@/components/Admin/AddUser" import AddUser from "@/components/Admin/AddUser"
import { UserActionsMenu } from "@/components/Common/UserActionsMenu" import { columns, type UserTableData } from "@/components/Admin/columns"
import { DataTable } from "@/components/Common/DataTable"
import PendingUsers from "@/components/Pending/PendingUsers" import PendingUsers from "@/components/Pending/PendingUsers"
import { import useAuth from "@/hooks/useAuth"
PaginationItems,
PaginationNextTrigger,
PaginationPrevTrigger,
PaginationRoot,
} from "@/components/ui/pagination.tsx"
const usersSearchSchema = z.object({ function getUsersQueryOptions() {
page: z.number().catch(1),
})
const PER_PAGE = 5
function getUsersQueryOptions({ page }: { page: number }) {
return { return {
queryFn: () => queryFn: () => UsersService.readUsers({ skip: 0, limit: 100 }),
UsersService.readUsers({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }), queryKey: ["users"],
queryKey: ["users", { page }],
} }
} }
export const Route = createFileRoute("/_layout/admin")({ export const Route = createFileRoute("/_layout/admin")({
component: Admin, component: Admin,
validateSearch: (search) => usersSearchSchema.parse(search),
}) })
function UsersTableContent() {
const { user: currentUser } = useAuth()
const { data: users } = useSuspenseQuery(getUsersQueryOptions())
const tableData: UserTableData[] = users.data.map((user: UserPublic) => ({
...user,
isCurrentUser: currentUser?.id === user.id,
}))
return <DataTable columns={columns} data={tableData} />
}
function UsersTable() { function UsersTable() {
const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const navigate = useNavigate({ from: Route.fullPath })
const { page } = Route.useSearch()
const { data, isLoading, isPlaceholderData } = useQuery({
...getUsersQueryOptions({ page }),
placeholderData: (prevData) => prevData,
})
const setPage = (page: number) => {
navigate({
to: "/admin",
search: (prev) => ({ ...prev, page }),
})
}
const users = data?.data.slice(0, PER_PAGE) ?? []
const count = data?.count ?? 0
if (isLoading) {
return <PendingUsers />
}
return ( return (
<> <Suspense fallback={<PendingUsers />}>
<Table.Root size={{ base: "sm", md: "md" }}> <UsersTableContent />
<Table.Header> </Suspense>
<Table.Row>
<Table.ColumnHeader w="sm">Full name</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Email</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Role</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Status</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{users?.map((user) => (
<Table.Row key={user.id} opacity={isPlaceholderData ? 0.5 : 1}>
<Table.Cell color={!user.full_name ? "gray" : "inherit"}>
{user.full_name || "N/A"}
{currentUser?.id === user.id && (
<Badge ml="1" colorScheme="teal">
You
</Badge>
)}
</Table.Cell>
<Table.Cell truncate maxW="sm">
{user.email}
</Table.Cell>
<Table.Cell>
{user.is_superuser ? "Superuser" : "User"}
</Table.Cell>
<Table.Cell>{user.is_active ? "Active" : "Inactive"}</Table.Cell>
<Table.Cell>
<UserActionsMenu
user={user}
disabled={currentUser?.id === user.id}
/>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
<Flex justifyContent="flex-end" mt={4}>
<PaginationRoot
count={count}
pageSize={PER_PAGE}
onPageChange={({ page }) => setPage(page)}
>
<Flex>
<PaginationPrevTrigger />
<PaginationItems />
<PaginationNextTrigger />
</Flex>
</PaginationRoot>
</Flex>
</>
) )
} }
function Admin() { function Admin() {
return ( return (
<Container maxW="full"> <div className="flex flex-col gap-6">
<Heading size="lg" pt={12}> <div className="flex items-center justify-between">
Users Management <div>
</Heading> <h1 className="text-2xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
<AddUser /> <AddUser />
</div>
<UsersTable /> <UsersTable />
</Container> </div>
) )
} }

View File

@@ -1,4 +1,3 @@
import { Box, Container, Text } from "@chakra-ui/react"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
@@ -11,13 +10,15 @@ function Dashboard() {
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
return ( return (
<Container maxW="full"> <div>
<Box pt={12} m={4}> <div>
<Text fontSize="2xl" truncate maxW="sm"> <h1 className="text-2xl truncate max-w-sm">
Hi, {currentUser?.full_name || currentUser?.email} 👋🏼 Hi, {currentUser?.full_name || currentUser?.email} 👋
</Text> </h1>
<Text>Welcome back, nice to see you again!</Text> <p className="text-muted-foreground">
</Box> Welcome back, nice to see you again!
</Container> </p>
</div>
</div>
) )
} }

View File

@@ -1,146 +1,62 @@
import { import { useSuspenseQuery } from "@tanstack/react-query"
Container, import { createFileRoute } from "@tanstack/react-router"
EmptyState, import { Search } from "lucide-react"
Flex, import { Suspense } from "react"
Heading,
Table,
VStack,
} from "@chakra-ui/react"
import { useQuery } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { FiSearch } from "react-icons/fi"
import { z } from "zod"
import { ItemsService } from "@/client" import { ItemsService } from "@/client"
import { ItemActionsMenu } from "@/components/Common/ItemActionsMenu" import { DataTable } from "@/components/Common/DataTable"
import AddItem from "@/components/Items/AddItem" import AddItem from "@/components/Items/AddItem"
import { columns } from "@/components/Items/columns"
import PendingItems from "@/components/Pending/PendingItems" import PendingItems from "@/components/Pending/PendingItems"
import {
PaginationItems,
PaginationNextTrigger,
PaginationPrevTrigger,
PaginationRoot,
} from "@/components/ui/pagination.tsx"
const itemsSearchSchema = z.object({ function getItemsQueryOptions() {
page: z.number().catch(1),
})
const PER_PAGE = 5
function getItemsQueryOptions({ page }: { page: number }) {
return { return {
queryFn: () => queryFn: () => ItemsService.readItems({ skip: 0, limit: 100 }),
ItemsService.readItems({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }), queryKey: ["items"],
queryKey: ["items", { page }],
} }
} }
export const Route = createFileRoute("/_layout/items")({ export const Route = createFileRoute("/_layout/items")({
component: Items, component: Items,
validateSearch: (search) => itemsSearchSchema.parse(search),
}) })
function ItemsTable() { function ItemsTableContent() {
const navigate = useNavigate({ from: Route.fullPath }) const { data: items } = useSuspenseQuery(getItemsQueryOptions())
const { page } = Route.useSearch()
const { data, isLoading, isPlaceholderData } = useQuery({ if (items.data.length === 0) {
...getItemsQueryOptions({ page }),
placeholderData: (prevData) => prevData,
})
const setPage = (page: number) => {
navigate({
to: "/items",
search: (prev) => ({ ...prev, page }),
})
}
const items = data?.data.slice(0, PER_PAGE) ?? []
const count = data?.count ?? 0
if (isLoading) {
return <PendingItems />
}
if (items.length === 0) {
return ( return (
<EmptyState.Root> <div className="flex flex-col items-center justify-center text-center py-12">
<EmptyState.Content> <div className="rounded-full bg-muted p-4 mb-4">
<EmptyState.Indicator> <Search className="h-8 w-8 text-muted-foreground" />
<FiSearch /> </div>
</EmptyState.Indicator> <h3 className="text-lg font-semibold">You don't have any items yet</h3>
<VStack textAlign="center"> <p className="text-muted-foreground">Add a new item to get started</p>
<EmptyState.Title>You don't have any items yet</EmptyState.Title> </div>
<EmptyState.Description>
Add a new item to get started
</EmptyState.Description>
</VStack>
</EmptyState.Content>
</EmptyState.Root>
) )
} }
return <DataTable columns={columns} data={items.data} />
}
function ItemsTable() {
return ( return (
<> <Suspense fallback={<PendingItems />}>
<Table.Root size={{ base: "sm", md: "md" }}> <ItemsTableContent />
<Table.Header> </Suspense>
<Table.Row>
<Table.ColumnHeader w="sm">ID</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Title</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Description</Table.ColumnHeader>
<Table.ColumnHeader w="sm">Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items?.map((item) => (
<Table.Row key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
<Table.Cell truncate maxW="sm">
{item.id}
</Table.Cell>
<Table.Cell truncate maxW="sm">
{item.title}
</Table.Cell>
<Table.Cell
color={!item.description ? "gray" : "inherit"}
truncate
maxW="30%"
>
{item.description || "N/A"}
</Table.Cell>
<Table.Cell>
<ItemActionsMenu item={item} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
<Flex justifyContent="flex-end" mt={4}>
<PaginationRoot
count={count}
pageSize={PER_PAGE}
onPageChange={({ page }) => setPage(page)}
>
<Flex>
<PaginationPrevTrigger />
<PaginationItems />
<PaginationNextTrigger />
</Flex>
</PaginationRoot>
</Flex>
</>
) )
} }
function Items() { function Items() {
return ( return (
<Container maxW="full"> <div className="flex flex-col gap-6">
<Heading size="lg" pt={12}> <div className="flex items-center justify-between">
Items Management <div>
</Heading> <h1 className="text-2xl font-bold tracking-tight">Items</h1>
<p className="text-muted-foreground">Create and manage your items</p>
</div>
<AddItem /> <AddItem />
</div>
<ItemsTable /> <ItemsTable />
</Container> </div>
) )
} }

View File

@@ -1,16 +1,14 @@
import { Container, Heading, Tabs } from "@chakra-ui/react"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import Appearance from "@/components/UserSettings/Appearance"
import ChangePassword from "@/components/UserSettings/ChangePassword" import ChangePassword from "@/components/UserSettings/ChangePassword"
import DeleteAccount from "@/components/UserSettings/DeleteAccount" import DeleteAccount from "@/components/UserSettings/DeleteAccount"
import UserInformation from "@/components/UserSettings/UserInformation" import UserInformation from "@/components/UserSettings/UserInformation"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
const tabsConfig = [ const tabsConfig = [
{ value: "my-profile", title: "My profile", component: UserInformation }, { value: "my-profile", title: "My profile", component: UserInformation },
{ value: "password", title: "Password", component: ChangePassword }, { value: "password", title: "Password", component: ChangePassword },
{ value: "appearance", title: "Appearance", component: Appearance },
{ value: "danger-zone", title: "Danger zone", component: DeleteAccount }, { value: "danger-zone", title: "Danger zone", component: DeleteAccount },
] ]
@@ -29,25 +27,28 @@ function UserSettings() {
} }
return ( return (
<Container maxW="full"> <div className="flex flex-col gap-6">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}> <div>
User Settings <h1 className="text-2xl font-bold tracking-tight">User Settings</h1>
</Heading> <p className="text-muted-foreground">
Manage your account settings and preferences
</p>
</div>
<Tabs.Root defaultValue="my-profile" variant="subtle"> <Tabs defaultValue="my-profile">
<Tabs.List> <TabsList>
{finalTabs.map((tab) => ( {finalTabs.map((tab) => (
<Tabs.Trigger key={tab.value} value={tab.value}> <TabsTrigger key={tab.value} value={tab.value}>
{tab.title} {tab.title}
</Tabs.Trigger> </TabsTrigger>
))} ))}
</Tabs.List> </TabsList>
{finalTabs.map((tab) => ( {finalTabs.map((tab) => (
<Tabs.Content key={tab.value} value={tab.value}> <TabsContent key={tab.value} value={tab.value}>
<tab.component /> <tab.component />
</Tabs.Content> </TabsContent>
))} ))}
</Tabs.Root> </Tabs>
</Container> </div>
) )
} }

View File

@@ -1,20 +1,36 @@
import { Container, Image, Input, Text } from "@chakra-ui/react" import { zodResolver } from "@hookform/resolvers/zod"
import { import {
createFileRoute, createFileRoute,
Link as RouterLink, Link as RouterLink,
redirect, redirect,
} from "@tanstack/react-router" } from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { FiLock, FiMail } from "react-icons/fi" import { z } from "zod"
import type { Body_login_login_access_token as AccessToken } from "@/client" import type { Body_login_login_access_token as AccessToken } from "@/client"
import { Button } from "@/components/ui/button" import { AuthLayout } from "@/components/Common/AuthLayout"
import { Field } from "@/components/ui/field" import {
import { InputGroup } from "@/components/ui/input-group" Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import { PasswordInput } from "@/components/ui/password-input" import { PasswordInput } from "@/components/ui/password-input"
import useAuth, { isLoggedIn } from "@/hooks/useAuth" import useAuth, { isLoggedIn } from "@/hooks/useAuth"
import Logo from "/assets/images/fastapi-logo.svg"
import { emailPattern, passwordRules } from "../utils" const formSchema = z.object({
username: z.email(),
password: z
.string()
.min(1, { message: "Password is required" })
.min(8, { message: "Password must be at least 8 characters" }),
}) satisfies z.ZodType<AccessToken>
type FormData = z.infer<typeof formSchema>
export const Route = createFileRoute("/login")({ export const Route = createFileRoute("/login")({
component: Login, component: Login,
@@ -28,12 +44,9 @@ export const Route = createFileRoute("/login")({
}) })
function Login() { function Login() {
const { loginMutation, error, resetError } = useAuth() const { loginMutation } = useAuth()
const { const form = useForm<FormData>({
register, resolver: zodResolver(formSchema),
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<AccessToken>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: { defaultValues: {
@@ -42,71 +55,82 @@ function Login() {
}, },
}) })
const onSubmit: SubmitHandler<AccessToken> = async (data) => { const onSubmit = (data: FormData) => {
if (isSubmitting) return if (loginMutation.isPending) return
loginMutation.mutate(data)
resetError()
try {
await loginMutation.mutateAsync(data)
} catch {
// error is handled by useAuth hook
}
} }
return ( return (
<Container <AuthLayout>
as="form" <Form {...form}>
onSubmit={handleSubmit(onSubmit)} <form
h="100vh" onSubmit={form.handleSubmit(onSubmit)}
maxW="sm" className="flex flex-col gap-6"
alignItems="stretch"
justifyContent="center"
gap={4}
centerContent
> >
<Image <div className="flex flex-col items-center gap-2 text-center">
src={Logo} <h1 className="text-2xl font-bold">Login to your account</h1>
alt="FastAPI logo" </div>
height="auto"
maxW="2xs" <div className="grid gap-4">
alignSelf="center" <FormField
mb={4} control={form.control}
/> name="username"
<Field render={({ field }) => (
invalid={!!errors.username} <FormItem>
errorText={errors.username?.message || !!error} <FormLabel>Email</FormLabel>
> <FormControl>
<InputGroup w="100%" startElement={<FiMail />}>
<Input <Input
{...register("username", { data-testid="email-input"
required: "Username is required", placeholder="user@example.com"
pattern: emailPattern,
})}
placeholder="Email"
type="email" type="email"
{...field}
/> />
</InputGroup> </FormControl>
</Field> <FormMessage className="text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormLabel>Password</FormLabel>
<RouterLink
to="/recover-password"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</RouterLink>
</div>
<FormControl>
<PasswordInput <PasswordInput
type="password" data-testid="password-input"
startElement={<FiLock />}
{...register("password", passwordRules())}
placeholder="Password" placeholder="Password"
errors={errors} {...field}
/> />
<RouterLink to="/recover-password" className="main-link"> </FormControl>
Forgot Password? <FormMessage className="text-xs" />
</RouterLink> </FormItem>
<Button variant="solid" type="submit" loading={isSubmitting} size="md"> )}
/>
<LoadingButton type="submit" loading={loginMutation.isPending}>
Log In Log In
</Button> </LoadingButton>
<Text> </div>
Don't have an account?{" "}
<RouterLink to="/signup" className="main-link"> <div className="text-center text-sm">
Sign Up Don't have an account yet?{" "}
<RouterLink to="/signup" className="underline underline-offset-4">
Sign up
</RouterLink> </RouterLink>
</Text> </div>
</Container> </form>
</Form>
</AuthLayout>
) )
} }

View File

@@ -1,20 +1,34 @@
import { Container, Heading, Input, Text } from "@chakra-ui/react" import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router" import {
import { type SubmitHandler, useForm } from "react-hook-form" createFileRoute,
import { FiMail } from "react-icons/fi" Link as RouterLink,
redirect,
} from "@tanstack/react-router"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type ApiError, LoginService } from "@/client" import { LoginService } from "@/client"
import { Button } from "@/components/ui/button" import { AuthLayout } from "@/components/Common/AuthLayout"
import { Field } from "@/components/ui/field" import {
import { InputGroup } from "@/components/ui/input-group" Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import { isLoggedIn } from "@/hooks/useAuth" import { isLoggedIn } from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { emailPattern, handleError } from "@/utils" import { handleError } from "@/utils"
interface FormData { const formSchema = z.object({
email: string email: z.email(),
} })
type FormData = z.infer<typeof formSchema>
export const Route = createFileRoute("/recover-password")({ export const Route = createFileRoute("/recover-password")({
component: RecoverPassword, component: RecoverPassword,
@@ -28,13 +42,13 @@ export const Route = createFileRoute("/recover-password")({
}) })
function RecoverPassword() { function RecoverPassword() {
const { const form = useForm<FormData>({
register, resolver: zodResolver(formSchema),
handleSubmit, defaultValues: {
reset, email: "",
formState: { errors, isSubmitting }, },
} = useForm<FormData>() })
const { showSuccessToast } = useCustomToast() const { showSuccessToast, showErrorToast } = useCustomToast()
const recoverPassword = async (data: FormData) => { const recoverPassword = async (data: FormData) => {
await LoginService.recoverPassword({ await LoginService.recoverPassword({
@@ -45,50 +59,65 @@ function RecoverPassword() {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: recoverPassword, mutationFn: recoverPassword,
onSuccess: () => { onSuccess: () => {
showSuccessToast("Password recovery email sent successfully.") showSuccessToast("Password recovery email sent successfully")
reset() form.reset()
},
onError: (err: ApiError) => {
handleError(err)
}, },
onError: handleError.bind(showErrorToast),
}) })
const onSubmit: SubmitHandler<FormData> = async (data) => { const onSubmit = async (data: FormData) => {
if (mutation.isPending) return
mutation.mutate(data) mutation.mutate(data)
} }
return ( return (
<Container <AuthLayout>
as="form" <Form {...form}>
onSubmit={handleSubmit(onSubmit)} <form
h="100vh" onSubmit={form.handleSubmit(onSubmit)}
maxW="sm" className="flex flex-col gap-6"
alignItems="stretch"
justifyContent="center"
gap={4}
centerContent
> >
<Heading size="xl" color="ui.main" textAlign="center" mb={2}> <div className="flex flex-col items-center gap-2 text-center">
Password Recovery <h1 className="text-2xl font-bold">Password Recovery</h1>
</Heading> </div>
<Text textAlign="center">
A password recovery email will be sent to the registered account. <div className="grid gap-4">
</Text> <FormField
<Field invalid={!!errors.email} errorText={errors.email?.message}> control={form.control}
<InputGroup w="100%" startElement={<FiMail />}> name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input <Input
{...register("email", { data-testid="email-input"
required: "Email is required", placeholder="user@example.com"
pattern: emailPattern,
})}
placeholder="Email"
type="email" type="email"
{...field}
/> />
</InputGroup> </FormControl>
</Field> <FormMessage />
<Button variant="solid" type="submit" loading={isSubmitting}> </FormItem>
)}
/>
<LoadingButton
type="submit"
className="w-full"
loading={mutation.isPending}
>
Continue Continue
</Button> </LoadingButton>
</Container> </div>
<div className="text-center text-sm">
Remember your password?{" "}
<RouterLink to="/login" className="underline underline-offset-4">
Log in
</RouterLink>
</div>
</form>
</Form>
</AuthLayout>
) )
} }

View File

@@ -1,106 +1,159 @@
import { Container, Heading, Text } from "@chakra-ui/react" import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" import {
import { type SubmitHandler, useForm } from "react-hook-form" createFileRoute,
import { FiLock } from "react-icons/fi" Link as RouterLink,
redirect,
useNavigate,
} from "@tanstack/react-router"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type ApiError, LoginService, type NewPassword } from "@/client" import { LoginService } from "@/client"
import { Button } from "@/components/ui/button" import { AuthLayout } from "@/components/Common/AuthLayout"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { LoadingButton } from "@/components/ui/loading-button"
import { PasswordInput } from "@/components/ui/password-input" import { PasswordInput } from "@/components/ui/password-input"
import { isLoggedIn } from "@/hooks/useAuth" import { isLoggedIn } from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast" import useCustomToast from "@/hooks/useCustomToast"
import { confirmPasswordRules, handleError, passwordRules } from "@/utils" import { handleError } from "@/utils"
interface NewPasswordForm extends NewPassword { const searchSchema = z.object({
confirm_password: string token: z.string().catch(""),
} })
const formSchema = z
.object({
new_password: z
.string()
.min(1, { message: "Password is required" })
.min(8, { message: "Password must be at least 8 characters" }),
confirm_password: z
.string()
.min(1, { message: "Password confirmation is required" }),
})
.refine((data) => data.new_password === data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
})
type FormData = z.infer<typeof formSchema>
export const Route = createFileRoute("/reset-password")({ export const Route = createFileRoute("/reset-password")({
component: ResetPassword, component: ResetPassword,
beforeLoad: async () => { validateSearch: searchSchema,
beforeLoad: async ({ search }) => {
if (isLoggedIn()) { if (isLoggedIn()) {
throw redirect({ throw redirect({ to: "/" })
to: "/", }
}) if (!search.token) {
throw redirect({ to: "/login" })
} }
}, },
}) })
function ResetPassword() { function ResetPassword() {
const { const { token } = Route.useSearch()
register, const { showSuccessToast, showErrorToast } = useCustomToast()
handleSubmit, const navigate = useNavigate()
getValues,
reset, const form = useForm<FormData>({
formState: { errors }, resolver: zodResolver(formSchema),
} = useForm<NewPasswordForm>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: { defaultValues: {
new_password: "", new_password: "",
confirm_password: "",
}, },
}) })
const { showSuccessToast } = useCustomToast()
const navigate = useNavigate()
const resetPassword = async (data: NewPassword) => {
const token = new URLSearchParams(window.location.search).get("token")
if (!token) return
await LoginService.resetPassword({
requestBody: { new_password: data.new_password, token: token },
})
}
const mutation = useMutation({ const mutation = useMutation({
mutationFn: resetPassword, mutationFn: (data: { new_password: string; token: string }) =>
LoginService.resetPassword({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showSuccessToast("Password updated successfully.") showSuccessToast("Password updated successfully")
reset() form.reset()
navigate({ to: "/login" }) navigate({ to: "/login" })
}, },
onError: (err: ApiError) => { onError: handleError.bind(showErrorToast),
handleError(err)
},
}) })
const onSubmit: SubmitHandler<NewPasswordForm> = async (data) => { const onSubmit = (data: FormData) => {
mutation.mutate(data) mutation.mutate({ new_password: data.new_password, token })
} }
return ( return (
<Container <AuthLayout>
as="form" <Form {...form}>
onSubmit={handleSubmit(onSubmit)} <form
h="100vh" onSubmit={form.handleSubmit(onSubmit)}
maxW="sm" className="flex flex-col gap-6"
alignItems="stretch"
justifyContent="center"
gap={4}
centerContent
> >
<Heading size="xl" color="ui.main" textAlign="center" mb={2}> <div className="flex flex-col items-center gap-2 text-center">
Reset Password <h1 className="text-2xl font-bold">Reset Password</h1>
</Heading> </div>
<Text textAlign="center">
Please enter your new password and confirm it to reset your password. <div className="grid gap-4">
</Text> <FormField
control={form.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<PasswordInput <PasswordInput
startElement={<FiLock />} data-testid="new-password-input"
type="new_password"
errors={errors}
{...register("new_password", passwordRules())}
placeholder="New Password" placeholder="New Password"
{...field}
/> />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput <PasswordInput
startElement={<FiLock />} data-testid="confirm-password-input"
type="confirm_password"
errors={errors}
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Confirm Password" placeholder="Confirm Password"
{...field}
/> />
<Button variant="solid" type="submit"> </FormControl>
<FormMessage />
</FormItem>
)}
/>
<LoadingButton
type="submit"
className="w-full"
loading={mutation.isPending}
>
Reset Password Reset Password
</Button> </LoadingButton>
</Container> </div>
<div className="text-center text-sm">
Remember your password?{" "}
<RouterLink to="/login" className="underline underline-offset-4">
Log in
</RouterLink>
</div>
</form>
</Form>
</AuthLayout>
) )
} }

View File

@@ -1,20 +1,43 @@
import { Container, Flex, Image, Input, Text } from "@chakra-ui/react" import { zodResolver } from "@hookform/resolvers/zod"
import { import {
createFileRoute, createFileRoute,
Link as RouterLink, Link as RouterLink,
redirect, redirect,
} from "@tanstack/react-router" } from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { FiLock, FiUser } from "react-icons/fi" import { z } from "zod"
import { AuthLayout } from "@/components/Common/AuthLayout"
import type { UserRegister } from "@/client" import {
import { Button } from "@/components/ui/button" Form,
import { Field } from "@/components/ui/field" FormControl,
import { InputGroup } from "@/components/ui/input-group" FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import { PasswordInput } from "@/components/ui/password-input" import { PasswordInput } from "@/components/ui/password-input"
import useAuth, { isLoggedIn } from "@/hooks/useAuth" import useAuth, { isLoggedIn } from "@/hooks/useAuth"
import { confirmPasswordRules, emailPattern, passwordRules } from "@/utils"
import Logo from "/assets/images/fastapi-logo.svg" const formSchema = z
.object({
email: z.email(),
full_name: z.string().min(1, { message: "Full Name is required" }),
password: z
.string()
.min(1, { message: "Password is required" })
.min(8, { message: "Password must be at least 8 characters" }),
confirm_password: z
.string()
.min(1, { message: "Password confirmation is required" }),
})
.refine((data) => data.password === data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
})
type FormData = z.infer<typeof formSchema>
export const Route = createFileRoute("/signup")({ export const Route = createFileRoute("/signup")({
component: SignUp, component: SignUp,
@@ -27,18 +50,10 @@ export const Route = createFileRoute("/signup")({
}, },
}) })
interface UserRegisterForm extends UserRegister {
confirm_password: string
}
function SignUp() { function SignUp() {
const { signUpMutation } = useAuth() const { signUpMutation } = useAuth()
const { const form = useForm<FormData>({
register, resolver: zodResolver(formSchema),
handleSubmit,
getValues,
formState: { errors, isSubmitting },
} = useForm<UserRegisterForm>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: { defaultValues: {
@@ -49,83 +64,118 @@ function SignUp() {
}, },
}) })
const onSubmit: SubmitHandler<UserRegisterForm> = (data) => { const onSubmit = (data: FormData) => {
signUpMutation.mutate(data) if (signUpMutation.isPending) return
// exclude confirm_password from submission data
const { confirm_password: _confirm_password, ...submitData } = data
signUpMutation.mutate(submitData)
} }
return ( return (
<Flex flexDir={{ base: "column", md: "row" }} justify="center" h="100vh"> <AuthLayout>
<Container <Form {...form}>
as="form" <form
onSubmit={handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
h="100vh" className="flex flex-col gap-6"
maxW="sm"
alignItems="stretch"
justifyContent="center"
gap={4}
centerContent
> >
<Image <div className="flex flex-col items-center gap-2 text-center">
src={Logo} <h1 className="text-2xl font-bold">Create an account</h1>
alt="FastAPI logo" </div>
height="auto"
maxW="2xs"
alignSelf="center"
mb={4}
/>
<Field
invalid={!!errors.full_name}
errorText={errors.full_name?.message}
>
<InputGroup w="100%" startElement={<FiUser />}>
<Input
minLength={3}
{...register("full_name", {
required: "Full Name is required",
})}
placeholder="Full Name"
type="text"
/>
</InputGroup>
</Field>
<Field invalid={!!errors.email} errorText={errors.email?.message}> <div className="grid gap-4">
<InputGroup w="100%" startElement={<FiUser />}> <FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input <Input
{...register("email", { data-testid="full-name-input"
required: "Email is required", placeholder="User"
pattern: emailPattern, type="text"
})} {...field}
placeholder="Email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
data-testid="email-input"
placeholder="user@example.com"
type="email" type="email"
{...field}
/> />
</InputGroup> </FormControl>
</Field> <FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput <PasswordInput
type="password" data-testid="password-input"
startElement={<FiLock />}
{...register("password", passwordRules())}
placeholder="Password" placeholder="Password"
errors={errors} {...field}
/> />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput <PasswordInput
type="confirm_password" data-testid="confirm-password-input"
startElement={<FiLock />}
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Confirm Password" placeholder="Confirm Password"
errors={errors} {...field}
/> />
<Button variant="solid" type="submit" loading={isSubmitting}> </FormControl>
<FormMessage />
</FormItem>
)}
/>
<LoadingButton
type="submit"
className="w-full"
loading={signUpMutation.isPending}
>
Sign Up Sign Up
</Button> </LoadingButton>
<Text> </div>
<div className="text-center text-sm">
Already have an account?{" "} Already have an account?{" "}
<RouterLink to="/login" className="main-link"> <RouterLink to="/login" className="underline underline-offset-4">
Log In Log in
</RouterLink> </RouterLink>
</Text> </div>
</Container> </form>
</Flex> </Form>
</AuthLayout>
) )
} }

View File

@@ -1,31 +0,0 @@
import { createSystem, defaultConfig } from "@chakra-ui/react"
import { buttonRecipe } from "./theme/button.recipe"
export const system = createSystem(defaultConfig, {
globalCss: {
html: {
fontSize: "16px",
},
body: {
fontSize: "0.875rem",
margin: 0,
padding: 0,
},
".main-link": {
color: "ui.main",
fontWeight: "bold",
},
},
theme: {
tokens: {
colors: {
ui: {
main: { value: "#009688" },
},
},
},
recipes: {
button: buttonRecipe,
},
},
})

View File

@@ -1,21 +0,0 @@
import { defineRecipe } from "@chakra-ui/react"
export const buttonRecipe = defineRecipe({
base: {
fontWeight: "bold",
display: "flex",
alignItems: "center",
justifyContent: "center",
colorPalette: "teal",
},
variants: {
variant: {
ghost: {
bg: "transparent",
_hover: {
bg: "gray.100",
},
},
},
},
})

View File

@@ -1,55 +1,31 @@
import { AxiosError } from "axios"
import type { ApiError } from "./client" import type { ApiError } from "./client"
import useCustomToast from "./hooks/useCustomToast"
export const emailPattern = { function extractErrorMessage(err: ApiError): string {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, if (err instanceof AxiosError) {
message: "Invalid email address", return err.message
} }
export const namePattern = {
value: /^[A-Za-z\s\u00C0-\u017F]{1,30}$/,
message: "Invalid name",
}
export const passwordRules = (isRequired = true) => {
const rules: any = {
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
}
if (isRequired) {
rules.required = "Password is required"
}
return rules
}
export const confirmPasswordRules = (
getValues: () => any,
isRequired = true,
) => {
const rules: any = {
validate: (value: string) => {
const password = getValues().password || getValues().new_password
return value === password ? true : "The passwords do not match"
},
}
if (isRequired) {
rules.required = "Password confirmation is required"
}
return rules
}
export const handleError = (err: ApiError) => {
const { showErrorToast } = useCustomToast()
const errDetail = (err.body as any)?.detail const errDetail = (err.body as any)?.detail
let errorMessage = errDetail || "Something went wrong."
if (Array.isArray(errDetail) && errDetail.length > 0) { if (Array.isArray(errDetail) && errDetail.length > 0) {
errorMessage = errDetail[0].msg return errDetail[0].msg
} }
showErrorToast(errorMessage) return errDetail || "Something went wrong."
}
export const handleError = function (
this: (msg: string) => void,
err: ApiError,
) {
const errorMessage = extractErrorMessage(err)
this(errorMessage)
}
export const getInitials = (name: string): string => {
return name
.split(" ")
.slice(0, 2)
.map((word) => word[0])
.join("")
.toUpperCase()
} }

View File

@@ -5,8 +5,8 @@ const authFile = "playwright/.auth/user.json"
setup("authenticate", async ({ page }) => { setup("authenticate", async ({ page }) => {
await page.goto("/login") await page.goto("/login")
await page.getByPlaceholder("Email").fill(firstSuperuser) await page.getByTestId("email-input").fill(firstSuperuser)
await page.getByPlaceholder("Password").fill(firstSuperuserPassword) await page.getByTestId("password-input").fill(firstSuperuserPassword)
await page.getByRole("button", { name: "Log In" }).click() await page.getByRole("button", { name: "Log In" }).click()
await page.waitForURL("/") await page.waitForURL("/")
await page.context().storageState({ path: authFile }) await page.context().storageState({ path: authFile })

View File

@@ -7,15 +7,13 @@ const __dirname = path.dirname(__filename)
dotenv.config({ path: path.join(__dirname, "../../.env") }) dotenv.config({ path: path.join(__dirname, "../../.env") })
const { FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD } = process.env function getEnvVar(name: string): string {
const value = process.env[name]
if (typeof FIRST_SUPERUSER !== "string") { if (!value) {
throw new Error("Environment variable FIRST_SUPERUSER is undefined") throw new Error(`Environment variable ${name} is undefined`)
}
return value
} }
if (typeof FIRST_SUPERUSER_PASSWORD !== "string") { export const firstSuperuser = getEnvVar("FIRST_SUPERUSER")
throw new Error("Environment variable FIRST_SUPERUSER_PASSWORD is undefined") export const firstSuperuserPassword = getEnvVar("FIRST_SUPERUSER_PASSWORD")
}
export const firstSuperuser = FIRST_SUPERUSER as string
export const firstSuperuserPassword = FIRST_SUPERUSER_PASSWORD as string

View File

@@ -4,21 +4,13 @@ import { randomPassword } from "./utils/random.ts"
test.use({ storageState: { cookies: [], origins: [] } }) test.use({ storageState: { cookies: [], origins: [] } })
type OptionsType = {
exact?: boolean
}
const fillForm = async (page: Page, email: string, password: string) => { const fillForm = async (page: Page, email: string, password: string) => {
await page.getByPlaceholder("Email").fill(email) await page.getByTestId("email-input").fill(email)
await page.getByPlaceholder("Password", { exact: true }).fill(password) await page.getByTestId("password-input").fill(password)
} }
const verifyInput = async ( const verifyInput = async (page: Page, testId: string) => {
page: Page, const input = page.getByTestId(testId)
placeholder: string,
options?: OptionsType,
) => {
const input = page.getByPlaceholder(placeholder, options)
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toHaveText("") await expect(input).toHaveText("")
await expect(input).toBeEditable() await expect(input).toBeEditable()
@@ -27,8 +19,8 @@ const verifyInput = async (
test("Inputs are visible, empty and editable", async ({ page }) => { test("Inputs are visible, empty and editable", async ({ page }) => {
await page.goto("/login") await page.goto("/login")
await verifyInput(page, "Email") await verifyInput(page, "email-input")
await verifyInput(page, "Password", { exact: true }) await verifyInput(page, "password-input")
}) })
test("Log In button is visible", async ({ page }) => { test("Log In button is visible", async ({ page }) => {
@@ -41,7 +33,7 @@ test("Forgot Password link is visible", async ({ page }) => {
await page.goto("/login") await page.goto("/login")
await expect( await expect(
page.getByRole("link", { name: "Forgot password?" }), page.getByRole("link", { name: "Forgot your password?" }),
).toBeVisible() ).toBeVisible()
}) })

View File

@@ -16,9 +16,9 @@ test("Password Recovery title is visible", async ({ page }) => {
test("Input is visible, empty and editable", async ({ page }) => { test("Input is visible, empty and editable", async ({ page }) => {
await page.goto("/recover-password") await page.goto("/recover-password")
await expect(page.getByPlaceholder("Email")).toBeVisible() await expect(page.getByTestId("email-input")).toBeVisible()
await expect(page.getByPlaceholder("Email")).toHaveText("") await expect(page.getByTestId("email-input")).toHaveText("")
await expect(page.getByPlaceholder("Email")).toBeEditable() await expect(page.getByTestId("email-input")).toBeEditable()
}) })
test("Continue button is visible", async ({ page }) => { test("Continue button is visible", async ({ page }) => {
@@ -40,7 +40,7 @@ test("User can reset password successfully using the link", async ({
await signUpNewUser(page, fullName, email, password) await signUpNewUser(page, fullName, email, password)
await page.goto("/recover-password") await page.goto("/recover-password")
await page.getByPlaceholder("Email").fill(email) await page.getByTestId("email-input").fill(email)
await page.getByRole("button", { name: "Continue" }).click() await page.getByRole("button", { name: "Continue" }).click()
@@ -64,8 +64,8 @@ test("User can reset password successfully using the link", async ({
// Set the new password and confirm it // Set the new password and confirm it
await page.goto(url) await page.goto(url)
await page.getByPlaceholder("New Password").fill(newPassword) await page.getByTestId("new-password-input").fill(newPassword)
await page.getByPlaceholder("Confirm Password").fill(newPassword) await page.getByTestId("confirm-password-input").fill(newPassword)
await page.getByRole("button", { name: "Reset Password" }).click() await page.getByRole("button", { name: "Reset Password" }).click()
await expect(page.getByText("Password updated successfully")).toBeVisible() await expect(page.getByText("Password updated successfully")).toBeVisible()
@@ -79,8 +79,8 @@ test("Expired or invalid reset link", async ({ page }) => {
await page.goto(invalidUrl) await page.goto(invalidUrl)
await page.getByPlaceholder("New Password").fill(password) await page.getByTestId("new-password-input").fill(password)
await page.getByPlaceholder("Confirm Password").fill(password) await page.getByTestId("confirm-password-input").fill(password)
await page.getByRole("button", { name: "Reset Password" }).click() await page.getByRole("button", { name: "Reset Password" }).click()
await expect(page.getByText("Invalid token")).toBeVisible() await expect(page.getByText("Invalid token")).toBeVisible()
@@ -96,7 +96,7 @@ test("Weak new password validation", async ({ page, request }) => {
await signUpNewUser(page, fullName, email, password) await signUpNewUser(page, fullName, email, password)
await page.goto("/recover-password") await page.goto("/recover-password")
await page.getByPlaceholder("Email").fill(email) await page.getByTestId("email-input").fill(email)
await page.getByRole("button", { name: "Continue" }).click() await page.getByRole("button", { name: "Continue" }).click()
const emailData = await findLastEmail({ const emailData = await findLastEmail({
@@ -115,8 +115,8 @@ test("Weak new password validation", async ({ page, request }) => {
// Set a weak new password // Set a weak new password
await page.goto(url) await page.goto(url)
await page.getByPlaceholder("New Password").fill(weakPassword) await page.getByTestId("new-password-input").fill(weakPassword)
await page.getByPlaceholder("Confirm Password").fill(weakPassword) await page.getByTestId("confirm-password-input").fill(weakPassword)
await page.getByRole("button", { name: "Reset Password" }).click() await page.getByRole("button", { name: "Reset Password" }).click()
await expect( await expect(

View File

@@ -4,10 +4,6 @@ import { randomEmail, randomPassword } from "./utils/random"
test.use({ storageState: { cookies: [], origins: [] } }) test.use({ storageState: { cookies: [], origins: [] } })
type OptionsType = {
exact?: boolean
}
const fillForm = async ( const fillForm = async (
page: Page, page: Page,
full_name: string, full_name: string,
@@ -15,18 +11,14 @@ const fillForm = async (
password: string, password: string,
confirm_password: string, confirm_password: string,
) => { ) => {
await page.getByPlaceholder("Full Name").fill(full_name) await page.getByTestId("full-name-input").fill(full_name)
await page.getByPlaceholder("Email").fill(email) await page.getByTestId("email-input").fill(email)
await page.getByPlaceholder("Password", { exact: true }).fill(password) await page.getByTestId("password-input").fill(password)
await page.getByPlaceholder("Confirm Password").fill(confirm_password) await page.getByTestId("confirm-password-input").fill(confirm_password)
} }
const verifyInput = async ( const verifyInput = async (page: Page, testId: string) => {
page: Page, const input = page.getByTestId(testId)
placeholder: string,
options?: OptionsType,
) => {
const input = page.getByPlaceholder(placeholder, options)
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toHaveText("") await expect(input).toHaveText("")
await expect(input).toBeEditable() await expect(input).toBeEditable()
@@ -35,10 +27,10 @@ const verifyInput = async (
test("Inputs are visible, empty and editable", async ({ page }) => { test("Inputs are visible, empty and editable", async ({ page }) => {
await page.goto("/signup") await page.goto("/signup")
await verifyInput(page, "Full Name") await verifyInput(page, "full-name-input")
await verifyInput(page, "Email") await verifyInput(page, "email-input")
await verifyInput(page, "Password", { exact: true }) await verifyInput(page, "password-input")
await verifyInput(page, "Confirm Password") await verifyInput(page, "confirm-password-input")
}) })
test("Sign Up button is visible", async ({ page }) => { test("Sign Up button is visible", async ({ page }) => {
@@ -126,7 +118,7 @@ test("Sign up with mismatched passwords", async ({ page }) => {
await fillForm(page, fullName, email, password, password2) await fillForm(page, fullName, email, password, password2)
await page.getByRole("button", { name: "Sign Up" }).click() await page.getByRole("button", { name: "Sign Up" }).click()
await expect(page.getByText("Passwords do not match")).toBeVisible() await expect(page.getByText("The passwords don't match")).toBeVisible()
}) })
test("Sign up with missing full name", async ({ page }) => { test("Sign up with missing full name", async ({ page }) => {
@@ -152,7 +144,7 @@ test("Sign up with missing email", async ({ page }) => {
await fillForm(page, fullName, email, password, password) await fillForm(page, fullName, email, password, password)
await page.getByRole("button", { name: "Sign Up" }).click() await page.getByRole("button", { name: "Sign Up" }).click()
await expect(page.getByText("Email is required")).toBeVisible() await expect(page.getByText("Invalid email address")).toBeVisible()
}) })
test("Sign up with missing password", async ({ page }) => { test("Sign up with missing password", async ({ page }) => {

Some files were not shown because too many files have changed in this diff Show More