Replace entire codebase with homarr-labs/homarr
This commit is contained in:
19
packages/ui/src/components/beta-badge.tsx
Normal file
19
packages/ui/src/components/beta-badge.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { BadgeProps } from "@mantine/core";
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
import { useTranslations } from "@homarr/translation/client";
|
||||
|
||||
interface BetaBadgeProps {
|
||||
size: BadgeProps["size"];
|
||||
}
|
||||
|
||||
export const BetaBadge = ({ size }: BetaBadgeProps) => {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<Badge size={size} color="green" variant="outline">
|
||||
{t("common.beta")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
11
packages/ui/src/components/count-badge.module.css
Normal file
11
packages/ui/src/components/count-badge.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.badge {
|
||||
@mixin light {
|
||||
--badge-bg: rgba(30, 34, 39, 0.08);
|
||||
--badge-color: var(--mantine-color-black);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
--badge-bg: #363c44;
|
||||
--badge-color: var(--mantine-color-white);
|
||||
}
|
||||
}
|
||||
11
packages/ui/src/components/count-badge.tsx
Normal file
11
packages/ui/src/components/count-badge.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
import classes from "./count-badge.module.css";
|
||||
|
||||
interface CountBadgeProps {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const CountBadge = ({ count }: CountBadgeProps) => {
|
||||
return <Badge className={classes.badge}>{count}</Badge>;
|
||||
};
|
||||
18
packages/ui/src/components/index.tsx
Normal file
18
packages/ui/src/components/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export * from "./count-badge";
|
||||
export { OverflowBadge } from "./overflow-badge";
|
||||
export { SearchInput } from "./search-input";
|
||||
export * from "./select-with-description";
|
||||
export * from "./select-with-description-and-badge";
|
||||
export { SelectWithCustomItems } from "./select-with-custom-items";
|
||||
export type { SelectWithCustomItemsProps } from "./select-with-custom-items";
|
||||
export { TablePagination } from "./table-pagination";
|
||||
export { TextMultiSelect } from "./text-multi-select";
|
||||
export { UserAvatar } from "./user-avatar";
|
||||
export { UserAvatarGroup } from "./user-avatar-group";
|
||||
export { CustomPasswordInput } from "./password-input/password-input";
|
||||
export { IntegrationAvatar } from "./integration-avatar";
|
||||
export { BetaBadge } from "./beta-badge";
|
||||
export { MaskedImage } from "./masked-image";
|
||||
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
||||
export { LanguageIcon } from "./language-icon";
|
||||
export { Link } from "./link";
|
||||
20
packages/ui/src/components/integration-avatar.tsx
Normal file
20
packages/ui/src/components/integration-avatar.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { MantineRadius, MantineSize } from "@mantine/core";
|
||||
import { Avatar } from "@mantine/core";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
|
||||
interface IntegrationAvatarProps {
|
||||
size: MantineSize;
|
||||
kind: IntegrationKind | null;
|
||||
radius?: MantineRadius;
|
||||
}
|
||||
|
||||
export const IntegrationAvatar = ({ kind, size, radius }: IntegrationAvatarProps) => {
|
||||
const url = kind ? getIconUrl(kind) : null;
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Avatar size={size} src={url} radius={radius} styles={{ image: { objectFit: "contain" } }} />;
|
||||
};
|
||||
11
packages/ui/src/components/language-icon.tsx
Normal file
11
packages/ui/src/components/language-icon.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Image } from "@mantine/core";
|
||||
|
||||
import type { LanguageIconDefinition } from "@homarr/translation";
|
||||
|
||||
export const LanguageIcon = ({ icon }: { icon: LanguageIconDefinition }) => {
|
||||
if (icon.type === "flag") {
|
||||
return <span className={`fi fi-${icon.flag}`} style={{ borderRadius: 4 }}></span>;
|
||||
}
|
||||
|
||||
return <Image src={icon.url} style={{ width: "1.3333em", height: "1.3333em" }} fit="contain" alt="Language icon" />;
|
||||
};
|
||||
5
packages/ui/src/components/link.tsx
Normal file
5
packages/ui/src/components/link.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import NextLink from "next/link";
|
||||
|
||||
export const Link = NextLink;
|
||||
3
packages/ui/src/components/masked-image.module.css
Normal file
3
packages/ui/src/components/masked-image.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.maskedImage {
|
||||
background-color: var(--image-color);
|
||||
}
|
||||
49
packages/ui/src/components/masked-image.tsx
Normal file
49
packages/ui/src/components/masked-image.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { getThemeColor, useMantineTheme } from "@mantine/core";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
import type { Property } from "csstype";
|
||||
|
||||
import classes from "./masked-image.module.css";
|
||||
|
||||
interface MaskedImageProps {
|
||||
imageUrl?: string;
|
||||
color: MantineColor;
|
||||
alt?: string;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
maskSize?: Property.MaskSize;
|
||||
maskRepeat?: Property.MaskRepeat;
|
||||
maskPosition?: Property.MaskPosition;
|
||||
}
|
||||
|
||||
export const MaskedImage = ({
|
||||
imageUrl,
|
||||
color,
|
||||
alt,
|
||||
style,
|
||||
className,
|
||||
maskSize = "contain",
|
||||
maskRepeat = "no-repeat",
|
||||
maskPosition = "center",
|
||||
}: MaskedImageProps) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={combineClasses(classes.maskedImage, className)}
|
||||
role="img"
|
||||
aria-label={alt}
|
||||
style={
|
||||
{
|
||||
...style,
|
||||
"--image-color": getThemeColor(color, theme),
|
||||
maskSize,
|
||||
maskRepeat,
|
||||
maskPosition,
|
||||
maskImage: imageUrl ? `url(${imageUrl})` : undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
55
packages/ui/src/components/masked-or-normal-image.tsx
Normal file
55
packages/ui/src/components/masked-or-normal-image.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Image } from "@mantine/core";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
import type { Property } from "csstype";
|
||||
|
||||
import { MaskedImage } from "./masked-image";
|
||||
|
||||
interface MaskedOrNormalImageProps {
|
||||
imageUrl?: string;
|
||||
hasColor?: boolean;
|
||||
color?: MantineColor;
|
||||
alt?: string;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
fit?: Property.ObjectFit;
|
||||
maskSize?: Property.MaskSize;
|
||||
maskRepeat?: Property.MaskRepeat;
|
||||
maskPosition?: Property.MaskPosition;
|
||||
}
|
||||
|
||||
export const MaskedOrNormalImage = ({
|
||||
imageUrl,
|
||||
hasColor = true,
|
||||
color = "iconColor",
|
||||
alt,
|
||||
style,
|
||||
className,
|
||||
fit = "contain",
|
||||
maskSize = "contain",
|
||||
maskRepeat = "no-repeat",
|
||||
maskPosition = "center",
|
||||
}: MaskedOrNormalImageProps) => {
|
||||
return hasColor ? (
|
||||
<MaskedImage
|
||||
imageUrl={imageUrl}
|
||||
color={color}
|
||||
alt={alt}
|
||||
className={combineClasses("masked-image", className)}
|
||||
maskSize={maskSize}
|
||||
maskRepeat={maskRepeat}
|
||||
maskPosition={maskPosition}
|
||||
style={{
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
className={combineClasses("normal-image", className)}
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
fit={fit}
|
||||
style={{ ...style }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
51
packages/ui/src/components/overflow-badge.tsx
Normal file
51
packages/ui/src/components/overflow-badge.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { BadgeProps, MantineSpacing } from "@mantine/core";
|
||||
import { Badge, Group, Popover, Stack, UnstyledButton } from "@mantine/core";
|
||||
|
||||
export function OverflowBadge({
|
||||
data,
|
||||
overflowCount = 3,
|
||||
disablePopover = false,
|
||||
groupGap = "xs",
|
||||
...props
|
||||
}: {
|
||||
data: string[];
|
||||
overflowCount?: number;
|
||||
disablePopover?: boolean;
|
||||
groupGap?: MantineSpacing;
|
||||
} & BadgeProps) {
|
||||
const badgeProps = {
|
||||
variant: "default",
|
||||
size: "lg",
|
||||
radius: "sm",
|
||||
...props,
|
||||
};
|
||||
return (
|
||||
<Popover width="content" shadow="md" disabled={disablePopover}>
|
||||
<Group gap={groupGap}>
|
||||
{data.slice(0, overflowCount).map((item) => (
|
||||
<Badge key={item} px="xs" {...badgeProps}>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
{data.length > overflowCount && (
|
||||
<Popover.Target>
|
||||
<UnstyledButton display="flex">
|
||||
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
|
||||
+{data.length - overflowCount}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
)}
|
||||
</Group>
|
||||
<Popover.Dropdown>
|
||||
<Stack>
|
||||
{data.slice(overflowCount).map((item) => (
|
||||
<Badge key={item} {...badgeProps}>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
35
packages/ui/src/components/password-input/password-input.tsx
Normal file
35
packages/ui/src/components/password-input/password-input.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { PasswordInput } from "@mantine/core";
|
||||
import type { PasswordInputProps } from "@mantine/core";
|
||||
|
||||
import { PasswordRequirementsPopover } from "./password-requirements-popover";
|
||||
|
||||
interface CustomPasswordInputProps extends PasswordInputProps {
|
||||
withPasswordRequirements?: boolean;
|
||||
}
|
||||
|
||||
export const CustomPasswordInput = ({ withPasswordRequirements, ...props }: CustomPasswordInputProps) => {
|
||||
if (withPasswordRequirements) {
|
||||
return <WithPasswordRequirements {...props} />;
|
||||
}
|
||||
|
||||
return <PasswordInput {...props} />;
|
||||
};
|
||||
|
||||
const WithPasswordRequirements = (props: PasswordInputProps) => {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.currentTarget.value);
|
||||
props.onChange?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<PasswordRequirementsPopover password={value}>
|
||||
<PasswordInput {...props} onChange={onChange} />
|
||||
</PasswordRequirementsPopover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { rem, Text } from "@mantine/core";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
|
||||
export function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
|
||||
return (
|
||||
<Text c={meets ? "teal" : "red"} display="flex" style={{ alignItems: "center" }} size="sm">
|
||||
{meets ? (
|
||||
<IconCheck style={{ width: rem(14), height: rem(14) }} />
|
||||
) : (
|
||||
<IconX style={{ width: rem(14), height: rem(14) }} />
|
||||
)}
|
||||
<Text span ml={10}>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import { Popover, Progress } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { passwordRequirements } from "@homarr/validation/user";
|
||||
|
||||
import { PasswordRequirement } from "./password-requirement";
|
||||
|
||||
export const PasswordRequirementsPopover = ({ password, children }: PropsWithChildren<{ password: string }>) => {
|
||||
const requirements = useRequirements();
|
||||
const strength = useStrength(password);
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
const checks = (
|
||||
<>
|
||||
{requirements.map((requirement) => (
|
||||
<PasswordRequirement key={requirement.label} label={requirement.label} meets={requirement.check(password)} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const color = strength === 100 ? "teal" : strength > 50 ? "yellow" : "red";
|
||||
|
||||
return (
|
||||
<Popover opened={popoverOpened} position="bottom" width="target" transitionProps={{ transition: "pop" }}>
|
||||
<Popover.Target>
|
||||
<div onFocusCapture={() => setPopoverOpened(true)} onBlurCapture={() => setPopoverOpened(false)}>
|
||||
{children}
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Progress color={color} value={strength} size={5} mb="xs" />
|
||||
{checks}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const useRequirements = () => {
|
||||
const t = useScopedI18n("user.field.password.requirement");
|
||||
|
||||
return passwordRequirements.map(({ check, value }) => ({ check, label: t(value) }));
|
||||
};
|
||||
|
||||
function useStrength(password: string) {
|
||||
const requirements = useRequirements();
|
||||
|
||||
return (100 / requirements.length) * requirements.filter(({ check }) => check(password)).length;
|
||||
}
|
||||
58
packages/ui/src/components/search-input.tsx
Normal file
58
packages/ui/src/components/search-input.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Loader, TextInput } from "@mantine/core";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
interface SearchInputProps {
|
||||
defaultValue?: string;
|
||||
placeholder: string;
|
||||
flexExpand?: boolean;
|
||||
}
|
||||
|
||||
export const SearchInput = ({ placeholder, defaultValue, flexExpand = false }: SearchInputProps) => {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const { replace } = useRouter();
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const handleSearchDebounced = useDebouncedCallback((value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("search", value.toString());
|
||||
if (params.has("page")) params.set("page", "1"); // Reset page to 1
|
||||
replace(`${pathName}?${params.toString()}`);
|
||||
setLoading(false);
|
||||
}, 250);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setLoading(true);
|
||||
handleSearchDebounced(event.currentTarget.value);
|
||||
},
|
||||
[setLoading, handleSearchDebounced],
|
||||
);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
leftSection={<LeftSection loading={loading} />}
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleSearch}
|
||||
placeholder={placeholder}
|
||||
style={{ flex: flexExpand ? "1" : undefined }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface LeftSectionProps {
|
||||
loading: boolean;
|
||||
}
|
||||
const LeftSection = ({ loading }: LeftSectionProps) => {
|
||||
if (loading) {
|
||||
return <Loader size="xs" />;
|
||||
}
|
||||
|
||||
return <IconSearch size={20} stroke={1.5} />;
|
||||
};
|
||||
98
packages/ui/src/components/select-with-custom-items.tsx
Normal file
98
packages/ui/src/components/select-with-custom-items.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import { Combobox, ComboboxClearButton, Input, InputBase, useCombobox } from "@mantine/core";
|
||||
import { useUncontrolled } from "@mantine/hooks";
|
||||
|
||||
interface BaseSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem> extends Pick<
|
||||
SelectProps,
|
||||
"label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder" | "clearable"
|
||||
> {
|
||||
data: TSelectItem[];
|
||||
description?: string;
|
||||
withAsterisk?: boolean;
|
||||
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
w?: string;
|
||||
}
|
||||
|
||||
type Props<TSelectItem extends BaseSelectItem> = SelectWithCustomItemsProps<TSelectItem> & {
|
||||
SelectOption: React.ComponentType<TSelectItem>;
|
||||
};
|
||||
|
||||
export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
|
||||
data,
|
||||
onChange,
|
||||
value,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
SelectOption,
|
||||
w,
|
||||
clearable,
|
||||
...props
|
||||
}: Props<TSelectItem>) => {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const [_value, setValue] = useUncontrolled({
|
||||
value,
|
||||
defaultValue,
|
||||
finalValue: null,
|
||||
onChange,
|
||||
});
|
||||
|
||||
const selectedOption = useMemo(() => data.find((item) => item.value === _value), [data, _value]);
|
||||
|
||||
const options = data.map((item) => (
|
||||
<Combobox.Option value={item.value} key={item.value}>
|
||||
<SelectOption {...item} />
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
const toggle = useCallback(() => combobox.toggleDropdown(), [combobox]);
|
||||
const onOptionSubmit = useCallback(
|
||||
(value: string) => {
|
||||
setValue(
|
||||
value,
|
||||
data.find((item) => item.value === value),
|
||||
);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
[setValue, data, combobox],
|
||||
);
|
||||
|
||||
const _clearable = clearable && Boolean(_value);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal={false} onOptionSubmit={onOptionSubmit}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
{...props}
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
__clearSection={<ComboboxClearButton onClear={() => setValue(null, null)} />}
|
||||
__clearable={_clearable}
|
||||
__defaultRightSection={<Combobox.Chevron />}
|
||||
onClick={toggle}
|
||||
rightSectionPointerEvents={_clearable ? "all" : "none"}
|
||||
multiline
|
||||
w={w}
|
||||
>
|
||||
{selectedOption ? <SelectOption {...selectedOption} /> : <Input.Placeholder>{placeholder}</Input.Placeholder>}
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>{options}</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
|
||||
import type { SelectWithCustomItemsProps } from "./select-with-custom-items";
|
||||
import { SelectWithCustomItems } from "./select-with-custom-items";
|
||||
|
||||
export interface SelectItemWithDescriptionBadge {
|
||||
value: string;
|
||||
label: string;
|
||||
badge?: { label: string; color: MantineColor };
|
||||
description: string;
|
||||
}
|
||||
type Props = SelectWithCustomItemsProps<SelectItemWithDescriptionBadge>;
|
||||
|
||||
export const SelectWithDescriptionBadge = (props: Props) => {
|
||||
return <SelectWithCustomItems<SelectItemWithDescriptionBadge> {...props} SelectOption={SelectOption} />;
|
||||
};
|
||||
|
||||
const SelectOption = ({ label, description, badge }: SelectItemWithDescriptionBadge) => {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="xs" opacity={0.6}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{badge && (
|
||||
<Badge color={badge.color} variant="outline" size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
30
packages/ui/src/components/select-with-description.tsx
Normal file
30
packages/ui/src/components/select-with-description.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import type { SelectWithCustomItemsProps } from "./select-with-custom-items";
|
||||
import { SelectWithCustomItems } from "./select-with-custom-items";
|
||||
|
||||
export interface SelectItemWithDescription {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
type Props = SelectWithCustomItemsProps<SelectItemWithDescription>;
|
||||
|
||||
export const SelectWithDescription = (props: Props) => {
|
||||
return <SelectWithCustomItems<SelectItemWithDescription> {...props} SelectOption={SelectOption} />;
|
||||
};
|
||||
|
||||
const SelectOption = ({ label, description }: SelectItemWithDescription) => {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="xs" opacity={0.6}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
packages/ui/src/components/table-pagination.tsx
Normal file
70
packages/ui/src/components/table-pagination.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { PaginationProps } from "@mantine/core";
|
||||
import { Pagination } from "@mantine/core";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
interface TablePaginationProps {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const TablePagination = ({ total }: TablePaginationProps) => {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const { replace } = useRouter();
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const current = Number(searchParams.get("page")) || 1;
|
||||
|
||||
const getItemProps = useCallback(
|
||||
(page: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", page.toString());
|
||||
|
||||
return {
|
||||
component: Link,
|
||||
href: `?${params.toString()}`,
|
||||
};
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const getControlProps = useCallback(
|
||||
(control: ControlType) => {
|
||||
return getItemProps(calculatePageFor(control, current, total));
|
||||
},
|
||||
[current, getItemProps, total],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(page: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", page.toString());
|
||||
replace(`${pathName}?${params.toString()}`);
|
||||
},
|
||||
[pathName, replace, searchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pagination total={total} getItemProps={getItemProps} getControlProps={getControlProps} onChange={handleChange} />
|
||||
);
|
||||
};
|
||||
|
||||
type ControlType = Parameters<Exclude<PaginationProps["getControlProps"], undefined>>[0];
|
||||
const calculatePageFor = (type: ControlType, current: number, total: number) => {
|
||||
switch (type) {
|
||||
case "first":
|
||||
return 1;
|
||||
case "previous":
|
||||
return Math.max(current - 1, 1);
|
||||
case "next":
|
||||
return current + 1;
|
||||
case "last":
|
||||
return total;
|
||||
default:
|
||||
console.error(`Unknown pagination control type: ${type as string}`);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
99
packages/ui/src/components/text-multi-select.tsx
Normal file
99
packages/ui/src/components/text-multi-select.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
import { Combobox, Group, Pill, PillsInput, Text, useCombobox } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface TextMultiSelectProps {
|
||||
label: string;
|
||||
value?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onFocus?: FocusEventHandler;
|
||||
onBlur?: FocusEventHandler;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const TextMultiSelect = ({ label, value = [], onChange, onBlur, onFocus, error }: TextMultiSelectProps) => {
|
||||
const t = useI18n();
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const exactOptionMatch = value.some((item) => item === search);
|
||||
|
||||
const handleValueSelect = (selectedValue: string) => {
|
||||
setSearch("");
|
||||
|
||||
if (selectedValue === "$create") {
|
||||
onChange([...value, search]);
|
||||
} else {
|
||||
onChange(value.filter((filterValue) => filterValue !== selectedValue));
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueRemove = (removedValue: string) =>
|
||||
onChange(value.filter((filterValue) => filterValue !== removedValue));
|
||||
|
||||
const values = value.map((item) => (
|
||||
<Pill key={item} withRemoveButton onRemove={() => handleValueRemove(item)}>
|
||||
{item}
|
||||
</Pill>
|
||||
));
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} onOptionSubmit={handleValueSelect} withinPortal={false}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput label={label} error={error} onClick={() => combobox.openDropdown()}>
|
||||
<Pill.Group>
|
||||
{values}
|
||||
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
value={search}
|
||||
placeholder={t("common.multiText.placeholder")}
|
||||
onChange={(event) => {
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Backspace" && search.length === 0) {
|
||||
event.preventDefault();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleValueRemove(value.at(-1)!);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</Pill.Group>
|
||||
</PillsInput>
|
||||
</Combobox.DropdownTarget>
|
||||
|
||||
{!exactOptionMatch && search.trim().length > 0 && (
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
<Combobox.Option value="$create">
|
||||
<Group>
|
||||
<IconPlus size={12} />
|
||||
<Text size="sm">{t("common.multiText.addLabel", { value: search })}</Text>
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
44
packages/ui/src/components/user-avatar-group.tsx
Normal file
44
packages/ui/src/components/user-avatar-group.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import { Avatar, AvatarGroup, Tooltip, TooltipGroup } from "@mantine/core";
|
||||
|
||||
import type { UserProps } from "./user-avatar";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
|
||||
interface UserAvatarGroupProps {
|
||||
size: MantineSize;
|
||||
limit: number;
|
||||
users: UserProps[];
|
||||
}
|
||||
|
||||
export const UserAvatarGroup = ({ size, limit, users }: UserAvatarGroupProps) => {
|
||||
return (
|
||||
<TooltipGroup openDelay={300} closeDelay={300}>
|
||||
<AvatarGroup>
|
||||
{users.slice(0, limit).map((user) => (
|
||||
<Tooltip key={user.name} label={user.name} withArrow>
|
||||
<UserAvatar user={user} size={size} />
|
||||
</Tooltip>
|
||||
))}
|
||||
<MoreUsers size={size} users={users} offset={limit} />
|
||||
</AvatarGroup>
|
||||
</TooltipGroup>
|
||||
);
|
||||
};
|
||||
|
||||
interface MoreUsersProps {
|
||||
size: MantineSize;
|
||||
users: unknown[];
|
||||
offset: number;
|
||||
}
|
||||
|
||||
const MoreUsers = ({ size, users, offset }: MoreUsersProps) => {
|
||||
if (users.length <= offset) return null;
|
||||
|
||||
const moreAmount = users.length - offset;
|
||||
|
||||
return (
|
||||
<Avatar size={size} radius="xl">
|
||||
+{moreAmount}
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
35
packages/ui/src/components/user-avatar.tsx
Normal file
35
packages/ui/src/components/user-avatar.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { AvatarProps } from "@mantine/core";
|
||||
import { Avatar } from "@mantine/core";
|
||||
import { enc, MD5 } from "crypto-js";
|
||||
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
export interface UserProps {
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
interface UserAvatarProps {
|
||||
user: UserProps | null;
|
||||
size: AvatarProps["size"];
|
||||
}
|
||||
|
||||
export const UserAvatar = ({ user, size }: UserAvatarProps) => {
|
||||
const { enableGravatar } = useSettings();
|
||||
|
||||
if (!user?.name) return <Avatar size={size} />;
|
||||
|
||||
if (user.image) {
|
||||
return <Avatar src={user.image} alt={user.name} size={size} />;
|
||||
}
|
||||
|
||||
if (user.email && enableGravatar) {
|
||||
const emailHash = MD5(user.email.trim().toLowerCase()).toString(enc.Hex);
|
||||
return <Avatar src={`https://seccdn.libravatar.org/avatar/${emailHash}?d=blank`} alt={user.name} size={size} />;
|
||||
}
|
||||
|
||||
return <Avatar name={user.name} color="initials" size={size}></Avatar>;
|
||||
};
|
||||
Reference in New Issue
Block a user