Viani commited on
Commit
c121e6a
·
1 Parent(s): 7b8fd86

feat: tracked orgs dialog + search suggestion banner

Browse files

Add "Tracked Orgs" navbar button that shows all ~40 candidate
organizations in a scrollable grid. Add suggestion banner in the
search dialog that prompts users to request adding an untracked
org when its activity qualifies for the top 16.

src/components/Navbar.tsx CHANGED
@@ -1,12 +1,28 @@
1
- import React from "react";
2
  import UserSearchDialog from "./UserSearchDialog";
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
- const Navbar: React.FC = () => {
5
  return (
6
  <nav className="w-full mt-4">
7
  <div className="max-w-6xl mx-auto px-4 py-3">
8
- <div className="flex items-center justify-end">
9
- <UserSearchDialog />
 
 
 
 
10
  </div>
11
  </div>
12
  </nav>
 
1
+ import React, { useMemo } from "react";
2
  import UserSearchDialog from "./UserSearchDialog";
3
+ import TrackedOrgsDialog from "./TrackedOrgsDialog";
4
+ import { CandidateSummary } from "../types/heatmap";
5
+
6
+ interface NavbarProps {
7
+ allCandidates: CandidateSummary[];
8
+ minActivityCount: number;
9
+ }
10
+
11
+ const Navbar: React.FC<NavbarProps> = ({ allCandidates, minActivityCount }) => {
12
+ const candidateAuthors = useMemo(
13
+ () => allCandidates.map((c) => c.author),
14
+ [allCandidates]
15
+ );
16
 
 
17
  return (
18
  <nav className="w-full mt-4">
19
  <div className="max-w-6xl mx-auto px-4 py-3">
20
+ <div className="flex items-center justify-end gap-4">
21
+ <TrackedOrgsDialog allCandidates={allCandidates} />
22
+ <UserSearchDialog
23
+ candidateAuthors={candidateAuthors}
24
+ minActivityCount={minActivityCount}
25
+ />
26
  </div>
27
  </div>
28
  </nav>
src/components/TrackedOrgsDialog.tsx ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ DialogTrigger,
8
+ } from "./ui/dialog";
9
+ import { CandidateSummary } from "../types/heatmap";
10
+ import { LEADERBOARD_SIZE } from "../constants/organizations";
11
+
12
+ interface TrackedOrgsDialogProps {
13
+ allCandidates: CandidateSummary[];
14
+ }
15
+
16
+ const TrackedOrgsDialog: React.FC<TrackedOrgsDialogProps> = ({
17
+ allCandidates,
18
+ }) => {
19
+ const [isOpen, setIsOpen] = useState(false);
20
+
21
+ return (
22
+ <Dialog open={isOpen} onOpenChange={setIsOpen}>
23
+ <DialogTrigger asChild>
24
+ <button className="text-sm text-foreground hover:text-blue-500 transition-colors duration-200">
25
+ Tracked Orgs
26
+ </button>
27
+ </DialogTrigger>
28
+ <DialogContent className="w-full max-w-[95vw] sm:max-w-2xl p-4 sm:p-6 max-h-[85vh] flex flex-col">
29
+ <DialogHeader>
30
+ <DialogTitle className="text-lg sm:text-xl mb-2">
31
+ Tracked Organizations
32
+ </DialogTitle>
33
+ <p className="text-sm text-muted-foreground">
34
+ Tracking {allCandidates.length} organizations, showing top{" "}
35
+ {LEADERBOARD_SIZE} by activity on the leaderboard.
36
+ </p>
37
+ </DialogHeader>
38
+ <div className="flex-grow overflow-y-auto mt-4">
39
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
40
+ {allCandidates.map((candidate) => (
41
+ <a
42
+ key={candidate.author}
43
+ href={`https://huggingface.co/${candidate.author}`}
44
+ target="_blank"
45
+ rel="noopener noreferrer"
46
+ className="flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-background/60 hover:bg-muted/50 hover:border-blue-500/40 transition-all duration-200"
47
+ >
48
+ {candidate.avatarUrl ? (
49
+ <img
50
+ src={candidate.avatarUrl}
51
+ alt={candidate.fullName}
52
+ className="w-7 h-7 rounded-md flex-shrink-0"
53
+ />
54
+ ) : (
55
+ <div className="w-7 h-7 rounded-md bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground flex-shrink-0">
56
+ {candidate.fullName.charAt(0).toUpperCase()}
57
+ </div>
58
+ )}
59
+ <span className="text-sm font-medium text-foreground truncate">
60
+ {candidate.fullName}
61
+ </span>
62
+ </a>
63
+ ))}
64
+ </div>
65
+ </div>
66
+ </DialogContent>
67
+ </Dialog>
68
+ );
69
+ };
70
+
71
+ export default TrackedOrgsDialog;
src/components/UserSearchDialog.tsx CHANGED
@@ -12,8 +12,17 @@ import { fetchAllAuthorsData, fetchOrganizationData } from "../utils/authors";
12
  import { generateCalendarData } from "../utils/calendar";
13
  import Heatmap from "./Heatmap";
14
  import { ModelData } from "../types/heatmap";
 
15
 
16
- const UserSearchDialog = () => {
 
 
 
 
 
 
 
 
17
  const [isOpen, setIsOpen] = useState(false);
18
  const [isLoading, setIsLoading] = useState(false);
19
  const [searchInput, setSearchInput] = useState("");
@@ -90,6 +99,27 @@ const UserSearchDialog = () => {
90
  return null;
91
  }, [searchedData, currentSearchTerm, userInfo]);
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  const handleDialogOpenChange = (open: boolean) => {
94
  setIsOpen(open);
95
  if (!open) {
@@ -141,6 +171,23 @@ const UserSearchDialog = () => {
141
  authorId={currentSearchTerm}
142
  />
143
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  <div>
145
  <div className="flex justify-between items-center mb-2">
146
  <h3 className="font-semibold text-sm sm:text-base">
 
12
  import { generateCalendarData } from "../utils/calendar";
13
  import Heatmap from "./Heatmap";
14
  import { ModelData } from "../types/heatmap";
15
+ import { LEADERBOARD_SIZE } from "../constants/organizations";
16
 
17
+ interface UserSearchDialogProps {
18
+ candidateAuthors: string[];
19
+ minActivityCount: number;
20
+ }
21
+
22
+ const UserSearchDialog: React.FC<UserSearchDialogProps> = ({
23
+ candidateAuthors,
24
+ minActivityCount,
25
+ }) => {
26
  const [isOpen, setIsOpen] = useState(false);
27
  const [isLoading, setIsLoading] = useState(false);
28
  const [searchInput, setSearchInput] = useState("");
 
99
  return null;
100
  }, [searchedData, currentSearchTerm, userInfo]);
101
 
102
+ const suggestionBanner = useMemo(() => {
103
+ if (!searchedHeatmapData || !currentSearchTerm) return null;
104
+ const isTracked = candidateAuthors.some(
105
+ (a) => a.toLowerCase() === currentSearchTerm.toLowerCase()
106
+ );
107
+ if (isTracked) return null;
108
+ const totalActivity = searchedHeatmapData.reduce(
109
+ (sum, day) => sum + day.count,
110
+ 0
111
+ );
112
+ if (totalActivity < minActivityCount) return null;
113
+ const title = encodeURIComponent(
114
+ `Add ${currentSearchTerm} to tracked organizations`
115
+ );
116
+ const description = encodeURIComponent(
117
+ `${currentSearchTerm} has ${totalActivity} new repos in the last year and qualifies for the top 16.`
118
+ );
119
+ const url = `https://huggingface.co/spaces/v1an1/model-release-heatmap/discussions/new?title=${title}&description=${description}`;
120
+ return { totalActivity, url };
121
+ }, [searchedHeatmapData, currentSearchTerm, candidateAuthors, minActivityCount]);
122
+
123
  const handleDialogOpenChange = (open: boolean) => {
124
  setIsOpen(open);
125
  if (!open) {
 
171
  authorId={currentSearchTerm}
172
  />
173
  </div>
174
+ {suggestionBanner && (
175
+ <div className="rounded-lg border border-blue-500/40 bg-blue-500/10 px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-2">
176
+ <p className="text-sm text-foreground flex-grow">
177
+ <strong>{currentSearchTerm}</strong> has{" "}
178
+ {suggestionBanner.totalActivity} repos this year and
179
+ qualifies for the top {LEADERBOARD_SIZE}!
180
+ </p>
181
+ <a
182
+ href={suggestionBanner.url}
183
+ target="_blank"
184
+ rel="noopener noreferrer"
185
+ className="text-sm font-medium text-blue-500 hover:text-blue-400 whitespace-nowrap transition-colors"
186
+ >
187
+ Request to add &rarr;
188
+ </a>
189
+ </div>
190
+ )}
191
  <div>
192
  <div className="flex justify-between items-center mb-2">
193
  <h3 className="font-semibold text-sm sm:text-base">
src/pages/index.tsx CHANGED
@@ -1,5 +1,9 @@
1
  import React, { useState, useEffect, useMemo } from "react";
2
- import { ProviderInfo, CompactCalendarData } from "../types/heatmap";
 
 
 
 
3
  import OrganizationButton from "../components/OrganizationButton";
4
  import HeatmapGrid from "../components/HeatmapGrid";
5
  import Navbar from "../components/Navbar";
@@ -10,9 +14,16 @@ import { CANDIDATES } from "../constants/organizations";
10
  interface PageProps {
11
  compactData: CompactCalendarData;
12
  providers: ProviderInfo[];
 
 
13
  }
14
 
15
- function Page({ compactData, providers }: PageProps) {
 
 
 
 
 
16
  const [isLoading, setIsLoading] = useState(true);
17
 
18
  const calendarData = useMemo(
@@ -28,7 +39,10 @@ function Page({ compactData, providers }: PageProps) {
28
 
29
  return (
30
  <div className="w-full">
31
- <Navbar />
 
 
 
32
 
33
  <div className="w-full p-4 py-16">
34
  <div className="text-center mb-16 max-w-4xl mx-auto">
@@ -75,12 +89,15 @@ function Page({ compactData, providers }: PageProps) {
75
 
76
  export async function getStaticProps() {
77
  try {
78
- const { calendarData, providers } = await getProviders(CANDIDATES);
 
79
 
80
  return {
81
  props: {
82
  compactData: compactCalendarData(calendarData),
83
  providers,
 
 
84
  },
85
  revalidate: 3600,
86
  };
@@ -90,6 +107,8 @@ export async function getStaticProps() {
90
  props: {
91
  compactData: {},
92
  providers: [],
 
 
93
  },
94
  revalidate: 60,
95
  };
 
1
  import React, { useState, useEffect, useMemo } from "react";
2
+ import {
3
+ ProviderInfo,
4
+ CompactCalendarData,
5
+ CandidateSummary,
6
+ } from "../types/heatmap";
7
  import OrganizationButton from "../components/OrganizationButton";
8
  import HeatmapGrid from "../components/HeatmapGrid";
9
  import Navbar from "../components/Navbar";
 
14
  interface PageProps {
15
  compactData: CompactCalendarData;
16
  providers: ProviderInfo[];
17
+ allCandidates: CandidateSummary[];
18
+ minActivityCount: number;
19
  }
20
 
21
+ function Page({
22
+ compactData,
23
+ providers,
24
+ allCandidates,
25
+ minActivityCount,
26
+ }: PageProps) {
27
  const [isLoading, setIsLoading] = useState(true);
28
 
29
  const calendarData = useMemo(
 
39
 
40
  return (
41
  <div className="w-full">
42
+ <Navbar
43
+ allCandidates={allCandidates}
44
+ minActivityCount={minActivityCount}
45
+ />
46
 
47
  <div className="w-full p-4 py-16">
48
  <div className="text-center mb-16 max-w-4xl mx-auto">
 
89
 
90
  export async function getStaticProps() {
91
  try {
92
+ const { calendarData, providers, allCandidates, minActivityCount } =
93
+ await getProviders(CANDIDATES);
94
 
95
  return {
96
  props: {
97
  compactData: compactCalendarData(calendarData),
98
  providers,
99
+ allCandidates,
100
+ minActivityCount,
101
  },
102
  revalidate: 3600,
103
  };
 
107
  props: {
108
  compactData: {},
109
  providers: [],
110
+ allCandidates: [],
111
+ minActivityCount: 0,
112
  },
113
  revalidate: 60,
114
  };
src/types/heatmap.ts CHANGED
@@ -59,3 +59,9 @@ export interface OrgCandidate {
59
  authors: string[];
60
  color?: string;
61
  }
 
 
 
 
 
 
 
59
  authors: string[];
60
  color?: string;
61
  }
62
+
63
+ export interface CandidateSummary {
64
+ author: string;
65
+ fullName: string;
66
+ avatarUrl: string | null;
67
+ }
src/utils/ranking.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
  CalendarData,
4
  ModelData,
5
  OrgCandidate,
 
6
  } from "../types/heatmap";
7
  import { generateCalendarData } from "./calendar";
8
  import { fetchAllProvidersData, fetchAllAuthorsData } from "./authors";
@@ -100,8 +101,22 @@ export const getProviders = async (candidates: OrgCandidate[]) => {
100
  }
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  return {
104
  calendarData: filteredCalendar,
105
  providers: coloredProviders,
 
 
106
  };
107
  };
 
3
  CalendarData,
4
  ModelData,
5
  OrgCandidate,
6
+ CandidateSummary,
7
  } from "../types/heatmap";
8
  import { generateCalendarData } from "./calendar";
9
  import { fetchAllProvidersData, fetchAllAuthorsData } from "./authors";
 
101
  }
102
  }
103
 
104
+ const lastProvider = coloredProviders[coloredProviders.length - 1];
105
+ const lastKey = lastProvider?.authors[0];
106
+ const minActivityCount = lastKey
107
+ ? (filteredCalendar[lastKey] || []).reduce((s, d) => s + d.count, 0)
108
+ : 0;
109
+
110
+ const allCandidates: CandidateSummary[] = sorted.map((p) => ({
111
+ author: p.authors[0],
112
+ fullName: p.fullName || p.authors[0],
113
+ avatarUrl: p.avatarUrl || null,
114
+ }));
115
+
116
  return {
117
  calendarData: filteredCalendar,
118
  providers: coloredProviders,
119
+ allCandidates,
120
+ minActivityCount,
121
  };
122
  };