Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • adrian/group-management
  • adrian/un-mock
  • master
3 results

Target

Select target project
  • fsi/idm/self-service-frontend
1 result
Select Git revision
  • adrian/group-management
  • adrian/un-mock
  • master
3 results
Show changes
Commits on Source (5)
Showing
with 300 additions and 205 deletions
......@@ -44,8 +44,12 @@ function Fallback({ error }: { error: Error }) {
fix this!
</p>
<div className="flex flex-row gap-8 flex-wrap justify-center">
<Button onClick={() => location.reload()}>Reload Page</Button>
<Button onClick={() => (location.pathname = "/")}>Go Home</Button>
<Button size="large" onClick={() => location.reload()}>
Reload Page
</Button>
<Button size="large" onClick={() => (location.pathname = "/")}>
Go Home
</Button>
</div>
<ErrorDetails error={error} />
<img className="w-full max-w-lg" src="https://cdn.ai-rub.de/public/angrybird.jpg" />
......
import { STYLES } from "../../constants";
import { STYLES, Z_INDEXES } from "../../constants";
import Navigation from "./Navigation";
import { useNavigationStore } from "../../data/state/navigation";
import { HeaderLogo } from "./Header";
......@@ -30,8 +30,18 @@ export default function MobileNavigation() {
return (
<>
<div className={backdropClasses} onClick={() => setVisible(false)} />
<div className={sidebarClasses}>
<div
className={backdropClasses}
onClick={() => setVisible(false)}
style={{
zIndex: Z_INDEXES.MOBILE_NAV,
}}
/>
<div
className={sidebarClasses}
style={{
zIndex: Z_INDEXES.MOBILE_NAV,
}}>
<HeaderLogo className="m-4" />
<Link to="/" unstable_viewTransition>
<div className="font-bold text-xl text-center">FSI Self-Service</div>
......
......@@ -46,6 +46,12 @@ const allGroups: ApiGroup[] = [
parentGroupId: "6",
memberCount: 1,
},
{
id: "8",
name: "OhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygod",
parentGroupId: null,
memberCount: 999999999,
},
];
export default function GroupManagementPage() {
......
......@@ -3,7 +3,7 @@ import { H2 } from "../widgets/typography/Heading";
import type { ApiGroup } from "../../data/schemas";
import UserList, { type GroupMember } from "../widgets/group/UserList";
import ChevronRight from "../../icons/ChevronRight.svg?react";
import Plus from "../../icons/Plus.svg?react";
import UserPlus from "../../icons/UserPlus.svg?react";
import { STYLES } from "../../constants";
import Button from "../widgets/Button";
......@@ -25,29 +25,25 @@ export default function GroupMembersManagementPage() {
const group = {
...mockGroup,
id: groupId,
id: groupId!,
};
return (
<>
<div className="flex flex-row items-center justify-between mb-4">
<H2 className="flex flex-row items-center gap-1">
<Link
to="/groups"
className={`w-8 h-8 grid justify-center items-center rounded-full ${STYLES.CLICKABLE}`}
unstable_viewTransition>
<ChevronRight className="h-5 rotate-180 fill-current relative top-0.5" />
</Link>
{group.name}
</H2>
<div>
<Button small outlined>
<Plus className="w-4 h-4 fill-current" />
Add Member
</Button>
</div>
<div className="grid grid-cols-[auto_1fr_auto] items-center justify-center gap-2 mt-3 mb-4">
<Link
to="/groups"
className={`w-8 h-8 grid justify-center items-center rounded-full ${STYLES.CLICKABLE} shrink-0`}
unstable_viewTransition>
<ChevronRight className="h-5 rotate-180 fill-current relative top-0.5" />
</Link>
<H2 className="break-words !m-0">{group.name}</H2>
<Button outlined>
<UserPlus className="w-4 h-4 fill-current" />
Add Members
</Button>
</div>
<UserList users={mockMembers} />
<UserList users={mockMembers} group={group} />
</>
);
}
......@@ -67,7 +67,7 @@ export default function LoginPage() {
<P className={`${STYLES.ERROR_COLOR}`}>
<Message id="login.error" vars={{ errorMessage }} />
</P>
<Button onClick={onRetryClick}>
<Button size="large" onClick={onRetryClick}>
<Message id="login.retry" />
</Button>
</>
......
......@@ -5,77 +5,94 @@ export default function WidgetsPage() {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap gap-4">
<Button buttonStyle="info">Info Normal</Button>
<Button buttonStyle="success">Success Normal</Button>
<Button buttonStyle="warn">Warn Normal</Button>
<Button buttonStyle="danger">Danger Normal</Button>
<Button buttonStyle="link">Link Normal</Button>
<Button kind="info" size="large">
Info Large
</Button>
<Button kind="success" size="large">
Success Large
</Button>
<Button kind="warn" size="large">
Warn Large
</Button>
<Button kind="danger" size="large">
Danger Large
</Button>
<Button kind="link" size="large">
Link Large
</Button>
</div>
<div className="flex flex-row flex-wrap gap-4">
<Button kind="info">Info Normal</Button>
<Button kind="success">Success Normal</Button>
<Button kind="warn">Warn Normal</Button>
<Button kind="danger">Danger Normal</Button>
<Button kind="link">Link Normal</Button>
</div>
<div className="flex flex-row flex-wrap gap-4">
<Button buttonStyle="info" small>
<Button kind="info" size="small">
Info Small
</Button>
<Button buttonStyle="success" small>
<Button kind="success" size="small">
Success Small
</Button>
<Button buttonStyle="warn" small>
<Button kind="warn" size="small">
Warn Small
</Button>
<Button buttonStyle="danger" small>
<Button kind="danger" size="small">
Danger Small
</Button>
<Button buttonStyle="link" small>
<Button kind="link" size="small">
Link Small
</Button>
</div>
<div className="flex flex-row flex-wrap gap-4">
<Button buttonStyle="info" outlined>
<Button kind="info" outlined>
Info Outlined
</Button>
<Button buttonStyle="success" outlined>
<Button kind="success" outlined>
Success Outlined
</Button>
<Button buttonStyle="warn" outlined>
<Button kind="warn" outlined>
Warn Outlined
</Button>
<Button buttonStyle="danger" outlined>
<Button kind="danger" outlined>
Danger Outlined
</Button>
<Button buttonStyle="link" outlined>
<Button kind="link" outlined>
Link Outlined
</Button>
</div>
<div className="flex flex-row flex-wrap gap-4">
<Button buttonStyle="info" disabled>
<Button kind="info" disabled>
Info Disabled
</Button>
<Button buttonStyle="success" disabled>
<Button kind="success" disabled>
Success Disabled
</Button>
<Button buttonStyle="warn" disabled>
<Button kind="warn" disabled>
Warn Disabled
</Button>
<Button buttonStyle="danger" disabled>
<Button kind="danger" disabled>
Danger Disabled
</Button>
<Button buttonStyle="link" disabled>
<Button kind="link" disabled>
Link Disabled
</Button>
</div>
<div className="flex flex-row flex-wrap gap-4">
<Button buttonStyle="info" outlined disabled>
<Button kind="info" outlined disabled>
Info Outlined Disabled
</Button>
<Button buttonStyle="success" outlined disabled>
<Button kind="success" outlined disabled>
Success Outlined Disabled
</Button>
<Button buttonStyle="warn" outlined disabled>
<Button kind="warn" outlined disabled>
Warn Outlined Disabled
</Button>
<Button buttonStyle="danger" outlined disabled>
<Button kind="danger" outlined disabled>
Danger Outlined Disabled
</Button>
<Button buttonStyle="link" outlined disabled>
<Button kind="link" outlined disabled>
Link Outlined Disabled
</Button>
</div>
......
import classNames from "classnames";
import Spinner from "./Spinner";
type ButtonStyle = "info" | "success" | "warn" | "danger" | "link";
type ButtonKind = "info" | "success" | "warn" | "danger" | "link";
type ButtonSize = "small" | "normal" | "large";
type ButtonOrLinkProps =
| (React.ButtonHTMLAttributes<HTMLButtonElement> & { as?: "button" })
| (React.AnchorHTMLAttributes<HTMLAnchorElement> & { as: "a" });
type Props = ButtonOrLinkProps & {
buttonStyle?: ButtonStyle;
small?: boolean;
kind?: ButtonKind;
size?: ButtonSize;
outlined?: boolean;
loading?: boolean;
};
const buttonStyleToClass: Record<ButtonStyle, string> = {
const buttonStyleToClass: Record<ButtonKind, string> = {
info: "text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-800 dark:hover:bg-blue-900",
success: "text-white bg-green-600 hover:bg-green-700 dark:bg-green-800 dark:hover:bg-green-900",
warn: "text-black bg-yellow-500 hover:bg-yellow-600 dark:bg-yellow-600 dark:hover:bg-yellow-700",
......@@ -22,15 +23,17 @@ const buttonStyleToClass: Record<ButtonStyle, string> = {
link: "text-inherit hover:opacity-75 hover:underline",
};
const buttonStyleToClassOutlined: Record<ButtonStyle, string> = {
info: "border-blue-500 hover:bg-blue-500 dark:border-blue-800 dark:hover:bg-blue-800 hover:text-white",
success: "border-green-600 hover:bg-green-600 dark:border-green-800 dark:hover:bg-green-800 hover:text-white",
const buttonStyleToClassOutlined: Record<ButtonKind, string> = {
info: "border-blue-500 hover:bg-blue-500 dark:border-blue-600 dark:hover:bg-blue-800 dark:hover:border-blue-800 hover:text-white",
success:
"border-green-600 hover:bg-green-600 dark:border-green-600 dark:hover:bg-green-800 dark:hover:border-green-800 hover:text-white",
warn: "border-yellow-500 hover:bg-yellow-500 dark:border-yellow-600 dark:hover:bg-yellow-600 hover:text-black",
danger: "border-red-500 hover:bg-red-500 dark:border-red-800 dark:hover:bg-red-800 hover:text-white",
danger:
"border-red-500 hover:bg-red-500 dark:border-red-700 dark:hover:bg-red-800 dark:hover:border-red-800 hover:text-white",
link: "border-slate-300 text-inherit hover:opacity-75 hover:underline",
};
const buttonStyleToClassDisabled: Record<ButtonStyle, string> = {
const buttonStyleToClassDisabled: Record<ButtonKind, string> = {
info: "bg-blue-500/25 dark:bg-blue-800/50",
success: "bg-green-500/25 dark:bg-green-800/50",
warn: "bg-yellow-500/25 dark:bg-yellow-800/50",
......@@ -38,7 +41,13 @@ const buttonStyleToClassDisabled: Record<ButtonStyle, string> = {
link: "",
};
export default function Button({ buttonStyle, small, outlined, loading, className, children, ...props }: Props) {
const buttonSizeToStyle: Record<ButtonSize, string> = {
small: "px-2 py-2",
normal: "px-2.5 py-3",
large: "px-4 py-4",
};
export default function Button({ kind: buttonStyle, size, outlined, loading, className, children, ...props }: Props) {
const disabled = "disabled" in props && props.disabled;
if (loading) {
outlined = false;
......@@ -52,13 +61,12 @@ export default function Button({ buttonStyle, small, outlined, loading, classNam
!disabled && !outlined && buttonStyleToClass[buttonStyle ?? "info"],
!disabled && outlined && buttonStyleToClassOutlined[buttonStyle ?? "info"],
disabled && buttonStyleToClassDisabled[buttonStyle ?? "info"],
buttonSizeToStyle[size ?? "normal"],
{
["text-inherit border"]: outlined,
// Force the same spacing as outlined buttons by creating a fake border
["border border-transparent"]: !outlined,
["text-gray-500 dark:text-gray-400 cursor-not-allowed"]: disabled,
["px-4 py-4"]: !small,
["px-2.5 py-3"]: small,
},
className
);
......
import classNames from "classnames";
import { STYLES } from "../../constants";
import type { WithChildren, WithChildrenAndClassName } from "../../utils/types";
export function GridTable({ className, children }: WithChildrenAndClassName) {
const classes =
// Grid Common
"grid items-center " +
// Colors
`border ${STYLES.BORDER_COLOR} ` +
// Misc
"rounded-lg overflow-hidden leading-none " +
className;
return <div className={classes}>{children}</div>;
}
export function GridTableTitle({ className, children }: WithChildrenAndClassName) {
const classes =
// Layout
"col-span-full justify-self-stretch px-4 py-2 " +
// Text
"font-bold text-lg " +
// Colors
`bg-slate-50 dark:bg-slate-700 border-b ${STYLES.BORDER_COLOR} ` +
className;
return <h3 className={classes}>{children}</h3>;
}
export function GridTableHeader({ className, children }: WithChildrenAndClassName) {
return (
<>
<div
className={classNames(
`grid grid-cols-subgrid col-span-full p-4 max-md:hidden ${STYLES.MUTED_COLOR}`,
className
)}>
{children}
</div>
<div className={`col-span-full border-double border-t-2 ${STYLES.BORDER_COLOR} -mx-4 max-md:hidden`} />
</>
);
}
export function GridTableEmpty({ className, children }: WithChildrenAndClassName) {
return (
<div className={classNames(`col-span-full text-center ${STYLES.MUTED_COLOR} whitespace-pre-line p-4`, className)}>
{children}
</div>
);
}
export function GridTableRow({ children }: WithChildren) {
return (
<>
{children}
<div className={`last:hidden col-span-full justify-self-stretch border-t ${STYLES.BORDER_COLOR} -mx-4`} />
</>
);
}
import type { TransitionEvent } from "react";
import type { WithChildren } from "../../utils/types";
import classNames from "classnames";
import { Z_INDEXES } from "../../constants";
import { useCallback, useEffect, useState } from "react";
interface Props {
isVisible: boolean;
}
export default function Overlay({ isVisible, children }: WithChildren<Props>) {
const [renderChildren, setRenderChildren] = useState<boolean>(isVisible);
const onTransitionEnd = useCallback(
(e: TransitionEvent) => {
// Stop rendering after the exit transition has played
if (!isVisible && e.propertyName === "opacity") {
setRenderChildren(false);
}
},
[isVisible]
);
useEffect(() => {
// Start rendering if requesting to show
if (isVisible) {
setRenderChildren(true);
}
}, [isVisible]);
return (
<div
className={classNames(
......@@ -18,8 +38,9 @@ export default function Overlay({ isVisible, children }: WithChildren<Props>) {
)}
style={{
zIndex: Z_INDEXES.GLOBAL_OVERLAY,
}}>
{children}
}}
onTransitionEnd={onTransitionEnd}>
{(isVisible || renderChildren) && children}
</div>
);
}
......@@ -49,10 +49,10 @@ export default function EditableAccountField({ value, renderForm }: Props) {
<div className="col-span-full sm:col-start-2 flex flex-col gap-4">
{renderForm(
<div className="mt-8 flex flex-row gap-4 items-center justify-center">
<Button buttonStyle="info" small outlined disabled={isLoading} onClick={() => setExpansion(false)}>
<Button kind="info" outlined disabled={isLoading} onClick={() => setExpansion(false)}>
<Message id="account.edit.cancel" />
</Button>
<Button buttonStyle="success" small type="submit" loading={isLoading}>
<Button kind="success" type="submit" loading={isLoading}>
<Message id="account.edit.confirm" />
</Button>
</div>
......
......@@ -77,10 +77,10 @@ export default function DiscordConnectionManagement({ className }: WithClassName
/>
</P>
<div className="flex flex-row gap-4 justify-center">
<Button buttonStyle="success" small disabled={isLoading} onClick={onDisconnectCancelClick}>
<Button kind="success" disabled={isLoading} onClick={onDisconnectCancelClick}>
<Message id="discord.connection.unlink-account.cancel" />
</Button>
<Button buttonStyle="danger" small outlined loading={isLoading} onClick={onDisconnectConfirmClick}>
<Button kind="danger" outlined loading={isLoading} onClick={onDisconnectConfirmClick}>
<Message id="discord.connection.unlink-account.confirm" />
</Button>
</div>
......@@ -102,9 +102,9 @@ export default function DiscordConnectionManagement({ className }: WithClassName
</div>
<Button
onClick={onConnectionButtonClick}
buttonStyle={user ? "danger" : "success"}
kind={user ? "danger" : "success"}
outlined={!!user}
small={!!user}
size={user ? "normal" : "large"}
loading={isLoading}>
{user ? <Message id="discord.connection.unlink-account" /> : <Message id="discord.connection.link-account" />}
</Button>
......
import { Fragment } from "react";
import { DISCORD_GUILD_ICON_URL, DISCORD_INVITE_URL, STYLES } from "../../../constants";
import { colorToHex } from "../../../utils/utils";
import Message from "../../i18n/Message";
import Button from "../Button";
import fallbackDiscordIcon from "./generic-disccord-icon.png";
import { GridTable, GridTableEmpty, GridTableHeader, GridTableRow, GridTableTitle } from "../GridTable";
export type DiscordServerListServerProps = GuildProps;
......@@ -14,49 +14,24 @@ interface Props {
}
export default function DiscordServerList({ title, emptyPlaceholder, guilds }: Props) {
const classes =
// Grid Common
"grid gap-4 p-4 " +
// Grid Small
"grid-cols-1 max-md:justify-items-center " +
// Grid Large
"md:grid-cols-[auto_1fr_1fr_auto_auto] items-center " +
// Colors
`border ${STYLES.BORDER_COLOR} ` +
// Misc
"rounded-lg overflow-hidden leading-none";
const headerClasses =
"col-span-full justify-self-stretch border-b font-bold text-lg -mx-4 -mt-4 px-4 py-2 " +
`bg-slate-50 dark:bg-slate-700 ${STYLES.BORDER_COLOR}`;
return (
<div className={classes}>
<h3 className={headerClasses}>{title}</h3>
{guilds.length ? (
<>
<div className={`contents max-md:hidden ${STYLES.MUTED_COLOR}`}>
<div className="col-span-2">
<Message id="widgets.discord.server" />
</div>
<div className="col-span-3">
<Message id="widgets.discord.roles" />
</div>
</div>
<div className={`col-span-full border-t ${STYLES.BORDER_COLOR} -mx-4`} />
</>
) : (
<div className={`col-span-full text-center ${STYLES.MUTED_COLOR} whitespace-pre-line ${STYLES.LINE_HEIGHT}`}>
{emptyPlaceholder}
<GridTable className="grid-cols-1 md:grid-cols-[auto_1fr_1fr_auto_auto]">
<GridTableTitle>{title}</GridTableTitle>
<GridTableHeader className="gap-4">
<div className="col-span-2">
<Message id="widgets.discord.server" />
</div>
<div className="col-span-3">
<Message id="widgets.discord.roles" />
</div>
)}
{guilds.map(g => (
<Fragment key={g.id}>
<Guild {...g} />
<div className={`last:hidden col-span-full justify-self-stretch border-t ${STYLES.BORDER_COLOR} -mx-4`} />
</Fragment>
</GridTableHeader>
{!guilds.length && <GridTableEmpty>{emptyPlaceholder}</GridTableEmpty>}
{guilds.map(guild => (
<GridTableRow key={guild.id}>
<Guild {...guild} />
</GridTableRow>
))}
</div>
</GridTable>
);
}
......@@ -73,7 +48,7 @@ interface GuildProps {
function Guild({ id, name, iconHash, memberCount, inviteCode, roles, isMember }: GuildProps) {
const guildIconUrl = iconHash ? DISCORD_GUILD_ICON_URL(id, iconHash) : fallbackDiscordIcon;
return (
<>
<div className="grid grid-cols-subgrid col-span-full p-4 gap-4 items-center max-md:justify-items-center">
<img
className={`w-14 h-14 md:w-12 md:h-12 object-contain rounded-xl border ${STYLES.BORDER_COLOR} max-md:mt-4`}
src={guildIconUrl}
......@@ -95,16 +70,16 @@ function Guild({ id, name, iconHash, memberCount, inviteCode, roles, isMember }:
</div>
<div className="max-md:mb-4">
{isMember ? (
<Button small disabled>
<Button disabled>
<Message id="widgets.discord.joined" />
</Button>
) : (
<Button as="a" small href={DISCORD_INVITE_URL(inviteCode)}>
<Button as="a" href={DISCORD_INVITE_URL(inviteCode)}>
<Message id="widgets.discord.join" />
</Button>
)}
</div>
</>
</div>
);
}
......
import { STYLES } from "../../../constants";
import { Fragment, useMemo } from "react";
import { useMemo } from "react";
import GroupListItem from "./GroupListItem";
import type { ApiGroup } from "../../../data/schemas";
import { GridTable, GridTableEmpty, GridTableRow, GridTableTitle } from "../GridTable";
interface Props {
groups: ApiGroup[];
......@@ -13,41 +13,16 @@ type GroupWithChildren = GroupWithLevel & { children: GroupWithChildren[] };
export default function GroupList({ groups }: Props) {
const sortedGroups = useMemo(() => createSortedGroupHierarchy(groups), [groups]);
const classes =
// Grid Common
"grid " +
// Grid Small
// TODO test if this actually looks good
"grid-cols-1 max-md:justify-items-center " +
// Grid Large
"md:grid-cols-[1fr_1fr_auto] items-center " +
// Colors
`border ${STYLES.BORDER_COLOR} ` +
// Misc
"rounded-lg overflow-hidden leading-none";
return (
<div className={classes}>
{groups.length ? (
<>
<div className={`grid grid-cols-subgrid col-span-3 p-4 max-md:hidden ${STYLES.MUTED_COLOR}`}>
<div>Group</div>
<div className="col-span-2">Members</div>
</div>
<div className={`col-span-full border-double border-t-2 ${STYLES.BORDER_COLOR} -mx-4`} />
</>
) : (
<div className={`col-span-full text-center ${STYLES.MUTED_COLOR} whitespace-pre-line ${STYLES.LINE_HEIGHT}`}>
There are no groups you can manage.
</div>
)}
<GridTable className="grid-cols-[1fr_auto] md:grid-cols-[1.333fr_1fr_auto] gap-x-4">
<GridTableTitle>Groups</GridTableTitle>
{!sortedGroups.length && <GridTableEmpty>There are no groups you can manage.</GridTableEmpty>}
{sortedGroups.map(group => (
<Fragment key={group.id}>
<GridTableRow key={group.id}>
<GroupListItem group={group} />
<div className={`last:hidden col-span-full justify-self-stretch border-t ${STYLES.BORDER_COLOR} -mx-4`} />
</Fragment>
</GridTableRow>
))}
</div>
</GridTable>
);
}
......
import classNames from "classnames";
import ChevronRight from "../../../icons/ChevronRight.svg?react";
import ArrowTurnUp from "../../../icons/ArrowTurnUp.svg?react";
import { STYLES } from "../../../constants";
import type { GroupWithLevel } from "./GroupList";
import { Link } from "react-router-dom";
import classNames from "classnames";
interface Props {
group: GroupWithLevel;
......@@ -15,22 +15,24 @@ export default function GroupListItem({ group }: Props) {
const classes =
// Layout
"grid grid-cols-subgrid col-span-3 p-4 " +
"grid grid-cols-subgrid col-span-full p-4 " +
// Misc
`text-left ${STYLES.CLICKABLE}`;
return (
<Link to={`/groups/${group.id}`} className={classes} unstable_viewTransition>
<div
className="flex flex-row items-center gap-2"
className="grid grid-cols-subgrid col-span-1 md:col-span-2 items-center max-md:gap-1"
style={{
paddingLeft: indent,
}}>
{group.level > 0 && <ArrowTurnUp className="w-3 h-3 rotate-90 fill-slate-400 dark:fill-slate-500" />}
{group.name}
<div className="flex flex-row items-center gap-2 break-words">
{group.level > 0 && <ArrowTurnUp className="w-3 h-3 rotate-90 fill-slate-400 dark:fill-slate-500" />}
{group.name}
</div>
<div className={classNames(STYLES.MUTED_COLOR)}>{group.memberCount} members</div>
</div>
<div className={classNames(STYLES.MUTED_COLOR)}>{group.memberCount} members</div>
<ChevronRight className="w-4 h-4 fill-current" />
<ChevronRight className="w-4 h-4 fill-current self-center justify-self-end" />
</Link>
);
}
import { Fragment, useMemo } from "react";
import { STYLES } from "../../../constants";
import { useMemo } from "react";
import UserListItem from "./UserListItem";
import { GridTable, GridTableEmpty, GridTableRow, GridTableTitle } from "../GridTable";
import type { ApiGroup } from "../../../data/schemas";
export interface GroupMember {
id: string;
......@@ -8,46 +9,22 @@ export interface GroupMember {
}
interface Props {
group: ApiGroup;
users: GroupMember[];
}
export default function UserList({ users }: Props) {
export default function UserList({ users, group }: Props) {
const sortedUsers = useMemo(() => users.sort((a, b) => a.name.localeCompare(b.name)), [users]);
const classes =
// Grid Common
"grid " +
// Grid Small
// TODO test if this actually looks good
"grid-cols-1 max-md:justify-items-center " +
// Grid Large
"md:grid-cols-[1fr_auto] items-center " +
// Colors
`border ${STYLES.BORDER_COLOR} ` +
// Misc
"rounded-lg overflow-hidden leading-none";
return (
<div className={classes}>
{users.length ? (
<>
<div className={`grid grid-cols-subgrid col-span-2 p-4 max-md:hidden ${STYLES.MUTED_COLOR}`}>
<div>User</div>
<div>Actions</div>
</div>
<div className={`col-span-full border-double border-t-2 ${STYLES.BORDER_COLOR} -mx-4`} />
</>
) : (
<div className={`col-span-full text-center ${STYLES.MUTED_COLOR} whitespace-pre-line ${STYLES.LINE_HEIGHT}`}>
No members bruh, go add some.
</div>
)}
<GridTable className="grid-cols-[auto_1fr_auto]">
<GridTableTitle>Group Members</GridTableTitle>
{!sortedUsers.length && <GridTableEmpty>No members bruh, go add some.</GridTableEmpty>}
{sortedUsers.map(user => (
<Fragment key={user.id}>
<UserListItem user={user} />
<div className={`last:hidden col-span-full justify-self-stretch border-t ${STYLES.BORDER_COLOR} -mx-4`} />
</Fragment>
<GridTableRow key={user.id}>
<UserListItem user={user} group={group} />
</GridTableRow>
))}
</div>
</GridTable>
);
}
import { useMemo } from "react";
import type { MouseEvent } from "react";
import { useCallback, useMemo, useState } from "react";
import type { GroupMember } from "./UserList";
import { generateInitials } from "../../../utils/utils";
import Button from "../Button";
import XMark from "../../../icons/XMark.svg?react";
import type { ApiGroup } from "../../../data/schemas";
import { STYLES } from "../../../constants";
interface Props {
group: ApiGroup;
user: GroupMember;
onRemove?: (user: GroupMember) => void;
}
export default function UserListItem({ user }: Props) {
const classes =
// Layout
"grid grid-cols-subgrid col-span-2 px-4 py-1";
export default function UserListItem({ user, group, onRemove }: Props) {
const [isLoading, setLoading] = useState(false);
const [showRemovalConfirmation, setShowRemovalConfirmation] = useState(false);
const onRemoveConfirmClick = useCallback(() => {
setLoading(true);
onRemove?.(user);
}, [onRemove, user]);
const onRemoveCancelClick = useCallback(() => {
setShowRemovalConfirmation(false);
}, []);
const onRemoveClick = useCallback(
(e: MouseEvent) => {
if (isLoading) {
return;
}
if (e.getModifierState("Shift")) {
// Skip confirmation prompt
onRemoveConfirmClick();
return;
}
setShowRemovalConfirmation(!showRemovalConfirmation);
},
[isLoading, showRemovalConfirmation, onRemoveConfirmClick]
);
const cancellationConfirmation = showRemovalConfirmation ? (
<div className={`col-span-full p-4 text-center border-t ${STYLES.BORDER_COLOR} border-dotted`}>
Do you really want to remove <strong>{user.name}</strong> from the group <strong>{group.name}</strong>?
<div className="flex flex-row gap-4 justify-center mt-4">
<Button kind="success" disabled={isLoading} onClick={onRemoveCancelClick}>
Nevermind
</Button>
<Button kind="danger" outlined loading={isLoading} onClick={onRemoveConfirmClick}>
Remove
</Button>
</div>
</div>
) : null;
return (
<div className={classes}>
<div className="flex flex-row items-center gap-3">
<>
<div className="grid grid-cols-subgrid col-span-full pl-4 pr-2 py-2 items-center">
<UserIcon user={user} />
<div>{user.name}</div>
</div>
<div className="flex flex-row gap-2 justify-end">
<Button small outlined buttonStyle="danger">
<div className="ml-3">{user.name}</div>
<Button outlined kind="danger" size="small" onClick={onRemoveClick} className="rounded-full">
<XMark className="w-4 h-4 fill-current" />
</Button>
</div>
</div>
{cancellationConfirmation}
</>
);
}
......@@ -40,8 +81,8 @@ function UserIcon({ user }: UserIconProps) {
"flex-shrink-0 w-7 h-7 grid items-center justify-center " +
// Colors
"bg-gray-300 text-gray-600 dark:bg-slate-600 dark:text-gray-300 " +
// Misc
"rounded-full leading-none text-xs font-semibold";
// Font & Misc
"leading-none text-xs font-semibold rounded-full";
return <div className={classes}>{initials}</div>;
}
......@@ -13,7 +13,7 @@ export const STYLES = {
BORDER_COLOR: "border-slate-300 dark:border-slate-600",
LINK_COLOR: "text-blue-700 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300",
CLICKABLE: "cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors",
CLICKABLE: "cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors",
};
export const NO_BREAK_SPACE = "\u00a0";
......@@ -21,6 +21,7 @@ export const NO_BREAK_SPACE = "\u00a0";
export const Z_INDEXES = {
HEADER: 9000,
GLOBAL_OVERLAY: 10000,
MOBILE_NAV: 11000,
};
export const API_OAUTH_AUTHORIZE_URL = (type: string) =>
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM504 312l0-64-64 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l64 0 0-64c0-13.3 10.7-24 24-24s24 10.7 24 24l0 64 64 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-64 0 0 64c0 13.3-10.7 24-24 24s-24-10.7-24-24z"/></svg>
export type WithClassName<T = unknown> = T & { className?: string };
export type WithChildren<T = unknown> = T & { children: React.ReactNode };
export type WithChildrenAndClassName<T = unknown> = WithChildren<WithClassName<T>>;