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"
/>
This commit is contained in:
Maxwell
2026-02-26 07:56:59 +10:00
committed by GitHub
parent 653883fb85
commit 78d0764e2e
5 changed files with 357 additions and 59 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ export default function RootLayout({
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} ${jetbrainsMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} ${jetbrainsMono.variable} antialiased bg-background text-foreground`}
suppressHydrationWarning
>
<QueryProvider>
@@ -1,7 +1,7 @@
"use client";
import { noSSR } from "foxact/no-ssr";
import { Suspense, useEffect, useState, useCallback, useTransition } from "react";
import { Suspense, useEffect, useState, useCallback, useTransition, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
@@ -25,6 +25,8 @@ import {
Lightbulb,
Check,
Loader2,
Pin,
PinOff,
} from "lucide-react";
import { cn, formatNumber } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago";
@@ -32,6 +34,7 @@ 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,
@@ -435,11 +438,57 @@ function ReposTabs({
repos: Array<RepoItem>;
trending: Array<TrendingRepoItem>;
}) {
const [tab, setTab] = useState<"repos" | "trending">("repos");
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(
@@ -474,23 +523,36 @@ function ReposTabs({
<ChevronRight className="w-3 h-3" />
</Link>
)}
{tab === "repos" && (
{(tab === "repos" || tab === "pinned") && (
<div className="ml-auto mr-3">
<CreateRepoDialog />
</div>
)}
</div>
<div className="overflow-y-auto">
{tab === "repos"
? repos
{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}
/>
))
: trending.map((repo) => (
))}
{tab === "trending" &&
trending.map((repo) => (
<TrendingRow key={repo.id} repo={repo} />
))}
</div>
@@ -654,11 +716,20 @@ function ItemRow({ item, type }: { item: IssueItem; type: "pr" | "issue" }) {
/* ── RepoRow ───────────────────────────────────────────────────────── */
function RepoRow({ repo }: { repo: RepoItem }) {
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="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"
className="flex items-center gap-2.5 flex-1 min-w-0"
>
<Image
src={repo.owner.avatar_url}
@@ -668,7 +739,9 @@ function RepoRow({ repo }: { repo: RepoItem }) {
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/40">
{repo.owner.login}
</span>
<span className="text-muted-foreground/25 mx-0.5">/</span>
<span className="font-medium">{repo.name}</span>
</span>
@@ -681,7 +754,8 @@ function RepoRow({ repo }: { repo: RepoItem }) {
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{
backgroundColor: getLanguageColor(
backgroundColor:
getLanguageColor(
repo.language,
),
}}
@@ -695,13 +769,99 @@ function RepoRow({ repo }: { repo: RepoItem }) {
{formatNumber(repo.stargazers_count)}
</span>
)}
{repo.updated_at && (
<span className="font-mono">
<TimeAgo date={repo.updated_at} />
</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>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useState, useEffect } from "react";
import { Pin } from "lucide-react";
import { cn } from "@/lib/utils";
import { isPinnedRepo, pinRepo, unpinRepo, type PinnedRepo } from "@/lib/pinned-repos";
interface PinButtonProps {
owner: string;
repo: string;
language: string | null;
stargazers_count: number;
isPrivate: boolean;
avatarUrl: string;
}
export function PinButton({
owner,
repo,
language,
stargazers_count,
isPrivate,
avatarUrl,
}: PinButtonProps) {
const [isPinned, setIsPinned] = useState(false);
const fullName = `${owner}/${repo}`;
useEffect(() => {
setIsPinned(isPinnedRepo(fullName));
}, [fullName]);
const toggle = () => {
if (isPinned) {
unpinRepo(fullName);
setIsPinned(false);
} else {
pinRepo({
id: Date.now(),
full_name: fullName,
name: repo,
owner: { login: owner, avatar_url: avatarUrl },
language,
stargazers_count,
private: isPrivate,
});
setIsPinned(true);
}
};
return (
<button
onClick={toggle}
className={cn(
"flex items-center justify-center gap-1.5 text-[11px] font-mono py-1.5 transition-colors cursor-pointer",
isPinned
? "border border-foreground/30 text-foreground hover:bg-foreground/10"
: "text-muted-foreground hover:text-foreground hover:border-border",
)}
>
<Pin className={cn("w-3 h-3", isPinned && "fill-current")} />
{isPinned ? "Pinned" : "Pin"}
</button>
);
}
@@ -15,6 +15,7 @@ import { TimeAgo } from "@/components/ui/time-ago";
import { formatBytes } from "@/lib/github-utils";
import { StarButton } from "@/components/repo/star-button";
import { ForkButton } from "@/components/repo/fork-button";
import { PinButton } from "@/components/repo/pin-button";
import { SidebarLanguages } from "@/components/repo/sidebar-languages";
import { SidebarContributors } from "@/components/repo/sidebar-contributors";
@@ -189,6 +190,15 @@ export function RepoSidebar({
starred={isStarred}
starCount={stars}
/>
<PinButton
owner={owner}
repo={repoName}
language={language}
stargazers_count={stars}
isPrivate={isPrivate}
avatarUrl={avatarUrl}
/>
<ForkButton
owner={owner}
repo={repoName}
+63
View File
@@ -0,0 +1,63 @@
const STORAGE_KEY = "better-github-pinned-repos";
const MAX_PINNED = 10;
export interface PinnedRepo {
id: number;
full_name: string;
name: string;
owner: { login: string; avatar_url: string };
language: string | null;
stargazers_count: number;
private: boolean;
pinnedAt: number;
}
export function getPinnedRepos(): PinnedRepo[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw) as PinnedRepo[];
} catch {
return [];
}
}
export function isPinnedRepo(fullName: string): boolean {
const pinned = getPinnedRepos();
return pinned.some((r) => r.full_name === fullName);
}
export function pinRepo(repo: Omit<PinnedRepo, "pinnedAt">): PinnedRepo[] {
if (typeof window === "undefined") return [];
try {
const pinned = getPinnedRepos();
if (pinned.some((r) => r.full_name === repo.full_name)) {
return pinned;
}
const updated = [{ ...repo, pinnedAt: Date.now() }, ...pinned].slice(0, MAX_PINNED);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
} catch {
return getPinnedRepos();
}
}
export function unpinRepo(fullName: string): PinnedRepo[] {
if (typeof window === "undefined") return [];
try {
const pinned = getPinnedRepos();
const updated = pinned.filter((r) => r.full_name !== fullName);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
} catch {
return getPinnedRepos();
}
}
export function togglePinRepo(repo: Omit<PinnedRepo, "pinnedAt">): PinnedRepo[] {
if (isPinnedRepo(repo.full_name)) {
return unpinRepo(repo.full_name);
}
return pinRepo(repo);
}