Files
better-auth-better-hub/apps/web/src/components/dashboard/dashboard-content.tsx
T
Maxwell 78d0764e2e feat: pin repos (#62)
Allows you to pin repositories
<img width="1696" height="1010" alt="CleanShot 2026-02-25 at 13 38
49@2x"
src="https://github.com/user-attachments/assets/06301869-8a57-43f1-9cbe-1167e2bfbb41"
/>

To pin a repo:
<img width="834" height="1408" alt="CleanShot 2026-02-25 at 13 39 05@2x"
src="https://github.com/user-attachments/assets/4bb760f0-65a0-4dfc-bb4f-549492cf1f54"
/>
2026-02-26 07:56:59 +10:00

1199 lines
33 KiB
TypeScript

"use client";
import { noSSR } from "foxact/no-ssr";
import { Suspense, useEffect, useState, useCallback, useTransition, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import {
GitPullRequest,
CircleDot,
Bell,
Eye,
Star,
GitFork,
ChevronRight,
CheckCircle2,
MessageSquare,
Lock,
Flame,
GitCommit,
MessageCircle,
Plus,
Trash2,
X,
Lightbulb,
Check,
Loader2,
Pin,
PinOff,
} from "lucide-react";
import { cn, formatNumber } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago";
import { toInternalUrl, getLanguageColor } from "@/lib/github-utils";
import { RecentlyViewed } from "./recently-viewed";
import { CreateRepoDialog } from "@/components/repo/create-repo-dialog";
import { markNotificationDone } from "@/app/(app)/repos/actions";
import { getPinnedRepos, togglePinRepo, unpinRepo, type PinnedRepo } from "@/lib/pinned-repos";
import type {
IssueItem,
RepoItem,
NotificationItem,
ActivityEvent,
TrendingRepoItem,
GitHubUser,
SearchResult,
} from "@/lib/github-types";
interface DashboardContentProps {
user: GitHubUser;
reviewRequests: SearchResult<IssueItem>;
myOpenPRs: SearchResult<IssueItem>;
myIssues: SearchResult<IssueItem>;
repos: Array<RepoItem>;
notifications: Array<NotificationItem>;
activity: Array<ActivityEvent>;
trending: Array<TrendingRepoItem>;
}
function extractRepoName(repoUrl: string) {
const parts = repoUrl.split("/");
return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
}
export function DashboardContent({
user,
reviewRequests,
myOpenPRs,
myIssues,
repos,
notifications,
activity,
trending,
}: DashboardContentProps) {
const greeting = getGreeting();
const today = new Date().toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
const hasWork =
reviewRequests.items.length > 0 ||
myOpenPRs.items.length > 0 ||
myIssues.items.length > 0;
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const validTabs: TabKey[] = ["reviews", "prs", "issues", "notifs"];
const initialTab: TabKey = validTabs.includes(tabParam as TabKey)
? (tabParam as TabKey)
: "reviews";
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
const handleStatClick = useCallback((tab: TabKey) => {
setActiveTab(tab);
const url = new URL(window.location.href);
if (tab === "reviews") {
url.searchParams.delete("tab");
} else {
url.searchParams.set("tab", tab);
}
window.history.replaceState(null, "", url.toString());
document.getElementById("work-tabs")?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}, []);
return (
<div className="flex flex-col flex-1 min-h-0 w-full">
{/* Header */}
<div className="shrink-0 pb-3">
<h1 className="text-sm font-medium" suppressHydrationWarning>
{greeting}, {user.name || user.login}
</h1>
<p
className="text-[11px] text-muted-foreground font-mono"
suppressHydrationWarning
>
{today}
</p>
</div>
<ExtensionBanner />
{/* Two-column layout */}
<div className="flex-1 min-h-0 flex flex-col lg:flex-row gap-4 pb-2">
{/* Left — overview + work items */}
<div className="lg:w-1/2 lg:min-h-0 lg:overflow-hidden flex flex-col gap-3 lg:pr-2">
{/* Stats */}
<div className="shrink-0 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<Stat
icon={<Eye className="w-3.5 h-3.5" />}
label="Needs review"
value={reviewRequests.total_count}
accent={reviewRequests.total_count > 0}
active={activeTab === "reviews"}
onClick={() => handleStatClick("reviews")}
/>
<Stat
icon={
<GitPullRequest className="w-3.5 h-3.5" />
}
label="Open PRs"
value={myOpenPRs.total_count}
accent={myOpenPRs.total_count > 0}
active={activeTab === "prs"}
onClick={() => handleStatClick("prs")}
/>
<Stat
icon={<CircleDot className="w-3.5 h-3.5" />}
label="Assigned Issues"
value={myIssues.total_count}
accent={myIssues.total_count > 0}
active={activeTab === "issues"}
onClick={() => handleStatClick("issues")}
/>
<Stat
icon={<Bell className="w-3.5 h-3.5" />}
label="Notifs"
value={
notifications.filter(
(n) => n.unread,
).length
}
active={activeTab === "notifs"}
onClick={() => handleStatClick("notifs")}
/>
</div>
{/* Tabbed work panel */}
<WorkTabs
reviewRequests={reviewRequests}
myOpenPRs={myOpenPRs}
myIssues={myIssues}
notifications={notifications}
hasWork={hasWork}
activeTab={activeTab}
activity={activity ?? []}
/>
</div>
{/* Right — recently viewed + repos/trending */}
<div className="lg:w-1/2 lg:min-h-0 lg:overflow-hidden flex flex-col gap-3 lg:pl-2">
{/* Recently Viewed */}
<RecentlyViewed />
{/* Repos + Trending (tabbed) */}
<ReposTabs repos={repos} trending={trending} />
</div>
</div>
</div>
);
}
const EXTENSION_DISMISSED_KEY = "extension-banner-dismissed";
function ExtensionBanner() {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!localStorage.getItem(EXTENSION_DISMISSED_KEY)) {
setVisible(true);
}
}, []);
if (!visible) return null;
return (
<div className="shrink-0 mb-3 flex items-center gap-3 px-3 py-2.5 border border-border rounded-md bg-muted/30">
<Lightbulb className="w-4 h-4 text-muted-foreground/60 shrink-0" />
<p className="flex-1 text-[11px] text-muted-foreground leading-relaxed">
Install our{" "}
<a
href="/extension"
className="text-foreground underline underline-offset-2 decoration-foreground/20 hover:decoration-foreground/40 transition-colors"
>
browser extension
</a>{" "}
to automatically redirect GitHub links to Better Hub.
</p>
<button
onClick={() => {
localStorage.setItem(EXTENSION_DISMISSED_KEY, "true");
setVisible(false);
}}
className="shrink-0 p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
type TabKey = "reviews" | "prs" | "issues" | "notifs";
function WorkTabs({
reviewRequests,
myOpenPRs,
myIssues,
notifications,
hasWork,
activeTab,
activity,
}: {
reviewRequests: SearchResult<IssueItem>;
myOpenPRs: SearchResult<IssueItem>;
myIssues: SearchResult<IssueItem>;
notifications: Array<NotificationItem>;
hasWork: boolean;
activeTab: TabKey;
activity: Array<ActivityEvent>;
}) {
const [doneIds, setDoneIds] = useState<Set<string>>(new Set());
const visibleNotifs = notifications.filter((n) => !doneIds.has(n.id));
if (!hasWork && activeTab !== "notifs") {
return (
<div className="flex-1 min-h-0 border border-border py-12 text-center">
<CheckCircle2 className="w-5 h-5 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-xs text-muted-foreground font-mono">
Nothing needs your attention
</p>
</div>
);
}
return (
<div
id="work-tabs"
className="flex-1 min-h-0 flex flex-col border border-border overflow-y-auto"
>
{/* Activity marquee */}
<Suspense fallback={<ActivityMarqueeSkeleton />}>
<ActivityMarquee activity={activity ?? []} />
</Suspense>
{activeTab === "reviews" &&
(reviewRequests.items.length > 0 ? (
reviewRequests.items.map((pr) => (
<ItemRow key={pr.id} item={pr} type="pr" />
))
) : (
<EmptyTab message="No reviews requested" />
))}
{activeTab === "prs" &&
(myOpenPRs.items.length > 0 ? (
myOpenPRs.items.map((pr) => (
<ItemRow key={pr.id} item={pr} type="pr" />
))
) : (
<EmptyTab message="No open PRs" />
))}
{activeTab === "issues" &&
(myIssues.items.length > 0 ? (
myIssues.items.map((issue) => (
<ItemRow key={issue.id} item={issue} type="issue" />
))
) : (
<EmptyTab message="No assigned issues" />
))}
{activeTab === "notifs" &&
(visibleNotifs.length > 0 ? (
visibleNotifs.map((notif) => (
<NotificationRow
key={notif.id}
notif={notif}
onDone={(id) =>
setDoneIds(
(prev) =>
new Set([
...prev,
id,
]),
)
}
/>
))
) : (
<EmptyTab message="No notifications" />
))}
</div>
);
}
function EmptyTab({ message }: { message: string }) {
return (
<div className="py-10 text-center">
<p className="text-xs text-muted-foreground/50 font-mono">{message}</p>
</div>
);
}
const reasonLabels: Record<string, string> = {
assign: "Assigned",
author: "Author",
comment: "Comment",
ci_activity: "CI",
invitation: "Invited",
manual: "Subscribed",
mention: "Mentioned",
review_requested: "Review requested",
security_alert: "Security",
state_change: "State change",
subscribed: "Watching",
team_mention: "Team mention",
};
function getNotificationHref(notif: NotificationItem): string {
const repo = notif.repository.full_name;
if (!notif.subject.url) return `/${repo}`;
const match = notif.subject.url.match(/repos\/[^/]+\/[^/]+\/(pulls|issues)\/(\d+)/);
if (match) {
const type = match[1] === "pulls" ? "pulls" : "issues";
return `/${repo}/${type}/${match[2]}`;
}
return `/${repo}`;
}
function NotificationRow({
notif,
onDone,
}: {
notif: NotificationItem;
onDone: (id: string) => void;
}) {
const href = getNotificationHref(notif);
const repo = notif.repository.full_name;
const [marking, startMarking] = useTransition();
const icon =
notif.subject.type === "PullRequest" ? (
<GitPullRequest className="w-3.5 h-3.5" />
) : notif.subject.type === "Issue" ? (
<CircleDot className="w-3.5 h-3.5" />
) : (
<Bell className="w-3.5 h-3.5" />
);
return (
<div className="group flex items-center gap-3 px-4 py-2 hover:bg-muted/50 dark:hover:bg-white/2 transition-colors border-b border-border/60 last:border-b-0">
<span className="text-muted-foreground/70 shrink-0">{icon}</span>
<Link href={href} className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{notif.unread && (
<span className="w-1.5 h-1.5 rounded-full bg-foreground shrink-0" />
)}
<span className="text-sm truncate group-hover:text-foreground transition-colors">
{notif.subject.title}
</span>
</div>
<div className="flex items-center gap-2 mt-px">
<span className="text-[11px] font-mono text-muted-foreground/70">
{repo}
</span>
<span
className={cn(
"text-[9px] font-mono px-1 py-0.5 border",
notif.reason === "review_requested"
? "border-warning/30 text-warning"
: notif.reason === "mention" ||
notif.reason ===
"team_mention"
? "border-foreground/20 text-foreground/60"
: "border-border text-muted-foreground",
)}
>
{reasonLabels[notif.reason] || notif.reason}
</span>
<span className="text-[11px] text-muted-foreground/50">
<TimeAgo date={notif.updated_at} />
</span>
</div>
</Link>
<button
disabled={marking}
onClick={() => {
startMarking(async () => {
const res = await markNotificationDone(notif.id);
if (res.success) onDone(notif.id);
});
}}
className="shrink-0 p-1 text-muted-foreground/30 opacity-0 group-hover:opacity-100 hover:text-foreground/70 transition-all cursor-pointer disabled:opacity-100"
title="Mark as done"
>
{marking ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Check className="w-3.5 h-3.5" />
)}
</button>
</div>
);
}
function ReposTabs({
repos,
trending,
}: {
repos: Array<RepoItem>;
trending: Array<TrendingRepoItem>;
}) {
const [tab, setTab] = useState<"pinned" | "repos" | "trending">("repos");
const [pinnedRepos, setPinnedRepos] = useState<PinnedRepo[]>([]);
useEffect(() => {
const pinned = getPinnedRepos();
setPinnedRepos(pinned);
if (pinned.length > 0) {
setTab("pinned");
}
}, []);
const pinnedSet = useMemo(
() => new Set(pinnedRepos.map((r) => r.full_name)),
[pinnedRepos],
);
const handleTogglePin = useCallback((repo: RepoItem) => {
const updated = togglePinRepo({
id: repo.id,
full_name: repo.full_name,
name: repo.name,
owner: repo.owner,
language: repo.language,
stargazers_count: repo.stargazers_count,
private: repo.private,
});
setPinnedRepos(updated);
}, []);
const handleUnpin = useCallback((fullName: string) => {
const updated = unpinRepo(fullName);
setPinnedRepos(updated);
}, []);
return (
<section className="flex-1 border border-border flex flex-col min-h-0">
<div className="shrink-0 flex items-center border-b border-border overflow-x-auto no-scrollbar">
{pinnedRepos.length > 0 && (
<button
onClick={() => setTab("pinned")}
className={cn(
"flex items-center gap-2 px-4 py-2 text-[11px] font-mono uppercase tracking-wider transition-colors cursor-pointer",
tab === "pinned"
? "text-foreground bg-muted/50 dark:bg-white/[0.04]"
: "text-muted-foreground hover:text-foreground/60",
)}
>
<Pin className="w-3 h-3" />
Pinned
</button>
)}
<button
onClick={() => setTab("repos")}
className={cn(
"flex items-center gap-2 px-4 py-2 text-[11px] font-mono uppercase tracking-wider transition-colors cursor-pointer",
tab === "repos"
? "text-foreground bg-muted/50 dark:bg-white/[0.04]"
: "text-muted-foreground hover:text-foreground/60",
)}
>
Repositories
</button>
{trending.length > 0 && (
<button
onClick={() => setTab("trending")}
className={cn(
"flex items-center gap-2 px-4 py-2 text-[11px] font-mono uppercase tracking-wider transition-colors cursor-pointer",
tab === "trending"
? "text-foreground bg-muted/50 dark:bg-white/[0.04]"
: "text-muted-foreground hover:text-foreground/60",
)}
>
<Flame className="w-3 h-3 text-orange-500/70" />
Trending
</button>
)}
{tab === "trending" && (
<Link
href="/trending"
className="ml-auto mr-3 flex items-center gap-1 text-[10px] font-mono text-muted-foreground/50 hover:text-foreground transition-colors"
>
See all
<ChevronRight className="w-3 h-3" />
</Link>
)}
{(tab === "repos" || tab === "pinned") && (
<div className="ml-auto mr-3">
<CreateRepoDialog />
</div>
)}
</div>
<div className="overflow-y-auto">
{tab === "pinned" &&
pinnedRepos.map((repo) => (
<PinnedRepoRow
key={repo.id}
repo={repo}
onUnpin={handleUnpin}
/>
))}
{tab === "repos" &&
repos
.slice(0, 10)
.map((repo) => (
<RepoRow
key={repo.id}
repo={repo}
isPinned={pinnedSet.has(
repo.full_name,
)}
onTogglePin={handleTogglePin}
/>
))}
{tab === "trending" &&
trending.map((repo) => (
<TrendingRow key={repo.id} repo={repo} />
))}
</div>
</section>
);
}
/* ── Stat ──────────────────────────────────────────────────────────── */
function Stat({
icon,
label,
value,
accent,
active,
onClick,
href,
}: {
icon: React.ReactNode;
label: string;
value: number;
accent?: boolean;
active?: boolean;
onClick?: () => void;
href?: string;
}) {
const content = (
<>
{/* Noise texture */}
<div className="pointer-events-none absolute inset-0 stat-noise opacity-[0.25] dark:opacity-[0.5] mix-blend-overlay" />
{/* Diagonal shine */}
<div className="pointer-events-none absolute -inset-1/2 w-[200%] h-[200%] rotate-12 bg-gradient-to-br from-transparent via-white/[0.5] dark:via-white/[0.03] to-transparent translate-x-[-30%] translate-y-[-10%]" />
<div className="relative flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<span
className={cn(
accent
? "text-foreground/60"
: "text-muted-foreground/40",
)}
>
{icon}
</span>
<span
className={cn(
"text-[10px] font-mono uppercase tracking-wider",
active
? "text-foreground"
: "text-muted-foreground/60",
)}
>
{label}
</span>
</div>
<div className="flex items-baseline gap-1.5">
<span
className={cn(
"text-lg font-medium tabular-nums tracking-tight",
accent
? "text-foreground"
: "text-foreground/60",
)}
>
{value}
</span>
{accent && value > 0 && (
<span className="w-1.5 h-1.5 rounded-full bg-foreground/40" />
)}
</div>
</div>
</>
);
const className = cn(
"stat-card relative overflow-hidden rounded-lg px-3 py-3 text-left w-full",
"bg-gradient-to-br from-black/[0.02] via-black/[0.01] to-transparent dark:from-white/[0.04] dark:via-white/[0.02] dark:to-transparent",
"transition-all duration-150 cursor-pointer",
"hover:border-black/[0.08] dark:hover:border-white/[0.12]",
"hover:bg-gradient-to-br hover:from-black/[0.04] hover:via-black/[0.02] dark:hover:from-white/[0.06] dark:hover:via-white/[0.03]",
"dark:active:from-white/[0.03] dark:active:via-white/[0.02]",
active ? "border border-primary/20!" : "border border-border/50!",
);
if (href) {
return (
<Link href={href} className={className}>
{content}
</Link>
);
}
return (
<button type="button" onClick={onClick} className={className}>
{content}
</button>
);
}
function ItemRow({ item, type }: { item: IssueItem; type: "pr" | "issue" }) {
const repo = extractRepoName(item.repository_url);
const isMerged = type === "pr" && item.pull_request?.merged_at;
const isDraft = type === "pr" && item.draft;
return (
<Link
href={toInternalUrl(item.html_url)}
className="group flex items-center gap-3 px-4 py-2 hover:bg-muted/50 dark:hover:bg-white/2 transition-colors border-b border-border/60 last:border-b-0"
>
{type === "pr" ? (
<GitPullRequest
className={cn(
"w-3.5 h-3.5 shrink-0",
isMerged
? "text-alert-important"
: isDraft
? "text-muted-foreground"
: "text-success",
)}
/>
) : (
<CircleDot className="w-3.5 h-3.5 text-success shrink-0" />
)}
<div className="flex-1 min-w-0">
<span className="text-sm truncate block group-hover:text-foreground transition-colors">
{item.title}
</span>
<div className="flex items-center gap-2 mt-px">
<span className="text-[11px] font-mono text-muted-foreground/70">
{repo}#{item.number}
</span>
<span className="text-[11px] text-muted-foreground/50">
<TimeAgo date={item.updated_at} />
</span>
{item.comments > 0 && (
<span className="flex items-center gap-0.5 text-[11px] text-muted-foreground/50">
<MessageSquare className="w-2.5 h-2.5" />
{item.comments}
</span>
)}
{item.labels
.filter((l) => l.name)
.slice(0, 2)
.map((label) => (
<span
key={label.name}
className="text-[9px] font-mono px-1 rounded-sm"
style={{
color: `#${label.color || "888"}`,
backgroundColor: `#${label.color || "888"}14`,
}}
>
{label.name}
</span>
))}
</div>
</div>
<ChevronRight className="w-3 h-3 text-muted-foreground/30 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</Link>
);
}
/* ── RepoRow ───────────────────────────────────────────────────────── */
function RepoRow({
repo,
isPinned,
onTogglePin,
}: {
repo: RepoItem;
isPinned?: boolean;
onTogglePin?: (repo: RepoItem) => void;
}) {
return (
<div className="group flex items-center gap-2.5 px-4 py-2 hover:bg-muted/50 dark:hover:bg-white/[0.02] transition-colors border-b border-border/40 last:border-b-0">
<Link
href={`/${repo.full_name}`}
className="flex items-center gap-2.5 flex-1 min-w-0"
>
<Image
src={repo.owner.avatar_url}
alt={repo.owner.login}
width={18}
height={18}
className="rounded-sm shrink-0 w-[18px] h-[18px] object-cover"
/>
<span className="text-xs font-mono truncate group-hover:text-foreground transition-colors min-w-0">
<span className="text-muted-foreground/40">
{repo.owner.login}
</span>
<span className="text-muted-foreground/25 mx-0.5">/</span>
<span className="font-medium">{repo.name}</span>
</span>
{repo.private && (
<Lock className="w-2.5 h-2.5 text-muted-foreground/40 shrink-0" />
)}
<div className="flex items-center gap-2.5 ml-auto shrink-0 text-[10px] text-muted-foreground/45">
{repo.language && (
<span className="flex items-center gap-1 font-mono">
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{
backgroundColor:
getLanguageColor(
repo.language,
),
}}
/>
{repo.language}
</span>
)}
{repo.stargazers_count > 0 && (
<span className="flex items-center gap-0.5">
<Star className="w-2.5 h-2.5" />
{formatNumber(repo.stargazers_count)}
</span>
)}
</div>
</Link>
{onTogglePin && (
<button
onClick={(e) => {
e.preventDefault();
onTogglePin(repo);
}}
className={cn(
"shrink-0 p-1 transition-all cursor-pointer",
isPinned
? "text-foreground/60 hover:text-foreground"
: "text-muted-foreground/30 opacity-0 group-hover:opacity-100 hover:text-foreground/60",
)}
title={isPinned ? "Unpin repository" : "Pin repository"}
>
{isPinned ? (
<PinOff className="w-3.5 h-3.5" />
) : (
<Pin className="w-3.5 h-3.5" />
)}
</button>
)}
</div>
);
}
/* ── PinnedRepoRow ─────────────────────────────────────────────────── */
function PinnedRepoRow({
repo,
onUnpin,
}: {
repo: PinnedRepo;
onUnpin: (fullName: string) => void;
}) {
return (
<div className="group flex items-center gap-2.5 px-4 py-2 hover:bg-muted/50 dark:hover:bg-white/[0.02] transition-colors border-b border-border/40 last:border-b-0">
<Link
href={`/${repo.full_name}`}
className="flex items-center gap-2.5 flex-1 min-w-0"
>
<Image
src={repo.owner.avatar_url}
alt={repo.owner.login}
width={18}
height={18}
className="rounded-sm shrink-0 w-[18px] h-[18px] object-cover"
/>
<span className="text-xs font-mono truncate group-hover:text-foreground transition-colors min-w-0">
<span className="text-muted-foreground/40">
{repo.owner.login}
</span>
<span className="text-muted-foreground/25 mx-0.5">/</span>
<span className="font-medium">{repo.name}</span>
</span>
{repo.private && (
<Lock className="w-2.5 h-2.5 text-muted-foreground/40 shrink-0" />
)}
<div className="flex items-center gap-2.5 ml-auto shrink-0 text-[10px] text-muted-foreground/45">
{repo.language && (
<span className="flex items-center gap-1 font-mono">
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{
backgroundColor:
getLanguageColor(
repo.language,
),
}}
/>
{repo.language}
</span>
)}
{repo.stargazers_count > 0 && (
<span className="flex items-center gap-0.5">
<Star className="w-2.5 h-2.5" />
{formatNumber(repo.stargazers_count)}
</span>
)}
</div>
</Link>
<button
onClick={(e) => {
e.preventDefault();
onUnpin(repo.full_name);
}}
className="shrink-0 p-1 text-foreground/60 hover:text-foreground transition-all cursor-pointer"
title="Unpin repository"
>
<PinOff className="w-3.5 h-3.5" />
</button>
</div>
);
}
/* ── TrendingRow ──────────────────────────────────────────────────── */
function TrendingRow({ repo }: { repo: TrendingRepoItem }) {
return (
<Link
href={`/${repo.full_name}`}
className="group flex items-start gap-2.5 px-4 py-2 hover:bg-muted/50 dark:hover:bg-white/[0.02] transition-colors border-b border-border/40 last:border-b-0"
>
<Image
src={repo.owner?.avatar_url ?? ""}
alt={repo.owner?.login ?? ""}
width={18}
height={18}
className="rounded-sm shrink-0 mt-0.5 w-[18px] h-[18px] object-cover"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono truncate group-hover:text-foreground transition-colors">
<span className="text-muted-foreground/40">
{repo.owner?.login}
</span>
<span className="text-muted-foreground/25 mx-0.5">
/
</span>
<span className="font-medium">{repo.name}</span>
</span>
<div className="flex items-center gap-2 ml-auto shrink-0 text-[10px] text-muted-foreground/45">
{repo.language && (
<span className="flex items-center gap-1 font-mono">
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{
backgroundColor:
getLanguageColor(
repo.language,
),
}}
/>
{repo.language}
</span>
)}
<span className="flex items-center gap-0.5">
<Star className="w-2.5 h-2.5" />
{formatNumber(repo.stargazers_count)}
</span>
{repo.forks_count > 0 && (
<span className="flex items-center gap-0.5">
<GitFork className="w-2.5 h-2.5" />
{formatNumber(repo.forks_count)}
</span>
)}
</div>
</div>
{repo.description && (
<p className="text-[10px] text-muted-foreground/40 truncate mt-0.5">
{repo.description}
</p>
)}
</div>
</Link>
);
}
/* ── ActivityMarquee ───────────────────────────────────────────────── */
function getMarqueeItem(
event: ActivityEvent,
): { icon: React.ReactNode; text: string; href: string; time: string } | null {
const repoFullName = event.repo?.name;
if (!repoFullName) return null;
const repoBase = `/${repoFullName}`;
const time = event.created_at
? new Date(event.created_at).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: false,
})
: "";
const p = event.payload;
switch (event.type) {
case "PushEvent": {
const msg = p.commits?.[0]?.message?.split("\n")[0];
const count = p.size ?? p.commits?.length ?? 0;
return {
icon: <GitCommit className="w-3 h-3" />,
text:
msg ||
(count > 1
? `Pushed ${count} commits to ${repoFullName}`
: `Pushed to ${repoFullName}`),
href: repoBase,
time,
};
}
case "PullRequestEvent": {
const pr = p.pull_request;
if (!pr?.number)
return {
icon: <GitPullRequest className="w-3 h-3" />,
text: `PR activity in ${repoFullName}`,
href: repoBase,
time,
};
const action =
p.action === "closed" && pr.merged
? "merged"
: p.action || "updated";
return {
icon: (
<GitPullRequest
className={cn(
"w-3 h-3",
action === "merged"
? "text-alert-important"
: action === "opened"
? "text-success"
: "",
)}
/>
),
text: `${action} #${pr.number}${pr.title ? `: ${pr.title}` : ""}`,
href: `${repoBase}/pulls/${pr.number}`,
time,
};
}
case "PullRequestReviewEvent": {
const pr = p.pull_request;
if (!pr?.number)
return {
icon: <Eye className="w-3 h-3" />,
text: `Reviewed PR in ${repoFullName}`,
href: repoBase,
time,
};
return {
icon: <Eye className="w-3 h-3" />,
text: `reviewed #${pr.number}${pr.title ? `: ${pr.title}` : ""}`,
href: `${repoBase}/pulls/${pr.number}`,
time,
};
}
case "PullRequestReviewCommentEvent": {
const pr = p.pull_request;
if (!pr?.number)
return {
icon: <MessageCircle className="w-3 h-3" />,
text: `Commented on PR in ${repoFullName}`,
href: repoBase,
time,
};
return {
icon: <MessageCircle className="w-3 h-3" />,
text: `commented on review #${pr.number}${pr.title ? `: ${pr.title}` : ""}`,
href: `${repoBase}/pulls/${pr.number}`,
time,
};
}
case "IssuesEvent": {
const issue = p.issue;
if (!issue?.number)
return {
icon: <CircleDot className="w-3 h-3" />,
text: `Issue activity in ${repoFullName}`,
href: repoBase,
time,
};
const action = p.action || "updated";
return {
icon: (
<CircleDot
className={cn(
"w-3 h-3",
action === "opened" ? "text-success" : "",
)}
/>
),
text: `${action} #${issue.number}${issue.title ? `: ${issue.title}` : ""}`,
href: `${repoBase}/issues/${issue.number}`,
time,
};
}
case "IssueCommentEvent": {
const issue = p.issue;
if (!issue?.number)
return {
icon: <MessageCircle className="w-3 h-3" />,
text: `Commented in ${repoFullName}`,
href: repoBase,
time,
};
return {
icon: <MessageCircle className="w-3 h-3" />,
text: `commented on #${issue.number}${issue.title ? `: ${issue.title}` : ""}`,
href: `${repoBase}/issues/${issue.number}`,
time,
};
}
case "CreateEvent":
return {
icon: <Plus className="w-3 h-3" />,
text: p.ref
? `created ${p.ref_type || "ref"} ${p.ref}`
: `created ${p.ref_type || "repo"} ${repoFullName}`,
href: repoBase,
time,
};
case "DeleteEvent":
return {
icon: <Trash2 className="w-3 h-3" />,
text: `deleted ${p.ref_type || "ref"} ${p.ref || ""}`.trim(),
href: repoBase,
time,
};
case "WatchEvent":
return {
icon: <Star className="w-3 h-3 text-warning" />,
text: `starred ${repoFullName}`,
href: repoBase,
time,
};
case "ForkEvent":
return {
icon: <GitFork className="w-3 h-3" />,
text: `forked ${repoFullName}`,
href: repoBase,
time,
};
case "ReleaseEvent":
return {
icon: <Plus className="w-3 h-3" />,
text: `${p.action || "published"} release ${p.release?.tag_name || ""} in ${repoFullName}`.trim(),
href: repoBase,
time,
};
case "CommitCommentEvent":
return {
icon: <MessageCircle className="w-3 h-3" />,
text: `commented on commit in ${repoFullName}`,
href: repoBase,
time,
};
case "GollumEvent":
return {
icon: <Plus className="w-3 h-3" />,
text: `updated wiki in ${repoFullName}`,
href: repoBase,
time,
};
case "MemberEvent":
return {
icon: <Plus className="w-3 h-3" />,
text: `${p.action || "added"} member ${p.member?.login || ""} to ${repoFullName}`.trim(),
href: repoBase,
time,
};
case "PublicEvent":
return {
icon: <Eye className="w-3 h-3" />,
text: `made ${repoFullName} public`,
href: repoBase,
time,
};
default:
return null;
}
}
function ActivityMarqueeSkeleton() {
return (
<div className="shrink-0 relative overflow-hidden border-y border-border">
<div className="flex items-center gap-3 py-2 px-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-1 shrink-0">
<div className="w-8 h-3 rounded bg-muted-foreground/10 animate-pulse" />
<div className="w-3 h-3 rounded bg-muted-foreground/10 animate-pulse" />
<div
className="h-3 rounded bg-muted-foreground/10 animate-pulse"
style={{ width: `${80 + i * 20}px` }}
/>
</div>
))}
</div>
</div>
);
}
function ActivityMarquee({ activity }: { activity: Array<ActivityEvent> }) {
noSSR();
const items = activity.map((e) => getMarqueeItem(e)).filter(Boolean) as Array<{
icon: React.ReactNode;
text: string;
href: string;
time: string;
}>;
if (items.length === 0) return null;
const content = items.map((item, i) => (
<Link
key={i}
href={item.href}
className="inline-flex items-center gap-1 shrink-0 hover:text-foreground transition-colors"
>
<span className="text-muted-foreground/30">{item.time}</span>
<span className="text-muted-foreground/50">{item.icon}</span>
<span>{item.text}</span>
<span className="text-muted-foreground/15 mx-1">&middot;</span>
</Link>
));
return (
<div className="shrink-0 relative overflow-hidden border-b">
<div className="pointer-events-none absolute inset-y-0 left-0 w-16 z-10 bg-gradient-to-r from-background to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-16 z-10 bg-gradient-to-l from-background to-transparent" />
<div className="flex whitespace-nowrap marquee-track text-[11px] font-mono text-muted-foreground py-2 px-3">
{content}
{content}
</div>
</div>
);
}
/* ── Greeting ──────────────────────────────────────────────────────── */
function getGreeting() {
const h = new Date().getHours();
if (h < 12) return "Good morning";
if (h < 18) return "Good afternoon";
return "Good evening";
}