feat: improve consistency and design (#1867)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2025-01-31 23:32:08 +01:00
committed by GitHub
parent 6a9b65f219
commit 5f36d8b125
21 changed files with 216 additions and 99 deletions

View File

@@ -1,5 +1,5 @@
.bannerContainer { .bannerContainer {
border-radius: 8px; border-radius: 16px;
overflow: hidden; overflow: hidden;
@mixin dark { @mixin dark {
background: linear-gradient( background: linear-gradient(

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import type { z } from "zod"; import type { z } from "zod";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -14,14 +14,21 @@ import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.app.manage>; type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps { interface AppFormProps {
submitButtonTranslation: (t: TranslationFunction) => string; buttonLabels: {
submit: string;
submitAndCreateAnother?: string;
};
initialValues?: FormType; initialValues?: FormType;
handleSubmit: (values: FormType) => void; handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
isPending: boolean; isPending: boolean;
} }
export const AppForm = (props: AppFormProps) => { export const AppForm = ({
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props; buttonLabels,
handleSubmit: originalHandleSubmit,
initialValues,
isPending,
}: AppFormProps) => {
const t = useI18n(); const t = useI18n();
const form = useZodForm(validation.app.manage, { const form = useZodForm(validation.app.manage, {
@@ -33,11 +40,23 @@ export const AppForm = (props: AppFormProps) => {
}, },
}); });
const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current;
const afterSuccess = shouldCreateAnother.current
? () => {
form.reset();
shouldCreateAnother.current = false;
}
: undefined;
originalHandleSubmit(values, redirect, afterSuccess);
};
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} /> <TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} /> <IconPicker {...form.getInputProps("iconUrl")} />
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} /> <Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} /> <TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
@@ -45,8 +64,19 @@ export const AppForm = (props: AppFormProps) => {
<Button variant="default" component={Link} href="/manage/apps"> <Button variant="default" component={Link} href="/manage/apps">
{t("common.action.backToOverview")} {t("common.action.backToOverview")}
</Button> </Button>
{buttonLabels.submitAndCreateAnother && (
<Button
type="submit"
onClick={() => {
shouldCreateAnother.current = true;
}}
loading={isPending}
>
{buttonLabels.submitAndCreateAnother}
</Button>
)}
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending}>
{submitButtonTranslation(t)} {buttonLabels.submit}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -8,8 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation"; import type { validation } from "@homarr/validation";
import { AppForm } from "../../_form"; import { AppForm } from "../../_form";
@@ -19,14 +18,15 @@ interface AppEditFormProps {
} }
export const AppEditForm = ({ app }: AppEditFormProps) => { export const AppEditForm = ({ app }: AppEditFormProps) => {
const t = useScopedI18n("app.page.edit.notification"); const tScoped = useScopedI18n("app.page.edit.notification");
const t = useI18n();
const router = useRouter(); const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({ const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => { onSuccess: () => {
showSuccessNotification({ showSuccessNotification({
title: t("success.title"), title: tScoped("success.title"),
message: t("success.message"), message: tScoped("success.message"),
}); });
void revalidatePathActionAsync("/manage/apps").then(() => { void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps"); router.push("/manage/apps");
@@ -34,8 +34,8 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
title: t("error.title"), title: tScoped("error.title"),
message: t("error.message"), message: tScoped("error.message"),
}); });
}, },
}); });
@@ -50,11 +50,11 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
[mutate, app.id], [mutate, app.id],
); );
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
return ( return (
<AppForm <AppForm
submitButtonTranslation={submitButtonTranslation} buttonLabels={{
submit: t("common.action.save"),
}}
initialValues={app} initialValues={app}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
isPending={isPending} isPending={isPending}

View File

@@ -7,44 +7,55 @@ import type { z } from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation"; import type { validation } from "@homarr/validation";
import { AppForm } from "../_form"; import { AppForm } from "../_form";
export const AppNewForm = () => { export const AppNewForm = () => {
const t = useScopedI18n("app.page.create.notification"); const tScoped = useScopedI18n("app.page.create.notification");
const t = useI18n();
const router = useRouter(); const router = useRouter();
const { mutate, isPending } = clientApi.app.create.useMutation({ const { mutate, isPending } = clientApi.app.create.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
title: t("error.title"), title: tScoped("error.title"),
message: t("error.message"), message: tScoped("error.message"),
}); });
}, },
}); });
const handleSubmit = useCallback( const handleSubmit = useCallback(
(values: z.infer<typeof validation.app.manage>) => { (values: z.infer<typeof validation.app.manage>, redirect: boolean, afterSuccess?: () => void) => {
mutate(values); mutate(values, {
}, onSuccess() {
[mutate], showSuccessNotification({
); title: tScoped("success.title"),
message: tScoped("success.message"),
});
afterSuccess?.();
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []); if (!redirect) {
return;
}
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
});
},
[mutate, router, tScoped],
);
return ( return (
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} /> <AppForm
buttonLabels={{
submit: t("common.action.create"),
submitAndCreateAnother: t("common.action.createAnother"),
}}
handleSubmit={handleSubmit}
isPending={isPending}
/>
); );
}; };

