User list view

This commit is contained in:
2025-10-11 23:14:33 +02:00
parent 205f294e78
commit 8c05c0e825
10 changed files with 449 additions and 5 deletions

View File

@@ -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:^",

View File

@@ -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,
}
],
},
{

View File

@@ -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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -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<Role | "">("");
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 (
<div className="p-4 overflow-y-auto flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-baseline gap-4">
<Input
className="w-full md:w-[32ch]"
type="text"
placeholder="Nazwa wyświetlana"
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
debounce.current = DEBOUNCE;
}}
/>
<Select
value={role}
onValueChange={(role) => setRole(role === "None" ? "" : role as Role)}
>
<SelectTrigger className="w-full md:w-[24ch]">
<SelectValue placeholder="Rola" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Role</SelectLabel>
<SelectItem value="None"><em>Dowolna</em></SelectItem>
<SelectItem value={Role.Viewer}>Przeglądanie</SelectItem>
<SelectItem value={Role.Editor}>Edytor</SelectItem>
<SelectItem value={Role.Admin}>Administrator</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<Table>
{breakpoint && (
<TableHeader className="bg-white sticky top-0">
<TableRow>
<TableHead className="w-10" />
<TableHead>Nazwa wyświetlana</TableHead>
<TableHead>ID</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
)}
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns} >
<div className="flex items-center justify-center gap-2">
<Loader2 className="animate-spin" />
Ładowanie
</div>
</TableCell>
</TableRow>
) : error !== null ? (
<TableRow>
<TableCell colSpan={columns} >
<div className="flex items-center justify-center gap-2">
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,
)}
</div>
</TableCell>
</TableRow>
) : (
userIds.map((userId) => <UserRow key={userId} userId={userId} />)
)}
</TableBody>
</Table>
</div>
);
}
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 (
<TableRow>
<TableCell colSpan={columns}>Ładowanie</TableCell>
</TableRow>
);
}
if (error !== null) {
return (
<TableRow>
<TableCell colSpan={columns}>
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,
)}
</TableCell>
</TableRow>
);
}
const rolesText = pipe(
user.roles,
HashSet.values,
Array.join(", "),
);
return (
<TableRow>
{breakpoint ? (<>
<TableCell>
<Avatar className="shadow">
<AvatarImage src={Option.getOrUndefined(user.avatarUrl)} />
</Avatar>
</TableCell>
<TableCell>
{Option.getOrUndefined(user.displayName)}
</TableCell>
<TableCell className="font-mono">
{user.userId}
</TableCell>
<TableCell>
{rolesText}
</TableCell>
</>) : (
<TableCell className="flex flex-col">
<div className="flex flex-row items-center gap-2 mb-2">
<Avatar className="shadow">
<AvatarImage src={Option.getOrUndefined(user.avatarUrl)} />
</Avatar>
<div>{Option.getOrUndefined(user.displayName)}</div>
</div>
<div className="text-xs font-mono">{user.userId}</div>
<div className="text-xs">{rolesText}</div>
</TableCell>
)}
</TableRow>
);
}

View File

@@ -41,7 +41,7 @@ export function Pieces() {
}), [name, author]);
return (
<div className="p-4 overflow-y-auto flex flex-col items-start gap-4">
<div className="p-4 overflow-y-auto flex flex-col gap-4">
<div className="flex flex-row flex-wrap gap-4">
<Dialog>
<DialogTrigger asChild>

View File

@@ -34,7 +34,7 @@ export function Repertoires() {
}), [name]);
return (
<div className="p-4 overflow-y-auto flex flex-col items-start gap-4">
<div className="p-4 overflow-y-auto flex flex-col gap-4">
<div className="flex flex-row flex-wrap gap-4">
<Dialog>
<DialogTrigger asChild>

View File

@@ -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() {
<Settings />Ustawienia
</Link>
</DropdownMenuItem>
{HashSet.has(user.roles, Role.Admin) && (
<DropdownMenuItem asChild>
<Link to="/admin">
<Users />Administracja
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onLogoutClick}>
<LogOut />Wyloguj się
</DropdownMenuItem>