fix(management): improve homepage (#4260)
This commit is contained in:
@@ -2,6 +2,7 @@ import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
|
|||||||
|
|
||||||
import { splitToNChunks } from "@homarr/common";
|
import { splitToNChunks } from "@homarr/common";
|
||||||
import { integrationDefs } from "@homarr/definitions";
|
import { integrationDefs } from "@homarr/definitions";
|
||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import classes from "./hero-banner.module.css";
|
import classes from "./hero-banner.module.css";
|
||||||
|
|
||||||
@@ -12,19 +13,20 @@ const icons = Object.values(integrationDefs)
|
|||||||
const countIconGroups = 3;
|
const countIconGroups = 3;
|
||||||
const animationDurationInSeconds = icons.length;
|
const animationDurationInSeconds = icons.length;
|
||||||
const arrayInChunks = splitToNChunks(icons, countIconGroups);
|
const arrayInChunks = splitToNChunks(icons, countIconGroups);
|
||||||
|
const gridSpan = 12 / countIconGroups;
|
||||||
|
|
||||||
export const HeroBanner = () => {
|
export const HeroBanner = async () => {
|
||||||
const gridSpan = 12 / countIconGroups;
|
const t = await getScopedI18n("management.page.home");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
||||||
Welcome back to your
|
{t("heroBanner.title")}
|
||||||
</Title>
|
</Title>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
|
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
|
||||||
<Title fz={{ base: "h3", md: "h1" }}>Homarr Board</Title>
|
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { HeroBanner } from "./_components/hero-banner";
|
import { HeroBanner } from "./_components/hero-banner";
|
||||||
|
|
||||||
interface LinkProps {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
count: number;
|
|
||||||
href: string;
|
|
||||||
hidden?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -29,78 +20,32 @@ export async function generateMetadata() {
|
|||||||
|
|
||||||
export default async function ManagementPage() {
|
export default async function ManagementPage() {
|
||||||
const statistics = await api.home.getStats();
|
const statistics = await api.home.getStats();
|
||||||
const session = await auth();
|
|
||||||
const t = await getScopedI18n("management.page.home");
|
const t = await getScopedI18n("management.page.home");
|
||||||
|
|
||||||
const links: LinkProps[] = [
|
|
||||||
{
|
|
||||||
count: statistics.countBoards,
|
|
||||||
href: "/manage/boards",
|
|
||||||
subtitle: t("statisticLabel.boards"),
|
|
||||||
title: t("statistic.board"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
count: statistics.countUsers,
|
|
||||||
href: "/manage/users",
|
|
||||||
subtitle: t("statisticLabel.authentication"),
|
|
||||||
title: t("statistic.user"),
|
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
count: statistics.countInvites,
|
|
||||||
href: "/manage/users/invites",
|
|
||||||
subtitle: t("statisticLabel.authentication"),
|
|
||||||
title: t("statistic.invite"),
|
|
||||||
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
count: statistics.countIntegrations,
|
|
||||||
href: "/manage/integrations",
|
|
||||||
subtitle: t("statisticLabel.resources"),
|
|
||||||
title: t("statistic.integration"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
count: statistics.countApps,
|
|
||||||
href: "/manage/apps",
|
|
||||||
subtitle: t("statisticLabel.resources"),
|
|
||||||
title: t("statistic.app"),
|
|
||||||
hidden: !session?.user,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
count: statistics.countGroups,
|
|
||||||
href: "/manage/users/groups",
|
|
||||||
subtitle: t("statisticLabel.authorization"),
|
|
||||||
title: t("statistic.group"),
|
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<HeroBanner />
|
<HeroBanner />
|
||||||
<Space h="md" />
|
<Space h="md" />
|
||||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
{links.map(
|
{statistics.map((statistic) => (
|
||||||
(link) =>
|
<Card component={Link} href={statistic.path} key={statistic.path} radius="lg">
|
||||||
!link.hidden && (
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Card component={Link} href={link.href} key={link.href} radius="lg">
|
<Group wrap="nowrap">
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Text size="2.4rem" fw="bolder">
|
||||||
<Group wrap="nowrap">
|
{statistic.count}
|
||||||
<Text size="2.4rem" fw="bolder">
|
</Text>
|
||||||
{link.count}
|
<Stack gap={0}>
|
||||||
</Text>
|
<Text c="red" size="xs">
|
||||||
<Stack gap={0}>
|
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
||||||
<Text c="red" size="xs">
|
</Text>
|
||||||
{link.subtitle}
|
<Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||||
</Text>
|
</Stack>
|
||||||
<Text fw="bold">{link.title}</Text>
|
</Group>
|
||||||
</Stack>
|
<IconArrowRight />
|
||||||
</Group>
|
</Group>
|
||||||
<IconArrowRight />
|
</Card>
|
||||||
</Group>
|
))}
|
||||||
</Card>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/request-handler": "workspace:^0.1.0",
|
"@homarr/request-handler": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
|||||||
@@ -1,31 +1,147 @@
|
|||||||
import type { AnySQLiteTable } from "drizzle-orm/sqlite-core";
|
|
||||||
|
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import type { Database } from "@homarr/db";
|
import { db, eq, inArray, or } from "@homarr/db";
|
||||||
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema";
|
import {
|
||||||
|
apps,
|
||||||
|
boards,
|
||||||
|
boardUserPermissions,
|
||||||
|
groupMembers,
|
||||||
|
groups,
|
||||||
|
integrations,
|
||||||
|
invites,
|
||||||
|
medias,
|
||||||
|
searchEngines,
|
||||||
|
users,
|
||||||
|
} from "@homarr/db/schema";
|
||||||
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
|
interface HomeStatistic {
|
||||||
|
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
|
||||||
|
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
|
||||||
|
count: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const homeRouter = createTRPCRouter({
|
export const homeRouter = createTRPCRouter({
|
||||||
getStats: publicProcedure.query(async ({ ctx }) => {
|
getStats: publicProcedure.query(async ({ ctx }) => {
|
||||||
const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false;
|
const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false;
|
||||||
const isCredentialsEnabled = isProviderEnabled("credentials");
|
const isCredentialsEnabled = isProviderEnabled("credentials");
|
||||||
|
|
||||||
return {
|
const statistics: HomeStatistic[] = [];
|
||||||
countBoards: await getCountForTableAsync(ctx.db, boards, true),
|
|
||||||
countUsers: await getCountForTableAsync(ctx.db, users, isAdmin),
|
const boardIds: string[] = [];
|
||||||
countGroups: await getCountForTableAsync(ctx.db, groups, true),
|
if (ctx.session?.user && !ctx.session.user.permissions.includes("board-view-all")) {
|
||||||
countInvites: await getCountForTableAsync(ctx.db, invites, isAdmin),
|
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||||
countIntegrations: await getCountForTableAsync(ctx.db, integrations, isCredentialsEnabled && isAdmin),
|
where: eq(boardUserPermissions.userId, ctx.session.user.id),
|
||||||
countApps: await getCountForTableAsync(ctx.db, apps, true),
|
});
|
||||||
};
|
|
||||||
|
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
|
||||||
|
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||||
|
with: {
|
||||||
|
group: {
|
||||||
|
with: {
|
||||||
|
boardPermissions: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
boardIds.push(
|
||||||
|
...permissionsOfCurrentUserWhenPresent
|
||||||
|
.map((permission) => permission.boardId)
|
||||||
|
.concat(
|
||||||
|
permissionsOfCurrentUserGroupsWhenPresent
|
||||||
|
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
|
||||||
|
.flat(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
statistics.push({
|
||||||
|
titleKey: "board",
|
||||||
|
subtitleKey: "boards",
|
||||||
|
count: await db.$count(
|
||||||
|
boards,
|
||||||
|
ctx.session?.user.permissions.includes("board-view-all")
|
||||||
|
? undefined
|
||||||
|
: or(
|
||||||
|
eq(boards.isPublic, true),
|
||||||
|
eq(boards.creatorId, ctx.session?.user.id ?? ""),
|
||||||
|
boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
path: "/manage/boards",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
statistics.push({
|
||||||
|
titleKey: "user",
|
||||||
|
subtitleKey: "authentication",
|
||||||
|
count: await db.$count(users),
|
||||||
|
path: "/manage/users",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdmin && isCredentialsEnabled) {
|
||||||
|
statistics.push({
|
||||||
|
titleKey: "invite",
|
||||||
|
subtitleKey: "authentication",
|
||||||
|
count: await db.$count(invites),
|
||||||
|
path: "/manage/users/invites",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.session?.user.permissions.includes("integration-create")) {
|
||||||
|
statistics.push({
|
||||||
|
titleKey: "integration",
|
||||||
|
subtitleKey: "resources",
|
||||||
|
count: await db.$count(integrations),
|
||||||
|
path: "/manage/integrations",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.session?.user) {
|
||||||
|
statistics.push({
|
||||||
|
titleKey: "app",
|
||||||
|
subtitleKey: "resources",
|
||||||
|
count: await db.$count(apps),
|
||||||
|
path: "/manage/apps",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
statistics.push({
|
||||||
|
titleKey: "group",
|
||||||
|
subtitleKey: "authorization",
|
||||||
|
count: await db.$count(groups),
|
||||||
|
path: "/manage/users/groups",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.session?.user.permissions.includes("search-engine-create")) {
|
||||||
|
statistics.push({
|
||||||
|
titleKey: "searchEngine",
|
||||||
|
subtitleKey: "resources",
|
||||||
|
count: await db.$count(searchEngines),
|
||||||
|
path: "/manage/search-engines",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.session?.user.permissions.includes("media-upload")) {
|
||||||
|
statistics.push({
|
||||||
|
titleKey: "media",
|
||||||
|
subtitleKey: "resources",
|
||||||
|
count: await db.$count(
|
||||||
|
medias,
|
||||||
|
ctx.session.user.permissions.includes("media-view-all")
|
||||||
|
? undefined
|
||||||
|
: eq(medias.creatorId, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
path: "/manage/medias",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return statistics;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getCountForTableAsync = async (db: Database, table: AnySQLiteTable, canView: boolean) => {
|
|
||||||
if (!canView) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await db.$count(table);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2986,13 +2986,19 @@
|
|||||||
"invite": "Invites",
|
"invite": "Invites",
|
||||||
"integration": "Integrations",
|
"integration": "Integrations",
|
||||||
"app": "Apps",
|
"app": "Apps",
|
||||||
"group": "Groups"
|
"group": "Groups",
|
||||||
|
"searchEngine": "Search engines",
|
||||||
|
"media": "Medias"
|
||||||
},
|
},
|
||||||
"statisticLabel": {
|
"statisticLabel": {
|
||||||
"boards": "Boards",
|
"boards": "Boards",
|
||||||
"resources": "Resources",
|
"resources": "Resources",
|
||||||
"authentication": "Authentication",
|
"authentication": "Authentication",
|
||||||
"authorization": "Authorization"
|
"authorization": "Authorization"
|
||||||
|
},
|
||||||
|
"heroBanner": {
|
||||||
|
"title": "Welcome back to your",
|
||||||
|
"subtitle": "{app} Board"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"board": {
|
"board": {
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -623,6 +623,9 @@ importers:
|
|||||||
'@homarr/server-settings':
|
'@homarr/server-settings':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../server-settings
|
version: link:../server-settings
|
||||||
|
'@homarr/translation':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../translation
|
||||||
'@homarr/validation':
|
'@homarr/validation':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../validation
|
version: link:../validation
|
||||||
|
|||||||
Reference in New Issue
Block a user