feat(medias): support upload of multiple items (#4169)

This commit is contained in:
Meier Lukas
2025-10-02 19:54:40 +02:00
committed by GitHub
parent f82f343631
commit dcb845b609
6 changed files with 94 additions and 66 deletions

View File

@@ -120,11 +120,14 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={({ url }) =>
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
startTransition(() => {
form.setFieldValue("backgroundImageUrl", url);
})
}
form.setFieldValue("backgroundImageUrl", first.url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">

View File

@@ -14,7 +14,7 @@ export const UploadMediaButton = () => {
};
return (
<UploadMedia onSettled={onSettledAsync}>
<UploadMedia onSettled={onSettledAsync} multiple>
{({ onClick, loading }) => (
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}

View File

@@ -55,34 +55,47 @@ export const mediaRouter = createTRPCRouter({
.requiresPermission("media-upload")
.input(mediaUploadSchema)
.mutation(async ({ ctx, input }) => {
const content = Buffer.from(await input.file.arrayBuffer());
const id = createId();
const media = {
id,
creatorId: ctx.session.user.id,
content,
size: input.file.size,
contentType: input.file.type,
name: input.file.name,
} satisfies InferInsertModel<typeof medias>;
await ctx.db.insert(medias).values(media);
const files = await Promise.all(
input.files.map(async (file) => ({
id: createId(),
meta: file,
content: Buffer.from(await file.arrayBuffer()),
})),
);
const insertMedias = files.map(
(file): InferInsertModel<typeof medias> => ({
id: file.id,
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({
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({
id: createId(),
checksum: icon.checksum,
name: icon.fileNameWithExtension,
url: icon.imageUrl,
iconRepositoryId: localIconRepository.id,
});
await ctx.db.insert(icons).values(
insertMedias.map((media) => {
const icon = mapMediaToIcon(media);
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 }) => {
const dbMedia = await ctx.db.query.medias.findFirst({

View File

@@ -165,11 +165,14 @@ export const IconPicker = ({
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={({ url }) => {
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
startTransition(() => {
setValue(url);
setPreviewUrl(url);
setSearch(url);
setValue(first.url);
setPreviewUrl(first.url);
setSearch(first.url);
});
}}
>

View File

@@ -9,27 +9,31 @@ import { supportedMediaUploadFormats } from "@homarr/validation/media";
interface UploadMediaProps {
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
multiple?: boolean;
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 { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
const handleFileUploadAsync = async (file: File | null) => {
if (!file) return;
const handleFileUploadAsync = async (files: File[] | File | null) => {
if (!files || (Array.isArray(files) && files.length === 0)) return;
const filesArray: File[] = Array.isArray(files) ? files : [files];
const formData = new FormData();
formData.append("file", file);
filesArray.forEach((file) => formData.append("files", file));
await mutateAsync(formData, {
async onSuccess(mediaId) {
async onSuccess(mediaIds) {
showSuccessNotification({
message: t("media.action.upload.notification.success.message"),
});
await onSuccess?.({
id: mediaId,
url: `/api/user-medias/${mediaId}`,
});
await onSuccess?.(
mediaIds.map((id) => ({
id,
url: `/api/user-medias/${id}`,
})),
);
},
onError() {
showErrorNotification({
@@ -43,7 +47,7 @@ export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps
};
return (
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")} multiple={multiple}>
{({ onClick }) => children({ onClick, loading: isPending })}
</FileButton>
);

View File

@@ -1,3 +1,4 @@
import z from "zod";
import { zfd } from "zod-form-data";
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 mediaUploadSchema = zfd.formData({
file: zfd.file().check((context) => {
if (!supportedMediaUploadFormats.includes(context.value.type)) {
context.issues.push({
code: "custom",
params: createCustomErrorParams({
key: "invalidFileType",
params: { expected: `one of ${supportedMediaUploadFormats.join(", ")}` },
}),
input: context.value.type,
});
return;
}
files: zfd.repeatable(
z.array(
zfd.file().check((context) => {
if (!supportedMediaUploadFormats.includes(context.value.type)) {
context.issues.push({
code: "custom",
params: createCustomErrorParams({
key: "invalidFileType",
params: { expected: `one of ${supportedMediaUploadFormats.join(", ")}` },
}),
input: context.value.type,
});
return;
}
if (context.value.size > 1024 * 1024 * 32) {
// Don't forget to update the limit in nginx.conf (client_max_body_size)
context.issues.push({
code: "custom",
params: createCustomErrorParams({
key: "fileTooLarge",
params: { maxSize: "32 MB" },
}),
input: context.value.size,
});
return;
}
}),
if (context.value.size > 1024 * 1024 * 32) {
// Don't forget to update the limit in nginx.conf (client_max_body_size)
context.issues.push({
code: "custom",
params: createCustomErrorParams({
key: "fileTooLarge",
params: { maxSize: "32 MB" },
}),
input: context.value.size,
});
return;
}
}),
),
),
});