diff --git a/wally-registry-frontend/src/app/package/[packageScope]/[packageName]/page.tsx b/wally-registry-frontend/src/app/package/[packageScope]/[packageName]/page.tsx new file mode 100644 index 00000000..4ddb43d4 --- /dev/null +++ b/wally-registry-frontend/src/app/package/[packageScope]/[packageName]/page.tsx @@ -0,0 +1,494 @@ +"use client" + +import { isMobile, notMobile } from "@/breakpoints" +import { Button } from "@/components/Button" +import ContentSection from "@/components/ContentSection" +import CopyCode from "@/components/CopyCode" +import NotFoundMessage from "@/components/NotFoundMessage" +import { Heading, Paragraph } from "@/components/Typography" +import { + buildWallyPackageDownloadLink, + getWallyPackageMetadata, +} from "@/services/wally.api" +import { WallyPackageMetadata } from "@/types/wally" +import capitalize from "@/utils/capitalize" +import { useParams, useRouter, useSearchParams } from "next/navigation" +import React, { useEffect, useState } from "react" +import styled from "styled-components" + +type WidthVariation = "full" | "half" + +interface StyledMetaItemProps { + width: WidthVariation +} + +const FlexColumns = styled.article` + display: flex; + flex-flow: row nowrap; + width: 100%; + min-height: 65vh; + + @media screen and (${isMobile}) { + flex-flow: row wrap; + } +` + +const WideColumn = styled.section` + width: 65%; + + @media screen and (${notMobile}) { + border-right: solid 2px rgba(0, 0, 0, 0.1); + } + + @media screen and (${isMobile}) { + width: 100%; + border-bottom: solid 2px rgba(0, 0, 0, 0.1); + } +` + +const NarrowColumn = styled.aside` + width: 35%; + + @media screen and (${notMobile}) { + padding-left: 1rem; + } + + @media screen and (${isMobile}) { + padding-top: 0.5rem; + width: 100%; + } +` + +const MetaHeader = styled.h2` + width: 100%; + font-size: 2rem; +` + +const MetaSubheader = styled.b` + font-weight: bold; + display: block; + font-size: 1.1rem; +` + +const MetaItemWrapper = styled.div` + width: ${(props) => (props.width === "full" ? "100%" : "50%")}; + display: inline-block; + margin: 0.5rem 0; + white-space: nowrap; + text-overflow: ellipsis; + + a:hover, + a:focus { + text-decoration: underline; + color: var(--wally-red); + } +` + +const VersionSelect = styled.select` + &:hover { + cursor: pointer; + } + + &:hover, + &:focus { + text-decoration: underline; + color: var(--wally-red); + } + + > option { + color: var(--wally-mauve); + } +` + +const AuthorItem = styled.p` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +` + +const DependencyLinkWrapper = styled.div` + display: block; + position: relative; + width: 100%; + + &:hover { + > span { + visibility: visible; + } + } +` + +const DependencyLinkItem = styled.a` + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +` + +const DependencyLinkTooltip = styled.span` + visibility: hidden; + position: absolute; + z-index: 2; + color: white; + font-size: 0.8rem; + background-color: var(--wally-brown); + border-radius: 5px; + padding: 10px; + top: -45px; + left: 50%; + transform: translateX(-50%); + + &::before { + content: ""; + position: absolute; + transform: rotate(45deg); + background-color: var(--wally-brown); + padding: 6px; + z-index: 1; + top: 77%; + left: 45%; + } +` + +const StyledDownloadLink = styled.a` + display: inline-block; + height: 1rem; + width: auto; + padding-right: 0.3rem; + cursor: pointer; +` + +const MetaItem = ({ + title, + width, + children, +}: { + title: string + width?: WidthVariation + children: React.ReactNode +}) => { + return ( + + {title} + {children} + + ) +} + +const DependencyLink = ({ packageInfo }: { packageInfo: string }) => { + let packageMatch = packageInfo.match(/(.+\/.+)@[^\d]+([\d.]+)/) + if (packageMatch != null) { + let name = packageMatch[1] + let version = packageMatch[2] + return ( + + + {name + "@" + version} + + {name + "@" + version} + + ) + } + return {packageInfo} +} + +const DownloadLink = ({ + url, + filename, + children, +}: { + url: string + filename: string + children: React.ReactNode +}) => { + const handleAction = async () => { + // Using bare JavaScript mutations over a React ref keeps this link tag keyboard accessible, because you can't include an href on the base anchor tag and overwrite it with a ref, and we need an href to ensure full keyboard compatibility + const link = document.createElement("a") + + const result = await fetch(url, { + headers: { + "wally-version": "0.3.2", + }, + }) + + const blob = await result.blob() + const href = window.URL.createObjectURL(blob) + + link.href = href + link.download = filename + + document.body.appendChild(link) + + link.click() + + link.parentNode?.removeChild(link) + } + + return ( + <> + + {children} + + + ) +} + +const DownloadIcon = ({ packageName }: { packageName: string }) => ( + + Download a Wally Package + Downloads {packageName} + + + + + +) + +type PackageParams = { + packageScope: string + packageName: string +} + +export default function Package() { + const searchParams = useSearchParams() + const router = useRouter() + + const { packageScope, packageName } = useParams() + const [packageHistory, setPackageHistory] = useState<[WallyPackageMetadata]>() + const [packageVersion, setPackageVersion] = useState() + const [isLoaded, setIsLoaded] = useState(false) + const [isError, setIsError] = useState(false) + + const urlPackageVersion = searchParams.get("version") + if (urlPackageVersion != null && urlPackageVersion !== packageVersion) { + setPackageVersion(urlPackageVersion) + } + + const loadPackageData = async (packageScope: string, packageName: string) => { + const packageData = await getWallyPackageMetadata(packageScope, packageName) + + if (packageData == undefined) { + setIsError(true) + setIsLoaded(true) + return + } + + const filteredPackageData = packageData.versions.some( + (pack: WallyPackageMetadata) => !pack.package.version.includes("-") + ) + ? packageData.versions.filter( + (pack: WallyPackageMetadata) => !pack.package.version.includes("-") + ) + : packageData.versions + + setPackageHistory(filteredPackageData) + + if (urlPackageVersion == null) { + const latestVersion = filteredPackageData[0].package.version + setPackageVersion(latestVersion) + router.replace( + `/package/${packageScope}/${packageName}?version=${latestVersion}` + ) + } + + setIsLoaded(true) + } + + useEffect(() => { + loadPackageData(packageScope, packageName) + }, [packageScope, packageName]) + + if (!isLoaded) { + return ( + <> + + Loading... + + + ) + } + + if (isError) { + return ( + <> + + + + + ) + } + + const packageMetadata = packageHistory?.find( + (item: WallyPackageMetadata) => item.package.version === packageVersion + ) + + if (packageMetadata == undefined) { + return ( + <> + + {packageName} + + + Couldn't find {capitalize(packageName)} version {packageVersion}. + Are you sure that's a valid version? + + + + + + ) + } + + return ( + <> + + + + {packageName} + + + {packageMetadata.package.description ?? + `${capitalize(packageName)} has no provided description.`} + + + + + Metadata + + + + + + + { + router.push( + `/package/${packageScope}/${packageName}?version=${a.target.value}` + ) + }} + > + {packageHistory?.map((item: WallyPackageMetadata) => { + return ( + + ) + })} + + + + {packageMetadata.package.license && ( + + + {packageMetadata?.package.license} + + + )} + + + + + + + + + {capitalize(packageMetadata.package.realm)} + + + {/* TODO: Re-implement when Wally API supports custom source repos */} + {/* {packageMetadata?.package.registry && ( + + + {packageMetadata?.package.registry.replace("https://", "")} + + + )} */} + + {packageMetadata.package.authors.length > 0 && ( + + {packageMetadata.package.authors.map((author) => ( + {author} + ))} + + )} + + {Object.keys(packageMetadata.dependencies).length > 0 && ( + + {Object.values(packageMetadata.dependencies).map( + (dependency) => ( + + ) + )} + + )} + + {Object.keys(packageMetadata["server-dependencies"]).length > 0 && ( + + {Object.values(packageMetadata["server-dependencies"]).map( + (dependency) => ( + + ) + )} + + )} + + {Object.keys(packageMetadata["dev-dependencies"]).length > 0 && ( + + {Object.values(packageMetadata["dev-dependencies"]).map( + (dependency) => ( + + ) + )} + + )} + + + + + ) +} diff --git a/wally-registry-frontend/src/utils/capitalize.ts b/wally-registry-frontend/src/utils/capitalize.ts new file mode 100644 index 00000000..c059c4c5 --- /dev/null +++ b/wally-registry-frontend/src/utils/capitalize.ts @@ -0,0 +1,8 @@ +/** + * Capitalizes the first letter of a string + * @param {string} text - The string to be capitalized + * @returns {string} The capitalized string + */ +export default function capitalize(text: string | undefined) { + return text ? text[0].toUpperCase() + text.substring(1) : "" +}