import { useState, Suspense, lazy } from "react";
import { useAuth } from "../../useAuth";
import Nav from "../../sections/Nav";
import Loading, { LoadingSpinner } from "../Loading";
import ErrorPage from "../ErrorPage";
import {
BoxSeam,
HouseDoor,
Book,
Building,
Calendar3,
Check2Square,
Hdd,
CheckSquare,
Square,
} from "react-bootstrap-icons";
import { useParams, NavLink, Redirect, Link } from "react-router-dom";
import {
authenticatedEndpoint,
ProfilePicture,
useAuthenticatedRequest,
} from "../../util";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CommonMembers from "./Members";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import HumanTime from "react-human-time";
type Tab = "readme" | "versions" | "members";
const Prism = lazy(() =>
import("react-syntax-highlighter").then((v) => ({ default: v.Prism }))
);
export interface CrateInfo {
name: string;
readme?: string;
description?: string;
repository?: string;
homepage?: string;
documentation?: string;
versions: CrateInfoVersion[];
}
export interface CrateInfoVersionUploader {
display_name: string;
picture_url?: string;
uuid: string;
}
export interface CrateInfoVersion {
vers: string;
deps: CrateInfoVersionDependency[];
features: { [key: string]: any };
size: number;
uploader: CrateInfoVersionUploader;
created_at: string;
}
export interface CrateInfoVersionDependency {
name: string;
req: string;
registry?: string;
}
interface UrlParameters {
organisation: string;
crate: string;
subview: Tab | undefined;
}
export default function SingleCrate() {
const auth = useAuth();
const {
organisation,
crate,
subview: currentTab,
} = useParams<UrlParameters>();
if (!auth) {
return <Redirect to="/login" />;
}
if (!currentTab) {
return <Redirect to={`/crates/${organisation}/${crate}/readme`} />;
}
const { response: crateInfo, error } = useAuthenticatedRequest<CrateInfo>(
{
auth,
endpoint: `crates/${organisation}/${crate}`,
},
[organisation, crate]
);
if (error) {
return <ErrorPage message={error} />;
} else if (!crateInfo) {
return <Loading />;
}
const crateVersion = crateInfo.versions[crateInfo.versions.length - 1];
const showLinks =
crateInfo.homepage || crateInfo.documentation || crateInfo.repository;
return (
<div>
<Nav />
<div className="container mt-4 pb-4">
<div className="row align-items-stretch">
<div className={`col-12 col-md-${showLinks ? 6 : 12} mb-3 mb-md-0`}>
<div className="card border-0 shadow-sm text-black h-100">
<div className="card-body">
<div className="d-flex flex-row align-items-center">
<div
className="text-white circle bg-primary bg-gradient d-inline rounded-circle d-inline-flex justify-content-center align-items-center"
style={{ width: "2rem", height: "2rem" }}
>
<BoxSeam />
</div>
<h1 className="text-primary d-inline px-2">
<Link
to={`/crates/${organisation}`}
className="text-muted text-decoration-none"
>
{organisation}/
</Link>
{crate}
</h1>
<h2 className="text-muted m-0">{crateVersion?.vers}</h2>
</div>
<p className="m-0">{crateInfo.description}</p>
</div>
</div>
</div>
{showLinks ? (
<div className="col-12 col-md-6">
<div className="card border-0 shadow-sm text-black h-100">
<div className="card-body d-flex flex-column justify-content-center">
{crateInfo.homepage ? (
<div>
<HouseDoor />{" "}
<a href={crateInfo.homepage}>{crateInfo.homepage}</a>
</div>
) : (
<></>
)}
{crateInfo.documentation ? (
<div>
<Book />{" "}
<a href={crateInfo.documentation}>
{crateInfo.documentation}
</a>
</div>
) : (
<></>
)}
{crateInfo.repository ? (
<div>
<Building />{" "}
<a href={crateInfo.repository}>{crateInfo.repository}</a>
</div>
) : (
<></>
)}
</div>
</div>
</div>
) : (
<></>
)}
</div>
<div className="row my-4">
<div className="col-12 col-md-9 mb-3 mb-md-0">
<div className="card border-0 shadow-sm text-black">
<div className="card-header">
<ul className="nav nav-pills card-header-pills">
<li className="nav-item">
<NavLink
to={`/crates/${organisation}/${crate}/readme`}
className="nav-link"
activeClassName="bg-primary bg-gradient active"
>
Readme
</NavLink>
</li>
<li className="nav-item">
<NavLink
to={`/crates/${organisation}/${crate}/versions`}
className="nav-link"
activeClassName="bg-primary bg-gradient active"
>
Versions
<span className={`badge rounded-pill bg-danger ms-1`}>
{crateInfo.versions.length}
</span>
</NavLink>
</li>
<li className="nav-item">
<NavLink
to={`/crates/${organisation}/${crate}/members`}
className="nav-link"
activeClassName="bg-primary bg-gradient active"
>
Members
</NavLink>
</li>
</ul>
</div>
<div className={currentTab != "members" ? "card-body" : ""}>
{currentTab == "readme" ? (
<Suspense fallback={<LoadingSpinner />}>
<ReadMe crate={crateInfo} />
</Suspense>
) : (
<></>
)}
{currentTab == "versions" ? (
<Versions crate={crateInfo} />
) : (
<></>
)}
{currentTab == "members" ? (
<Members crate={crate} organisation={organisation} />
) : (
<></>
)}
</div>
</div>
</div>
<div className="col-12 col-md-3">
<div className="card border-0 shadow-sm text-black">
<div className="card-body pb-0">
<h5 className="card-title">Dependencies</h5>
</div>
<ul className="list-group list-group-flush mb-2">
{(crateVersion?.deps || []).length === 0 ? (
<li className="list-group-item">
This crate has no dependencies
</li>
) : (
<></>
)}
{crateVersion?.deps.map((dep) => (
<Dependency
key={`${dep.name}-${dep.req}`}
organisation={organisation}
dep={dep}
/>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
);
}
interface CratesMembersResponse {
allowed_permissions: string[];
members: Member[];
}
interface Member {
uuid: string;
display_name: string;
permissions: string[];
}
function Dependency({
organisation,
dep,
}: {
organisation: string;
dep: CrateInfoVersionDependency;
}) {
let link = <>{dep.name}</>;
if (dep.registry === null || dep.registry === undefined) {
link = (
<a target="_blank" href={`/crates/${organisation}/${dep.name}`}>
{link}
</a>
);
} else if (dep.registry === "https://github.com/rust-lang/crates.io-index") {
link = (
<a target="_blank" href={`https://crates.io/crates/${dep.name}`}>
{link}
</a>
);
} else if (dep.registry.indexOf("ssh://") === 0) {
const parts = dep.registry.split("/");
const org = parts[parts.length - 1];
if (org) {
link = <Link to={`/crates/${org}/${dep.name}`}>{link}</Link>;
}
}
return (
<li className="list-group-item">
{link} = "<strong>{dep.req}</strong>"
</li>
);
}
interface MembersProps {
organisation: string;
crate: string;
}
function Members({ organisation, crate }: MembersProps) {
const auth = useAuth();
if (!auth) {
return <></>;
}
const [reload, setReload] = useState(0);
const { response, error } = useAuthenticatedRequest<CratesMembersResponse>(
{
auth,
endpoint: `crates/${organisation}/${crate}/members`,
},
[reload]
);
if (error) {
return <div className="card-body">{error}</div>;
} else if (!response) {
return (
<div className="card-body">
<div className="d-flex justify-content-center align-items-center">
<div className="spinner-border text-light" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
const saveMemberPermissions = async (
prospectiveMember: boolean,
uuid: string,
selectedPermissions: string[]
) => {
let res = await fetch(
authenticatedEndpoint(auth, `crates/${organisation}/${crate}/members`),
{
method: prospectiveMember ? "PUT" : "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
user_uuid: uuid,
permissions: selectedPermissions,
}),
}
);
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setReload(reload + 1);
};
const deleteMember = async (uuid: string) => {
let res = await fetch(
authenticatedEndpoint(auth, `crates/${organisation}/${crate}/members`),
{
method: "DELETE",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
user_uuid: uuid,
}),
}
);
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setReload(reload + 1);
};
return (
<CommonMembers
members={response.members}
possiblePermissions={response.allowed_permissions}
saveMemberPermissions={saveMemberPermissions}
deleteMember={deleteMember}
/>
);
}
function Versions(props: { crate: CrateInfo }) {
const humanFileSize = (size: number) => {
const i = Math.floor(Math.log(size) / Math.log(1024));
return (
Number((size / Math.pow(1024, i)).toFixed(2)) +
" " +
["B", "kB", "MB", "GB", "TB"][i]
);
};
if (props.crate.versions.length === 0) {
return <>There hasn't yet been any versions published for this crate</>;
}
return (
<div>
{[...props.crate.versions].reverse().map((version, index) => (
<div
key={index}
className={`card text-white bg-gradient ${
index == 0 ? "bg-primary" : "bg-dark mt-2"
}`}
>
<div className="card-body d-flex align-items-center">
<h5 className="m-0">{version.vers}</h5>
<div className="text-uppercase ms-4" style={{ fontSize: ".75rem" }}>
<div>
<div className="d-inline-block">
By
<ProfilePicture
src={version.uploader.picture_url}
height="22px"
width="22px"
className="ms-1 me-1"
/>
<Link
to={`/users/${version.uploader.uuid}`}
className="link-light"
>
{version.uploader.display_name}
</Link>
</div>
<div className="ms-3 d-inline-block">
<OverlayTrigger
overlay={
<Tooltip
id={`tooltip-${props.crate.name}-version-${version.vers}-date`}
>
{new Date(version.created_at).toLocaleString()}
</Tooltip>
}
>
<span>
<Calendar3 />{" "}
<HumanTime
time={new Date(version.created_at).getTime()}
/>
</span>
</OverlayTrigger>
</div>
</div>
<div>
<div className="d-inline-block">
<Hdd /> {humanFileSize(version.size)}
</div>
<div className="ms-3 d-inline-block">
<OverlayTrigger
overlay={
<Tooltip
id={`tooltip-${props.crate.name}-version-${version.vers}-feature-${index}`}
>
<div className="text-start m-2">
{Object.keys(version.features).map(
(feature, index) => (
<div key={index}>
{version.features["default"].includes(
feature
) ? (
<CheckSquare className="me-2" />
) : (
<Square className="me-2" />
)}
{feature}
</div>
)
)}
</div>
</Tooltip>
}
>
<span>
<Check2Square /> {Object.keys(version.features).length}{" "}
Features
</span>
</OverlayTrigger>
</div>
</div>
</div>
</div>
</div>
))}
</div>
);
}
function ReadMe(props: { crate: CrateInfo }) {
if (!props.crate.readme) {
return <>This crate has not added a README.</>;
}
return (
<ReactMarkdown
children={props.crate.readme}
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<Prism
children={String(children).replace(/\n$/, "")}
language={match[1]}
PreTag="pre"
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
/>
);
}