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") && ( {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">

View File

@@ -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")}

View File

@@ -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({

View File

@@ -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);
}); });
}} }}
> >

View File

@@ -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>
); );

View File

@@ -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;
} }
}), }),
),
),
}); });