Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions

View 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>
);
};

View 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);
}
}

View 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>;
};

View 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";

View 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" } }} />;
};

View 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" />;
};

View File

@@ -0,0 +1,5 @@
"use client";
import NextLink from "next/link";
export const Link = NextLink;

View File

@@ -0,0 +1,3 @@
.maskedImage {
background-color: var(--image-color);
}

View 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
}
/>
);
};

View 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 }}
/>
);
};

View 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>
);
}

View 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>
);
};

View File

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

View File

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

View 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} />;
};

View 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>
);
};

View File

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

View 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>
);
};

View 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;
}
};

View 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>
);
};

View 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>
);
};

View 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>;
};