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
124 changes: 122 additions & 2 deletions apps/dashboard/src/components/layouts/dashboard-topbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CloseIcon,
GitPullRequestIcon,
HomeIcon,
IssuesIcon,
Expand All @@ -20,10 +21,11 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@quickhub/ui/components/dropdown-menu";
import { Link } from "@tanstack/react-router";
import { Link, useRouter } from "@tanstack/react-router";
import { useTheme } from "next-themes";
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { signOutToLogin } from "#/lib/auth-actions";
import { removeTab, type Tab, useTabs } from "#/lib/tab-store";

interface DashboardTopbarProps {
user: {
Expand Down Expand Up @@ -52,13 +54,46 @@ const themeOptions = [
{ value: "system", icon: SystemIcon, label: "System" },
] as const;

const tabIconMap = {
pull: GitPullRequestIcon,
issue: IssuesIcon,
} as const;

export function DashboardTopbar({
user,
tabsReady,
counts,
}: DashboardTopbarProps) {
const { theme, setTheme } = useTheme();
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
const openTabs = useTabs();
const router = useRouter();
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);

const updateScrollState = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
}, []);

useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const ro = new ResizeObserver(updateScrollState);
ro.observe(el);
return () => ro.disconnect();
}, [updateScrollState]);

useEffect(() => {
const el = scrollRef.current;
if (!el || openTabs.length === 0) return;
el.scrollLeft = el.scrollWidth;
updateScrollState();
}, [openTabs.length, updateScrollState]);

const displayName = user.name ?? user.email;
const initials = displayName
.split(" ")
Expand Down Expand Up @@ -197,6 +232,53 @@ export function DashboardTopbar({
))}
</div>

{openTabs.length > 0 && (
<div
aria-hidden={!tabsReady}
className={`flex items-center gap-3 transition-[opacity,transform] duration-300 ease-out ${
tabsReady
? "translate-y-0 opacity-100"
: "pointer-events-none -translate-y-0.5 opacity-0"
}`}
>
<div className="h-4 border-l border-border/50" />
<div className="relative min-w-0">
<div
className={`pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-muted to-transparent transition-opacity ${canScrollLeft ? "opacity-100" : "opacity-0"}`}
/>
<div
className={`pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-muted to-transparent transition-opacity ${canScrollRight ? "opacity-100" : "opacity-0"}`}
/>
{/* biome-ignore lint/a11y/noStaticElementInteractions: scroll container needs onScroll for gradient visibility */}
<div
ref={scrollRef}
onScroll={updateScrollState}
onMouseEnter={updateScrollState}
className="no-scrollbar flex items-center gap-0.5 overflow-x-auto"
>
{openTabs.map((tab) => {
const Icon = tabIconMap[tab.type];
return (
<DetailTab
key={tab.id}
tab={tab}
icon={Icon}
onClose={(id) => {
const isActive =
router.state.location.pathname === tab.url;
removeTab(id);
if (isActive) {
void router.navigate({ to: "/" });
}
}}
/>
);
})}
</div>
</div>
</div>
)}

<div className="ml-auto">
<Button
variant="ghost"
Expand All @@ -209,3 +291,41 @@ export function DashboardTopbar({
</nav>
);
}

function DetailTab({
tab,
icon: Icon,
onClose,
}: {
tab: Tab;
icon: typeof GitPullRequestIcon;
onClose: (id: string) => void;
}) {
return (
<Link
to={tab.url}
activeOptions={{ exact: true }}
activeProps={{ className: "active" }}
className="group relative flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-surface-1 hover:text-foreground [&.active]:bg-surface-1 [&.active]:text-foreground"
>
<Icon size={13} strokeWidth={2} className={`shrink-0 ${tab.iconColor}`} />
<span className="max-w-32 truncate">{tab.title}</span>
<span className="tabular-nums opacity-60">#{tab.number}</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClose(tab.id);
}}
className="absolute inset-y-0 right-0 flex items-center rounded-r-md bg-surface-1 pl-1.5 pr-1.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Close ${tab.title}`}
>
<span className="absolute inset-y-0 -left-3 w-3 bg-gradient-to-r from-transparent to-surface-1" />
<span className="relative flex size-4 items-center justify-center rounded-sm hover:bg-border/50">
<CloseIcon size={10} strokeWidth={2} />
</span>
</button>
</Link>
);
}
65 changes: 65 additions & 0 deletions apps/dashboard/src/lib/tab-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useSyncExternalStore } from "react";

