feat: add user activity chart on home page (#1092)

Co-authored-by: dicedtomato <git@diced.sh>

also adds settings to hide the recents, activity graph, and file type table
This commit is contained in:
zorex
2026-06-03 05:19:22 +03:00
committed by GitHub
parent d5e2bc3ec2
commit 97a75c0f84
7 changed files with 514 additions and 108 deletions
+120 -108
View File
@@ -3,6 +3,7 @@ import Stat from '@/components/Stat';
import type { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import useLogin from '@/lib/client/hooks/useLogin';
import { useSettingsStore } from '@/lib/client/store/settings';
import { isAdministrator } from '@/lib/role';
import { Button, Group, Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
import {
@@ -17,11 +18,12 @@ import { lazy, Suspense } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const ActivityChart = lazy(() => import('./parts/ActivityChart'));
const Recents = lazy(() => import('./parts/Recents'));
export default function DashboardHome() {
const { user } = useLogin();
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
const { homeShowActivity, homeShowRecents, homeShowTypes } = useSettingsStore((state) => state.settings);
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
const config = useConfig();
@@ -38,6 +40,32 @@ export default function DashboardHome() {
</Text>
</Skeleton>
{homeShowRecents && (
<Suspense
fallback={
<Paper radius='md' withBorder p='md' mt='lg'>
<Skeleton height={24} width={180} mb='xs' animate />
<Skeleton height={260} mt='md' animate />
</Paper>
}
>
<Group mt='md' mb='xs' style={{ alignItems: 'center' }}>
<Title order={2}>Recent files</Title>
<Button
variant='outline'
size='compact-xs'
component={Link}
to='/dashboard/files'
leftSection={<IconFiles size='1rem' />}
>
View all files
</Button>
</Group>
<Recents />
</Suspense>
)}
{user?.quota && (user.quota.maxBytes || user.quota.maxFiles) ? (
<Text size='sm' c='dimmed'>
{user.quota.filesQuota === 'BY_BYTES' ? (
@@ -60,41 +88,9 @@ export default function DashboardHome() {
</Text>
) : null}
<Group mt='md' mb='xs' style={{ alignItems: 'center' }}>
<Title order={2}>Recent files</Title>
<Button
variant='outline'
size='compact-xs'
component={Link}
to='/dashboard/files'
leftSection={<IconFiles size='1rem' />}
>
View all files
</Button>
</Group>
{recentLoading ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
{[...Array(3)].map((_, i) => (
<Skeleton key={i} height={350} animate />
))}
</SimpleGrid>
) : recent?.length !== 0 ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
{recent!.map((file, i) => (
<Suspense fallback={<Skeleton height={350} animate />} key={i}>
<DashboardFile file={file} />
</Suspense>
))}
</SimpleGrid>
) : (
<Text size='sm' c='dimmed'>
You have no recent files. The last three files you uploaded will appear here.
</Text>
)}
<Group mt='md' style={{ alignItems: 'center' }}>
<Title order={2}>Stats</Title>
{(!config.features?.metrics?.adminOnly || isAdministrator(user?.role)) && (
<Button
variant='outline'
@@ -113,82 +109,98 @@ export default function DashboardHome() {
</Text>
{statsLoading ? (
<>
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
{[...Array(8)].map((_, i) => (
<Skeleton key={i} height={105} />
))}
</SimpleGrid>
<Paper withBorder my='md'>
<ScrollArea.Autosize mah={400} type='auto'>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>File Type</Table.Th>
<Table.Th>Count</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton animate>
<Text>...</Text>
</Skeleton>
</Table.Td>
<Table.Td>
<Skeleton animate>
<Text>...</Text>
</Skeleton>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
</>
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
{[...Array(8)].map((_, i) => (
<Skeleton key={i} height={105} />
))}
</SimpleGrid>
) : (
<>
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
<Stat Icon={IconFiles} title='Files uploaded' value={stats!.filesUploaded} />
<Stat Icon={IconStarFilled} title='Favorite files' value={stats!.favoriteFiles} />
<Stat Icon={IconDeviceSdCard} title='Storage used' value={bytes(stats!.storageUsed)} />
<Stat Icon={IconDeviceSdCard} title='Average storage used' value={bytes(stats!.avgStorageUsed)} />
<Stat Icon={IconEyeFilled} title='File views' value={stats!.views} />
<Stat Icon={IconEyeFilled} title='Average file views' value={Math.round(stats!.avgViews)} />
<SimpleGrid cols={{ base: 1, md: 2, lg: 4 }} spacing={{ base: 'sm', md: 'md' }}>
<Stat Icon={IconFiles} title='Files uploaded' value={stats!.filesUploaded} />
<Stat Icon={IconStarFilled} title='Favorite files' value={stats!.favoriteFiles} />
<Stat Icon={IconDeviceSdCard} title='Storage used' value={bytes(stats!.storageUsed)} />
<Stat Icon={IconDeviceSdCard} title='Average storage used' value={bytes(stats!.avgStorageUsed)} />
<Stat Icon={IconEyeFilled} title='File views' value={stats!.views} />
<Stat Icon={IconEyeFilled} title='Average file views' value={Math.round(stats!.avgViews)} />
<Stat Icon={IconLink} title='Links created' value={stats!.urlsCreated} />
<Stat Icon={IconLink} title='Total link views' value={Math.round(stats!.urlViews)} />
</SimpleGrid>
<Stat Icon={IconLink} title='Links created' value={stats!.urlsCreated} />
<Stat Icon={IconLink} title='Total link views' value={Math.round(stats!.urlViews)} />
</SimpleGrid>
)}
{Object.keys(stats!.sortTypeCount).length !== 0 && (
<>
<Paper withBorder my='md'>
<ScrollArea.Autosize mah={400} type='auto'>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>File Type</Table.Th>
<Table.Th>Count</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{Object.entries(stats!.sortTypeCount)
.sort(([, a], [, b]) => b - a)
.map(([type, count], i) => (
<Table.Tr key={i}>
<Table.Td>{type}</Table.Td>
<Table.Td>{count}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
</>
)}
</>
{homeShowActivity && (
<Suspense
fallback={
<Paper radius='md' withBorder p='md' mt='lg'>
<Skeleton height={24} width={180} mb='xs' animate />
<Skeleton height={260} mt='md' animate />
</Paper>
}
>
<ActivityChart />
</Suspense>
)}
{statsLoading ? (
<Paper withBorder my='md'>
<ScrollArea.Autosize mah={400} type='auto'>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>File Type</Table.Th>
<Table.Th>Count</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...Array(5)].map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton animate>
<Text>...</Text>
</Skeleton>
</Table.Td>
<Table.Td>
<Skeleton animate>
<Text>...</Text>
</Skeleton>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
) : (
Object.keys(stats!.sortTypeCount).length !== 0 &&
homeShowTypes && (
<>
<Title order={3} mt='lg' mb='xs'>
File types
</Title>
<Paper withBorder my='md'>
<ScrollArea.Autosize mah={400} type='auto'>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>File Type</Table.Th>
<Table.Th>Count</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{Object.entries(stats!.sortTypeCount)
.sort(([, a], [, b]) => b - a)
.map(([type, count], i) => (
<Table.Tr key={i}>
<Table.Td>{type}</Table.Td>
<Table.Td>{count}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Paper>
</>
)
)}
</>
);
@@ -0,0 +1,204 @@
import type { Response } from '@/lib/api/response';
import { ChartTooltip, LineChart } from '@mantine/charts';
import { Box, Group, Paper, Select, Skeleton, Text, Title } from '@mantine/core';
import { IconChartAreaLine, IconLogin2, IconUpload } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { useState } from 'react';
import useSWR from 'swr';
const CHART_HEIGHT = 260;
function parseChartDate(value: unknown): dayjs.Dayjs | null {
if (value == null || value === '') return null;
if (typeof value === 'number' && Number.isFinite(value)) {
const d = dayjs(value);
return d.isValid() ? d : null;
}
if (typeof value === 'string') {
const d = dayjs(value);
return d.isValid() ? d : null;
}
return null;
}
function formatDayLabel(value: unknown) {
const d = parseChartDate(value);
if (!d) return '';
const today = dayjs().startOf('day');
if (d.isSame(today, 'day')) return 'Today';
if (d.isSame(today.subtract(1, 'day'), 'day')) return 'Yesterday';
return d.format('MMM D');
}
export default function ActivityChart() {
const [days, setDays] = useState(14);
const { data, isLoading } = useSWR<Response['/api/user/activity']>('/api/user/activity?days=' + days);
if (isLoading) {
return (
<Paper radius='md' withBorder p='md' mt='lg'>
<Skeleton height={24} width={180} mb='xs' animate />
<Skeleton height={16} width={240} mb='lg' animate />
<Skeleton height={CHART_HEIGHT} animate />
</Paper>
);
}
if (!data?.series.length) return null;
const chartData = data.series
.map((point) => {
const d = dayjs(point.date);
if (!d.isValid()) return null;
return {
date: d.valueOf(),
uploads: point.uploads,
logins: point.logins,
};
})
.filter((point) => point !== null);
if (chartData.length === 0) return null;
const hasActivity = data.totals.uploads > 0 || data.totals.logins > 0;
return (
<Paper radius='md' withBorder p='md' mt='lg'>
<Group justify='space-between' align='flex-start' mb='lg' wrap='nowrap'>
<Box>
<Title order={3} fw={600}>
Activity
</Title>
<Group gap='xs' style={{ alignItems: 'center' }}>
<Text size='sm' c='dimmed' mt={4}>
Your uploads and logins over the last{' '}
</Text>
<Select
value={String(days)}
onChange={(v) => setDays(Number(v))}
data={[
{ value: '1', label: '1 day' },
{ value: '7', label: '7 days' },
{ value: '14', label: '14 days' },
{ value: '30', label: '30 days' },
]}
size='0.4rem'
variant='filled'
p={0}
m={0}
fw={500}
styles={{
input: {
color: 'var(--mantine-primary-color-filled)',
padding: 10,
width: '10em',
fontSize: '0.875rem',
},
section: {
margin: 0,
},
option: {
fontSize: '1rem',
},
wrapper: {
borderRadius: 1,
},
}}
comboboxProps={{
dropdownPadding: 0,
}}
/>
</Group>
</Box>
<Group gap='lg' visibleFrom='sm'>
<Group gap='xs'>
<IconUpload size='1rem' style={{ opacity: 0.85 }} color='var(--mantine-primary-color-filled)' />
<Box>
<Text size='xs' c='dimmed' lh={1.2}>
Uploads
</Text>
<Text size='sm' fw={600} lh={1.3}>
{data.totals.uploads}
</Text>
</Box>
</Group>
<Group gap='xs'>
<IconLogin2 size='1rem' style={{ opacity: 0.65 }} color='var(--mantine-color-gray-5)' />
<Box>
<Text size='xs' c='dimmed' lh={1.2}>
Logins
</Text>
<Text size='sm' fw={600} lh={1.3}>
{data.totals.logins}
</Text>
</Box>
</Group>
</Group>
</Group>
{!hasActivity ? (
<Paper withBorder h={CHART_HEIGHT} radius='md' p='md' ta='center'>
<Group align='center' justify='center' h='100%'>
<IconChartAreaLine size='1.75rem' style={{ opacity: 0.35 }} />
<Text size='sm' c='dimmed'>
No uploads or logins in this period yet
</Text>
</Group>
</Paper>
) : (
<LineChart
h={CHART_HEIGHT}
data={chartData}
dataKey='date'
curveType='natural'
connectNulls
withLegend={false}
withDots={false}
activeDotProps={{ r: 4, strokeWidth: 2 }}
gridAxis='none'
tickLine='none'
strokeWidth={2}
series={[
{
name: 'uploads',
label: 'Uploads',
color: 'var(--mantine-primary-color-filled)',
},
{
name: 'logins',
label: 'Logins',
color: 'gray.5',
},
]}
xAxisProps={{
tickMargin: 12,
minTickGap: 32,
tickFormatter: (v) => formatDayLabel(v),
}}
yAxisProps={{
width: 36,
tickMargin: 8,
}}
tooltipProps={{
content: ({ label, payload }) => (
<ChartTooltip
label={formatDayLabel(label) || '—'}
payload={payload}
series={[
{ name: 'uploads', label: 'Uploads', color: 'var(--mantine-primary-color-filled)' },
{ name: 'logins', label: 'Logins', color: 'gray.5' },
]}
/>
),
}}
/>
)}
</Paper>
);
}
@@ -0,0 +1,36 @@
import { Response } from '@/lib/api/response';
import { SimpleGrid, Skeleton, Text } from '@mantine/core';
import { lazy, Suspense } from 'react';
import useSWR from 'swr';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function Recents() {
const { data, isLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
if (isLoading)
return (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
{[...Array(3)].map((_, i) => (
<Skeleton key={i} height={350} animate />
))}
</SimpleGrid>
);
if (data?.length)
return (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
{data!.map((file, i) => (
<Suspense fallback={<Skeleton height={350} animate />} key={i}>
<DashboardFile file={file} />
</Suspense>
))}
</SimpleGrid>
);
return (
<Text size='sm' c='dimmed'>
You have no recent files. The last three files you uploaded will appear here.
</Text>
);
}
@@ -63,6 +63,26 @@ export default function SettingsDashboard() {
checked={settings.fileNavButtons}
onChange={(event) => update('fileNavButtons', event.currentTarget.checked)}
/>
<Switch
label='Show recents'
description='Show recent uploads and logins on the home page.'
checked={settings.homeShowRecents}
onChange={(event) => update('homeShowRecents', event.currentTarget.checked)}
/>
<Switch
label='Show activity'
description='Show your recent activity as a graph on the home page.'
checked={settings.homeShowActivity}
onChange={(event) => update('homeShowActivity', event.currentTarget.checked)}
/>
<Switch
label='Show file types'
description='Show the file types table on the home page.'
checked={settings.homeShowTypes}
onChange={(event) => update('homeShowTypes', event.currentTarget.checked)}
/>
</Stack>
<Select
+2
View File
@@ -34,6 +34,7 @@ import { ApiUserMfaPasskeyResponse } from '@/server/routes/api/user/mfa/passkey'
import { ApiUserMfaTotpResponse } from '@/server/routes/api/user/mfa/totp';
import { ApiUserRecentResponse } from '@/server/routes/api/user/recent';
import { ApiUserSessionsResponse } from '@/server/routes/api/user/sessions';
import { ApiUserActivityResponse } from '@/server/routes/api/user/activity';
import { ApiUserStatsResponse } from '@/server/routes/api/user/stats';
import { ApiUserTagsResponse } from '@/server/routes/api/user/tags';
import { ApiUserTagsIdResponse } from '@/server/routes/api/user/tags/[id]';
@@ -70,6 +71,7 @@ export type Response = {
'/api/user/sessions': ApiUserSessionsResponse;
'/api/user': ApiUserResponse;
'/api/user/stats': ApiUserStatsResponse;
'/api/user/activity': ApiUserActivityResponse;
'/api/user/recent': ApiUserRecentResponse;
'/api/user/token': ApiUserTokenResponse;
'/api/user/export': ApiUserExportResponse;
+6
View File
@@ -12,6 +12,9 @@ export type SettingsStore = {
themeDark: string;
themeLight: string;
domain: '' | string;
homeShowRecents: boolean;
homeShowActivity: boolean;
homeShowTypes: boolean;
};
update: <K extends keyof SettingsStore['settings']>(key: K, value: SettingsStore['settings'][K]) => void;
@@ -27,6 +30,9 @@ const defaultSettings: SettingsStore['settings'] = {
themeDark: 'builtin:dark_blue',
themeLight: 'builtin:light_blue',
domain: '',
homeShowRecents: true,
homeShowActivity: true,
homeShowTypes: true,
};
export const useSettingsStore = create<SettingsStore>()(
+126
View File
@@ -0,0 +1,126 @@
import { prisma } from '@/lib/db';
import { userMiddleware } from '@/server/middleware/user';
import typedPlugin from '@/server/typedPlugin';
import dayjs from 'dayjs';
import z from 'zod';
export type ApiUserActivityDay = {
date: string;
uploads: number;
logins: number;
};
export type ApiUserActivityResponse = {
days: number;
series: ApiUserActivityDay[];
totals: {
uploads: number;
logins: number;
};
};
export const PATH = '/api/user/activity';
const MAX_DAYS = 90;
const DEFAULT_DAYS = 14;
export default typedPlugin(
async (server) => {
server.get(
PATH,
{
schema: {
description: 'Daily upload and login counts for the authenticated user over a recent window.',
querystring: z.object({
days: z.coerce.number().int().min(1).max(MAX_DAYS).default(DEFAULT_DAYS),
}),
response: {
200: z.object({
days: z.number(),
series: z.array(
z.object({
date: z.string(),
uploads: z.number(),
logins: z.number(),
}),
),
totals: z.object({
uploads: z.number(),
logins: z.number(),
}),
}),
},
tags: ['auth'],
},
preHandler: [userMiddleware],
},
async (req, res) => {
const days = req.query.days;
const start = dayjs()
.subtract(days - 1, 'day')
.startOf('day')
.toDate();
const [files, sessions] = await Promise.all([
prisma.file.findMany({
where: {
userId: req.user.id,
createdAt: { gte: start },
},
select: { createdAt: true },
}),
prisma.userSession.findMany({
where: {
userId: req.user.id,
createdAt: { gte: start },
},
select: { createdAt: true },
}),
]);
const uploadsByDay = new Map<string, number>();
const loginsByDay = new Map<string, number>();
for (const file of files) {
const key = dayjs(file.createdAt).format('YYYY-MM-DD');
uploadsByDay.set(key, (uploadsByDay.get(key) ?? 0) + 1);
}
for (const session of sessions) {
const key = dayjs(session.createdAt).format('YYYY-MM-DD');
loginsByDay.set(key, (loginsByDay.get(key) ?? 0) + 1);
}
const series: ApiUserActivityDay[] = [];
let totalUploads = 0;
let totalLogins = 0;
for (let i = days - 1; i >= 0; i--) {
const day = dayjs().subtract(i, 'day').startOf('day');
const key = day.format('YYYY-MM-DD');
const uploads = uploadsByDay.get(key) ?? 0;
const logins = loginsByDay.get(key) ?? 0;
totalUploads += uploads;
totalLogins += logins;
series.push({
date: day.toISOString(),
uploads,
logins,
});
}
return res.send({
days,
series,
totals: {
uploads: totalUploads,
logins: totalLogins,
},
});
},
);
},
{ name: PATH },
);