Spaces:
Running
Running
feat: tracked orgs dialog + search suggestion banner
Browse filesAdd "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 +20 -4
- src/components/TrackedOrgsDialog.tsx +71 -0
- src/components/UserSearchDialog.tsx +48 -1
- src/pages/index.tsx +23 -4
- src/types/heatmap.ts +6 -0
- src/utils/ranking.ts +15 -0
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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 →
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
};
|