feat: add media management (#1337)
* feat: add media management * feat: add missing page search item * fix: medias should be hidden for anonymous users * chore: rename show-all to include-from-all-users * fix: inconsistent table column for creator-id of media * fix: schema check not working because of custom type for blob in mysql * chore: temporarily remove migrations * chore: readd removed migrations
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
IconLayoutDashboard,
|
||||
IconLogs,
|
||||
IconMailForward,
|
||||
IconPhoto,
|
||||
IconPlug,
|
||||
IconQuestionMark,
|
||||
IconReport,
|
||||
@@ -62,6 +63,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/search-engines",
|
||||
label: t("items.searchEngies"),
|
||||
},
|
||||
{
|
||||
icon: IconPhoto,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconUser,
|
||||
label: t("items.users.label"),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface CopyMediaProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
export const CopyMedia = ({ media }: CopyMediaProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
const url =
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.protocol}://${window.location.hostname}:${window.location.port}/api/user-medias/${media.id}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<CopyButton value={url}>
|
||||
{({ copy, copied }) => (
|
||||
<Tooltip label={t("media.action.copy.label")} openDelay={500}>
|
||||
<ActionIcon onClick={copy} color={copied ? "teal" : "gray"} variant="subtle">
|
||||
{copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DeleteMediaProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
|
||||
|
||||
const onClick = () => {
|
||||
openConfirmModal({
|
||||
title: t("media.action.delete.label"),
|
||||
children: t("media.action.delete.description", { name: <b>{media.name}</b> }),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onConfirm: async () => {
|
||||
await mutateAsync({ id: media.id });
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={t("media.action.delete.label")} openDelay={500}>
|
||||
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Switch } from "@mantine/core";
|
||||
import type { SwitchProps } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
type ShowAllSwitchProps = Pick<SwitchProps, "defaultChecked">;
|
||||
|
||||
export const IncludeFromAllUsersSwitch = ({ defaultChecked }: ShowAllSwitchProps) => {
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [checked, setChecked] = useState(defaultChecked);
|
||||
const t = useI18n();
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setChecked(event.target.checked);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("includeFromAllUsers", event.target.checked.toString());
|
||||
if (params.has("page")) params.set("page", "1"); // Reset page to 1
|
||||
router.replace(`${pathName}?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={defaultChecked}
|
||||
checked={checked}
|
||||
label={t("management.page.media.includeFromAllUsers")}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Button, FileButton } from "@mantine/core";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||
|
||||
export const UploadMedia = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||
|
||||
const handleFileUploadAsync = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await mutateAsync(formData, {
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: t("media.action.upload.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("media.action.upload.notification.error.message"),
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||
{({ onClick }) => (
|
||||
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||
{t("media.action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
);
|
||||
};
|
||||
128
apps/nextjs/src/app/[locale]/manage/medias/page.tsx
Normal file
128
apps/nextjs/src/app/[locale]/manage/medias/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CopyMedia } from "./_actions/copy-media";
|
||||
import { DeleteMedia } from "./_actions/delete-media";
|
||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||
import { UploadMedia } from "./_actions/upload-media";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
includeFromAllUsers: z
|
||||
.string()
|
||||
.regex(/true|false/)
|
||||
.catch("false")
|
||||
.transform((value) => value === "true"),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
|
||||
interface MediaListPageProps {
|
||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||
const isAdmin = session.user.permissions.includes("admin");
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("media.plural")}</Title>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||
{isAdmin && <IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />}
|
||||
</Group>
|
||||
|
||||
<UploadMedia />
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh></TableTh>
|
||||
<TableTh>{t("media.field.name")}</TableTh>
|
||||
<TableTh>{t("media.field.size")}</TableTh>
|
||||
<TableTh>{t("media.field.creator")}</TableTh>
|
||||
<TableTh></TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{medias.map((media) => (
|
||||
<Row key={media.id} media={media} />
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="end">
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
const Row = ({ media }: RowProps) => {
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd w={64}>
|
||||
<Image
|
||||
src={`/api/user-medias/${media.id}`}
|
||||
alt={media.name}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
<TableTd>{humanFileSize(media.size)}</TableTd>
|
||||
<TableTd>
|
||||
{media.creator ? (
|
||||
<Group gap="sm">
|
||||
<UserAvatar user={media.creator} size="sm" />
|
||||
<Anchor component={Link} href={`/manage/users/${media.creator.id}/general`} size="sm">
|
||||
{media.creator.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableTd>
|
||||
<TableTd w={64}>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<CopyMedia media={media} />
|
||||
<DeleteMedia media={media} />
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
29
apps/nextjs/src/app/api/user-medias/[id]/route.ts
Normal file
29
apps/nextjs/src/app/api/user-medias/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { medias } from "@homarr/db/schema/sqlite";
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const image = await db.query.medias.findFirst({
|
||||
where: eq(medias.id, params.id),
|
||||
columns: {
|
||||
content: true,
|
||||
contentType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", image.contentType);
|
||||
headers.set("Content-Length", image.content.length.toString());
|
||||
|
||||
return new NextResponse(image.content, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user