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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { LoaderCircleIcon } from "lucide-react";

export function DashboardContentLoading() {
return (
<div className="flex h-full items-center justify-center">
<LoaderCircleIcon
className="size-5 animate-spin text-muted-foreground"
strokeWidth={1.75}
/>
</div>
);
}
60 changes: 58 additions & 2 deletions apps/dashboard/src/components/layouts/dashboard-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,73 @@
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, Outlet } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import {
githubMyIssuesQueryOptions,
githubMyPullsQueryOptions,
} from "#/lib/github.query";
import { useHasMounted } from "#/lib/use-has-mounted";
import { DashboardTopbar } from "./dashboard-topbar";

const routeApi = getRouteApi("/_protected");

export function DashboardLayout() {
const { user } = routeApi.useRouteContext();
const scope = { userId: user.id };
const hasMounted = useHasMounted();
const [isContentVisible, setIsContentVisible] = useState(false);

useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsContentVisible(true);
});

return () => {
window.cancelAnimationFrame(frameId);
};
}, []);

const pullsQuery = useQuery({
...githubMyPullsQueryOptions(scope),
enabled: hasMounted,
});
const issuesQuery = useQuery({
...githubMyIssuesQueryOptions(scope),
enabled: hasMounted,
});
const pullCount = pullsQuery.data
? pullsQuery.data.reviewRequested.length +
pullsQuery.data.assigned.length +
pullsQuery.data.authored.length +
pullsQuery.data.mentioned.length +
pullsQuery.data.involved.length
: undefined;
const issueCount = issuesQuery.data
? issuesQuery.data.assigned.length +
issuesQuery.data.authored.length +
issuesQuery.data.mentioned.length
: undefined;
const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data);

