feat(frontend): sidebar migration started

This commit is contained in:
2025-09-23 20:54:28 -05:00
parent cb2e6252e0
commit bee436d341
57 changed files with 2608 additions and 359 deletions

View File

@@ -0,0 +1,23 @@
meta {
name: UpdateApp
type: http
seq: 3
}
get {
url: https://usmcd1vms036.alpla.net/lst/api/controller/update
body: none
auth: inherit
}
headers {
Content-Type: application/json
}
body:json {
{}
}
settings {
encodeUrl: true
}

View File

@@ -5,7 +5,7 @@ meta {
} }
get { get {
url: http://{{url}}/lst/api/user/me url: {{url}}/lst/api/user/me
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -5,7 +5,7 @@ meta {
} }
post { post {
url: http://{{url}}/lst/api/admin/users url: {{url}}/lst/api/admin/users
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -0,0 +1,22 @@
meta {
name: Get user Roles
type: http
seq: 9
}
get {
url: {{url}}/lst/api/user/roles
body: json
auth: inherit
}
body:json {
{
"module":"users",
"role":"admin"
}
}
settings {
encodeUrl: true
}

View File

@@ -5,7 +5,7 @@ meta {
} }
post { post {
url: http://{{url}}/lst/api/admin/:userID/grant url: {{url}}/lst/api/admin/:userID/grant
body: json body: json
auth: inherit auth: inherit
} }

View File

@@ -5,16 +5,16 @@ meta {
} }
post { post {
url: http://{{url}}/lst/api/user/register url: {{url}}/lst/api/user/register
body: json body: json
auth: inherit auth: inherit
} }
body:json { body:json {
{ {
"username":"matthes011", "username":"matthes01",
"name":"blake", "name":"blake",
"email":"blake1.matthes@alpla.com", "email":"blake.matthes@alpla.com",
"password":"nova0511" "password":"nova0511"
} }
} }

View File

@@ -5,7 +5,7 @@ meta {
} }
get { get {
url: http://{{url}}/lst/api/auth/get-session url: {{url}}/lst/api/auth/get-session
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -5,7 +5,7 @@ meta {
} }
post { post {
url: http://{{url}}/lst/api/auth/sign-in/email url: {{url}}/lst/api/auth/sign-in/email
body: json body: json
auth: inherit auth: inherit
} }

View File

@@ -5,7 +5,7 @@ meta {
} }
post { post {
url: http://{{url}}/lst/api/auth/sign-in/username url: {{url}}/lst/api/auth/sign-in/username
body: json body: json
auth: inherit auth: inherit
} }

View File

