feat(medias): support upload of multiple items (#4169)
This commit is contained in:
@@ -120,11 +120,14 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
|||||||
/>
|
/>
|
||||||
{session?.user.permissions.includes("media-upload") && (
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
<UploadMedia
|
<UploadMedia
|
||||||
onSuccess={({ url }) =>
|
onSuccess={(medias) => {
|
||||||
|
const first = medias.at(0);
|
||||||
|
if (!first) return;
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
form.setFieldValue("backgroundImageUrl", url);
|
form.setFieldValue("backgroundImageUrl", first.url);
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
{({ onClick, loading }) => (
|
{({ onClick, loading }) => (
|
||||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const UploadMediaButton = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UploadMedia onSettled={onSettledAsync}>
|
<UploadMedia onSettled={onSettledAsync} multiple>
|
||||||
{({ onClick, loading }) => (
|
{({ onClick, loading }) => (
|
||||||
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||||
{t("media.action.upload.label")}
|
{t("media.action.upload.label")}
|
||||||
|
|||||||
@@ -55,34 +55,47 @@ export const mediaRouter = createTRPCRouter({
|
|||||||
.requiresPermission("media-upload")
|
.requiresPermission("media-upload")
|
||||||
.input(mediaUploadSchema)
|
.input(mediaUploadSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const content = Buffer.from(await input.file.arrayBuffer());
|
const files = await Promise.all(
|
||||||
const id = createId();
|
input.files.map(async (file) => ({
|
||||||
const media = {
|
id: createId(),
|
||||||
id,
|
meta: file,
|
||||||
creatorId: ctx.session.user.id,
|
content: Buffer.from(await file.arrayBuffer()),
|
||||||
content,
|
})),
|
||||||
size: input.file.size,
|
);
|
||||||
contentType: input.file.type,
|
const insertMedias = files.map(
|
||||||
name: input.file.name,
|
(file): InferInsertModel<typeof medias> => ({
|
||||||
} satisfies InferInsertModel<typeof medias>;
|
id: file.id,
|
||||||
await ctx.db.insert(medias).values(media);
|
creatorId: ctx.session.user.id,
|
||||||
|
content: file.content,
|
||||||
|
size: file.meta.size,
|
||||||
|
contentType: file.meta.type,
|
||||||
|
name: file.meta.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await ctx.db.insert(medias).values(insertMedias);
|
||||||
|
|
||||||
const localIconRepository = await ctx.db.query.iconRepositories.findFirst({
|
const localIconRepository = await ctx.db.query.iconRepositories.findFirst({
|
||||||
where: eq(iconRepositories.slug, LOCAL_ICON_REPOSITORY_SLUG),
|
where: eq(iconRepositories.slug, LOCAL_ICON_REPOSITORY_SLUG),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!localIconRepository) return id;
|
const ids = files.map((file) => file.id);
|
||||||
|
if (!localIconRepository) return ids;
|
||||||
|
|
||||||
const icon = mapMediaToIcon(media);
|
await ctx.db.insert(icons).values(
|
||||||
await ctx.db.insert(icons).values({
|
insertMedias.map((media) => {
|
||||||
id: createId(),
|
const icon = mapMediaToIcon(media);
|
||||||
checksum: icon.checksum,
|
|
||||||
name: icon.fileNameWithExtension,
|
|
||||||
url: icon.imageUrl,
|
|
||||||
iconRepositoryId: localIconRepository.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return id;
|
return {
|
||||||
|
id: createId(),
|
||||||
|
checksum: icon.checksum,
|
||||||
|
name: icon.fileNameWithExtension,
|
||||||
|
url: icon.imageUrl,
|
||||||
|
iconRepositoryId: localIconRepository.id,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ids;
|
||||||
}),
|
}),
|
||||||
deleteMedia: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
|
deleteMedia: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
|
||||||
const dbMedia = await ctx.db.query.medias.findFirst({
|
const dbMedia = await ctx.db.query.medias.findFirst({
|
||||||
|
|||||||
@@ -165,11 +165,14 @@ export const IconPicker = ({
|
|||||||
/>
|
/>
|
||||||
{session?.user.permissions.includes("media-upload") && (
|
{session?.user.permissions.includes("media-upload") && (
|
||||||
<UploadMedia
|
<UploadMedia
|
||||||
onSuccess={({ url }) => {
|
onSuccess={(medias) => {
|
||||||
|
const first = medias.at(0);
|
||||||
|
if (!first) return;
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setValue(url);
|
setValue(first.url);
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(first.url);
|
||||||
setSearch(url);
|
setSearch(first.url);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,27 +9,31 @@ import { supportedMediaUploadFormats } from "@homarr/validation/media";
|
|||||||
|
|
||||||
interface UploadMediaProps {
|
interface UploadMediaProps {
|
||||||
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
|
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
|
||||||
|
multiple?: boolean;
|
||||||
onSettled?: () => MaybePromise<void>;
|
onSettled?: () => MaybePromise<void>;
|
||||||
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
|
onSuccess?: (media: { id: string; url: string }[]) => MaybePromise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
|
export const UploadMedia = ({ children, onSettled, onSuccess, multiple = false }: UploadMediaProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||||
|
|
||||||
const handleFileUploadAsync = async (file: File | null) => {
|
const handleFileUploadAsync = async (files: File[] | File | null) => {
|
||||||
if (!file) return;
|
if (!files || (Array.isArray(files) && files.length === 0)) return;
|
||||||
|
const filesArray: File[] = Array.isArray(files) ? files : [files];
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
filesArray.forEach((file) => formData.append("files", file));
|
||||||
await mutateAsync(formData, {
|
await mutateAsync(formData, {
|
||||||
async onSuccess(mediaId) {
|
async onSuccess(mediaIds) {
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
message: t("media.action.upload.notification.success.message"),
|
message: t("media.action.upload.notification.success.message"),
|
||||||
});
|
});
|
||||||
await onSuccess?.({
|
await onSuccess?.(
|
||||||
id: mediaId,
|
mediaIds.map((id) => ({
|
||||||
url: `/api/user-medias/${mediaId}`,
|
id,
|
||||||
});
|
url: `/api/user-medias/${id}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError() {
|
onError() {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -43,7 +47,7 @@ export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")} multiple={multiple}>
|
||||||
{({ onClick }) => children({ onClick, loading: isPending })}
|
{({ onClick }) => children({ onClick, loading: isPending })}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import z from "zod";
|
||||||
import { zfd } from "zod-form-data";
|
import { zfd } from "zod-form-data";
|
||||||
|
|
||||||
import { createCustomErrorParams } from "./form/i18n";
|
import { createCustomErrorParams } from "./form/i18n";
|
||||||
@@ -5,30 +6,34 @@ import { createCustomErrorParams } from "./form/i18n";
|
|||||||
export const supportedMediaUploadFormats = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"];
|
export const supportedMediaUploadFormats = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"];
|
||||||
|
|
||||||
export const mediaUploadSchema = zfd.formData({
|
export const mediaUploadSchema = zfd.formData({
|
||||||
file: zfd.file().check((context) => {
|
files: zfd.repeatable(
|
||||||
if (!supportedMediaUploadFormats.includes(context.value.type)) {
|
z.array(
|
||||||
context.issues.push({
|
zfd.file().check((context) => {
|
||||||
code: "custom",
|
if (!supportedMediaUploadFormats.includes(context.value.type)) {
|
||||||
params: createCustomErrorParams({
|
context.issues.push({
|
||||||
key: "invalidFileType",
|
code: "custom",
|
||||||
params: { expected: `one of ${supportedMediaUploadFormats.join(", ")}` },
|
params: createCustomErrorParams({
|
||||||
}),
|
key: "invalidFileType",
|
||||||
input: context.value.type,
|
params: { expected: `one of ${supportedMediaUploadFormats.join(", ")}` },
|
||||||
});
|
}),
|
||||||
return;
|
input: context.value.type,
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (context.value.size > 1024 * 1024 * 32) {
|
if (context.value.size > 1024 * 1024 * 32) {
|
||||||
// Don't forget to update the limit in nginx.conf (client_max_body_size)
|
// Don't forget to update the limit in nginx.conf (client_max_body_size)
|
||||||
context.issues.push({
|
context.issues.push({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
params: createCustomErrorParams({
|
params: createCustomErrorParams({
|
||||||
key: "fileTooLarge",
|
key: "fileTooLarge",
|
||||||
params: { maxSize: "32 MB" },
|
params: { maxSize: "32 MB" },
|
||||||
}),
|
}),
|
||||||
input: context.value.size,
|
input: context.value.size,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user