Vectrum
Sign In
139 lines · 6.7 KB · TypeScript
import Link from "next/link";
import type { ReactNode } from "react";
import { deleteReleaseAction } from "@/app/actions/releases";
import { CopyButton } from "@/components/clone-button";
import { ConfirmButton } from "@/components/confirm-button";
import { ShaChip } from "@/components/file-table";
import { CodeIcon, DownloadIcon, PackageIcon, PencilIcon, TagIcon } from "@/components/icons";
import { Markdown } from "@/components/markdown";
import { liftRowClass, plateHeadingClass } from "@/components/repo-rows";
import { Avatar, Box, ButtonLink, Label, RefChip } from "@/components/ui";
import { encodePath, formatBytes, formatDate, timeAgo } from "@/lib/format";
import { releaseTitle, type Release } from "@/lib/releases";
import type { Repo } from "@/lib/repo";

export const releaseHref = (repo: Repo, tag: string) => `${repo.base}/releases/tag/${encodePath(tag)}`;
export const assetHref = (repo: Repo, tag: string, name: string) => `${repo.base}/releases/download/${encodePath(tag)}/${encodeURIComponent(name)}`;

function Time({ ms, prefix }: { ms: number; prefix?: string }) {
  return (
    <span className="type-meta text-ink3">
      {prefix && `${prefix} `}
      <time dateTime={new Date(ms).toISOString()} title={formatDate(ms)} className="type-readout">
        {timeAgo(ms)}
      </time>
    </span>
  );
}

function AssetRow({ href, name, icon, children }: { href: string; name: string; icon: ReactNode; children?: ReactNode }) {
  return (
    <li className={`${liftRowClass} flex min-h-11 flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1.5`}>
      <span className="icon-tile size-7 shrink-0 rounded-row">{icon}</span>
      <a href={href} download className="min-w-0 truncate font-mono text-[12px] text-ink decoration-ink3 underline-offset-4 hover:underline">
        {name}
      </a>
      {children}
    </li>
  );
}

/**
 * A release on its plate: what it is (title, state, tag, commit, who and when), its notes, and its
 * files with the source code at the tag.
 */
export function ReleaseCard({ repo, release, sha, latest, linkTitle = true }: { repo: Repo; release: Release; sha: string | null; latest: boolean; linkTitle?: boolean }) {
  const title = releaseTitle(release);
  const tagPath = `refs/tags/${encodePath(release.tag)}`;
  return (
    <Box className="overflow-hidden">
      <div className="flex flex-col gap-2.5 px-6 pt-5 pb-4">
        <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
          <h2 className="min-w-0 text-[19px] leading-7 font-semibold tracking-[-0.02em] text-ink">
            {linkTitle ? (
              <Link href={releaseHref(repo, release.tag)} className="decoration-ink3 underline-offset-4 hover:underline">
                {title}
              </Link>
            ) : (
              title
            )}
          </h2>
          {release.draft && <Label tone="attention">Draft</Label>}
          {release.prerelease && <Label tone="attention">Pre-release</Label>}
          {latest && <Label tone="success">Latest</Label>}
          {repo.canWrite && (
            <div className="ml-auto flex items-center gap-1">
              <ButtonLink href={`${repo.base}/releases/edit/${encodePath(release.tag)}`} variant="invisible" icon={<PencilIcon width={14} height={14} />}>
                Edit
              </ButtonLink>
              <ConfirmButton
                action={deleteReleaseAction.bind(null, repo.owner, repo.name, release.id)}
                message={`Delete the release ${title}? ${release.assets.length === 1 ? "Its file is" : "Its files are"} deleted with it${sha ? `; the tag ${release.tag} stays` : ""}.`}
                variant="default"
              >
                Delete
              </ConfirmButton>
            </div>
          )}
        </div>
        <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
          <span className="inline-flex items-center gap-1.5">
            <TagIcon width={13} height={13} className="text-ink3" />
            <RefChip>{release.tag}</RefChip>
          </span>
          {sha ? (
            <ShaChip sha={sha} href={`${repo.base}/commit/${sha}`} />
          ) : (
            <span className="type-meta text-ink3">
              {release.draft ? <>Tag is created from <RefChip>{release.target}</RefChip> when published</> : "Tag no longer exists"}
            </span>
          )}
          {release.author && (
            <Link href={`/${release.author}`} className="inline-flex items-center gap-1.5 type-meta font-medium text-ink2 hover:text-ink">
              <Avatar name={release.author} size={18} /> {release.author}
            </Link>
          )}
          {release.publishedAt ? <Time ms={release.publishedAt} prefix="released" /> : <Time ms={release.createdAt} prefix="drafted" />}
        </div>
      </div>

      {release.body.trim() && (
        <div className="rule-t px-6 py-5">
          <Markdown context={sha ? { base: repo.base, gitRef: release.tag, dir: "" } : undefined}>{release.body}</Markdown>
        </div>
      )}

      <div className="rule-t p-1.5">
        <h3 className={plateHeadingClass}>
          Assets <span className="type-readout text-ink3 [text-shadow:none]">{release.assets.length + (sha ? 2 : 0)}</span>
        </h3>
        <ul>
          {release.assets.map((a) => (
            <AssetRow key={a.id} href={assetHref(repo, release.tag, a.name)} name={a.name} icon={<PackageIcon width={14} height={14} />}>
              <span className="ml-auto flex shrink-0 items-center gap-0.5 max-md:hidden">
                <code className="type-readout text-ink3" title={`SHA-256 ${a.sha256}`}>
                  sha256:{a.sha256.slice(0, 12)}
                </code>
                <CopyButton text={a.sha256} label="Copy SHA-256 checksum" />
              </span>
              <span className="w-20 shrink-0 text-right type-readout text-ink2 max-md:ml-auto">{formatBytes(a.size)}</span>
              <span className="flex w-14 shrink-0 items-center justify-end gap-1 type-readout text-ink3" title={`${a.downloads} downloads`}>
                <DownloadIcon width={12} height={12} /> {a.downloads}
              </span>
              <span className="w-24 shrink-0 text-right max-sm:hidden">
                <Time ms={a.updatedAt} />
              </span>
            </AssetRow>
          ))}
          {sha && (
            <>
              <AssetRow href={`${repo.base}/archive/${tagPath}.zip`} name="Source code (zip)" icon={<CodeIcon width={14} height={14} />} />
              <AssetRow href={`${repo.base}/archive/${tagPath}.tar.gz`} name="Source code (tar.gz)" icon={<CodeIcon width={14} height={14} />} />
            </>
          )}
        </ul>
        {release.assets.length === 0 && !sha && <p className="px-3 pb-3 type-meta text-ink3">No files yet.</p>}
      </div>
    </Box>
  );
}