Files
kandev/apps/web/components/github/pr-shared.tsx
T
2026-08-16 12:08:59 +01:00

255 lines
7.9 KiB
TypeScript

import { useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { IconMessagePlus, IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
export function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "";
const diff = Date.now() - date.getTime();
if (diff < 0) return t("github:timeAgoJustNow");
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return t("github:timeAgoJustNow");
if (minutes < 60) return t("github:timeAgoMinutes", { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t("github:timeAgoHours", { count: hours });
const days = Math.floor(hours / 24);
if (days < 7) return t("github:timeAgoDays", { count: days });
if (days < 30) return t("github:timeAgoWeeks", { count: Math.floor(days / 7) });
if (days < 365) return t("github:timeAgoMonths", { count: Math.floor(days / 30) });
return t("github:timeAgoYears", { count: Math.floor(days / 365) });
}
function formatMs(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
export function formatDuration(startedAt: string, completedAt: string): string {
return formatMs(new Date(completedAt).getTime() - new Date(startedAt).getTime());
}
export function formatElapsed(startedAt: string): string {
return formatMs(Date.now() - new Date(startedAt).getTime());
}
export function AuthorAvatar({ src, author }: { src: string; author: string }) {
const inner = src ? (
<img src={src} alt={author} className="h-5 w-5 rounded-full shrink-0" />
) : (
<div className="h-5 w-5 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-medium text-muted-foreground">
{author[0]?.toUpperCase() ?? "?"}
</div>
);
return (
<a
href={`https://github.com/${author}`}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 cursor-pointer"
>
{inner}
</a>
);
}
export function AuthorLink({ author }: { author: string }) {
return (
<a
href={`https://github.com/${author}`}
target="_blank"
rel="noopener noreferrer"
className="inline-block min-w-0 max-w-full truncate text-xs font-medium hover:underline cursor-pointer sm:max-w-none"
>
{author}
</a>
);
}
export function CollapsibleSection({
title,
count,
defaultOpen,
subtitle,
onAddAll,
addAllLabel,
children,
}: {
title: string;
count: number;
defaultOpen?: boolean;
subtitle?: React.ReactNode;
onAddAll?: () => void;
addAllLabel?: string;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(defaultOpen ?? true);
return (
<div>
<div className="flex items-center">
<button
type="button"
className="flex items-center gap-1.5 flex-1 py-2 text-xs font-semibold text-foreground/80 hover:text-foreground cursor-pointer"
onClick={() => setOpen(!open)}
>
{open ? (
<IconChevronDown className="h-3.5 w-3.5" />
) : (
<IconChevronRight className="h-3.5 w-3.5" />
)}
{title}
<span className="text-muted-foreground font-normal">({count})</span>
</button>
{onAddAll && count > 0 && open && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 cursor-pointer text-[10px] text-muted-foreground hover:text-foreground gap-1"
onClick={onAddAll}
>
<IconMessagePlus className="h-3 w-3" />
{t("github:addAll")}
</Button>
</TooltipTrigger>
<TooltipContent>{addAllLabel ?? t("github:addAllToChatContext")}</TooltipContent>
</Tooltip>
)}
</div>
{subtitle && open && <div className="pb-1">{subtitle}</div>}
{open && <div className="space-y-2 pb-3">{children}</div>}
</div>
);
}
export function AddToContextButton({
onClick,
tooltip,
}: {
onClick: () => void;
tooltip?: string;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<IconMessagePlus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip ?? t("github:addToChatContext")}</TooltipContent>
</Tooltip>
);
}
/** Sanitization schema: default safe tags + details/summary for collapsible sections. */
const sanitizeSchema = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames ?? []), "details", "summary"],
};
export function PRMarkdownBody({ body }: { body: string }) {
return (
<div className="markdown-body max-w-none text-sm">
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={markdownComponents}
>
{body}
</ReactMarkdown>
</div>
);
}
export function getTimeAgoColor(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const hours = diff / (1000 * 60 * 60);
if (hours < 24) return "text-green-600 dark:text-green-400";
if (hours < 48) return "text-muted-foreground";
return "text-orange-600 dark:text-orange-400";
}
export function ExpandableBody({ body, className }: { body: string; className?: string }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
return (
<div className={className}>
<div className={expanded ? "" : "line-clamp-4"}>
<PRMarkdownBody body={body} />
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline mt-0.5 cursor-pointer"
>
{expanded ? t("github:showLess") : t("github:showMore")}
</button>
</div>
);
}
export function FeedbackItemRow({
author,
authorAvatar,
body,
createdAt,
metaBadge,
onAddAsContext,
trailingAction,
isReply,
}: {
author: string;
authorAvatar: string;
body?: string;
createdAt: string;
metaBadge?: React.ReactNode;
onAddAsContext?: () => void;
trailingAction?: React.ReactNode;
isReply?: boolean;
}) {
return (
<div className={isReply ? "ml-4 pl-2.5 border-l-2 border-border" : ""}>
<div className="px-2.5 py-2 rounded-md border border-border bg-muted/30 space-y-1">
<div className="block sm:flex sm:flex-nowrap sm:items-center sm:gap-2">
<AuthorAvatar src={authorAvatar} author={author} />
<AuthorLink author={author} />
{metaBadge}
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{formatTimeAgo(createdAt)}
</span>
{onAddAsContext && <AddToContextButton onClick={onAddAsContext} />}
{trailingAction}
</div>
{body && <ExpandableBody body={body} className="pl-7" />}
</div>
</div>
);
}