org-activity-heatmap / src /components /UserSearchDialog.tsx
Viani's picture
feat: tracked orgs dialog + search suggestion banner
c121e6a
import React, { useState, useMemo } from "react";
import { Input } from "./ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { fetchAllAuthorsData, fetchOrganizationData } from "../utils/authors";
import { generateCalendarData } from "../utils/calendar";
import Heatmap from "./Heatmap";
import { ModelData } from "../types/heatmap";
import { LEADERBOARD_SIZE } from "../constants/organizations";
interface UserSearchDialogProps {
candidateAuthors: string[];
minActivityCount: number;
}
const UserSearchDialog: React.FC<UserSearchDialogProps> = ({
candidateAuthors,
minActivityCount,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchedData, setSearchedData] = useState<ModelData[] | null>(null);
const [isCopied, setIsCopied] = useState(false);
const [currentSearchTerm, setCurrentSearchTerm] = useState("");
const [userInfo, setUserInfo] = useState<{
fullName: string;
avatarUrl: string | null;
} | null>(null);
const handleSearch = async () => {
if (searchInput.trim()) {
setIsLoading(true);
try {
const authorInfo = await fetchOrganizationData([searchInput.trim()]);
const resolvedName =
authorInfo.authorsData?.[0]?.author || searchInput.trim();
const authorData = await fetchAllAuthorsData([resolvedName]);
setSearchedData(authorData);
setUserInfo(authorInfo);
setCurrentSearchTerm(resolvedName);
} catch (error) {
console.error("Error fetching data for searched user:", error);
setSearchedData(null);
setUserInfo(null);
setCurrentSearchTerm("");
}
setIsLoading(false);
} else {
setSearchedData(null);
setUserInfo(null);
setCurrentSearchTerm("");
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleSearch();
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchInput(e.target.value);
};
const getIframeCode = (username: string) => {
return `<iframe
src="https://cfahlgren1-model-release-heatmap.hf.space/${username}"
class="w-full h-[300px]"
frameborder="0"
></iframe>`;
};
const handleCopyCode = () => {
const iframeCode = getIframeCode(searchInput);
navigator.clipboard.writeText(iframeCode).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
};
const searchedHeatmapData = useMemo(() => {
if (searchedData && searchedData.length > 0 && userInfo) {
return generateCalendarData(searchedData, [
{
authors: [currentSearchTerm],
color: "#0088cc",
fullName: userInfo.fullName,
avatarUrl: userInfo.avatarUrl,
},
])[currentSearchTerm];
}
return null;
}, [searchedData, currentSearchTerm, userInfo]);
const suggestionBanner = useMemo(() => {
if (!searchedHeatmapData || !currentSearchTerm) return null;
const isTracked = candidateAuthors.some(
(a) => a.toLowerCase() === currentSearchTerm.toLowerCase()
);
if (isTracked) return null;
const totalActivity = searchedHeatmapData.reduce(
(sum, day) => sum + day.count,
0
);
if (totalActivity < minActivityCount) return null;
const title = encodeURIComponent(
`Add ${currentSearchTerm} to tracked organizations`
);
const description = encodeURIComponent(
`${currentSearchTerm} has ${totalActivity} new repos in the last year and qualifies for the top 16.`
);
const url = `https://huggingface.co/spaces/v1an1/model-release-heatmap/discussions/new?title=${title}&description=${description}`;
return { totalActivity, url };
}, [searchedHeatmapData, currentSearchTerm, candidateAuthors, minActivityCount]);
const handleDialogOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
setSearchInput("");
setSearchedData(null);
setCurrentSearchTerm("");
setIsLoading(false);
setIsCopied(false);
setUserInfo(null);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
<button className="text-sm text-foreground hover:text-blue-500 transition-colors duration-200">
Search
</button>
</DialogTrigger>
<DialogContent className="w-full max-w-[95vw] sm:max-w-4xl p-4 sm:p-6 max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-lg sm:text-xl mb-4">
Get your Hugging Face Heatmap
</DialogTitle>
</DialogHeader>
<div className="flex-grow overflow-y-auto">
<div className="grid gap-4 py-4">
<div className="flex items-center space-x-2 p-2 bg-background rounded-md">
<Input
type="text"
placeholder="Enter username"
value={searchInput}
onChange={handleInputChange}
onKeyDown={handleKeyPress}
className="flex-grow"
/>
</div>
{isLoading ? (
<p className="text-center">Loading...</p>
) : searchedHeatmapData && userInfo ? (
<div className="mt-4 space-y-4">
<div className="overflow-x-auto pb-2">
<Heatmap
data={searchedHeatmapData}
color="#FF9D00"
providerName={currentSearchTerm}
fullName={userInfo.fullName}
avatarUrl={userInfo.avatarUrl || ""}
authorId={currentSearchTerm}
/>
</div>
{suggestionBanner && (
<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">
<p className="text-sm text-foreground flex-grow">
<strong>{currentSearchTerm}</strong> has{" "}
{suggestionBanner.totalActivity} repos this year and
qualifies for the top {LEADERBOARD_SIZE}!
</p>
<a
href={suggestionBanner.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-500 hover:text-blue-400 whitespace-nowrap transition-colors"
>
Request to add &rarr;
</a>
</div>
)}
<div>
<div className="flex justify-between items-center mb-2">
<h3 className="font-semibold text-sm sm:text-base">
Embed in iFrame
</h3>
<Button
onClick={handleCopyCode}
variant="link"
size="sm"
>
{isCopied ? "Copied!" : "Copy"}
</Button>
</div>
<div className="overflow-x-auto">
<pre className="bg-secondary p-2 rounded text-xs whitespace-pre-wrap break-all">
<code>{getIframeCode(searchInput)}</code>
</pre>
</div>
</div>
</div>
) : searchedData !== null && searchedData.length === 0 ? (
<p className="text-center text-slate-500 text-sm italic">
User or Organization not found
</p>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default UserSearchDialog;