View File

@@ -45,7 +45,7 @@ export default async function AppsPage(props: AppsPageProps) {
<Stack> <Stack>
<Title>{t("page.list.title")}</Title> <Title>{t("page.list.title")}</Title>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} /> <SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
{session.user.permissions.includes("app-create") && ( {session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new"> <MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")} {t("page.create.title")}

View File

@@ -67,7 +67,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock; const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return ( return (
<Card withBorder> <Card radius="lg" withBorder>
<CardSection p="sm" withBorder> <CardSection p="sm" withBorder>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
@@ -106,15 +106,25 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Group> </Group>
</CardSection> </CardSection>
<CardSection p="sm"> <CardSection>
<Group wrap="nowrap"> <Group gap={0} wrap="nowrap">
<Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth> <Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
{t("action.open.label")} {t("action.open.label")}
</Button> </Button>
{isMenuVisible && ( {isMenuVisible && (
<Menu position="bottom-end"> <Menu position="bottom-end">
<MenuTarget> <MenuTarget>
<ActionIcon variant="default" size="lg"> <ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size="lg"
>
<IconDotsVertical size={16} stroke={1.5} /> <IconDotsVertical size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
</MenuTarget> </MenuTarget>

View File

@@ -33,6 +33,7 @@ export const IntegrationCreateDropdownContent = () => {
value={search} value={search}
data-autofocus data-autofocus
onChange={handleSearch} onChange={handleSearch}
variant="filled"
/> />
{filteredKinds.length > 0 ? ( {filteredKinds.length > 0 ? (

View File

@@ -0,0 +1,36 @@
.root {
border-radius: var(--mantine-radius-lg);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.item {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border: 1px solid transparent;
position: relative;
z-index: 0;
transition: transform 150ms ease;
overflow: hidden;
&[data-first="true"] {
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
}
&[data-last="true"] {
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
}
&[data-active] {
transform: scale(1.01);
z-index: 1;
background-color: var(--mantine-color-body);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-lg);
}
}
.chevron {
&[data-rotate] {
transform: rotate(180deg);
}
}

View File

@@ -44,6 +44,7 @@ import { NoResults } from "~/components/no-results";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
import classes from "./page.module.css";
interface IntegrationsPageProps { interface IntegrationsPageProps {
searchParams: Promise<{ searchParams: Promise<{
@@ -133,7 +134,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />; return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
} }
const grouppedIntegrations = integrations.reduce( const groupedIntegrations = integrations.reduce(
(acc, integration) => { (acc, integration) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!acc[integration.kind]) { if (!acc[integration.kind]) {
@@ -147,11 +148,13 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>, {} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
); );
const entries = objectEntries(groupedIntegrations);
return ( return (
<ActiveTabAccordion defaultValue={activeTab} variant="separated"> <ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => ( {entries.map(([kind, integrations], index) => (
<AccordionItem key={kind} value={kind}> <AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}> <AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group> <Group>
<Text>{getIntegrationName(kind)}</Text> <Text>{getIntegrationName(kind)}</Text>
<CountBadge count={integrations.length} /> <CountBadge count={integrations.length} />

View File

@@ -1,26 +1,26 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core"; import { AppShellMain } from "@mantine/core";
import { import {
IconAffiliateFilled,
IconBook2, IconBook2,
IconBox, IconBox,
IconBrandDiscord, IconBrandDiscord,
IconBrandDocker, IconBrandDocker,
IconBrandGithub, IconBrandGithub,
IconBrandTablerFilled,
IconCertificate, IconCertificate,
IconClipboardListFilled,
IconDirectionsFilled,
IconGitFork, IconGitFork,
IconHome, IconHelpSquareRoundedFilled,
IconInfoSmall, IconHomeFilled,
IconLayoutDashboard, IconLayoutDashboardFilled,
IconLogs,
IconMailForward, IconMailForward,
IconPhoto, IconPhotoFilled,
IconPlug, IconPointerFilled,
IconQuestionMark,
IconReport,
IconSearch, IconSearch,
IconSettings, IconSettingsFilled,
IconTool, IconUserFilled,
IconUser,
IconUsers, IconUsers,
IconUsersGroup, IconUsersGroup,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@@ -31,6 +31,7 @@ import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation"; import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
@@ -41,11 +42,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
const navigationLinks: NavigationLink[] = [ const navigationLinks: NavigationLink[] = [
{ {
label: t("items.home"), label: t("items.home"),
icon: IconHome, icon: IconHomeFilled,
href: "/manage", href: "/manage",
}, },
{ {
icon: IconLayoutDashboard, icon: IconLayoutDashboardFilled,
href: "/manage/boards", href: "/manage/boards",
label: t("items.boards"), label: t("items.boards"),
}, },
@@ -54,9 +55,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/apps", href: "/manage/apps",
label: t("items.apps"), label: t("items.apps"),
hidden: !session, hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
}, },
{ {
icon: IconPlug, icon: IconAffiliateFilled,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session, hidden: !session,
@@ -66,15 +70,18 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/search-engines", href: "/manage/search-engines",
label: t("items.searchEngies"), label: t("items.searchEngies"),
hidden: !session, hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
}, },
{ {
icon: IconPhoto, icon: IconPhotoFilled,
href: "/manage/medias", href: "/manage/medias",
label: t("items.medias"), label: t("items.medias"),
hidden: !session, hidden: !session,
}, },
{ {
icon: IconUser, icon: IconUserFilled,
label: t("items.users.label"), label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
items: [ items: [
@@ -98,7 +105,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
{ {
label: t("items.tools.label"), label: t("items.tools.label"),
icon: IconTool, icon: IconPointerFilled,
// As permissions always include there children permissions, we can check other-view-logs as admin includes it // As permissions always include there children permissions, we can check other-view-logs as admin includes it
hidden: !session?.user.permissions.includes("other-view-logs"), hidden: !session?.user.permissions.includes("other-view-logs"),
items: [ items: [
@@ -110,13 +117,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
{ {
label: t("items.tools.items.api"), label: t("items.tools.items.api"),
icon: IconPlug, icon: IconDirectionsFilled,
href: "/manage/tools/api", href: "/manage/tools/api",
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.tools.items.logs"), label: t("items.tools.items.logs"),
icon: IconLogs, icon: IconBrandTablerFilled,
href: "/manage/tools/logs", href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"), hidden: !session?.user.permissions.includes("other-view-logs"),
}, },
@@ -128,7 +135,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
{ {
label: t("items.tools.items.tasks"), label: t("items.tools.items.tasks"),
icon: IconReport, icon: IconClipboardListFilled,
href: "/manage/tools/tasks", href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
@@ -137,12 +144,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{ {
label: t("items.settings"), label: t("items.settings"),
href: "/manage/settings", href: "/manage/settings",
icon: IconSettings, icon: IconSettingsFilled,
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
}, },
{ {
label: t("items.help.label"), label: t("items.help.label"),
icon: IconQuestionMark, icon: IconHelpSquareRoundedFilled,
items: [ items: [
{ {
label: t("items.help.items.documentation"), label: t("items.help.items.documentation"),
@@ -172,7 +179,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
{ {
label: t("items.about"), label: t("items.about"),
icon: IconInfoSmall, icon: homarrLogoPath,
href: "/manage/about", href: "/manage/about",
}, },
]; ];

View File

@@ -61,7 +61,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
const { items: medias, totalCount } = await api.media.getPaginated(searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
return ( return (
<ManageContainer size="xl"> <ManageContainer>
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Stack> <Stack>
<Title>{t("media.plural")}</Title> <Title>{t("media.plural")}</Title>

View File

@@ -83,7 +83,7 @@ export default async function ManagementPage() {
{links.map( {links.map(
(link) => (link) =>
!link.hidden && ( !link.hidden && (
<Card component={Link} href={link.href} key={link.href} withBorder> <Card component={Link} href={link.href} key={link.href} radius="lg">
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap"> <Group wrap="nowrap">
<Text size="2.4rem" fw="bolder"> <Text size="2.4rem" fw="bolder">

View File

@@ -58,7 +58,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} /> <IconPicker {...form.getInputProps("iconUrl")} />
<Fieldset legend={t("search.engine.page.edit.configControl")}> <Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl <SegmentedControl

View File

@@ -45,7 +45,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
<Stack> <Stack>
<Title>{tEngine("page.list.title")}</Title> <Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} /> <SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
{session.user.permissions.includes("search-engine-create") && ( {session.user.permissions.includes("search-engine-create") && (
<MobileAffixButton component={Link} href="/manage/search-engines/new"> <MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")} {tEngine("page.create.title")}

View File

@@ -1,5 +1,5 @@
import type { FocusEventHandler } from "react"; import type { FocusEventHandler } from "react";
import { startTransition, useState } from "react"; import { startTransition } from "react";
import { import {
ActionIcon, ActionIcon,
Box, Box,
@@ -17,7 +17,7 @@ import {
UnstyledButton, UnstyledButton,
useCombobox, useCombobox,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react"; import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -28,17 +28,27 @@ import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media"
import classes from "./icon-picker.module.css"; import classes from "./icon-picker.module.css";
interface IconPickerProps { interface IconPickerProps {
initialValue?: string; value?: string;
onChange: (iconUrl: string) => void; onChange: (iconUrl: string) => void;
error?: string | null; error?: string | null;
onFocus?: FocusEventHandler; onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler; onBlur?: FocusEventHandler;
} }
export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: IconPickerProps) => { export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur }: IconPickerProps) => {
const [value, setValue] = useState<string>(initialValue ?? ""); const [value, setValue] = useUncontrolled({
const [search, setSearch] = useState(initialValue ?? ""); value: propsValue,
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null); onChange,
});
const [search, setSearch] = useUncontrolled({
value,
onChange: (value) => {
setValue(value);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const { data: session } = useSession(); const { data: session } = useSession();
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
@@ -68,10 +78,9 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
onClick={() => { onClick={() => {
const value = item.url; const value = item.url;
startTransition(() => { startTransition(() => {
setValue(value);
setPreviewUrl(value); setPreviewUrl(value);
setSearch(value); setSearch(value);
onChange(value); setValue(value);
combobox.closeDropdown(); combobox.closeDropdown();
}); });
}} }}
@@ -128,7 +137,6 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
setSearch(event.currentTarget.value); setSearch(event.currentTarget.value);
setValue(event.currentTarget.value); setValue(event.currentTarget.value);
setPreviewUrl(null); setPreviewUrl(null);
onChange(event.currentTarget.value);
}} }}
onClick={() => combobox.openDropdown()} onClick={() => combobox.openDropdown()}
onFocus={(event) => { onFocus={(event) => {
@@ -154,7 +162,6 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
setValue(url); setValue(url);
setPreviewUrl(url); setPreviewUrl(url);
setSearch(url); setSearch(url);
onChange(url);
}); });
}} }}
> >

View File

@@ -20,6 +20,7 @@ export const DesktopSearchInput = () => {
size="sm" size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />} leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight} onClick={openSpotlight}
radius="xl"
> >
{`${t("search.placeholder")}...`} {`${t("search.placeholder")}...`}
</TextInput> </TextInput>

View File

@@ -1,7 +1,7 @@
import type { JSX } from "react"; import type { JSX } from "react";
import { AppShellNavbar, AppShellSection, ScrollArea } from "@mantine/core"; import { AppShellNavbar, AppShellSection, Image, ScrollArea } from "@mantine/core";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon, TablerIconProps } from "@homarr/ui";
import type { ClientNavigationLink } from "./navigation-link"; import type { ClientNavigationLink } from "./navigation-link";
import { CommonNavLink } from "./navigation-link"; import { CommonNavLink } from "./navigation-link";
@@ -27,8 +27,13 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
return null; return null;
} }
const { icon: TablerIcon, ...props } = link; const { icon: TablerIcon, iconProps, ...props } = link;
const Icon = <TablerIcon size={20} stroke={1.5} />; const Icon =
typeof TablerIcon === "string" ? (
<Image src={TablerIcon} w={20} h={20} />
) : (
<TablerIcon size={20} stroke={1.5} {...iconProps} />
);
let clientLink: ClientNavigationLink; let clientLink: ClientNavigationLink;
if ("items" in props) { if ("items" in props) {
clientLink = { clientLink = {
@@ -38,7 +43,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
.map((item) => { .map((item) => {
return { return {
...item, ...item,
icon: <item.icon size={20} stroke={1.5} />, icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
}; };
}), }),
} as ClientNavigationLink; } as ClientNavigationLink;
@@ -55,7 +60,8 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
interface CommonNavigationLinkProps { interface CommonNavigationLinkProps {
label: string; label: string;
icon: TablerIcon; icon: TablerIcon | string;
iconProps?: TablerIconProps;
hidden?: boolean; hidden?: boolean;
} }

View File

@@ -805,6 +805,7 @@
"apply": "Apply", "apply": "Apply",
"backToOverview": "Back to overview", "backToOverview": "Back to overview",
"create": "Create", "create": "Create",
"createAnother": "Create and start over",
"edit": "Edit", "edit": "Edit",
"import": "Import", "import": "Import",
"insert": "Insert", "insert": "Insert",

View File

@@ -1,5 +1,6 @@
import type { Icon123 } from "@tabler/icons-react"; import type { Icon123, IconProps } from "@tabler/icons-react";
export * from "./src"; export * from "./src";
export type TablerIcon = typeof Icon123; export type TablerIcon = typeof Icon123;
export type TablerIconProps = IconProps;

View File

@@ -1,4 +1,4 @@
import type { MantineSize } from "@mantine/core"; import type { MantineRadius, MantineSize } from "@mantine/core";
import { Avatar } from "@mantine/core"; import { Avatar } from "@mantine/core";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
@@ -7,13 +7,14 @@ import { getIconUrl } from "@homarr/definitions";
interface IntegrationAvatarProps { interface IntegrationAvatarProps {
size: MantineSize; size: MantineSize;
kind: IntegrationKind | null; kind: IntegrationKind | null;
radius?: MantineRadius;
} }
export const IntegrationAvatar = ({ kind, size }: IntegrationAvatarProps) => { export const IntegrationAvatar = ({ kind, size, radius }: IntegrationAvatarProps) => {
const url = kind ? getIconUrl(kind) : null; const url = kind ? getIconUrl(kind) : null;
if (!url) { if (!url) {
return null; return null;
} }
return <Avatar size={size} src={url} />; return <Avatar size={size} src={url} radius={radius} styles={{ image: { objectFit: "contain" } }} />;
}; };

View File

@@ -10,9 +10,10 @@ import { IconSearch } from "@tabler/icons-react";
interface SearchInputProps { interface SearchInputProps {
defaultValue?: string; defaultValue?: string;
placeholder: string; placeholder: string;
flexExpand?: boolean;
} }
export const SearchInput = ({ placeholder, defaultValue }: SearchInputProps) => { export const SearchInput = ({ placeholder, defaultValue, flexExpand = false }: SearchInputProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter(); const { replace } = useRouter();
const pathName = usePathname(); const pathName = usePathname();
@@ -40,6 +41,7 @@ export const SearchInput = ({ placeholder, defaultValue }: SearchInputProps) =>
defaultValue={defaultValue} defaultValue={defaultValue}
onChange={handleSearch} onChange={handleSearch}
placeholder={placeholder} placeholder={placeholder}
style={{ flex: flexExpand ? "1" : undefined }}
/> />
); );
}; };