export type TabType = "pull" | "issue";

export interface Tab {
id: string;
type: TabType;
title: string;
number: number;
url: string;
repo: string;
iconColor: string;
}

const STORAGE_KEY = "quickhub:tabs";

function loadTabs(): Tab[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}

function persistTabs() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
} catch {}
}

let tabs: Tab[] = loadTabs();
const listeners = new Set<() => void>();

function emitChange() {
persistTabs();
for (const listener of listeners) {
listener();
}
}

function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}

function getSnapshot() {
return tabs;
}

export function addTab(tab: Tab) {
if (tabs.some((t) => t.id === tab.id)) return;
tabs = [...tabs, tab];
emitChange();
}

export function removeTab(id: string) {
tabs = tabs.filter((t) => t.id !== id);
emitChange();
}

export function useTabs() {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
26 changes: 26 additions & 0 deletions apps/dashboard/src/lib/use-register-tab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect } from "react";
import { addTab, type TabType } from "./tab-store";

export function useRegisterTab(
tab: {
type: TabType;
title: string | undefined;
number: number;
url: string;
repo: string;
iconColor: string;
} | null,
) {
useEffect(() => {
if (!tab?.title) return;
addTab({
id: `${tab.type}:${tab.repo}#${tab.number}`,
type: tab.type,
title: tab.title,
number: tab.number,
url: tab.url,
repo: tab.repo,
iconColor: tab.iconColor,
});
}, [tab?.type, tab?.title, tab?.number, tab?.url, tab?.repo, tab?.iconColor]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "#/lib/github.query";
import type { GitHubActor, IssueDetail } from "#/lib/github.types";
import { useHasMounted } from "#/lib/use-has-mounted";
import { useRegisterTab } from "#/lib/use-register-tab";

export const Route = createFileRoute(
"/_protected/$owner/$repo/issues/$issueId",
Expand Down Expand Up @@ -63,13 +64,27 @@ function IssueDetailPage() {
enabled: hasMounted && detailQuery.data != null,
});

const issue = detailQuery.data;

useRegisterTab(
issue
? {
type: "issue",
title: issue.title,
number: issue.number,
url: `/${owner}/${repo}/issues/${issueId}`,
repo: `${owner}/${repo}`,
iconColor: getIssueStateConfig(issue).color,
}
: null,
);

if (detailQuery.error) throw detailQuery.error;

if (hasMounted && detailQuery.isPending) {
return <DashboardContentLoading />;
}

const issue = detailQuery.data;
if (!issue) return null;

const stateConfig = getIssueStateConfig(issue);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "#/lib/github.query";
import type { GitHubActor, PullDetail, PullStatus } from "#/lib/github.types";
import { useHasMounted } from "#/lib/use-has-mounted";
import { useRegisterTab } from "#/lib/use-register-tab";

export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({
component: PullDetailPage,
Expand Down Expand Up @@ -86,13 +87,27 @@ function PullDetailPage() {
enabled: hasMounted && detailQuery.data != null,
});

const pr = detailQuery.data;

useRegisterTab(
pr
? {
type: "pull",
title: pr.title,
number: pr.number,
url: `/${owner}/${repo}/pull/${pullId}`,
repo: `${owner}/${repo}`,
iconColor: getPrStateConfig(pr).color,
}
: null,
);

if (detailQuery.error) throw detailQuery.error;

if (hasMounted && detailQuery.isPending) {
return <DashboardContentLoading />;
}

const pr = detailQuery.data;
if (!pr) return null;

const stateConfig = getPrStateConfig(pr);
Expand Down
1 change: 1 addition & 0 deletions packages/icons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
AddCircleHalfDotIcon as IssuesIcon,
BookOpen01Icon as BookOpenIcon,
Bug01Icon as BugIcon,
Cancel01Icon as CloseIcon,
CheckListIcon as ReviewsIcon,
CodeIcon,
Comment01Icon as CommentIcon,
Expand Down
7 changes: 7 additions & 0 deletions packages/ui/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@
background: var(--ring);
}

.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}

/* Stable scrollbar — auto-hides, no layout shift, inset thumb */
.overflow-stable {
overflow-y: auto;
Expand Down