feat: add hero banner (#463)
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
.bannerContainer {
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(
|
||||||
|
130deg,
|
||||||
|
#fa52521f 0%,
|
||||||
|
var(--mantine-color-dark-6) 35%,
|
||||||
|
var(--mantine-color-dark-6) 100%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollContainer {
|
||||||
|
height: 100%;
|
||||||
|
transform: rotateZ(10deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scrolling {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollAnimationContainer {
|
||||||
|
animation: scrolling;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.scrollAnimationContainer {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { splitToNChunks } from "@homarr/common";
|
||||||
|
|
||||||
|
import classes from "./hero-banner.module.css";
|
||||||
|
|
||||||
|
const icons = [
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
|
||||||
|
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
|
||||||
|
];
|
||||||
|
|
||||||
|
const countIconGroups = 3;
|
||||||
|
const animationDurationInSeconds = 12;
|
||||||
|
|
||||||
|
export const HeroBanner = () => {
|
||||||
|
const arrayInChunks = splitToNChunks(icons, countIconGroups);
|
||||||
|
const gridSpan = 12 / countIconGroups;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classes.bannerContainer} bg="dark.6" pos="relative">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Title order={2} c="dimmed">
|
||||||
|
Welcome back to your
|
||||||
|
</Title>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Image src="/logo/logo.png" w={40} h={40} />
|
||||||
|
<Title>Homarr Dashboard</Title>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
className={classes.scrollContainer}
|
||||||
|
w={"30%"}
|
||||||
|
top={0}
|
||||||
|
right={0}
|
||||||
|
pos="absolute"
|
||||||
|
>
|
||||||
|
<Grid>
|
||||||
|
{Array(countIconGroups)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, columnIndex) => (
|
||||||
|
<GridCol key={`grid-column-${columnIndex}`} span={gridSpan}>
|
||||||
|
<Stack
|
||||||
|
className={classes.scrollAnimationContainer}
|
||||||
|
style={{
|
||||||
|
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{arrayInChunks[columnIndex]?.map((icon, index) => (
|
||||||
|
<Image
|
||||||
|
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
||||||
|
src={icon}
|
||||||
|
radius="md"
|
||||||
|
w={50}
|
||||||
|
h={50}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* This is used for making the animation seem seamless */}
|
||||||
|
{arrayInChunks[columnIndex]?.map((icon, index) => (
|
||||||
|
<Image
|
||||||
|
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
||||||
|
src={icon}
|
||||||
|
radius="md"
|
||||||
|
w={50}
|
||||||
|
h={50}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</GridCol>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { Title } from "@mantine/core";
|
import Link from "next/link";
|
||||||
|
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
||||||
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { Test } from "./test";
|
import { HeroBanner } from "./_components/hero-banner";
|
||||||
|
|
||||||
|
interface LinkProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
count: number;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
@@ -14,20 +24,76 @@ export async function generateMetadata() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ManagementPage() {
|
export default async function ManagementPage() {
|
||||||
const t = await getScopedI18n("management.title");
|
const statistics = await api.home.getStats();
|
||||||
|
const t = await getScopedI18n("management.page.home");
|
||||||
const dateNow = new Date();
|
|
||||||
const timeOfDay =
|
|
||||||
dateNow.getHours() < 10
|
|
||||||
? "morning"
|
|
||||||
: dateNow.getHours() < 17
|
|
||||||
? "afternoon"
|
|
||||||
: "evening";
|
|
||||||
|
|
||||||
|
const links: LinkProps[] = [
|
||||||
|
{
|
||||||
|
count: statistics.countBoards,
|
||||||
|
href: "/manage/boards",
|
||||||
|
subtitle: t("statisticLabel.boards"),
|
||||||
|
title: t("statistic.countBoards"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: statistics.countUsers,
|
||||||
|
href: "/manage/boards",
|
||||||
|
subtitle: t("statisticLabel.authentication"),
|
||||||
|
title: t("statistic.createUser"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: statistics.countInvites,
|
||||||
|
href: "/manage/boards",
|
||||||
|
subtitle: t("statisticLabel.authentication"),
|
||||||
|
title: t("statistic.createInvite"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: statistics.countIntegrations,
|
||||||
|
href: "/manage/integrations",
|
||||||
|
subtitle: t("statisticLabel.resources"),
|
||||||
|
title: t("statistic.addIntegration"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: statistics.countApps,
|
||||||
|
href: "/manage/apps",
|
||||||
|
subtitle: t("statisticLabel.resources"),
|
||||||
|
title: t("statistic.addApp"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: statistics.countGroups,
|
||||||
|
href: "/manage/users/groups",
|
||||||
|
subtitle: t("statisticLabel.authorization"),
|
||||||
|
title: t("statistic.manageRoles"),
|
||||||
|
},
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>{t(timeOfDay, { username: "admin" })}</Title>
|
<HeroBanner />
|
||||||
<Test />
|
<Space h="md" />
|
||||||
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
|
{links.map((link, index) => (
|
||||||
|
<Card
|
||||||
|
component={Link}
|
||||||
|
href={link.href}
|
||||||
|
key={`link-${index}`}
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<Text size="2.4rem" fw="bolder">
|
||||||
|
{link.count}
|
||||||
|
</Text>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text c="red" size="xs">
|
||||||
|
{link.subtitle}
|
||||||
|
</Text>
|
||||||
|
<Text fw="bold">{link.title}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<IconArrowRight />
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import type { ChangeEvent } from "react";
|
|
||||||
import { Button, Stack, Text, TextInput } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
|
|
||||||
export const Test = () => {
|
|
||||||
const [value, setValue] = useState("");
|
|
||||||
const [message, setMessage] = useState<string>("Hello, world!");
|
|
||||||
const { mutate } = clientApi.user.setMessage.useMutation();
|
|
||||||
clientApi.user.test.useSubscription(undefined, {
|
|
||||||
onData({ message }) {
|
|
||||||
setMessage(message);
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
alert(err);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onChange = useCallback(
|
|
||||||
(event: ChangeEvent<HTMLInputElement>) => setValue(event.target.value),
|
|
||||||
[setValue],
|
|
||||||
);
|
|
||||||
const onClick = useCallback(() => {
|
|
||||||
mutate(value);
|
|
||||||
setValue("");
|
|
||||||
}, [mutate, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<TextInput label="Update message" value={value} onChange={onChange} />
|
|
||||||
<Button onClick={onClick}>Update message</Button>
|
|
||||||
<Text>This message gets through subscription: {message}</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { appRouter as innerAppRouter } from "./router/app";
|
import { appRouter as innerAppRouter } from "./router/app";
|
||||||
import { boardRouter } from "./router/board";
|
import { boardRouter } from "./router/board";
|
||||||
import { groupRouter } from "./router/group";
|
import { groupRouter } from "./router/group";
|
||||||
|
import { homeRouter } from "./router/home";
|
||||||
import { iconsRouter } from "./router/icons";
|
import { iconsRouter } from "./router/icons";
|
||||||
import { integrationRouter } from "./router/integration";
|
import { integrationRouter } from "./router/integration";
|
||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
location: locationRouter,
|
location: locationRouter,
|
||||||
log: logRouter,
|
log: logRouter,
|
||||||
icon: iconsRouter,
|
icon: iconsRouter,
|
||||||
|
home: homeRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
31
packages/api/src/router/home.ts
Normal file
31
packages/api/src/router/home.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { count } from "@homarr/db";
|
||||||
|
import {
|
||||||
|
apps,
|
||||||
|
boards,
|
||||||
|
groups,
|
||||||
|
integrations,
|
||||||
|
invites,
|
||||||
|
users,
|
||||||
|
} from "@homarr/db/schema/sqlite";
|
||||||
|
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
|
export const homeRouter = createTRPCRouter({
|
||||||
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return {
|
||||||
|
countBoards:
|
||||||
|
(await ctx.db.select({ count: count() }).from(boards))[0]?.count ?? 0,
|
||||||
|
countUsers:
|
||||||
|
(await ctx.db.select({ count: count() }).from(users))[0]?.count ?? 0,
|
||||||
|
countGroups:
|
||||||
|
(await ctx.db.select({ count: count() }).from(groups))[0]?.count ?? 0,
|
||||||
|
countInvites:
|
||||||
|
(await ctx.db.select({ count: count() }).from(invites))[0]?.count ?? 0,
|
||||||
|
countIntegrations:
|
||||||
|
(await ctx.db.select({ count: count() }).from(integrations))[0]
|
||||||
|
?.count ?? 0,
|
||||||
|
countApps:
|
||||||
|
(await ctx.db.select({ count: count() }).from(apps))[0]?.count ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
7
packages/common/src/array.ts
Normal file
7
packages/common/src/array.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const splitToNChunks = <T>(array: T[], chunks: number): T[][] => {
|
||||||
|
const result: T[][] = [];
|
||||||
|
for (let i = chunks; i > 0; i--) {
|
||||||
|
result.push(array.splice(0, Math.ceil(array.length / i)));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./object";
|
export * from "./object";
|
||||||
export * from "./string";
|
export * from "./string";
|
||||||
export * from "./cookie";
|
export * from "./cookie";
|
||||||
|
export * from "./array";
|
||||||
export * from "./stopwatch";
|
export * from "./stopwatch";
|
||||||
|
|||||||
@@ -1099,6 +1099,22 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
|
home: {
|
||||||
|
statistic: {
|
||||||
|
countBoards: "Boards",
|
||||||
|
createUser: "Create new user",
|
||||||
|
createInvite: "Create new invite",
|
||||||
|
addIntegration: "Create integration",
|
||||||
|
addApp: "Add app",
|
||||||
|
manageRoles: "Manage roles",
|
||||||
|
},
|
||||||
|
statisticLabel: {
|
||||||
|
boards: "Boards",
|
||||||
|
resources: "Resources",
|
||||||
|
authentication: "Authentication",
|
||||||
|
authorization: "Authorization",
|
||||||
|
},
|
||||||
|
},
|
||||||
board: {
|
board: {
|
||||||
title: "Your boards",
|
title: "Your boards",
|
||||||
action: {
|
action: {
|
||||||
|
|||||||
Reference in New Issue
Block a user