File size: 5,226 Bytes
2eb87e3
 
 
 
 
 
 
 
 
 
 
 
d22337b
2eb87e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d22337b
2eb87e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d22337b
 
 
 
 
 
 
2eb87e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import type { ProjectCard } from "@/shared/types";
import { formatRelativeTime, formatStars, totalFindings } from "../shared/lib/format";
import { SeverityBar } from "./SeverityBar";

function OwnerAvatar({ owner, compact = false }: { owner: string; compact?: boolean }) {
  const [failed, setFailed] = useState(false);
  const sizeClass = compact ? "h-7 w-7" : "h-8 w-8";
  if (failed) {
    return (
      <div
        className={`flex shrink-0 items-center justify-center rounded-full bg-surface-sunken font-semibold text-ink-secondary ${sizeClass} ${compact ? "text-[10px]" : "text-xs"}`}
        aria-hidden
      >
        {owner.slice(0, 1).toUpperCase()}
      </div>
    );
  }
  return (
    <img
      src={`https://github.com/${owner}.png?size=64`}
      alt=""
      width={32}
      height={32}
      className={`${sizeClass} shrink-0 rounded-full border border-line bg-surface-sunken`}
      onError={() => setFailed(true)}
    />
  );
}

export function ProjectRow({ project, compact = false }: { project: ProjectCard; compact?: boolean }) {
  const nav = useNavigate();
  const state = project.latest_scan?.state;
  const scanning = state === "scanning" || state === "dispatching";
  const waiting = state !== "completed" && !scanning;
  const soFar = project.latest_scan?.findings_so_far ?? 0;
  const findings = totalFindings(project.severity_counts);

  return (
    <div
      role="link"
      tabIndex={0}
      onClick={() => nav(`/p/${project.owner_login}/${project.name}`)}
      onKeyDown={(e) => {
        if (e.key === "Enter") nav(`/p/${project.owner_login}/${project.name}`);
      }}
      className={`group block cursor-pointer overflow-hidden border-b border-line px-1 transition-colors hover:bg-surface-sunken/60 focus-ring rounded-sm ${
        compact ? "h-[104px] py-3" : "py-4"
      }`}
    >
      <div className={`flex items-start ${compact ? "gap-2.5" : "gap-3"}`}>
        <OwnerAvatar owner={project.owner_login} compact={compact} />
        <div className="min-w-0 flex-1">
          <div className="flex items-start justify-between gap-4">
            <div className="min-w-0 flex-1">
              <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
                <span className={`truncate font-medium text-ink ${compact ? "text-[12px]" : "text-sm"}`}>
                  {project.full_name}
                </span>
              </div>
              {project.description && (
                <p className={`mt-0.5 line-clamp-1 text-ink-secondary ${compact ? "text-[11px] leading-4" : "text-[13px]"}`}>
                  {project.description}
                </p>
              )}
              <div className={`flex flex-wrap items-center gap-x-2 gap-y-1 text-ink-tertiary ${compact ? "mt-1 text-[10px]" : "mt-1.5 text-[13px]"}`}>
                {project.language && (
                  <span className="inline-flex items-center gap-1">
                    <span className="h-1.5 w-1.5 rounded-full bg-accent-600/70" />
                    {project.language}
                  </span>
                )}
                <span>·</span>
                <span>★ {formatStars(project.stars)}</span>
                {project.latest_scan?.finished_at && (
                  <>
                    <span>·</span>
                    <span>scanned {formatRelativeTime(project.latest_scan.finished_at)}</span>
                  </>
                )}
                {!waiting && findings > 0 && (
                  <>
                    <span>·</span>
                    <span className="font-mono">{findings} findings</span>
                  </>
                )}
              </div>
              <div className={compact ? "mt-1.5" : "mt-2.5"}>
                {scanning ? (
                  <span
                    className={`inline-flex items-center gap-1.5 font-medium text-running-ink ${compact ? "text-[10px]" : "text-[13px]"}`}
                  >
                    <span className="relative flex h-1.5 w-1.5">
                      <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-running opacity-60 motion-reduce:animate-none" />
                      <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-running" />
                    </span>
                    {soFar > 0 ? `Scanning · ${soFar} findings so far` : "Scanning…"}
                  </span>
                ) : waiting ? (
                  <span className={`${compact ? "text-[10px]" : "text-[13px]"} text-ink-tertiary`}>In scan queue</span>
                ) : compact && findings === 0 ? (
                  <span className="text-[10px] text-ink-tertiary">No findings</span>
                ) : (
                  <SeverityBar
                    counts={project.severity_counts}
                    showLegend
                    widthClass={compact ? "w-32" : "w-40"}
                    heightClass={compact ? "h-1" : "h-1.5"}
                    legendClass={compact ? "text-[9px]" : "text-[11px]"}
                  />
                )}
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}