feat(icons): add upload button to icon picker (#1859)
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user