diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index f643b15..8400058 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -44,6 +44,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-dropzone": "^15.0.0", + "recharts": "^3.8.1", "tailwindcss": "^4.1.18" }, "devDependencies": { diff --git a/apps/dashboard/src/components/filters/filter-bar.tsx b/apps/dashboard/src/components/filters/filter-bar.tsx index a74eab1..468d006 100644 --- a/apps/dashboard/src/components/filters/filter-bar.tsx +++ b/apps/dashboard/src/components/filters/filter-bar.tsx @@ -22,8 +22,10 @@ import type { export const FilterBar = memo(function FilterBar({ state, + searchPlaceholder = "Search by title, author, repo, or #…", }: { state: ListFilterState; + searchPlaceholder?: string; }) { return (
@@ -31,6 +33,7 @@ export const FilterBar = memo(function FilterBar({ ({ ); }) as (props: { state: ListFilterState; + searchPlaceholder?: string; }) => React.ReactNode; // ── Search Input ─────────────────────────────────────────────────────── @@ -85,9 +89,11 @@ export const FilterBar = memo(function FilterBar({ function SearchInput({ value, onChange, + placeholder, }: { value: string; onChange: (v: string) => void; + placeholder: string; }) { return (
@@ -96,7 +102,7 @@ function SearchInput({ type="text" value={value} onChange={(e) => onChange(e.target.value)} - placeholder="Search by title, author, repo…" + placeholder={placeholder} className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" /> {value && ( @@ -274,17 +280,19 @@ function AddFilterButton({ const [open, setOpen] = useState(false); const [selectedFieldId, setSelectedFieldId] = useState(null); const ref = useRef(null); + const directFilterDef = filterDefs.length === 1 ? filterDefs[0] : null; const close = () => { setOpen(false); setSelectedFieldId(null); }; const activeFieldIds = new Set(activeFilters.map((f) => f.fieldId)); - const selectedOptions = selectedFieldId - ? (availableOptions.get(selectedFieldId) ?? []) + const activeFieldId = directFilterDef?.id ?? selectedFieldId; + const selectedOptions = activeFieldId + ? (availableOptions.get(activeFieldId) ?? []) : []; const selectedValues = - activeFilters.find((f) => f.fieldId === selectedFieldId)?.values ?? + activeFilters.find((f) => f.fieldId === activeFieldId)?.values ?? new Set(); return ( @@ -306,13 +314,27 @@ function AddFilterButton({ {/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss */}
- - {selectedFieldId && ( + {directFilterDef ? ( + { + if (selectedValues.has(value)) { + onRemoveValue(directFilterDef.id, value); + } else { + onAdd(directFilterDef.id, value); + } + }} + /> + ) : ( + + )} + {!directFilterDef && selectedFieldId && ( - +
); } diff --git a/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx b/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx index 9b6405d..73a8c0a 100644 --- a/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx @@ -1,4 +1,5 @@ import { + ArchiveIcon, GitPullRequestIcon, HomeIcon, InboxIcon, @@ -84,6 +85,7 @@ export function DashboardMobileNav({ const navItems: MobileNavItem[] = [ { to: "/", label: "Overview", icon: HomeIcon }, { to: "/inbox", label: "Inbox", icon: InboxIcon }, + { to: "/repos", label: "Repos", icon: ArchiveIcon }, { to: "/pulls", label: "Pulls", diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index 2b1db7d..f967ee3 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -1,4 +1,5 @@ import { + ArchiveIcon, BugIcon, ExternalLinkIcon, GitPullRequestIcon, @@ -276,6 +277,13 @@ export function DashboardTopbar({ + + + + Repositories + + + diff --git a/apps/dashboard/src/components/repo/repo-commit-sparkline.tsx b/apps/dashboard/src/components/repo/repo-commit-sparkline.tsx new file mode 100644 index 0000000..9fc15eb --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-commit-sparkline.tsx @@ -0,0 +1,111 @@ +import { useQuery } from "@tanstack/react-query"; +import { useId } from "react"; +import { Area, AreaChart } from "recharts"; +import { + type GitHubQueryScope, + githubRepoParticipationQueryOptions, +} from "#/lib/github.query"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +const CHART_W = 168; +const CHART_H = 52; +const ACTIVE_LINE = "#60C679"; + +/** Inactive series: theme foreground so contrast tracks light/dark; opacity keeps it subdued vs text. */ +const INACTIVE_STROKE = "var(--foreground)"; + +const ACTIVE_GRADIENT_STOPS = [ + { offset: "0%", opacity: 0.52 }, + { offset: "50%", opacity: 0.2 }, + { offset: "100%", opacity: 0 }, +] as const; + +const INACTIVE_GRADIENT_STOPS = [ + { offset: "0%", opacity: 0.44 }, + { offset: "50%", opacity: 0.14 }, + { offset: "100%", opacity: 0 }, +] as const; + +/** Weekly commit sparkline; muted when there are no commits in the loaded range. */ +export function RepoCommitSparkline({ + scope, + owner, + repo, +}: { + scope: GitHubQueryScope; + owner: string; + repo: string; +}) { + const fillGradientId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const hasMounted = useHasMounted(); + const query = useQuery({ + ...githubRepoParticipationQueryOptions(scope, { owner, repo }), + enabled: hasMounted, + }); + + if (!hasMounted || query.isLoading) { + return ( +
+ ); + } + + const weekly = query.data?.weeklyCommits ?? []; + const hasActivity = + weekly.length > 0 && weekly.some((n) => typeof n === "number" && n > 0); + const strokeColor = hasActivity ? ACTIVE_LINE : INACTIVE_STROKE; + const strokeOpacity = hasActivity ? 1 : 0.5; + const gradientStops = hasActivity + ? ACTIVE_GRADIENT_STOPS + : INACTIVE_GRADIENT_STOPS; + + const chartData = + weekly.length > 0 + ? weekly.map((commits, i) => ({ i, commits })) + : Array.from({ length: 52 }, (_, i) => ({ i, commits: 0 })); + + const chartLabel = hasActivity + ? `Weekly commit activity for ${owner}/${repo}, last 52 weeks` + : `No commits in the last 52 weeks for ${owner}/${repo}`; + + return ( +
+ + + + {gradientStops.map((stop) => ( + + ))} + + + + +
+ ); +} diff --git a/apps/dashboard/src/components/repo/repository-row.tsx b/apps/dashboard/src/components/repo/repository-row.tsx new file mode 100644 index 0000000..749646d --- /dev/null +++ b/apps/dashboard/src/components/repo/repository-row.tsx @@ -0,0 +1,215 @@ +import { + GitForkIcon, + SquareLock02Icon, + StarIcon, + ViewIcon, +} from "@diffkit/icons"; +import { Link } from "@tanstack/react-router"; +import { + Fragment, + memo, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; +import { RepoCommitSparkline } from "#/components/repo/repo-commit-sparkline"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import type { GitHubQueryScope } from "#/lib/github.query"; +import type { UserRepoSummary } from "#/lib/github.types"; + +const languageColors: Record = { + Astro: "#ff5a03", + CSS: "#563d7c", + Go: "#00add8", + HTML: "#e34c26", + JavaScript: "#f1e05a", + MDX: "#fcb32c", + Python: "#3572a5", + Rust: "#dea584", + Shell: "#89e051", + Swift: "#f05138", + TypeScript: "#3178c6", +}; + +const visibilityAria: Record = { + public: "Public repository", + private: "Private repository", + internal: "Internal repository", +}; + +function VisibilityBadge({ + visibility, +}: { + visibility: UserRepoSummary["visibility"]; +}) { + const label = visibilityAria[visibility]; + const Icon = visibility === "public" ? ViewIcon : SquareLock02Icon; + + return ( + + + + ); +} + +export const RepositoryRow = memo(function RepositoryRow({ + repo, + scope, +}: { + repo: UserRepoSummary; + scope: GitHubQueryScope; +}) { + const metaEntries: { key: string; node: ReactNode }[] = []; + if (repo.language) { + metaEntries.push({ + key: "lang", + node: ( + + + {repo.language} + + ), + }); + } + if (repo.updatedAt) { + metaEntries.push({ + key: "time", + node: ( + {formatRelativeTime(repo.updatedAt)} + ), + }); + } + if (repo.stars > 0) { + metaEntries.push({ + key: "stars", + node: ( + + + {formatCount(repo.stars)} + + ), + }); + } + if (repo.forks > 0) { + metaEntries.push({ + key: "forks", + node: ( + + + {formatCount(repo.forks)} + + ), + }); + } + + return ( + +
+
+

+ {repo.fullName} +

+ +
+
+
+ {repo.description ? ( +

+ {repo.description} +

+ ) : ( + + )} +
+ {metaEntries.length > 0 ? ( +
+ {metaEntries.map((entry, index) => ( + + {index > 0 ? ( + · + ) : null} + {entry.node} + + ))} +
+ ) : ( +
+ +
+ )} + + + ); +}); + +function LazyRepoCommitSparkline({ + scope, + owner, + repo, +}: { + scope: GitHubQueryScope; + owner: string; + repo: string; +}) { + const containerRef = useRef(null); + const [showChart, setShowChart] = useState(false); + + useEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setShowChart(true); + observer.disconnect(); + } + }, + { rootMargin: "240px 0px", threshold: 0 }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+ {showChart ? ( + + ) : ( +
+ )} +
+ ); +} + +function formatCount(count: number): string { + if (count >= 1000) { + return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`; + } + return count.toString(); +} diff --git a/apps/dashboard/src/lib/command-palette/registry.ts b/apps/dashboard/src/lib/command-palette/registry.ts index d9c20fb..89fb82b 100644 --- a/apps/dashboard/src/lib/command-palette/registry.ts +++ b/apps/dashboard/src/lib/command-palette/registry.ts @@ -1,4 +1,5 @@ import { + ArchiveIcon, DashboardIcon, ExternalLinkIcon, GitPullRequestIcon, @@ -47,6 +48,15 @@ registerCommands([ shortcut: ["G", "H"], action: { type: "navigate", to: "/" }, }, + { + id: "nav:repos", + label: "Go to Repositories", + group: "Pages", + icon: ArchiveIcon, + keywords: ["repos", "code"], + shortcut: ["G", "O"], + action: { type: "navigate", to: "/repos" }, + }, { id: "nav:pulls", label: "Go to Pull Requests", diff --git a/apps/dashboard/src/lib/github-cache-policy.ts b/apps/dashboard/src/lib/github-cache-policy.ts index 882616f..1f63cfc 100644 --- a/apps/dashboard/src/lib/github-cache-policy.ts +++ b/apps/dashboard/src/lib/github-cache-policy.ts @@ -40,6 +40,10 @@ export const githubCachePolicy = { staleTimeMs: 30 * 60 * 1000, gcTimeMs: 24 * 60 * 60 * 1000, }, + repoParticipation: { + staleTimeMs: 60 * 60 * 1000, + gcTimeMs: 24 * 60 * 60 * 1000, + }, installationAccess: { staleTimeMs: 30 * 60 * 1000, gcTimeMs: 24 * 60 * 60 * 1000, diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index c287343..e7a55d7 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -42,6 +42,7 @@ import type { RepoCollaborator, RepoContributorsResult, RepoOverview, + RepoParticipationStats, RepositoryRef, RepoTreeEntry, RequestedTeam, @@ -76,6 +77,11 @@ import { } from "./github-cache"; import { githubCachePolicy } from "./github-cache-policy"; import { githubRevalidationSignalKeys } from "./github-revalidation"; +import { + filterUserRepoSummaries, + type ReposHubInput, + type ReposHubResult, +} from "./repos-hub-filter"; type GitHubClient = OctokitType; type AuthSession = { @@ -390,6 +396,36 @@ type GitHubGraphQLIssuePageResponse = { type AuthenticatedUserRepo = Awaited< ReturnType >["data"][number]; +type ListForUserRepo = Awaited< + ReturnType +>["data"][number]; + +function mapGithubRestRepoToUserRepoSummary( + repo: AuthenticatedUserRepo | ListForUserRepo, +): UserRepoSummary { + const visibility: UserRepoSummary["visibility"] = + repo.visibility === "internal" + ? "internal" + : repo.visibility === "private" || repo.private + ? "private" + : "public"; + return { + id: repo.id!, + name: repo.name ?? "", + fullName: repo.full_name ?? "", + description: repo.description ?? null, + stars: repo.stargazers_count ?? 0, + forks: repo.forks_count ?? 0, + language: repo.language ?? null, + updatedAt: repo.updated_at ?? null, + createdAt: repo.created_at ?? null, + isPrivate: Boolean(repo.private), + visibility, + url: repo.html_url ?? "", + owner: repo.owner.login ?? "", + }; +} + type RepoPullDetail = Awaited< ReturnType >["data"]; @@ -2000,7 +2036,108 @@ async function getGitHubContextForRepository(input: { owner: string; repo: string; }) { - return getGitHubContextForOwner(input.owner); + return getOrCreateCachedContext( + `repo:${input.owner}/${input.repo}`, + async () => { + const context = await getGitHubContext(); + if (!context) { + return null; + } + + const { installations } = await getGitHubAppUserInstallations( + context.session.user.id, + ); + const installation = findGitHubAppInstallationForOwner( + installations, + input.owner, + ); + if (!installation) { + debug("github-access", "no installation for repo owner, using OAuth", { + owner: input.owner, + repo: input.repo, + }); + return context; + } + + if ( + installation.repositorySelection === "selected" && + !(await appInstallationHasRepositoryAccess( + context, + installation, + input, + )) + ) { + debug( + "github-access", + "installation does not include repo, using OAuth", + { + owner: input.owner, + repo: input.repo, + installationId: installation.id, + }, + ); + return context; + } + + const installationContext = await getGitHubContextForInstallation( + context, + installation, + ); + if (!installationContext) { + console.error( + "[github-access] installation client failed, falling back to OAuth token", + input.owner, + ); + return context; + } + + return installationContext; + }, + ); +} + +async function appInstallationHasRepositoryAccess( + context: GitHubContext, + installation: GitHubAppInstallation, + params: RepoCollaboratorsInput, +) { + if (installation.repositorySelection === "all") { + return true; + } + + if (installation.repositorySelection !== "selected") { + return false; + } + + try { + const { getGitHubAppUserClientByUserId } = await import("./auth-runtime"); + const appUserOctokit = await getGitHubAppUserClientByUserId( + context.session.user.id, + ); + if (!appUserOctokit) { + return false; + } + + const repositories = await listPaginatedGitHubItems({ + request: (page) => + appUserOctokit.rest.apps.listInstallationReposForAuthenticatedUser({ + installation_id: installation.id, + page, + per_page: 100, + }), + getItems: (payload) => + ((payload as GitHubInstallationRepositoriesPayload).repositories ?? + []) as NonNullable< + GitHubInstallationRepositoriesPayload["repositories"] + >, + }); + + return repositories.some((repository) => + repositoryMatchesInstallationRepository(repository, params), + ); + } catch { + return false; + } } function findGitHubAppInstallationForOwner( @@ -2765,9 +2902,17 @@ async function installationHasRepositoryAccess( } try { + const { getGitHubAppUserClientByUserId } = await import("./auth-runtime"); + const appUserOctokit = await getGitHubAppUserClientByUserId( + context.session.user.id, + ); + if (!appUserOctokit) { + return false; + } + const repositories = await listPaginatedGitHubItems({ request: (page) => - context.octokit.rest.apps.listInstallationReposForAuthenticatedUser({ + appUserOctokit.rest.apps.listInstallationReposForAuthenticatedUser({ installation_id: installation.id as number, page, per_page: 100, @@ -5039,59 +5184,45 @@ export const refreshInstallationAccess = createServerFn({ return { ok: true }; }); -export const getUserRepos = createServerFn({ method: "GET" }).handler( - async (): Promise => { - const context = await getGitHubContext(); - if (!context) { - return []; - } - - const [repos, accessIndex] = await Promise.all([ - getCachedGitHubRequest({ - context, - resource: "repos.list", - params: { sort: "updated", perPage: 10 }, - freshForMs: githubCachePolicy.reposList.staleTimeMs, - signalKeys: [githubRevalidationSignalKeys.installationAccess], - namespaceKeys: ["repos.list"], - cacheMode: "split", - request: (headers) => - context.octokit.rest.repos.listForAuthenticatedUser({ - sort: "updated", - per_page: 10, - headers, - }), - mapData: (repos) => - repos.map( - (repo: AuthenticatedUserRepo): UserRepoSummary => ({ - id: repo.id, - name: repo.name, - fullName: repo.full_name, - description: repo.description, - stars: repo.stargazers_count, - language: repo.language, - updatedAt: repo.updated_at, - isPrivate: repo.private, - url: repo.html_url, - owner: repo.owner.login, - }), - ), - }), - getInstallationAccessIndex(context), - ]); +async function fetchInstallationFilteredAuthenticatedRepos( + context: GitHubContext, +): Promise { + const [repos, accessIndex] = await Promise.all([ + getCachedPaginatedGitHubRequest({ + context, + resource: "repos.list", + params: { sort: "updated", perPage: 100 }, + freshForMs: githubCachePolicy.reposList.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.installationAccess], + namespaceKeys: ["repos.list"], + cacheMode: "split", + pageSize: 100, + request: (page) => + context.octokit.rest.repos.listForAuthenticatedUser({ + sort: "updated", + per_page: 100, + page, + }), + mapData: (items) => items.map(mapGithubRestRepoToUserRepoSummary), + }), + getInstallationAccessIndex(context), + ]); - const filtered = repos.filter((repo) => - isRepoVisibleWithInstallationAccess( - accessIndex, - repo.owner, - repo.name, - repo.isPrivate, - ), - ); + const filtered = repos.filter((repo) => + isRepoVisibleWithInstallationAccess( + accessIndex, + repo.owner, + repo.name, + repo.isPrivate, + ), + ); - const removedCount = repos.length - filtered.length; - if (removedCount > 0) { - debug("installation-access", "getUserRepos filtered", { + const removedCount = repos.length - filtered.length; + if (removedCount > 0) { + debug( + "installation-access", + "fetchInstallationFilteredAuthenticatedRepos", + { total: repos.length, kept: filtered.length, removed: removedCount, @@ -5106,13 +5237,105 @@ export const getUserRepos = createServerFn({ method: "GET" }).handler( ), ) .map((repo) => repo.fullName), - }); - } + }, + ); + } + + return filtered; +} - return filtered; +async function fetchPublicReposForUser( + context: GitHubContext, + username: string, +): Promise { + return getCachedPaginatedGitHubRequest({ + context, + resource: "repos.listForUser", + params: { username, sort: "updated", perPage: 100 }, + freshForMs: githubCachePolicy.reposList.staleTimeMs, + namespaceKeys: ["repos.listForUser"], + cacheMode: "split", + pageSize: 100, + request: (page, signal) => + context.octokit.rest.repos.listForUser({ + username, + sort: "updated", + per_page: 100, + page, + request: { signal }, + }), + mapData: (items) => items.map(mapGithubRestRepoToUserRepoSummary), + }); +} + +export const getUserRepos = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + return fetchInstallationFilteredAuthenticatedRepos(context); }, ); +export const getReposHub = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return { + totals: { all: 0, public: 0, private: 0 }, + matchingCount: 0, + repos: [], + }; + } + + const limit = Math.min(Math.max(1, data.limit), 10_000); + const all = await fetchInstallationFilteredAuthenticatedRepos(context); + const totals = { + all: all.length, + public: all.filter((r) => !r.isPrivate).length, + private: all.filter((r) => r.isPrivate).length, + }; + const filtered = filterUserRepoSummaries(all, { + searchQuery: data.searchQuery, + visibility: data.visibility, + sortId: data.sortId, + }); + + return { + totals, + matchingCount: filtered.length, + repos: filtered.slice(0, limit), + }; + }); + +export const getProfileRepos = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + const viewer = await getViewer(context); + const isOwnProfile = + viewer.login.toLowerCase() === data.username.toLowerCase(); + + if (isOwnProfile) { + return fetchInstallationFilteredAuthenticatedRepos(context); + } + + try { + return await fetchPublicReposForUser(context, data.username); + } catch (error) { + if (error instanceof RequestError && error.status === 404) { + return []; + } + throw error; + } + }); + export const searchCommandPaletteGitHub = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { @@ -7521,6 +7744,107 @@ type RepoOverviewInput = { repo: string; }; +export const getRepoParticipationStats = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) { + return { weeklyCommits: [] }; + } + + const repoMetaKey = githubRevalidationSignalKeys.repoMeta(data); + const repoCodeKey = githubRevalidationSignalKeys.repoCode(data); + + return getOrRevalidateGitHubResource({ + userId: context.session.user.id, + resource: "repos.participationStats", + params: data, + freshForMs: githubCachePolicy.repoParticipation.staleTimeMs, + signalKeys: [repoMetaKey, repoCodeKey], + namespaceKeys: [repoMetaKey, repoCodeKey], + cacheMode: "split", + fetcher: async (conditionals) => { + const maxAttempts = 6; + const retryDelayMs = 1_500; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const headers = buildConditionalHeaders( + attempt === 0 ? conditionals : { etag: null, lastModified: null }, + ); + + try { + const response = + await context.octokit.rest.repos.getParticipationStats({ + owner: data.owner, + repo: data.repo, + headers, + }); + + const weeklyCommits = Array.isArray(response.data?.all) + ? response.data.all + : []; + + return { + kind: "success", + data: { weeklyCommits }, + metadata: createGitHubResponseMetadata( + response.status, + normalizeResponseHeaders(response.headers), + ), + }; + } catch (error) { + if ( + error instanceof RequestError && + error.status === 304 && + error.response?.headers + ) { + return { + kind: "not-modified", + metadata: createGitHubResponseMetadata( + 304, + normalizeResponseHeaders( + error.response.headers as Record, + ), + ), + }; + } + + if (error instanceof RequestError && error.status === 202) { + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => + setTimeout(resolve, retryDelayMs), + ); + continue; + } + throw error; + } + + if (error instanceof RequestError) { + if (error.status === 404 || error.status === 403) { + return { + kind: "success", + data: { weeklyCommits: [] }, + metadata: createGitHubResponseMetadata( + error.status, + error.response?.headers + ? normalizeResponseHeaders( + error.response.headers as Record, + ) + : {}, + ), + }; + } + } + + throw error; + } + } + + throw new Error("participation stats: exhausted 202 retries"); + }, + }); + }); + export const getRepoOverview = createServerFn({ method: "GET" }) .inputValidator(identityValidator) .handler(async ({ data }): Promise => { diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 0500589..4439ad8 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -13,6 +13,7 @@ import { getMyPulls, getNotifications, getOrgTeams, + getProfileRepos, getPullComments, getPullFileSummaries, getPullFiles, @@ -29,6 +30,8 @@ import { getRepoFileContent, getRepoLabels, getRepoOverview, + getRepoParticipationStats, + getReposHub, getRepoTree, getReviewThreadStatuses, getTimelineEventPage, @@ -42,6 +45,7 @@ import { } from "./github.functions"; import { githubCachePolicy } from "./github-cache-policy"; import { ensureDefinedQueryData } from "./query-data"; +import type { ReposHubInput } from "./repos-hub-filter"; type RepoState = "all" | "closed" | "open"; type PullSort = "created" | "long-running" | "popularity" | "updated"; @@ -126,6 +130,8 @@ export const githubQueryKeys = { repos: { list: (scope: GitHubQueryScope) => ["github", scope.userId, "repos", "list"] as const, + hub: (scope: GitHubQueryScope, input: ReposHubInput) => + ["github", scope.userId, "repos", "hub", input] as const, }, search: { commandPalette: ( @@ -179,6 +185,8 @@ export const githubQueryKeys = { ) => ["github", scope.userId, "timelineEventPage", input] as const, profile: (scope: GitHubQueryScope, username: string) => ["github", scope.userId, "profile", username] as const, + profileRepos: (scope: GitHubQueryScope, username: string) => + ["github", scope.userId, "profileRepos", username] as const, contributions: (scope: GitHubQueryScope, username: string) => ["github", scope.userId, "contributions", username] as const, pinnedRepos: (scope: GitHubQueryScope, username: string) => @@ -214,6 +222,10 @@ export const githubQueryKeys = { scope: GitHubQueryScope, input: { owner: string; repo: string }, ) => ["github", scope.userId, "repo", "contributors", input] as const, + participation: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, + ) => ["github", scope.userId, "repo", "participation", input] as const, discussions: ( scope: GitHubQueryScope, input: { owner: string; repo: string }, @@ -261,6 +273,32 @@ export function githubUserReposQueryOptions(scope: GitHubQueryScope) { }); } +export function githubReposHubQueryOptions( + scope: GitHubQueryScope, + input: ReposHubInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.repos.hub(scope, input), + queryFn: () => getReposHub({ data: input }), + staleTime: githubCachePolicy.reposList.staleTimeMs, + gcTime: githubCachePolicy.reposList.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubProfileReposQueryOptions( + scope: GitHubQueryScope, + username: string, +) { + return queryOptions({ + queryKey: githubQueryKeys.profileRepos(scope, username), + queryFn: () => getProfileRepos({ data: { username } }), + staleTime: githubCachePolicy.reposList.staleTimeMs, + gcTime: githubCachePolicy.reposList.gcTimeMs, + meta: persistedMeta, + }); +} + export function githubCommandPaletteSearchQueryOptions( scope: GitHubQueryScope, input: CommandPaletteSearchInput, @@ -638,6 +676,19 @@ export function githubRepoBranchesQueryOptions( }); } +export function githubRepoParticipationQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.participation(scope, input), + queryFn: () => getRepoParticipationStats({ data: input }), + staleTime: githubCachePolicy.repoParticipation.staleTimeMs, + gcTime: githubCachePolicy.repoParticipation.gcTimeMs, + meta: persistedMeta, + }); +} + export function githubRepoContributorsQueryOptions( scope: GitHubQueryScope, input: { owner: string; repo: string }, diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 118af65..ac83f17 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -28,15 +28,24 @@ export type GitHubLabel = { description: string | null; }; +/** Weekly total commit counts; index `0` is oldest week, last index is most recent (GitHub participation API). */ +export type RepoParticipationStats = { + weeklyCommits: number[]; +}; + export type UserRepoSummary = { id: number; name: string; fullName: string; description: string | null; stars: number; + forks: number; language: string | null; updatedAt: string | null; + createdAt: string | null; isPrivate: boolean; + /** GitHub visibility (`internal` on Enterprise); derived when the API omits `visibility`. */ + visibility: "public" | "private" | "internal"; url: string; owner: string; }; diff --git a/apps/dashboard/src/lib/repo-list-page.ts b/apps/dashboard/src/lib/repo-list-page.ts new file mode 100644 index 0000000..84ac27d --- /dev/null +++ b/apps/dashboard/src/lib/repo-list-page.ts @@ -0,0 +1,29 @@ +import { parseAsInteger } from "nuqs"; + +/** Match GitHub’s default repo list page size */ +export const REPO_LIST_PAGE_SIZE = 30; + +export const repoListPageQueryParser = parseAsInteger + .withDefault(1) + .withOptions({ history: "push" }); + +export function maxRepoListPage(itemCount: number): number { + return Math.max(1, Math.ceil(itemCount / REPO_LIST_PAGE_SIZE)); +} + +export function safeRepoListPage(page: number, itemCount: number): number { + const max = maxRepoListPage(itemCount); + return Math.min(Math.max(1, page), max); +} + +/** Cumulative slice for “Load more”: page 1 → first chunk, page 2 → first two chunks, etc. */ +export function sliceReposForPage(items: T[], page: number): T[] { + const safe = safeRepoListPage(page, items.length); + const end = Math.min(items.length, safe * REPO_LIST_PAGE_SIZE); + return items.slice(0, end); +} + +export function repoListHasNextPage(page: number, itemCount: number): boolean { + const safe = safeRepoListPage(page, itemCount); + return safe * REPO_LIST_PAGE_SIZE < itemCount; +} diff --git a/apps/dashboard/src/lib/repos-hub-filter.ts b/apps/dashboard/src/lib/repos-hub-filter.ts new file mode 100644 index 0000000..3b28d03 --- /dev/null +++ b/apps/dashboard/src/lib/repos-hub-filter.ts @@ -0,0 +1,110 @@ +import type { SerializedFilterStore } from "#/components/filters/filter-cookie"; +import { REPO_LIST_PAGE_SIZE } from "#/lib/repo-list-page"; +import type { UserRepoSummary } from "./github.types"; + +const REPOS_FILTER_PAGE_ID = "repos"; +const DEFAULT_REPOS_SORT = "updated"; +const VALID_REPOS_SORT_IDS = new Set([ + "updated", + "created", + "created-asc", + "title", +]); + +/** Matches client hub query key: cookie filters + URL page (for loader prefetch). */ +export function buildReposHubPrefetchInput( + filterStore: SerializedFilterStore, + urlPage: number, +): ReposHubInput { + const raw = filterStore[REPOS_FILTER_PAGE_ID]; + const searchQuery = + raw && typeof raw === "object" && typeof raw.searchQuery === "string" + ? raw.searchQuery + : ""; + let sortId = + raw && typeof raw === "object" && typeof raw.sortId === "string" + ? raw.sortId + : DEFAULT_REPOS_SORT; + if (!VALID_REPOS_SORT_IDS.has(sortId)) sortId = DEFAULT_REPOS_SORT; + + const visibility: string[] = []; + if (raw && typeof raw === "object" && Array.isArray(raw.activeFilters)) { + for (const f of raw.activeFilters) { + if ( + typeof f === "object" && + f !== null && + (f as { fieldId?: string }).fieldId === "visibility" && + Array.isArray((f as { values: unknown }).values) + ) { + for (const v of (f as { values: string[] }).values) { + if (typeof v === "string") visibility.push(v); + } + } + } + } + visibility.sort(); + + const page = + Number.isFinite(urlPage) && urlPage > 0 + ? Math.min(Math.floor(urlPage), 10_000) + : 1; + + return { + searchQuery, + visibility, + sortId, + limit: page * REPO_LIST_PAGE_SIZE, + }; +} + +export type ReposHubResult = { + totals: { all: number; public: number; private: number }; + matchingCount: number; + repos: UserRepoSummary[]; +}; + +export type ReposHubInput = { + searchQuery: string; + /** Selected visibility pill values: `"public"` and/or `"private"`; empty = no visibility filter */ + visibility: string[]; + sortId: string; + /** Cumulative max rows to return after filter + sort */ + limit: number; +}; + +function getTime(value: string | null): number { + return value ? Date.parse(value) || 0 : 0; +} + +const sortCompare: Record< + string, + (a: UserRepoSummary, b: UserRepoSummary) => number +> = { + updated: (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt), + created: (a, b) => getTime(b.createdAt) - getTime(a.createdAt), + "created-asc": (a, b) => getTime(a.createdAt) - getTime(b.createdAt), + title: (a, b) => a.name.localeCompare(b.name), +}; + +export function filterUserRepoSummaries( + repos: UserRepoSummary[], + input: Omit, +): UserRepoSummary[] { + let result = repos; + const q = input.searchQuery.toLowerCase().trim(); + if (q) { + result = result.filter( + (r) => + r.name.toLowerCase().includes(q) || + r.fullName.toLowerCase().includes(q) || + r.owner.toLowerCase().includes(q) || + (r.description?.toLowerCase().includes(q) ?? false), + ); + } + if (input.visibility.length > 0) { + const vis = new Set(input.visibility); + result = result.filter((r) => vis.has(r.isPrivate ? "private" : "public")); + } + const sortFn = sortCompare[input.sortId] ?? sortCompare.updated; + return [...result].sort(sortFn); +} diff --git a/apps/dashboard/src/lib/use-debounced-value.ts b/apps/dashboard/src/lib/use-debounced-value.ts new file mode 100644 index 0000000..05c56d0 --- /dev/null +++ b/apps/dashboard/src/lib/use-debounced-value.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(t); + }, [value, delayMs]); + return debounced; +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 848019e..cd0b61b 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as SplatRouteImport } from './routes/$' import { Route as ProtectedIndexRouteImport } from './routes/_protected/index' import { Route as ProtectedSettingsRouteImport } from './routes/_protected/settings' import { Route as ProtectedReviewsRouteImport } from './routes/_protected/reviews' +import { Route as ProtectedReposRouteImport } from './routes/_protected/repos' import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls' import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues' import { Route as ProtectedInboxRouteImport } from './routes/_protected/inbox' @@ -82,6 +83,11 @@ const ProtectedReviewsRoute = ProtectedReviewsRouteImport.update({ path: '/reviews', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedReposRoute = ProtectedReposRouteImport.update({ + id: '/repos', + path: '/repos', + getParentRoute: () => ProtectedRoute, +} as any) const ProtectedPullsRoute = ProtectedPullsRouteImport.update({ id: '/pulls', path: '/pulls', @@ -196,6 +202,7 @@ export interface FileRoutesByFullPath { '/inbox': typeof ProtectedInboxRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute + '/repos': typeof ProtectedReposRoute '/reviews': typeof ProtectedReviewsRoute '/settings': typeof ProtectedSettingsRouteWithChildren '/settings/shortcuts': typeof ProtectedSettingsShortcutsRoute @@ -224,6 +231,7 @@ export interface FileRoutesByTo { '/inbox': typeof ProtectedInboxRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute + '/repos': typeof ProtectedReposRoute '/reviews': typeof ProtectedReviewsRoute '/': typeof ProtectedIndexRoute '/settings/shortcuts': typeof ProtectedSettingsShortcutsRoute @@ -254,6 +262,7 @@ export interface FileRoutesById { '/_protected/inbox': typeof ProtectedInboxRoute '/_protected/issues': typeof ProtectedIssuesRoute '/_protected/pulls': typeof ProtectedPullsRoute + '/_protected/repos': typeof ProtectedReposRoute '/_protected/reviews': typeof ProtectedReviewsRoute '/_protected/settings': typeof ProtectedSettingsRouteWithChildren '/_protected/': typeof ProtectedIndexRoute @@ -286,6 +295,7 @@ export interface FileRouteTypes { | '/inbox' | '/issues' | '/pulls' + | '/repos' | '/reviews' | '/settings' | '/settings/shortcuts' @@ -314,6 +324,7 @@ export interface FileRouteTypes { | '/inbox' | '/issues' | '/pulls' + | '/repos' | '/reviews' | '/' | '/settings/shortcuts' @@ -343,6 +354,7 @@ export interface FileRouteTypes { | '/_protected/inbox' | '/_protected/issues' | '/_protected/pulls' + | '/_protected/repos' | '/_protected/reviews' | '/_protected/settings' | '/_protected/' @@ -442,6 +454,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedReviewsRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/repos': { + id: '/_protected/repos' + path: '/repos' + fullPath: '/repos' + preLoaderRoute: typeof ProtectedReposRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/pulls': { id: '/_protected/pulls' path: '/pulls' @@ -595,6 +614,7 @@ interface ProtectedRouteChildren { ProtectedInboxRoute: typeof ProtectedInboxRoute ProtectedIssuesRoute: typeof ProtectedIssuesRoute ProtectedPullsRoute: typeof ProtectedPullsRoute + ProtectedReposRoute: typeof ProtectedReposRoute ProtectedReviewsRoute: typeof ProtectedReviewsRoute ProtectedSettingsRoute: typeof ProtectedSettingsRouteWithChildren ProtectedIndexRoute: typeof ProtectedIndexRoute @@ -614,6 +634,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedInboxRoute: ProtectedInboxRoute, ProtectedIssuesRoute: ProtectedIssuesRoute, ProtectedPullsRoute: ProtectedPullsRoute, + ProtectedReposRoute: ProtectedReposRoute, ProtectedReviewsRoute: ProtectedReviewsRoute, ProtectedSettingsRoute: ProtectedSettingsRouteWithChildren, ProtectedIndexRoute: ProtectedIndexRoute, diff --git a/apps/dashboard/src/routes/_protected/$owner/index.tsx b/apps/dashboard/src/routes/_protected/$owner/index.tsx index ea529b5..8ab0d21 100644 --- a/apps/dashboard/src/routes/_protected/$owner/index.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/index.tsx @@ -14,12 +14,17 @@ import { import { Button } from "@diffkit/ui/components/button"; import { Skeleton } from "@diffkit/ui/components/skeleton"; import { Spinner } from "@diffkit/ui/components/spinner"; +import { Tabs, TabsList, TabsTrigger } from "@diffkit/ui/components/tabs"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { parseAsStringLiteral, useQueryState } from "nuqs"; +import { useEffect, useMemo, useRef } from "react"; import { ContributionGraph } from "#/components/profile/contribution-graph"; import { PinnedRepoCard } from "#/components/profile/pinned-repo-card"; import { UserActivityFeed } from "#/components/profile/user-activity-feed"; +import { RepositoryRow } from "#/components/repo/repository-row"; import { + githubProfileReposQueryOptions, githubUserActivityQueryOptions, githubUserContributionsQueryOptions, githubUserPinnedReposQueryOptions, @@ -27,6 +32,12 @@ import { githubViewerQueryOptions, } from "#/lib/github.query"; import type { GitHubUserProfile } from "#/lib/github.types"; +import { + repoListHasNextPage, + repoListPageQueryParser, + safeRepoListPage, + sliceReposForPage, +} from "#/lib/repo-list-page"; import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; @@ -55,11 +66,33 @@ export const Route = createFileRoute("/_protected/$owner/")({ component: ProfilePage, }); +/** GitHub-style profile tabs: `?tab=repositories` */ +const profileTabSearchParser = parseAsStringLiteral([ + "repositories", +] as const).withOptions({ history: "push" }); + function ProfilePage() { const { owner } = Route.useParams(); const { user } = Route.useRouteContext(); const scope = { userId: user.id }; const hasMounted = useHasMounted(); + const [tabSearchParam, setTabSearchParam] = useQueryState( + "tab", + profileTabSearchParser, + ); + const [reposPage, setReposPage] = useQueryState( + "page", + repoListPageQueryParser, + ); + const profileTab = tabSearchParam === "repositories" ? "repos" : "overview"; + + const prevOwnerRef = useRef(owner); + useEffect(() => { + if (prevOwnerRef.current !== owner) { + prevOwnerRef.current = owner; + void setReposPage(null); + } + }, [owner, setReposPage]); // Server-side: available immediately from loader const profileQuery = useQuery(githubUserProfileQueryOptions(scope, owner)); @@ -81,12 +114,36 @@ function ProfilePage() { const activityQuery = useInfiniteQuery({ ...githubUserActivityQueryOptions(scope, owner, isOwnProfile), - enabled: hasMounted && viewerQuery.data !== undefined, + enabled: + hasMounted && viewerQuery.data !== undefined && profileTab === "overview", }); const activity = activityQuery.data?.pages.flat(); + const profileReposQuery = useQuery({ + ...githubProfileReposQueryOptions(scope, owner), + enabled: hasMounted && profileTab === "repos", + }); + + const profileRepos = + profileTab === "repos" ? (profileReposQuery.data ?? []) : []; + const safeReposPage = safeRepoListPage(reposPage ?? 1, profileRepos.length); + useEffect(() => { + if (profileTab !== "repos") return; + if ((reposPage ?? 1) !== safeReposPage) { + void setReposPage(safeReposPage === 1 ? null : safeReposPage); + } + }, [profileTab, reposPage, safeReposPage, setReposPage]); + + const paginatedProfileRepos = useMemo( + () => sliceReposForPage(profileRepos, safeReposPage), + [profileRepos, safeReposPage], + ); + if (profileQuery.error) throw profileQuery.error; if (profileQuery.data === null) throw new Error("Not found"); + if (profileTab === "repos" && profileReposQuery.error) { + throw profileReposQuery.error; + } return (
@@ -141,21 +198,50 @@ function ProfilePage() { {/* User info */} {profile ? (
-
-

- {profile.name ?? profile.login} -

-
- @{profile.login} - · - - Joined{" "} - {new Date(profile.createdAt).toLocaleDateString("en-US", { - month: "long", - year: "numeric", - })} - +
+
+

+ {profile.name ?? profile.login} +

+
+ @{profile.login} + · + + Joined{" "} + {new Date(profile.createdAt).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + })} + +
+ { + void setTabSearchParam( + value === "repos" ? "repositories" : null, + ); + if (value !== "repos") { + void setReposPage(null); + } + }} + className="w-full sm:w-auto sm:shrink-0" + > + + + Overview + + + Repositories + + +
{profile.bio && ( @@ -195,46 +281,98 @@ function ProfilePage() {
- {/* Pinned Repos */} - {pinnedRepos && pinnedRepos.length > 0 && ( -
-

- Pinned -

-
- {pinnedRepos.map((repo) => ( - - ))} -
-
- )} + {profileTab === "overview" ? ( + <> + {/* Pinned Repos */} + {pinnedRepos && pinnedRepos.length > 0 && ( +
+

+ Pinned +

+
+ {pinnedRepos.map((repo) => ( + + ))} +
+
+ )} - {/* Activity */} - {activity && activity.length > 0 && ( -
-
-

Recent activity

- - {activity.length} - -
- - {activityQuery.hasNextPage && ( - )} - Load more - +
)} + + ) : ( +
+
+ {profileReposQuery.isLoading ? ( +
+ +
+ ) : !profileReposQuery.data || + profileReposQuery.data.length === 0 ? ( +

+ No repositories to show. +

+ ) : ( + <> +
+ {paginatedProfileRepos.map((repo) => ( +
+ +
+ ))} +
+ {repoListHasNextPage( + safeReposPage, + profileRepos.length, + ) ? ( + + ) : null} + + )} +
)}
diff --git a/apps/dashboard/src/routes/_protected/repos.tsx b/apps/dashboard/src/routes/_protected/repos.tsx new file mode 100644 index 0000000..b843475 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/repos.tsx @@ -0,0 +1,321 @@ +import { + ArchiveIcon, + ChevronDownIcon, + FilterIcon, + LockIcon, + ViewIcon, +} from "@diffkit/icons"; +import { Button } from "@diffkit/ui/components/button"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { useQueryState } from "nuqs"; +import { createElement, useEffect, useMemo, useRef } from "react"; +import { + type FilterableItem, + FilterBar, + type FilterDefinition, + getFilterCookie, + type SortOption, + useListFilters, +} from "#/components/filters"; +import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; +import { RepositoryRow } from "#/components/repo/repository-row"; +import { + githubReposHubQueryOptions, + githubUserReposQueryOptions, +} from "#/lib/github.query"; +import type { UserRepoSummary } from "#/lib/github.types"; +import { + REPO_LIST_PAGE_SIZE, + repoListHasNextPage, + repoListPageQueryParser, + safeRepoListPage, +} from "#/lib/repo-list-page"; +import { buildReposHubPrefetchInput } from "#/lib/repos-hub-filter"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { useDebouncedValue } from "#/lib/use-debounced-value"; + +export const Route = createFileRoute("/_protected/repos")({ + ssr: false, + validateSearch: (raw: Record): { page?: number } => { + const pageRaw = raw.page; + if ( + typeof pageRaw === "number" && + Number.isFinite(pageRaw) && + pageRaw > 0 + ) { + return { page: Math.floor(pageRaw) }; + } + if (typeof pageRaw === "string" && pageRaw.length > 0) { + const n = Number.parseInt(pageRaw, 10); + if (Number.isFinite(n) && n > 0) return { page: n }; + } + return {}; + }, + loader: async ({ context, location }) => { + const scope = { userId: context.user.id }; + const filterStore = await getFilterCookie(); + const search = location.search as { page?: number }; + const urlPage = + typeof search.page === "number" && search.page > 0 ? search.page : 1; + const hubPrefetchInput = buildReposHubPrefetchInput(filterStore, urlPage); + await context.queryClient.prefetchQuery( + githubReposHubQueryOptions(scope, hubPrefetchInput), + ); + await context.queryClient.prefetchQuery(githubUserReposQueryOptions(scope)); + return { filterStore }; + }, + pendingComponent: DashboardContentLoading, + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Repositories"), + description: "Your GitHub repositories in Diffkit.", + robots: "noindex", + }), + component: RepositoriesPage, +}); + +type RepositoryFilterItem = FilterableItem & { + repo: UserRepoSummary; + isPrivate: boolean; +}; + +const repositoryFilterDefs: FilterDefinition[] = [ + { + id: "visibility", + label: "Visibility", + icon: FilterIcon, + extractOptions: () => [ + { + value: "public", + label: "Public", + icon: createElement(ViewIcon, { + size: 14, + className: "text-muted-foreground", + }), + }, + { + value: "private", + label: "Private", + icon: createElement(LockIcon, { + size: 14, + className: "text-muted-foreground", + }), + }, + ], + match: (item, values) => + values.has(asRepo(item).isPrivate ? "private" : "public"), + }, +]; + +const repositorySortOptions: SortOption[] = [ + { + id: "updated", + label: "Recently updated", + compare: (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt), + }, + { + id: "created", + label: "Newest first", + compare: (a, b) => getTime(b.createdAt) - getTime(a.createdAt), + }, + { + id: "created-asc", + label: "Oldest first", + compare: (a, b) => getTime(a.createdAt) - getTime(b.createdAt), + }, + { + id: "title", + label: "Title A-Z", + compare: (a, b) => a.title.localeCompare(b.title), + }, +]; + +const EMPTY_REPO_ITEMS: RepositoryFilterItem[] = []; + +function RepositoriesPage() { + const { filterStore } = Route.useLoaderData(); + const { user } = Route.useRouteContext(); + const scope = useMemo(() => ({ userId: user.id }), [user.id]); + const [page, setPage] = useQueryState("page", repoListPageQueryParser); + + const filterState = useListFilters({ + pageId: "repos", + items: EMPTY_REPO_ITEMS, + filterDefs: repositoryFilterDefs, + sortOptions: repositorySortOptions, + defaultSortId: "updated", + initialStore: filterStore, + }); + + const debouncedSearch = useDebouncedValue(filterState.searchQuery, 300); + + const visibilityValues = useMemo(() => { + const f = filterState.activeFilters.find((x) => x.fieldId === "visibility"); + return f ? [...f.values].sort() : []; + }, [filterState.activeFilters]); + + const limit = (page ?? 1) * REPO_LIST_PAGE_SIZE; + + const hubInput = useMemo( + () => ({ + searchQuery: debouncedSearch, + visibility: visibilityValues, + sortId: filterState.sortId, + limit, + }), + [debouncedSearch, visibilityValues, filterState.sortId, limit], + ); + + const hubQuery = useQuery({ + ...githubReposHubQueryOptions(scope, hubInput), + placeholderData: keepPreviousData, + }); + + const reposFilterSignature = useMemo(() => { + const filterParts = filterState.activeFilters + .map((f) => `${f.fieldId}:${[...f.values].sort().join(",")}`) + .sort() + .join("|"); + return `${debouncedSearch}\0${filterState.sortId}\0${filterParts}`; + }, [debouncedSearch, filterState.sortId, filterState.activeFilters]); + + const prevFilterSignature = useRef(reposFilterSignature); + useEffect(() => { + if (prevFilterSignature.current !== reposFilterSignature) { + prevFilterSignature.current = reposFilterSignature; + void setPage(null); + } + }, [reposFilterSignature, setPage]); + + const matchingCount = hubQuery.data?.matchingCount ?? 0; + const safePage = safeRepoListPage(page ?? 1, matchingCount); + + useEffect(() => { + if (!hubQuery.isSuccess || hubQuery.data === undefined) return; + const safe = safeRepoListPage(page ?? 1, hubQuery.data.matchingCount); + if ((page ?? 1) !== safe) { + void setPage(safe === 1 ? null : safe); + } + }, [hubQuery.isSuccess, hubQuery.data, page, setPage]); + + if (hubQuery.error) throw hubQuery.error; + + if (!hubQuery.data && hubQuery.isPending) { + return ; + } + + const hub = hubQuery.data; + const totals = hub?.totals ?? { all: 0, public: 0, private: 0 }; + const displayedRepos = hub?.repos ?? []; + + return ( +
+
+ + +
+ + + {totals.all === 0 ? ( +

+ No repositories found. +

+ ) : matchingCount === 0 ? ( +

+ No repositories match these filters. +

+ ) : ( + <> +
+ {displayedRepos.map((repo) => ( +
+ +
+ ))} +
+ {repoListHasNextPage(safePage, matchingCount) ? ( + + ) : null} + + )} +
+
+
+ ); +} + +function RepositoryMetricCard({ + icon: Icon, + label, + value, +}: { + icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; + label: string; + value: number; +}) { + return ( +
+
+
+ +
+

{label}

+
+

{value}

+
+ ); +} + +function asRepo(item: FilterableItem) { + return item as RepositoryFilterItem; +} + +function getTime(value: unknown) { + return typeof value === "string" ? Date.parse(value) || 0 : 0; +} diff --git a/apps/dashboard/src/routes/_protected/settings/shortcuts.tsx b/apps/dashboard/src/routes/_protected/settings/shortcuts.tsx index 518f1b5..fe5bc3e 100644 --- a/apps/dashboard/src/routes/_protected/settings/shortcuts.tsx +++ b/apps/dashboard/src/routes/_protected/settings/shortcuts.tsx @@ -202,6 +202,7 @@ const shortcutGroups: ShortcutGroup[] = [ { keys: ["G", "H"], description: "Go to Overview" }, { keys: ["G", "G"], description: "Open Current Page on GitHub" }, { keys: ["G", "U"], description: "Go to Profile" }, + { keys: ["G", "O"], description: "Go to Repositories" }, { keys: ["G", "P"], description: "Go to Pull Requests" }, { keys: ["G", "I"], description: "Go to Issues" }, { keys: ["G", "R"], description: "Go to Reviews" }, diff --git a/apps/dashboard/src/routes/setup.tsx b/apps/dashboard/src/routes/setup.tsx index cae42e2..cdf4275 100644 --- a/apps/dashboard/src/routes/setup.tsx +++ b/apps/dashboard/src/routes/setup.tsx @@ -1,6 +1,6 @@ -import { LoaderCircleIcon } from "@diffkit/icons"; import { Button } from "@diffkit/ui/components/button"; import { Logo } from "@diffkit/ui/components/logo"; +import { Spinner } from "@diffkit/ui/components/spinner"; import { createFileRoute, Link, redirect } from "@tanstack/react-router"; import { getSession } from "#/lib/auth.functions"; import { getGitHubAppAccessState } from "#/lib/github.functions"; @@ -17,10 +17,7 @@ function SetupPageLoading() { return (
- +
); diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 404ff81..1ae8236 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -32,6 +32,7 @@ export { Download02Icon as DownloadIcon, DragDropVerticalIcon as GripVerticalIcon, File02Icon as FileIcon, + FilterIcon, Folder01Icon as FolderIcon, FolderLibraryIcon, GitBranchIcon, @@ -61,7 +62,7 @@ export { Settings01Icon as SettingsIcon, SidebarLeftIcon as PanelLeftIcon, SourceCodeIcon as CodeIcon, - StarIcon, + SquareLock02Icon, Sun01Icon as SunIcon, Tick02Icon as CheckIcon, Tick02Icon as TickIcon, @@ -75,3 +76,4 @@ export { ArchiveDownIcon } from "./archive-down-icon"; export { GitHubLogo, GitHubWordmarkLogo, XLogo } from "./brand-logos"; export { PenIcon } from "./pen-icon"; export { SeparatorHorizontalIcon } from "./separator-horizontal-icon"; +export { StarIcon } from "./star-icon"; diff --git a/packages/icons/src/star-icon.tsx b/packages/icons/src/star-icon.tsx new file mode 100644 index 0000000..0d2249e --- /dev/null +++ b/packages/icons/src/star-icon.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from "react"; + +export function StarIcon( + props: SVGProps & { size?: number; strokeWidth?: number } +) { + const { size = 24, width, height, strokeWidth = 2, ...rest } = props; + return ( + + Star + + + ); +} diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index d061f2f..512558d 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -26,7 +26,7 @@ function TabsList({ =12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3373,6 +3464,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -3559,6 +3653,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -3763,9 +3860,19 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -4333,6 +4440,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -4402,6 +4521,22 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -4434,6 +4569,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -4808,6 +4946,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6703,6 +6844,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.40': {} '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -6917,6 +7070,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -7377,6 +7532,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -7419,6 +7598,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': @@ -7744,6 +7925,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-urls@7.0.0(@noble/hashes@2.0.1): dependencies: whatwg-mimetype: 5.0.0 @@ -7759,6 +7978,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: @@ -7844,6 +8065,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-toolkit@1.45.1: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -8175,8 +8398,14 @@ snapshots: dependencies: safer-buffer: 2.1.2 + immer@10.2.0: {} + + immer@11.1.4: {} + inline-style-parser@0.2.7: {} + internmap@2.0.3: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -8920,6 +9149,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -9002,6 +9240,32 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 17.0.2 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -9058,6 +9322,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-pkg-maps@1.0.0: {} restore-cursor@5.1.0: @@ -9467,6 +9733,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14