Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/components/InfiniteFetchTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";

export default function InfiniteFetchTrigger({
onTrigger,
loading,
hasMore,
className,
children,
}: {
onTrigger: () => void;
loading: boolean;
hasMore: boolean;
className?: string;
children: React.ReactNode;
}) {
const triggerRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!hasMore) {
return;
}

const node = triggerRef.current;
if (!node) {
return;
}

const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loading) {
onTrigger();
}
},
{
rootMargin: "200px",
},
);

observer.observe(node);

return () => observer.disconnect();
}, [hasMore, loading, onTrigger]);

if (!hasMore) return null;

return (
<>
<div ref={triggerRef} className={className}>
{children}
</div>
</>
);
}
1 change: 1 addition & 0 deletions src/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"AdminLayout": {
"title": "الإدارة",
"links": {
"dashboard": "لوحة التحكم",
"languages": "اللغات",
"users": "المستخدمون"
}
Expand Down
1 change: 1 addition & 0 deletions src/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"AdminLayout": {
"title": "Admin",
"links": {
"dashboard": "Dashboard",
"languages": "Languages",
"users": "Users"
}
Expand Down
23 changes: 23 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteImport } from
import { Route as mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeDotverseIdRouteImport } from "./ui/translation/routes/$code.$verseId";
import { Route as mainReadDotDotDotDotDotDotUiStudyRoutesCodeDotchapterIdRouteImport } from "./ui/study/routes/$code.$chapterId";
import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRouteImport } from "./ui/admin/routes/_main.jobs";
import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRouteImport } from "./ui/admin/routes/_main.dashboard";
import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRouteImport } from "./ui/admin/routes/languages.$code/route";
import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeIndexRouteImport } from "./ui/admin/routes/languages.$code/index";
import { Route as mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotusersIndexRouteImport } from "./ui/admin/routes/_main.users/index";
Expand Down Expand Up @@ -155,6 +156,12 @@ const mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute =
path: "/jobs",
getParentRoute: () => mainAdminDotDotDotDotDotDotUiAdminRoutesMainRoute,
} as any);
const mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute =
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRouteImport.update({
id: "/dashboard",
path: "/dashboard",
getParentRoute: () => mainAdminDotDotDotDotDotDotUiAdminRoutesMainRoute,
} as any);
const mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRoute =
mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRouteImport.update(
{
Expand Down Expand Up @@ -246,6 +253,7 @@ export interface FileRoutesByFullPath {
"/read/$code": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeRouteWithChildren;
"/translate/$code": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeRouteWithChildren;
"/admin/languages/$code": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRouteWithChildren;
"/admin/dashboard": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute;
"/admin/jobs": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute;
"/read/$code/$chapterId": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeDotchapterIdRoute;
"/translate/$code/$verseId": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeDotverseIdRoute;
Expand Down Expand Up @@ -275,6 +283,7 @@ export interface FileRoutesByTo {
"/p/$": typeof MainPSplatRoute;
"/read/$code": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeRouteWithChildren;
"/translate/$code": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeRouteWithChildren;
"/admin/dashboard": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute;
"/admin/jobs": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute;
"/read/$code/$chapterId": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeDotchapterIdRoute;
"/translate/$code/$verseId": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeDotverseIdRoute;
Expand Down Expand Up @@ -308,6 +317,7 @@ export interface FileRoutesById {
"/_main/read/$code": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeRouteWithChildren;
"/_main/translate/$code": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeRouteWithChildren;
"/_main/admin/languages/$code": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesLanguagesDotcodeRouteRouteWithChildren;
"/_main/admin/_main/dashboard": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute;
"/_main/admin/_main/jobs": typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute;
"/_main/read/$code/$chapterId": typeof mainReadDotDotDotDotDotDotUiStudyRoutesCodeDotchapterIdRoute;
"/_main/translate/$code/$verseId": typeof mainTranslateDotDotDotDotDotDotUiTranslationRoutesCodeDotverseIdRoute;
Expand Down Expand Up @@ -340,6 +350,7 @@ export interface FileRouteTypes {
| "/read/$code"
| "/translate/$code"
| "/admin/languages/$code"
| "/admin/dashboard"
| "/admin/jobs"
| "/read/$code/$chapterId"
| "/translate/$code/$verseId"
Expand Down Expand Up @@ -369,6 +380,7 @@ export interface FileRouteTypes {
| "/p/$"
| "/read/$code"
| "/translate/$code"
| "/admin/dashboard"
| "/admin/jobs"
| "/read/$code/$chapterId"
| "/translate/$code/$verseId"
Expand Down Expand Up @@ -401,6 +413,7 @@ export interface FileRouteTypes {
| "/_main/read/$code"
| "/_main/translate/$code"
| "/_main/admin/languages/$code"
| "/_main/admin/_main/dashboard"
| "/_main/admin/_main/jobs"
| "/_main/read/$code/$chapterId"
| "/_main/translate/$code/$verseId"
Expand Down Expand Up @@ -570,6 +583,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRouteImport;
parentRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainRoute;
};
"/_main/admin/_main/dashboard": {
id: "/_main/admin/_main/dashboard";
path: "/dashboard";
fullPath: "/admin/dashboard";
preLoaderRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRouteImport;
parentRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainRoute;
};
"/_main/admin/languages/$code": {
id: "/_main/admin/languages/$code";
path: "/admin/languages/$code";
Expand Down Expand Up @@ -697,6 +717,7 @@ const mainTranslateDotDotDotDotDotDotUiTranslationRoutesRouteRouteWithChildren =
);

interface mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteChildren {
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute;
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute;
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotlanguagesNewRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotlanguagesNewRoute;
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotusersInviteRoute: typeof mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotusersInviteRoute;
Expand All @@ -706,6 +727,8 @@ interface mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteChildren {

const mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteChildren: mainAdminDotDotDotDotDotDotUiAdminRoutesMainRouteChildren =
{
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute:
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotdashboardRoute,
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute:
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotjobsRoute,
mainAdminDotDotDotDotDotDotUiAdminRoutesMainDotlanguagesNewRoute:
Expand Down
147 changes: 147 additions & 0 deletions src/ui/admin/components/PlatformUsersDashboardCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { useMemo } from "react";
import { type ActivityChartRange } from "./ActivityChart";
import ActivityChart from "./ActivityChart";
import ContributionBar from "./ContributionBar";
import ServerAction from "@/components/ServerAction";
import { Icon } from "@/components/Icon";
import { disableUser } from "@/modules/users/actions/disableUser";
import { infiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query";
import {
DashboardCard,
DashboardCardEmptyState,
DashboardCardHeader,
} from "./DashboardCard";
import StatusBadge from "./StatusBadge";
import { getPlatformDashboardContributors } from "@/ui/admin/serverFns/getPlatformDashboardContributors";
import InfiniteFetchTrigger from "@/components/InfiniteFetchTrigger";

const PAGE_SIZE = 10;

export function platformDashboardContributorsInfiniteQueryOptions(
range: ActivityChartRange,
) {
return infiniteQueryOptions({
queryKey: ["platformDashboardContributors", range],
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam }) =>
getPlatformDashboardContributors({
data: {
range,
limit: PAGE_SIZE,
cursor: pageParam,
},
}),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
}

export default function PlatformUsersDashboardCard({
className = "",
range,
}: {
className?: string;
range: ActivityChartRange;
}) {
const { data, isFetchingNextPage, fetchNextPage, hasNextPage } =
useInfiniteQuery(platformDashboardContributorsInfiniteQueryOptions(range));

const fullUsers = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);

const yMin = fullUsers.reduce(
(min, user) =>
user.activity.reduce(
(entryMin, entry) => Math.min(entryMin, entry.net),
min,
),
0,
);
const yMax = fullUsers.reduce(
(max, user) =>
user.activity.reduce(
(entryMax, entry) => Math.max(entryMax, entry.net),
max,
),
0,
);
const maxContribution = fullUsers.reduce(
(max, user) => Math.max(max, user.contributedGlosses),
0,
);

return (
<DashboardCard className={className}>
<DashboardCardHeader title="Contributors" />
<div className="flex-1 overflow-auto relative">
{fullUsers.length === 0 ?
<DashboardCardEmptyState>No users found.</DashboardCardEmptyState>
: <div className="divide-y divide-gray-200 dark:divide-gray-700 grid grid-cols-[1fr_1fr]">
{fullUsers.map((user) => {
return (
<div
key={user.id}
className="grid grid-cols-subgrid col-span-2 gap-y-1 gap-x-4 py-3 px-4 items-stretch"
>
<div className="min-w-0 col-span-2 flex items-center gap-2">
<h3 className="text-sm font-bold text-nowrap text-ellipsis">
{user.name ?? user.email}
</h3>
{user.status === "invited" && (
<StatusBadge color="brown">Invited</StatusBadge>
)}
<div className="flex grow justify-end gap-3">
<ServerAction
variant="tertiary"
destructive
actionData={{ userId: user.id }}
action={disableUser}
successMessage="User disabled"
invalidate
confirm="Are you sure you want to disable this user?"
>
<Icon icon="trash" size="sm" />
<span className="sr-only">Disable</span>
</ServerAction>
</div>
</div>
<div className="min-w-0 self-end">
<ContributionBar
contribution={user.contributedGlosses}
max={maxContribution}
/>
<div className="mt-1 flex text-xs tabular-nums text-gray-600 dark:text-gray-400">
<span className="grow">
{user.contributedGlosses.toLocaleString()} glosses
</span>
</div>
</div>
<ActivityChart
className="min-w-0 place-self-stretch h-12"
data={user.activity}
total={user.activityTotal}
yMin={yMin}
yMax={yMax}
range={range}
/>
</div>
);
})}

<InfiniteFetchTrigger
hasMore={hasNextPage ?? false}
loading={isFetchingNextPage}
className="col-span-2 px-4 py-2 text-sm text-gray-500 dark:text-gray-400"
onTrigger={() => {
void fetchNextPage();
}}
>
Loading more contributors...
</InfiniteFetchTrigger>
</div>
}
</div>
</DashboardCard>
);
}
Loading
Loading