app/components/IssueComponents.tsx (404 lines of code) (raw):
import React, { useEffect } from "react";
import type { GitHubIssue } from "~/lib/githubService";
// Simple Issue Pill that matches the exact text size
export const IssuePill = ({
issue,
isLoading,
position,
onHover,
onLeave,
onClick,
}: {
issue?: GitHubIssue;
isLoading?: boolean;
position: { left: number; top: number; width: number; height: number };
onHover: (e: React.MouseEvent) => void;
onLeave: () => void;
onClick: (e: React.MouseEvent) => void;
}) => {
if (isLoading) {
return (
<span
className="pointer-events-none absolute flex items-center justify-center rounded bg-gray-100 dark:bg-gray-700"
style={{
left: position.left,
top: position.top,
width: position.width,
height: position.height,
}}
>
<i className="fas fa-spinner fa-spin text-xs text-gray-500"></i>
</span>
);
}
if (!issue) return null;
return (
<span
className={`pointer-events-auto absolute flex cursor-pointer items-center justify-center rounded transition-all duration-200 ${
issue.state === "open"
? "bg-green-100 hover:bg-green-200 dark:bg-green-900/30 dark:hover:bg-green-900/50"
: "bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/30 dark:hover:bg-purple-900/50"
}`}
style={{
left: position.left,
top: position.top,
width: position.width,
height: position.height,
}}
onMouseEnter={onHover}
onMouseLeave={onLeave}
onClick={onClick}
title={`#${issue.number}: ${issue.title}`}
/>
);
};
// Utility function for safe popover positioning
const getSafePosition = (
targetX: number,
targetY: number,
elementWidth: number,
elementHeight: number,
offset = 10
) => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scrollY = window.scrollY;
let x = targetX;
let y = targetY + offset;
// Adjust horizontal position if element would overflow
if (x + elementWidth > viewportWidth) {
x = Math.max(10, viewportWidth - elementWidth - 10);
}
// Adjust vertical position if element would overflow
if (y + elementHeight > viewportHeight + scrollY) {
y = Math.max(scrollY + 10, targetY - elementHeight - offset);
}
return { x, y };
};
// Issue Popover Component
export const IssuePopover = ({
issue,
position,
onClose,
}: {
issue: GitHubIssue;
position: { x: number; y: number };
onClose: () => void;
}) => {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest("[data-issue-popover]")) {
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const truncateBody = (body: string) => {
return body.length > 200 ? body.substring(0, 200) + "..." : body;
};
const popoverWidth = 320;
const popoverHeight = 300;
const safePosition = getSafePosition(
position.x,
position.y,
popoverWidth,
popoverHeight
);
return (
<div
data-issue-popover
className="fixed z-[9999] rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
style={{
left: safePosition.x,
top: safePosition.y,
width: popoverWidth,
maxHeight: popoverHeight,
}}
>
<div className="p-4">
<div className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
issue.state === "open"
? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
: "bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400"
}`}
>
<i
className={`fas ${issue.state === "open" ? "fa-circle-dot" : "fa-check-circle"} mr-1`}
></i>
{issue.state}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
#{issue.number}
</span>
</div>
<button
onClick={onClose}
className="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-200"
aria-label="Close popover"
>
<i className="fas fa-times text-sm"></i>
</button>
</div>
<h3 className="mb-2 font-semibold leading-tight text-gray-900 dark:text-white">
{issue.title}
</h3>
<div className="mb-3 flex items-center gap-2">
<img
src={issue.user.avatar_url}
alt={issue.user.login}
className="h-5 w-5 rounded-full"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
{issue.user.login} opened {formatDate(issue.created_at)}
</span>
</div>
{issue.labels.length > 0 && (
<div className="mb-3 flex flex-wrap gap-1">
{issue.labels.slice(0, 3).map((label) => (
<span
key={label.name}
className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
borderColor: `#${label.color}40`,
}}
>
{label.name}
</span>
))}
{issue.labels.length > 3 && (
<span className="text-xs text-gray-500 dark:text-gray-400">
+{issue.labels.length - 3} more
</span>
)}
</div>
)}
{issue.body && (
<div className="max-h-32 overflow-y-auto border-t border-gray-200 pt-3 text-sm text-gray-700 dark:border-gray-600 dark:text-gray-300">
<p className="whitespace-pre-wrap">{truncateBody(issue.body)}</p>
</div>
)}
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-600">
<a
href={issue.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 transition-colors hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
<i className="fab fa-github text-xs"></i>
View on GitHub
<i className="fas fa-external-link-alt text-xs"></i>
</a>
</div>
</div>
</div>
);
};
// Issue Autocomplete Component
export const IssueAutocomplete = ({
issues,
position,
onSelect,
onClose,
searchTerm,
}: {
issues: GitHubIssue[];
position: { x: number; y: number };
onSelect: (issue: GitHubIssue) => void;
onClose: () => void;
searchTerm: string;
}) => {
const [selectedIndex, setSelectedIndex] = React.useState(0);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, issues.length - 1));
break;
case "ArrowUp":
event.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
break;
case "Enter":
event.preventDefault();
if (issues[selectedIndex]) onSelect(issues[selectedIndex]);
break;
case "Escape":
event.preventDefault();
onClose();
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [issues, selectedIndex, onSelect, onClose]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest("[data-issue-autocomplete]")) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
// Reset selected index when issues change
useEffect(() => {
setSelectedIndex(0);
}, [issues]);
const autocompleteWidth = 400;
const autocompleteHeight = Math.min(issues.length * 60 + 60, 320);
const safePosition = getSafePosition(
position.x,
position.y,
autocompleteWidth,
autocompleteHeight,
5
);
if (issues.length === 0) {
return (
<div
data-issue-autocomplete
className="fixed z-[9998] rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
style={{
left: safePosition.x,
top: safePosition.y,
width: autocompleteWidth,
}}
>
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
No issues found {searchTerm && `matching "${searchTerm}"`}
<div className="mt-1 text-xs text-gray-400">
Type issue number or search terms
</div>
</div>
</div>
);
}
return (
<div
data-issue-autocomplete
className="fixed z-[9998] max-h-80 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
style={{
left: safePosition.x,
top: safePosition.y,
width: autocompleteWidth,
}}
>
<div className="p-2">
<div className="mb-2 px-2 text-xs text-gray-500 dark:text-gray-400">
GitHub Issues {searchTerm && `matching "${searchTerm}"`}
</div>
{issues.map((issue, index) => (
<div
key={issue.id}
className={`flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 ${
index === selectedIndex
? "bg-blue-50 dark:bg-blue-900/20"
: "hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
onMouseDown={(e) => {
e.preventDefault();
onSelect(issue);
}}
onMouseEnter={() => setSelectedIndex(index)}
>
<span
className={`text-xs ${issue.state === "open" ? "text-green-600 dark:text-green-400" : "text-purple-600 dark:text-purple-400"}`}
>
<i
className={`fas ${issue.state === "open" ? "fa-circle-dot" : "fa-check-circle"}`}
></i>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-gray-900 dark:text-white">
{issue.title}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
#{issue.number}
</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
by {issue.user.login}
</div>
</div>
</div>
))}
</div>
</div>
);
};
// Simple Issue Pills Hover Component
export const IssueHoverPopover = ({
issue,
position,
}: {
issue: GitHubIssue;
position: { x: number; y: number };
}) => {
const popoverWidth = 250;
const popoverHeight = 100;
const safePosition = getSafePosition(
position.x,
position.y,
popoverWidth,
popoverHeight,
10
);
return (
<div
className="pointer-events-none fixed z-[9999] rounded-lg border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-700 dark:bg-gray-800"
style={{
left: safePosition.x,
top: safePosition.y,
width: popoverWidth,
}}
>
<div className="mb-2 flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
issue.state === "open"
? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
: "bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400"
}`}
>
<i
className={`fas ${issue.state === "open" ? "fa-circle-dot" : "fa-check-circle"} mr-1`}
></i>
{issue.state}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
#{issue.number}
</span>
</div>
<h4 className="mb-1 line-clamp-2 text-sm font-semibold text-gray-900 dark:text-white">
{issue.title}
</h4>
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<img
src={issue.user.avatar_url}
alt={issue.user.login}
className="h-4 w-4 rounded-full"
/>
<span>{issue.user.login}</span>
</div>
</div>
);
};