mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
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") && (
|
||||
<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">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user