diff --git a/packages/backend/src/the_api.ts b/packages/backend/src/the_api.ts index b1e0f23..f90e784 100644 --- a/packages/backend/src/the_api.ts +++ b/packages/backend/src/the_api.ts @@ -59,7 +59,7 @@ export const handle = implement(api, { let query = db .selectFrom("User") - .leftJoin("UserRole", "User.userId", "UserRole.role") + .leftJoin("UserRole", "User.userId", "UserRole.userId") .select("User.userId") .groupBy("User.userId") .orderBy("displayName") diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 5f5dc2d..46477bd 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-dialog": "catalog:", "@radix-ui/react-dropdown-menu": "catalog:", "@radix-ui/react-label": "catalog:", + "@radix-ui/react-select": "catalog:", "@radix-ui/react-slot": "catalog:", "clsx": "catalog:", "common": "workspace:^", diff --git a/packages/frontend/src/app.tsx b/packages/frontend/src/app.tsx index 6ed6924..39e0680 100644 --- a/packages/frontend/src/app.tsx +++ b/packages/frontend/src/app.tsx @@ -1,3 +1,4 @@ +import { Admin } from "@/routes/Admin"; import { Home } from "@/routes/Home"; import { Login } from "@/routes/Login"; import { Piece } from "@/routes/Piece"; @@ -61,6 +62,10 @@ const router = createBrowserRouter([ path: "settings", Component: Settings, }, + { + path: "admin", + Component: Admin, + } ], }, { diff --git a/packages/frontend/src/components/ui/select.tsx b/packages/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..be06477 --- /dev/null +++ b/packages/frontend/src/components/ui/select.tsx @@ -0,0 +1,156 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/packages/frontend/src/routes/Admin.tsx b/packages/frontend/src/routes/Admin.tsx new file mode 100644 index 0000000..4ef71b5 --- /dev/null +++ b/packages/frontend/src/routes/Admin.tsx @@ -0,0 +1,183 @@ +import { userCache } from "@/cache"; +import { client } from "@/client"; +import { Avatar, AvatarImage } from "@/components/ui/avatar"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { useBreakpoint } from "@/hooks/useBreakpoint"; +import { useCache } from "@/hooks/useCache"; +import { useLoading } from "@/hooks/useLoading"; +import { DEBOUNCE } from "@/snippets"; +import { UserId } from "common"; +import { Role } from "common/the_api"; +import { Array, Effect, HashSet, Match, Option, pipe } from "effect"; +import { Loader2 } from "lucide-react"; +import { useRef, useState } from "react"; + +export function Admin() { + + const [displayName, setDisplayName] = useState(""); + const [role, setRole] = useState(""); + + const debounce = useRef(Effect.void); + const breakpoint = useBreakpoint(); + const columns = breakpoint ? 4 : 1; + + const { isLoading, error, data: userIds } = useLoading(Effect.gen(function* () { + yield* debounce.current; + const data = yield* client.queryUsers({ + displayName: displayName !== "" ? Option.some(displayName) : Option.none(), + role: role !== "" ? Option.some(role) : Option.none(), + offset: 0, + limit: 100, + }); + return data; + }), [displayName, role]); + + return ( +
+
+ { + setDisplayName(e.target.value); + debounce.current = DEBOUNCE; + }} + /> + +
+ + {breakpoint && ( + + + + Nazwa wyświetlana + ID + Role + + + )} + + {isLoading ? ( + + +
+ + Ładowanie… +
+
+
+ ) : error !== null ? ( + + +
+ Wystąpił błąd: {Match.value(error).pipe( + Match.tag("FetchError", () => "Nie można połączyć się z serwerem"), + Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"), + Match.exhaustive, + )} +
+
+
+ ) : ( + userIds.map((userId) => ) + )} +
+
+
+ ); +} + +namespace UserRow { + export interface Props { + readonly userId: UserId; + } +} + +function UserRow(props: UserRow.Props) { + + const { isLoading, error, data: user } = useCache(userCache, props.userId); + + const breakpoint = useBreakpoint(); + const columns = breakpoint ? 4 : 1; + + if (isLoading) { + return ( + + Ładowanie… + + ); + } + + if (error !== null) { + return ( + + + Wystąpił błąd: {Match.value(error).pipe( + Match.tag("FetchError", () => "Nie można połączyć się z serwerem"), + Match.tag("NotFound", () => "Użytkownik nie istnieje"), + Match.tag("Unauthenticated", () => "Zaloguj się, aby kontynuować"), + Match.exhaustive, + )} + + + ); + } + + const rolesText = pipe( + user.roles, + HashSet.values, + Array.join(", "), + ); + + return ( + + {breakpoint ? (<> + + + + + + + {Option.getOrUndefined(user.displayName)} + + + {user.userId} + + + {rolesText} + + ) : ( + +
+ + + +
{Option.getOrUndefined(user.displayName)}
+
+
{user.userId}
+
{rolesText}
+
+ )} +
+ ); +} diff --git a/packages/frontend/src/routes/Pieces.tsx b/packages/frontend/src/routes/Pieces.tsx index 1cb199f..8c5db6c 100644 --- a/packages/frontend/src/routes/Pieces.tsx +++ b/packages/frontend/src/routes/Pieces.tsx @@ -41,7 +41,7 @@ export function Pieces() { }), [name, author]); return ( -
+
diff --git a/packages/frontend/src/routes/Repertoires.tsx b/packages/frontend/src/routes/Repertoires.tsx index 79411f9..b17c06f 100644 --- a/packages/frontend/src/routes/Repertoires.tsx +++ b/packages/frontend/src/routes/Repertoires.tsx @@ -34,7 +34,7 @@ export function Repertoires() { }), [name]); return ( -
+
diff --git a/packages/frontend/src/routes/Root.tsx b/packages/frontend/src/routes/Root.tsx index b1032d8..f3582c8 100644 --- a/packages/frontend/src/routes/Root.tsx +++ b/packages/frontend/src/routes/Root.tsx @@ -3,8 +3,9 @@ import { Avatar, AvatarImage } from "@/components/ui/avatar"; import { buttonVariants } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { setUser, useStore } from "@/hooks/useStore"; -import { Effect, Option, pipe } from "effect"; -import { LogOut, Settings } from "lucide-react"; +import { Role } from "common/the_api"; +import { Effect, HashSet, Option, pipe } from "effect"; +import { LogOut, Settings, Users } from "lucide-react"; import { useEffect } from "react"; import { Link, Outlet, useNavigate } from "react-router-dom"; @@ -63,6 +64,13 @@ export function Root() { Ustawienia + {HashSet.has(user.roles, Role.Admin) && ( + + + Administracja + + + )} Wyloguj się diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49bc14b..f6f044e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7 + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6 '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3 @@ -184,6 +187,9 @@ importers: '@radix-ui/react-label': specifier: 'catalog:' version: 2.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-select': + specifier: 'catalog:' + version: 2.2.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-slot': specifier: 'catalog:' version: 1.2.3(@types/react@19.2.2)(react@19.2.0) @@ -708,6 +714,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -938,6 +947,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -1001,6 +1023,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -1019,6 +1050,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} @@ -3108,6 +3152,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@radix-ui/number@1.1.1': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': @@ -3335,6 +3381,35 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) @@ -3383,6 +3458,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.2)(react@19.2.0)': dependencies: '@radix-ui/rect': 1.1.1 @@ -3397,6 +3478,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@radix-ui/rect@1.1.1': {} '@rolldown/pluginutils@1.0.0-beta.27': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d42dc6e..6910d6c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalog: '@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-select': '^2.2.6' '@radix-ui/react-slot': '^1.2.3' '@stylistic/eslint-plugin': '^4.4.1' '@tailwindcss/vite': '^4.1.14'