@@ -5,7 +5,7 @@ meta {
} }
post { post {
url: http://{{url}}/lst/api/auth/sign-out url: {{url}}/lst/api/auth/session
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -5,7 +5,7 @@ meta {
} }
get { get {
url: http://{{url}}/lst/api/system/health url: {{url}}/lst/api/system/health
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -1,4 +1,4 @@
vars { vars {
url: localhost:4200 url: http://localhost:4200
session_cookie: session_cookie:
} }

View File

@@ -16,7 +16,7 @@ export const systemAdminRole = async (userId: string) => {
}, },
{ {
userId: userId, userId: userId,
module: "system", module: "admin",
role: "systemAdmin", role: "systemAdmin",
}, },
{ {

View File

@@ -25,7 +25,6 @@ const registerSchema = z.object({
router.post("/", async (req: Request, res: Response) => { router.post("/", async (req: Request, res: Response) => {
// check if we are the first user so we can add as system admin to all modules // check if we are the first user so we can add as system admin to all modules
const totalUsers = await db.select({ count: count() }).from(user); const totalUsers = await db.select({ count: count() }).from(user);
console.log(totalUsers[0].count);
try { try {
// Parse + validate incoming JSON against Zod schema // Parse + validate incoming JSON against Zod schema
const validated = registerSchema.parse(req.body); const validated = registerSchema.parse(req.body);

View File

@@ -1,9 +1,11 @@
import type { Express, Request, Response } from "express"; import type { Express, Request, Response } from "express";
import me from "./me.js"; import me from "./me.js";
import register from "./register.js"; import register from "./register.js";
import userRoles from "./userroles.js";
import { requireAuth } from "../../../pkg/middleware/authMiddleware.js"; import { requireAuth } from "../../../pkg/middleware/authMiddleware.js";
export const setupAuthRoutes = (app: Express, basePath: string) => { export const setupAuthRoutes = (app: Express, basePath: string) => {
app.use(basePath + "/api/user/me", requireAuth(), me); app.use(basePath + "/api/user/me", requireAuth(), me);
app.use(basePath + "/api/user/register", register); app.use(basePath + "/api/user/register", register);
app.use(basePath + "/api/user/roles", requireAuth(), userRoles);
}; };

View File

@@ -0,0 +1,32 @@
import { Router } from "express";
import type { Request, Response } from "express";
import z from "zod";
import { db } from "../../../pkg/db/db.js";
import { userRoles } from "../../../pkg/db/schema/user_roles.js";
import { DrizzleError, eq } from "drizzle-orm";
import { requireAuth } from "../../../pkg/middleware/authMiddleware.js";
import { auth } from "../../../pkg/auth/auth.js";
import { authClient } from "../../../pkg/auth/auth-client.js";
const router = Router();
router.get("/", async (req: Request, res: Response) => {
const userID = req.user?.id || "";
try {
// get the roles the user has
const roles = await db
.select()
.from(userRoles)
.where(eq(userRoles.userId, userID));
return res.status(200).json({ success: true, data: roles });
} catch (err) {
if (err instanceof DrizzleError && err.cause) {
return res
.status(400)
.json({ message: "db error", error: err.cause });
}
}
});
export default router;

View File

@@ -1,3 +1,5 @@
# What type of deploy ment do we have for the frontend # What type of deploy ment do we have for the frontend
APP_MODE=dev APP_MODE=dev
# The port is only needed if you plan to use a different port but you will need to change it in the wrapper too
PORT=8080

View File

@@ -201,9 +201,9 @@
// You can define your own logMessage function // You can define your own logMessage function
logMessage("info", `Updating to ${fromMyInput}`); logMessage("info", `Updating to ${fromMyInput}`);
input.value = ""; //input.value = "";
input.focus(); //input.focus();
}; };
}; };

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_adminLayout/admin/settings')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_adminLayout/admin/settings"!</div>
}

View File

@@ -0,0 +1,15 @@
import * as React from 'react'
import { Outlet, createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute({
component: RootComponent,
})
function RootComponent() {
return (
<React.Fragment>
<div>Hello "__root"!</div>
<Outlet />
</React.Fragment>
)
}

View File

@@ -8,10 +8,15 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3", "@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.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@tanstack/react-form": "^1.23.0", "@tanstack/react-form": "^1.23.0",
"@tanstack/react-query": "^5.89.0", "@tanstack/react-query": "^5.89.0",
@@ -21,6 +26,7 @@
"better-auth": "^1.3.11", "better-auth": "^1.3.11",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
@@ -32,6 +38,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@tanstack/router-plugin": "^1.131.36", "@tanstack/router-plugin": "^1.131.36",
"@types/js-cookie": "^3.0.6",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
@@ -1516,6 +1523,33 @@
} }
} }
}, },
"node_modules/@radix-ui/react-avatar": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
"integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-is-hydrated": "0.1.0",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox": { "node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
@@ -1602,6 +1636,42 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -1644,6 +1714,35 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": { "node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
@@ -1725,6 +1824,46 @@
} }
} }
}, },
"node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -1828,6 +1967,37 @@
} }
} }
}, },
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": { "node_modules/@radix-ui/react-select": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
@@ -1871,6 +2041,29 @@
} }
} }
}, },
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -1889,6 +2082,40 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -1959,6 +2186,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-is-hydrated": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": { "node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
@@ -3171,6 +3416,13 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -4886,6 +5138,15 @@
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@@ -10,10 +10,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3", "@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.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@tanstack/react-form": "^1.23.0", "@tanstack/react-form": "^1.23.0",
"@tanstack/react-query": "^5.89.0", "@tanstack/react-query": "^5.89.0",
@@ -23,6 +28,7 @@
"better-auth": "^1.3.11", "better-auth": "^1.3.11",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
@@ -34,6 +40,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@tanstack/router-plugin": "^1.131.36", "@tanstack/router-plugin": "^1.131.36",
"@types/js-cookie": "^3.0.6",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,37 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "../lib/providers/theme-provider";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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 onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,72 @@
import { Server, Settings, User, type LucideIcon } from "lucide-react";
import { userAccess, type UserRoles } from "../../lib/authClient";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "../ui/sidebar";
import { Link } from "@tanstack/react-router";
type Items = {
title: string;
url: string;
icon: LucideIcon;
role: UserRoles["role"][];
module: string;
active: boolean;
};
export default function Admin() {
const items: Items[] = [
{
title: "Users",
url: "/lst/app/admin/users",
icon: User,
role: ["systemAdmin", "admin"],
module: "admin",
active: true,
},
{
title: "Settings",
url: "/lst/app/admin/settings",
icon: Settings,
role: ["systemAdmin", "admin"],
module: "admin",
active: true,
},
{
title: "Servers",
url: "/lst/app/admin/servers",
icon: Server,
role: ["systemAdmin", "admin"],
module: "admin",
active: true,
},
];
return (
<SidebarGroup>
<SidebarGroupLabel>Admin</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<>
{userAccess(item.module, item.role) &&
item.active && (
<SidebarMenuButton asChild>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
)}
</>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,36 @@
import { Link } from "@tanstack/react-router";
import {
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "../ui/sidebar";
export function Header() {
return (
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<Link to="/">
<SidebarMenuButton size="lg" asChild>
<div className="flex flex-row">
<img
src={"imgs/dkLst.png"}
alt="Description"
className="size-8"
/>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">
Logistics Support Tool
</span>
<span className="">v2.0.0</span>
</div>
</div>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
);
}

View File

@@ -1,29 +1,80 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useAuth, useLogout } from "../../lib/authClient"; import { useAuth, useLogout } from "../../lib/authClient";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ModeToggle } from "../mode-toggle";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
export default function Nav() { export default function Nav() {
const { session } = useAuth(); const { session } = useAuth();
const logout = useLogout(); const logout = useLogout();
return ( return (
<nav> <nav className="flex justify-end w-full shadow ">
{session?.session ? ( <div className="m-2 flex flex-row gap-1">
<> <div className="m-1">
<Button <ModeToggle />
onClick={() => { </div>
logout(); {/* <div className="mr-1 ml-1">
}} {settings.length > 0 && (
> <a
Sign Out href={`https://${server[0].value}.alpla.net/lst/d`}
</Button> target="_blank"
</> >
) : ( LST - Docs |
<> </a>
<Button> )}
<Link to="/login">Login</Link> </div> */}
</Button> {session ? (
</> <div className="m-1">
)} <DropdownMenu>
<DropdownMenuTrigger>
<Avatar>
<AvatarImage
src="https://github.com/evilrabbit.png"
alt="@evilrabbit"
/>
<AvatarFallback>CN</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>
Hello {session.user?.username}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
{/* <Link to="/passwordChange">
Password Change
</Link> */}
</DropdownMenuItem>
{/* <DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuItem>Team</DropdownMenuItem>
<DropdownMenuItem>Subscription</DropdownMenuItem> */}
<hr className="solid"></hr>
<DropdownMenuItem>
<div className="m-auto">
<button onClick={() => logout()}>
Logout
</button>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="m-1">
<Button>
<Link to="/login">Login</Link>
</Button>
</div>
)}
</div>
</nav> </nav>
); );
} }

View File

@@ -0,0 +1,19 @@
import { Sidebar, SidebarFooter, SidebarTrigger } from "../ui/sidebar";
import { Header } from "./Header";
import Admin from "./Admin";
import { userAccess } from "../../lib/authClient";
export default function SideBarNav() {
return (
<div className="flex min-h-screen">
<Sidebar collapsible="icon">
<Header />
{userAccess(null, ["systemAdmin", "admin"]) && <Admin />}
<SidebarFooter>
<SidebarTrigger />
</SidebarFooter>
</Sidebar>
</div>
);
}

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

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

View File

@@ -1,30 +1,30 @@
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Checkbox({ function Checkbox({
className, className,
...props ...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) { }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( 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", "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 className
)} )}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none" className="flex items-center justify-center text-current transition-none"
> >
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };

View File

@@ -0,0 +1,263 @@
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,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -1,21 +1,21 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( 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", "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]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View File

@@ -1,22 +1,22 @@
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Label({ function Label({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( 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", "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 className
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

View File

@@ -1,183 +1,189 @@
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
className, className,
size = "default", size = "default",
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( 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", "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 className
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "popper", position = "popper",
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( 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", "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" && 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", "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 className
)} )}
position={position} position={position}
{...props} {...props}
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)} )}
> >
{children} {children}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) { }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn(
{...props} "text-muted-foreground px-2 py-1.5 text-xs",
/> className
) )}
{...props}
/>
);
} }
function SelectItem({ function SelectItem({
className, className,
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) { }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( 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", "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 className
)} )}
{...props} {...props}
> >
<span className="absolute right-2 flex size-3.5 items-center justify-center"> <span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) { }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn(
{...props} "bg-border pointer-events-none -mx-1 my-1 h-px",
/> className
) )}
{...props}
/>
);
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
className, className,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectScrollDownButton, SelectScrollDownButton,
SelectScrollUpButton, SelectScrollUpButton,
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, 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,137 @@
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,750 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "../../hooks/use-mobile";
import { cn } from "../../lib/utils";
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";
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);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
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, setOpenMobile]);
// 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,
setOpenMobile,
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 } = useSidebar();
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">Toggle Sidebar</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-background 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", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 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 p-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 rounded-md 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",
{
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",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
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

@@ -0,0 +1,13 @@
import { cn } from "../../lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

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

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

View File

@@ -3,7 +3,8 @@ import { usernameClient } from "better-auth/client/plugins";
import { create } from "zustand"; import { create } from "zustand";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "@tanstack/react-router"; import { redirect, useNavigate, useRouter } from "@tanstack/react-router";
import { api } from "./axiosAPI";
// ---- TYPES ---- // ---- TYPES ----
export type Session = typeof authClient.$Infer.Session | null; export type Session = typeof authClient.$Infer.Session | null;
@@ -24,7 +25,7 @@ export type UserRoles = {
type UserRoleState = { type UserRoleState = {
userRoles: UserRoles[] | null; userRoles: UserRoles[] | null;
fetchRoles: (userId: string) => Promise<void>; fetchRoles: () => Promise<void>;
clearRoles: () => void; clearRoles: () => void;
}; };
@@ -37,14 +38,11 @@ export const useAuth = create<SessionState>((set) => ({
export const useUserRoles = create<UserRoleState>((set) => ({ export const useUserRoles = create<UserRoleState>((set) => ({
userRoles: null, userRoles: null,
fetchRoles: async (userId: string) => { fetchRoles: async () => {
try { try {
const res = await fetch(`/api/${userId}/roles`, { const res = await api.get("/api/user/roles");
credentials: "include", const roles = res.data;
}); set({ userRoles: roles.data });
if (!res.ok) throw new Error("Failed to fetch roles");
const roles = await res.json();
set({ userRoles: roles });
} catch (err) { } catch (err) {
console.error("Error fetching roles:", err); console.error("Error fetching roles:", err);
set({ userRoles: null }); set({ userRoles: null });
@@ -52,6 +50,59 @@ export const useUserRoles = create<UserRoleState>((set) => ({
}, },
clearRoles: () => set({ userRoles: null }), clearRoles: () => set({ userRoles: null }),
})); }));
export function userAccess(
moduleName: string | null,
roles: UserRoles["role"] | UserRoles["role"][]
): boolean {
const { userRoles } = useUserRoles();
if (!userRoles) return false;
const roleArray = Array.isArray(roles) ? roles : [roles];
return userRoles.some(
(m) =>
(moduleName ? m.module === moduleName : true) &&
roleArray.includes(m.role)
);
}
export async function checkUserAccess({
allowedRoles,
moduleName,
}: {
allowedRoles: UserRoles["role"][];
moduleName?: string;
//location: { pathname: string; search: string };
}) {
try {
// fetch roles from your API (credentials required)
const res = await api.get("/api/user/roles", { withCredentials: true });
const roles = res.data.data as UserRoles[];
const hasAccess = roles.some(
(r) =>
(moduleName ? r.module === moduleName : true) &&
allowedRoles.includes(r.role)
);
if (!hasAccess) {
throw redirect({
to: "/",
search: { from: location.pathname + location.search },
});
}
// return roles so the route component can use them if needed
return roles;
} catch {
throw redirect({
to: "/login",
search: { redirect: location.pathname + location.search },
});
}
}
// ---- BETTER AUTH CLIENT ---- // ---- BETTER AUTH CLIENT ----
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: `${window.location.origin}/lst/api/auth`, baseURL: `${window.location.origin}/lst/api/auth`,
@@ -85,6 +136,9 @@ export async function signin(data: { username: string; password: string }) {
export const useLogout = () => { export const useLogout = () => {
const { clearSession } = useAuth(); const { clearSession } = useAuth();
const { clearRoles } = useUserRoles();
const navigate = useNavigate();
const router = useRouter(); const router = useRouter();
const logout = async () => { const logout = async () => {
await authClient.signOut(); await authClient.signOut();
@@ -92,7 +146,8 @@ export const useLogout = () => {
router.invalidate(); router.invalidate();
router.clearCache(); router.clearCache();
clearSession(); clearSession();
clearRoles();
navigate({ to: "/" });
window.location.reload(); window.location.reload();
}; };

View File

@@ -0,0 +1,6 @@
import axios from "axios";
export const api = axios.create({
baseURL: "/lst",
withCredentials: true, // ✅ always send credentials (cookies)
});

View File

@@ -13,12 +13,11 @@ export const CheckboxField = ({ label }: CheckboxFieldProps) => {
return ( return (
<div> <div>
<div className="m-2 p-2 flex flex-row"> <div className="flex items-center gap-3">
<div> <Label htmlFor={field.name}>
<Label htmlFor="active"> <span>{label}</span>
<span>{label}</span> </Label>
</Label>
</div>
<Checkbox <Checkbox
id={field.name} id={field.name}
checked={field.state.value} checked={field.state.value}

View File

@@ -1,18 +1,22 @@
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useSession } from "../authClient"; import { useSession, useUserRoles } from "../authClient";
export function SessionGuard({ children }: { children: React.ReactNode }) { export function SessionGuard({ children }: { children: React.ReactNode }) {
const { data: session, isLoading } = useSession(); const { data: session, isLoading } = useSession();
const { fetchRoles } = useUserRoles();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (!isLoading && !session) { // if (!isLoading && !session) {
router.navigate({ to: "/" }); // redirect if not logged in // router.navigate({ to: "/" }); // redirect if not logged in
// }
if (session) {
fetchRoles();
} }
}, [isLoading, session, router]); }, [isLoading, session, router]);
if (isLoading) return <div>Checking session</div>; if (isLoading) return <div>App Loading</div>;
return <>{children}</>; return <>{children}</>;
} }

View File

@@ -0,0 +1,67 @@
import {createContext, useContext, useEffect, useState} from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
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);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
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

@@ -20,6 +20,7 @@ const queryClient = new QueryClient({
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
basepath: "/lst/app", basepath: "/lst/app",
defaultPendingMinMs: 500,
context: { context: {
queryClient: {} as QueryClient, queryClient: {} as QueryClient,
//login: () => {}, //login: () => {},

View File

@@ -9,9 +9,17 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as AdminLayoutRouteRouteImport } from './routes/_adminLayout/route'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as authLoginRouteImport } from './routes/(auth)/login' import { Route as authLoginRouteImport } from './routes/(auth)/login'
import { Route as AdminLayoutAdminUsersRouteImport } from './routes/_adminLayout/admin/users'
import { Route as AdminLayoutAdminSettingsRouteImport } from './routes/_adminLayout/admin/settings'
import { Route as AdminLayoutAdminServersRouteImport } from './routes/_adminLayout/admin/servers'
const AdminLayoutRouteRoute = AdminLayoutRouteRouteImport.update({
id: '/_adminLayout',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -22,35 +30,81 @@ const authLoginRoute = authLoginRouteImport.update({
path: '/login', path: '/login',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AdminLayoutAdminUsersRoute = AdminLayoutAdminUsersRouteImport.update({
id: '/admin/users',
path: '/admin/users',
getParentRoute: () => AdminLayoutRouteRoute,
} as any)
const AdminLayoutAdminSettingsRoute =
AdminLayoutAdminSettingsRouteImport.update({
id: '/admin/settings',
path: '/admin/settings',
getParentRoute: () => AdminLayoutRouteRoute,
} as any)
const AdminLayoutAdminServersRoute = AdminLayoutAdminServersRouteImport.update({
id: '/admin/servers',
path: '/admin/servers',
getParentRoute: () => AdminLayoutRouteRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof authLoginRoute '/login': typeof authLoginRoute
'/admin/servers': typeof AdminLayoutAdminServersRoute
'/admin/settings': typeof AdminLayoutAdminSettingsRoute
'/admin/users': typeof AdminLayoutAdminUsersRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof authLoginRoute '/login': typeof authLoginRoute
'/admin/servers': typeof AdminLayoutAdminServersRoute
'/admin/settings': typeof AdminLayoutAdminSettingsRoute
'/admin/users': typeof AdminLayoutAdminUsersRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/_adminLayout': typeof AdminLayoutRouteRouteWithChildren
'/(auth)/login': typeof authLoginRoute '/(auth)/login': typeof authLoginRoute
'/_adminLayout/admin/servers': typeof AdminLayoutAdminServersRoute
'/_adminLayout/admin/settings': typeof AdminLayoutAdminSettingsRoute
'/_adminLayout/admin/users': typeof AdminLayoutAdminUsersRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/login' fullPaths:
| '/'
| '/login'
| '/admin/servers'
| '/admin/settings'
| '/admin/users'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/login' to: '/' | '/login' | '/admin/servers' | '/admin/settings' | '/admin/users'
id: '__root__' | '/' | '/(auth)/login' id:
| '__root__'
| '/'
| '/_adminLayout'
| '/(auth)/login'
| '/_adminLayout/admin/servers'
| '/_adminLayout/admin/settings'
| '/_adminLayout/admin/users'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AdminLayoutRouteRoute: typeof AdminLayoutRouteRouteWithChildren
authLoginRoute: typeof authLoginRoute authLoginRoute: typeof authLoginRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/_adminLayout': {
id: '/_adminLayout'
path: ''
fullPath: ''
preLoaderRoute: typeof AdminLayoutRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -65,11 +119,48 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof authLoginRouteImport preLoaderRoute: typeof authLoginRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_adminLayout/admin/users': {
id: '/_adminLayout/admin/users'
path: '/admin/users'
fullPath: '/admin/users'
preLoaderRoute: typeof AdminLayoutAdminUsersRouteImport
parentRoute: typeof AdminLayoutRouteRoute
}
'/_adminLayout/admin/settings': {
id: '/_adminLayout/admin/settings'
path: '/admin/settings'
fullPath: '/admin/settings'
preLoaderRoute: typeof AdminLayoutAdminSettingsRouteImport
parentRoute: typeof AdminLayoutRouteRoute
}
'/_adminLayout/admin/servers': {
id: '/_adminLayout/admin/servers'
path: '/admin/servers'
fullPath: '/admin/servers'
preLoaderRoute: typeof AdminLayoutAdminServersRouteImport
parentRoute: typeof AdminLayoutRouteRoute
}
} }
} }
interface AdminLayoutRouteRouteChildren {
AdminLayoutAdminServersRoute: typeof AdminLayoutAdminServersRoute
AdminLayoutAdminSettingsRoute: typeof AdminLayoutAdminSettingsRoute
AdminLayoutAdminUsersRoute: typeof AdminLayoutAdminUsersRoute
}
const AdminLayoutRouteRouteChildren: AdminLayoutRouteRouteChildren = {
AdminLayoutAdminServersRoute: AdminLayoutAdminServersRoute,
AdminLayoutAdminSettingsRoute: AdminLayoutAdminSettingsRoute,
AdminLayoutAdminUsersRoute: AdminLayoutAdminUsersRoute,
}
const AdminLayoutRouteRouteWithChildren =
AdminLayoutRouteRoute._addFileChildren(AdminLayoutRouteRouteChildren)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AdminLayoutRouteRoute: AdminLayoutRouteRouteWithChildren,
authLoginRoute: authLoginRoute, authLoginRoute: authLoginRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@@ -1,20 +1,43 @@
import { useRouter, useSearch } from "@tanstack/react-router"; import { Link, useRouter, useSearch } from "@tanstack/react-router";
import { getSession, signin, useAuth } from "../../../lib/authClient"; import {
getSession,
signin,
useAuth,
useUserRoles,
} from "../../../lib/authClient";
import { useAppForm } from "../../../lib/formStuff"; import { useAppForm } from "../../../lib/formStuff";
import { LstCard } from "../../../components/ui/lstCard"; import { LstCard } from "../../../components/ui/lstCard";
import { toast } from "sonner"; import { toast } from "sonner";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
export default function LoginForm() { export default function LoginForm() {
const router = useRouter(); const router = useRouter();
const search = useSearch({ from: "/(auth)/login" }); const search = useSearch({ from: "/(auth)/login" });
const username = localStorage.getItem("username") || "";
const rememeberMe = localStorage.getItem("rememberMe") === "true";
const { setSession } = useAuth(); const { setSession } = useAuth();
const { fetchRoles } = useUserRoles();
const form = useAppForm({ const form = useAppForm({
defaultValues: { defaultValues: {
username: "", username: username,
password: "", password: "",
rememberMe: rememeberMe,
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
if (value.rememberMe) {
localStorage.setItem("rememberMe", value.rememberMe.toString());
localStorage.setItem("username", value.username);
} else {
localStorage.removeItem("rememberMe");
localStorage.removeItem("username");
}
try { try {
await signin({ await signin({
username: value.username, username: value.username,
@@ -23,6 +46,7 @@ export default function LoginForm() {
const session = await getSession(); const session = await getSession();
setSession(session); setSession(session);
fetchRoles();
toast.success( toast.success(
`Welcome back ${session?.user.name ? session?.user.name : session?.user.username} ` `Welcome back ${session?.user.name ? session?.user.name : session?.user.username} `
@@ -37,38 +61,68 @@ export default function LoginForm() {
return ( return (
<div className="ml-[25%]"> <div className="ml-[25%]">
<LstCard className="p-3 w-96"> <LstCard className="p-3 w-96">
<form <CardHeader>
onSubmit={(e) => { <CardTitle>Login to your account</CardTitle>
e.preventDefault(); <CardDescription>
form.handleSubmit(); Enter your username and password below
}} </CardDescription>
> </CardHeader>
<form.AppField <CardContent>
name="username" <form
children={(field) => ( onSubmit={(e) => {
<field.InputField e.preventDefault();
label="Username" form.handleSubmit();
inputType="string" }}
required={true} >
<form.AppField
name="username"
children={(field) => (
<field.InputField
label="Username"
inputType="string"
required={true}
/>
)}
/>
<form.AppField
name="password"
children={(field) => (
<field.InputField
label="Password"
inputType="password"
required={true}
/>
)}
/>
<div className="flex flex-row justify-between mt-3 mb-3">
<form.AppField
name="rememberMe"
children={(field) => (
<field.CheckboxField label="Remember me" />
)}
/> />
)} <Link
/> to="/"
<form.AppField className="inline-block text-sm underline-offset-4 hover:underline"
name="password" >
children={(field) => ( Forgot your password?
<field.InputField </Link>
label="Password" </div>
inputType="password"
required={true} <div className="flex justify-end mt-2">
/> <form.AppForm>
)} <form.SubmitButton>Login</form.SubmitButton>
/> </form.AppForm>
<div className="flex justify-end mt-2"> </div>
<form.AppForm> </form>
<form.SubmitButton>Login</form.SubmitButton>
</form.AppForm> <div className="mt-4 text-center text-sm">
Don&apos;t have an account?{" "}
<a href="#" className="underline underline-offset-4">
Sign up
</a>
</div> </div>
</form> </CardContent>
</LstCard> </LstCard>
</div> </div>
); );

View File

@@ -23,7 +23,7 @@ export const Route = createFileRoute("/(auth)/login")({
function RouteComponent() { function RouteComponent() {
return ( return (
<div> <div className="m-[25%]">
<LoginForm /> <LoginForm />
</div> </div>
); );

View File

@@ -2,8 +2,12 @@ import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import Cookies from "js-cookie";
import { SessionGuard } from "../lib/providers/SessionProvider"; import { SessionGuard } from "../lib/providers/SessionProvider";
import Nav from "../components/navBar/Nav"; import Nav from "../components/navBar/Nav";
import { ThemeProvider } from "../lib/providers/theme-provider";
import { SidebarProvider } from "../components/ui/sidebar";
import SideBarNav from "../components/navBar/SideBarNav";
interface RootRouteContext { interface RootRouteContext {
queryClient: QueryClient; queryClient: QueryClient;
@@ -15,13 +19,25 @@ interface RootRouteContext {
const RootLayout = () => { const RootLayout = () => {
//const { logout, login } = Route.useRouteContext(); //const { logout, login } = Route.useRouteContext();
const defaultOpen = Cookies.get("sidebar_state") === "true";
return ( return (
<div> <div>
<SessionGuard> <SessionGuard>
<Nav /> <ThemeProvider>
<Outlet /> <div className="flex flex-col h-screen overflow-hidden">
<Toaster expand={true} richColors closeButton /> <Nav />
<TanStackRouterDevtools /> <div className="flex flex-1 overflow-hidden">
<SidebarProvider defaultOpen={defaultOpen}>
<SideBarNav />
<div className="flex-1 overflow-y-auto">
<Outlet />
</div>
</SidebarProvider>
</div>
<Toaster expand richColors closeButton />
<TanStackRouterDevtools />
</div>
</ThemeProvider>
</SessionGuard> </SessionGuard>
</div> </div>
); );

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_adminLayout/admin/servers')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_adminLayout/admin/servers"!</div>
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_adminLayout/admin/settings')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_adminLayout/admin/settings"!</div>
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_adminLayout/admin/users')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_admin/admin/users"!</div>
}

View File

@@ -0,0 +1,19 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { checkUserAccess } from "../../lib/authClient";
export const Route = createFileRoute("/_adminLayout")({
beforeLoad: async () =>
checkUserAccess({
allowedRoles: ["admin", "systemAdmin"],
moduleName: "admin", // optional
}),
component: RouteComponent,
});
function RouteComponent() {
return (
<div>
<Outlet />
</div>
);
}