return (
<div className="flex h-dvh flex-col bg-muted">
<DashboardTopbar user={user} />
<DashboardTopbar
user={user}
tabsReady={tabsReady}
counts={{
pulls: pullCount,
issues: issueCount,
reviews: pullsQuery.data?.reviewRequested.length,
}}
/>
<div className="flex flex-1 flex-col overflow-hidden p-2 pt-0">
<div className="flex-1 overflow-hidden rounded-xl border bg-card shadow-[0_1px_4px_0_rgba(0,0,0,0.03)]">
<Outlet />
<div
className={`h-full transition-opacity duration-300 ease-out ${
isContentVisible ? "opacity-100" : "opacity-0"
}`}
>
<Outlet />
</div>
</div>
</div>
</div>
Expand Down
89 changes: 73 additions & 16 deletions apps/dashboard/src/components/layouts/dashboard-topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ import {
SunIcon,
SystemIcon,
} from "@quickhub/icons";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@quickhub/ui/components/avatar";
import { Avatar, AvatarFallback } from "@quickhub/ui/components/avatar";
import { Button } from "@quickhub/ui/components/button";
import {
DropdownMenu,
Expand All @@ -26,6 +22,7 @@ import {
} from "@quickhub/ui/components/dropdown-menu";
import { Link } from "@tanstack/react-router";
import { useTheme } from "next-themes";
import { useState } from "react";
import { signOutToLogin } from "#/lib/auth-actions";

interface DashboardTopbarProps {
Expand All @@ -34,23 +31,34 @@ interface DashboardTopbarProps {
email: string;
image?: string | null;
};
tabsReady: boolean;
counts: {
pulls?: number;
issues?: number;
reviews?: number;
};
}

const navItems = [
{ to: "/", label: "Overview", icon: HomeIcon },
{ to: "/pull-requests", label: "Pull Requests", icon: GitPullRequestIcon },
{ to: "/issues", label: "Issues", icon: IssuesIcon },
{ to: "/reviews", label: "Reviews", icon: ReviewsIcon },
] as const;
type NavItem = {
to: string;
label: string;
icon: typeof HomeIcon;
count?: number;
};

const themeOptions = [
{ value: "light", icon: SunIcon, label: "Light" },
{ value: "dark", icon: MoonIcon, label: "Dark" },
{ value: "system", icon: SystemIcon, label: "System" },
] as const;

export function DashboardTopbar({ user }: DashboardTopbarProps) {
export function DashboardTopbar({
user,
tabsReady,
counts,
}: DashboardTopbarProps) {
const { theme, setTheme } = useTheme();
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
const displayName = user.name ?? user.email;
const initials = displayName
.split(" ")
Expand All @@ -59,6 +67,28 @@ export function DashboardTopbar({ user }: DashboardTopbarProps) {
.slice(0, 2)
.toUpperCase();

const navItems: NavItem[] = [
{ to: "/", label: "Overview", icon: HomeIcon },
{
to: "/pulls",
label: "Pull Requests",
icon: GitPullRequestIcon,
count: counts.pulls,
},
{
to: "/issues",
label: "Issues",
icon: IssuesIcon,
count: counts.issues,
},
{
to: "/reviews",
label: "Reviews",
icon: ReviewsIcon,
count: counts.reviews,
},
];

return (
<nav className="flex items-center gap-3 px-3 py-2">
<DropdownMenu>
Expand All @@ -68,8 +98,18 @@ export function DashboardTopbar({ user }: DashboardTopbarProps) {
className="flex size-8 items-center justify-center rounded-full"
>
<Avatar className="size-7">
<AvatarImage src={user.image ?? undefined} alt={displayName} />
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
{user.image && !avatarLoadFailed ? (
<img
src={user.image}
alt={displayName}
className="size-full object-cover"
onError={() => {
setAvatarLoadFailed(true);
}}
/>
) : (
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
)}
</Avatar>
</button>
</DropdownMenuTrigger>
Expand Down Expand Up @@ -119,7 +159,14 @@ export function DashboardTopbar({ user }: DashboardTopbarProps) {
</DropdownMenuContent>
</DropdownMenu>

<div className="flex items-center gap-0.5">
<div
aria-hidden={!tabsReady}
className={`flex items-center gap-0.5 transition-[opacity,transform] duration-300 ease-out ${
tabsReady
? "translate-y-0 opacity-100"
: "pointer-events-none -translate-y-0.5 opacity-0"
}`}
>
{navItems.map((item) => (
<Button
key={item.label}
Expand All @@ -134,7 +181,17 @@ export function DashboardTopbar({ user }: DashboardTopbarProps) {
activeOptions={{ exact: true }}
activeProps={{ className: "active" }}
>
<span>{item.label}</span>
<span className="flex items-center gap-2">
<span>{item.label}</span>
{typeof item.count === "number" ? (
<span
data-slot="tab-count"
className="tabular-nums text-muted-foreground"
>
{item.count}
</span>
) : null}
</span>
</Link>
</Button>
))}
Expand Down
11 changes: 11 additions & 0 deletions apps/dashboard/src/lib/use-has-mounted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useEffect, useState } from "react";

export function useHasMounted() {
const [hasMounted, setHasMounted] = useState(false);

useEffect(() => {
setHasMounted(true);
}, []);

return hasMounted;
}
40 changes: 17 additions & 23 deletions apps/dashboard/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as ProtectedRouteImport } from './routes/_protected'
import { Route as ProtectedIndexRouteImport } from './routes/_protected/index'
import { Route as ProtectedReviewsRouteImport } from './routes/_protected/reviews'
import { Route as ProtectedPullRequestsRouteImport } from './routes/_protected/pull-requests'
import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls'
import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'

Expand All @@ -36,9 +36,9 @@ const ProtectedReviewsRoute = ProtectedReviewsRouteImport.update({
path: '/reviews',
getParentRoute: () => ProtectedRoute,
} as any)
const ProtectedPullRequestsRoute = ProtectedPullRequestsRouteImport.update({
id: '/pull-requests',
path: '/pull-requests',
const ProtectedPullsRoute = ProtectedPullsRouteImport.update({
id: '/pulls',
path: '/pulls',
getParentRoute: () => ProtectedRoute,
} as any)
const ProtectedIssuesRoute = ProtectedIssuesRouteImport.update({
Expand All @@ -56,14 +56,14 @@ export interface FileRoutesByFullPath {
'/': typeof ProtectedIndexRoute
'/login': typeof LoginRoute
'/issues': typeof ProtectedIssuesRoute
'/pull-requests': typeof ProtectedPullRequestsRoute
'/pulls': typeof ProtectedPullsRoute
'/reviews': typeof ProtectedReviewsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/issues': typeof ProtectedIssuesRoute
'/pull-requests': typeof ProtectedPullRequestsRoute
'/pulls': typeof ProtectedPullsRoute
'/reviews': typeof ProtectedReviewsRoute
'/': typeof ProtectedIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
Expand All @@ -73,28 +73,22 @@ export interface FileRoutesById {
'/_protected': typeof ProtectedRouteWithChildren
'/login': typeof LoginRoute
'/_protected/issues': typeof ProtectedIssuesRoute
'/_protected/pull-requests': typeof ProtectedPullRequestsRoute
'/_protected/pulls': typeof ProtectedPullsRoute
'/_protected/reviews': typeof ProtectedReviewsRoute
'/_protected/': typeof ProtectedIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/issues'
| '/pull-requests'
| '/reviews'
| '/api/auth/$'
fullPaths: '/' | '/login' | '/issues' | '/pulls' | '/reviews' | '/api/auth/$'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/issues' | '/pull-requests' | '/reviews' | '/' | '/api/auth/$'
to: '/login' | '/issues' | '/pulls' | '/reviews' | '/' | '/api/auth/$'
id:
| '__root__'
| '/_protected'
| '/login'
| '/_protected/issues'
| '/_protected/pull-requests'
| '/_protected/pulls'
| '/_protected/reviews'
| '/_protected/'
| '/api/auth/$'
Expand Down Expand Up @@ -136,11 +130,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProtectedReviewsRouteImport
parentRoute: typeof ProtectedRoute
}
'/_protected/pull-requests': {
id: '/_protected/pull-requests'
path: '/pull-requests'
fullPath: '/pull-requests'
preLoaderRoute: typeof ProtectedPullRequestsRouteImport
'/_protected/pulls': {
id: '/_protected/pulls'
path: '/pulls'
fullPath: '/pulls'
preLoaderRoute: typeof ProtectedPullsRouteImport
parentRoute: typeof ProtectedRoute
}
'/_protected/issues': {
Expand All @@ -162,14 +156,14 @@ declare module '@tanstack/react-router' {

interface ProtectedRouteChildren {
ProtectedIssuesRoute: typeof ProtectedIssuesRoute
ProtectedPullRequestsRoute: typeof ProtectedPullRequestsRoute
ProtectedPullsRoute: typeof ProtectedPullsRoute
ProtectedReviewsRoute: typeof ProtectedReviewsRoute
ProtectedIndexRoute: typeof ProtectedIndexRoute
}

const ProtectedRouteChildren: ProtectedRouteChildren = {
ProtectedIssuesRoute: ProtectedIssuesRoute,
ProtectedPullRequestsRoute: ProtectedPullRequestsRoute,
ProtectedPullsRoute: ProtectedPullsRoute,
ProtectedReviewsRoute: ProtectedReviewsRoute,
ProtectedIndexRoute: ProtectedIndexRoute,
}
Expand Down
Loading