feat(icons): add upload button to icon picker (#1859)

This commit is contained in:
Meier Lukas
2025-01-04 22:43:30 +01:00
committed by GitHub
parent 1562eae4d7
commit 1e9098d052
3 changed files with 96 additions and 44 deletions

View File

@@ -1,15 +1,40 @@
"use client"; "use client";
import type { JSX } from "react";
import { Button, FileButton } from "@mantine/core"; import { Button, FileButton } from "@mantine/core";
import { IconUpload } from "@tabler/icons-react"; import { IconUpload } from "@tabler/icons-react";
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 type { MaybePromise } from "@homarr/common/types";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { supportedMediaUploadFormats } from "@homarr/validation"; import { supportedMediaUploadFormats } from "@homarr/validation";
export const UploadMedia = () => { export const UploadMediaButton = () => {
const t = useI18n();
const onSettledAsync = async () => {
await revalidatePathActionAsync("/manage/medias");
};
return (
<UploadMedia onSettled={onSettledAsync}>
{({ onClick, loading }) => (
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
</Button>
)}
</UploadMedia>
);
};
interface UploadMediaProps {
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
onSettled?: () => MaybePromise<void>;
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
}
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
const t = useI18n(); const t = useI18n();
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation(); const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
@@ -18,10 +43,14 @@ export const UploadMedia = () => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
await mutateAsync(formData, { await mutateAsync(formData, {
onSuccess() { async onSuccess(mediaId) {
showSuccessNotification({ showSuccessNotification({
message: t("media.action.upload.notification.success.message"), message: t("media.action.upload.notification.success.message"),
}); });
await onSuccess?.({
id: mediaId,
url: `/api/user-medias/${mediaId}`,
});
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
@@ -29,18 +58,14 @@ export const UploadMedia = () => {
}); });
}, },
async onSettled() { async onSettled() {
await revalidatePathActionAsync("/manage/medias"); await onSettled?.();
}, },
}); });
}; };
return ( return (
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}> <FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
{({ onClick }) => ( {({ onClick }) => children({ onClick, loading: isPending })}
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
</Button>
)}
</FileButton> </FileButton>
); );
}; };

View File

@@ -16,7 +16,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CopyMedia } from "./_actions/copy-media"; import { CopyMedia } from "./_actions/copy-media";
import { DeleteMedia } from "./_actions/delete-media"; import { DeleteMedia } from "./_actions/delete-media";
import { IncludeFromAllUsersSwitch } from "./_actions/show-all"; import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
import { UploadMedia } from "./_actions/upload-media"; import { UploadMediaButton } from "./_actions/upload-media";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
search: z.string().optional(), search: z.string().optional(),
@@ -61,7 +61,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
)} )}
</Group> </Group>
{session.user.permissions.includes("media-upload") && <UploadMedia />} {session.user.permissions.includes("media-upload") && <UploadMediaButton />}
</Group> </Group>
<Table striped highlightOnHover> <Table striped highlightOnHover>
<TableThead> <TableThead>

View File

@@ -1,10 +1,12 @@
import type { FocusEventHandler } from "react"; import type { FocusEventHandler } from "react";
import { startTransition, useState } from "react"; import { startTransition, useState } from "react";
import { import {
ActionIcon,
Box, Box,
Card, Card,
Combobox, Combobox,
Flex, Flex,
Group,
Image, Image,
Indicator, Indicator,
InputBase, InputBase,
@@ -16,10 +18,13 @@ import {
useCombobox, useCombobox,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
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 {
@@ -34,6 +39,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
const [value, setValue] = useState<string>(initialValue ?? ""); const [value, setValue] = useState<string>(initialValue ?? "");
const [search, setSearch] = useState(initialValue ?? ""); const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null); const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
const { data: session } = useSession();
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
@@ -105,40 +111,61 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
return ( return (
<Combobox store={combobox} withinPortal> <Combobox store={combobox} withinPortal>
<Combobox.Target> <Combobox.Target>
<InputBase <Group wrap="nowrap" gap="xs" w="100%" align="start">
rightSection={<Combobox.Chevron />} <InputBase
leftSection={ flex={1}
previewUrl ? ( rightSection={<Combobox.Chevron />}
// eslint-disable-next-line @next/next/no-img-element leftSection={
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> previewUrl ? (
) : null // eslint-disable-next-line @next/next/no-img-element
} <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
value={search} ) : null
onChange={(event) => { }
combobox.openDropdown(); value={search}
combobox.updateSelectedOptionIndex(); onChange={(event) => {
setSearch(event.currentTarget.value); combobox.openDropdown();
setValue(event.currentTarget.value); combobox.updateSelectedOptionIndex();
setPreviewUrl(null); setSearch(event.currentTarget.value);
onChange(event.currentTarget.value); setValue(event.currentTarget.value);
}} setPreviewUrl(null);
onClick={() => combobox.openDropdown()} onChange(event.currentTarget.value);
onFocus={(event) => { }}
onFocus?.(event); onClick={() => combobox.openDropdown()}
combobox.openDropdown(); onFocus={(event) => {
}} onFocus?.(event);
onBlur={(event) => { combobox.openDropdown();
onBlur?.(event); }}
combobox.closeDropdown(); onBlur={(event) => {
setPreviewUrl(value); onBlur?.(event);
setSearch(value || ""); combobox.closeDropdown();
}} setPreviewUrl(value);
rightSectionPointerEvents="none" setSearch(value || "");
withAsterisk }}
error={error} rightSectionPointerEvents="none"
label={tCommon("iconPicker.label")} withAsterisk
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })} error={error}
/> label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={({ url }) => {
startTransition(() => {
setValue(url);
setPreviewUrl(url);
setSearch(url);
onChange(url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
</Combobox.Target> </Combobox.Target>
<Combobox.Dropdown> <Combobox.Dropdown>