Add support to embed Artifact Hub items (#1249)

Closes #1228

Signed-off-by: Cintia Sanchez Garcia <cynthiasg@icloud.com>
This commit is contained in:
Cynthia S. Garcia 2021-04-16 12:41:05 +02:00 committed by GitHub
parent 93c054beb5
commit c3bb4d1654
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 13550 additions and 27 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ node_modules/
web/build
web/public/docs
web/coverage
widget/build
charts/artifact-hub/charts
Chart.lock
docs/www/content/topics/*

View File

@ -46,13 +46,13 @@
.navbar:not(.homeNavbar) {
position: sticky !important;
top: 0;
z-index: 1071;
z-index: 1040;
}
:global(.noScroll-modal) .navbar:not(.homeNavbar),
/* :global(.noScroll-modal) .navbar:not(.homeNavbar),
:global(.noScroll-sidebar) .navbar:not(.homeNavbar) {
z-index: 1;
}
} */
}
@media (max-width: 767.98px) {

View File

@ -0,0 +1,46 @@
.iconWrapper {
width: 2rem;
height: 2rem;
background-color: var(--white);
border: 1px solid var(--color-1-500);
padding: 2px;
overflow: hidden;
line-height: 1;
font-size: 1.25rem !important;
}
.dropdown {
min-width: 10rem;
top: 120% !important;
}
.arrow {
right: 0.3rem !important;
}
.dropdownItem:not(.isDisabled):hover {
background-color: var(--color-1-10);
}
.dropdownItem + .dropdownItem {
border-top: 1px solid var(--border-md);
}
.dropdownItem:hover {
text-decoration: none;
}
.isDisabled {
cursor: auto;
}
@media only screen and (max-width: 767.98px) {
.dropdown {
width: 18rem;
right: -5rem;
}
.arrow {
right: 6.25rem !important;
}
}

View File

@ -0,0 +1,84 @@
import classnames from 'classnames';
import React, { useEffect, useRef, useState } from 'react';
import { BiCode } from 'react-icons/bi';
import { HiDotsVertical } from 'react-icons/hi';
import useOutsideClick from '../../hooks/useOutsideClick';
import { SearchFiltersURL } from '../../types';
import styles from './MoreActionsButton.module.css';
import WidgetModal from './WidgetModal';
interface Props {
packageId: string;
packageName: string;
packageDescription: string;
visibleWidget: boolean;
searchUrlReferer?: SearchFiltersURL;
fromStarredPage?: boolean;
}
const MoreActionsButton = (props: Props) => {
const [openStatus, setOpenStatus] = useState(false);
const [visibleWidget, setVisibleWidget] = useState<boolean>(props.visibleWidget);
const [currentPkgId, setCurrentPkgId] = useState<string>(props.packageId);
const ref = useRef(null);
useOutsideClick([ref], openStatus, () => setOpenStatus(false));
useEffect(() => {
if (props.packageId !== currentPkgId && openStatus) {
setVisibleWidget(false);
setCurrentPkgId(props.packageId);
}
}, [props.packageId]); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<>
<div className="d-none d-md-block position-relative ml-2">
<button
data-testid="moreActionsBtn"
className="btn p-0 position-relative"
type="button"
onClick={() => {
setOpenStatus(true);
}}
>
<div className="d-flex flex-row align-items-center justify-content-center">
<div
className={`rounded-circle d-flex align-items-center justify-content-center text-primary iconSubsWrapper ${styles.iconWrapper}`}
>
<HiDotsVertical />
</div>
</div>
</button>
<div
ref={ref}
data-testid="subsBtnDropdown"
className={classnames('dropdown-menu dropdown-menu-right p-0', styles.dropdown, { show: openStatus })}
>
<div className={`arrow ${styles.arrow}`} />
<div className={`${styles.dropdownItem} dropdownItem`}>
<button
className={`${styles.dropdownItem} dropdownItem btn btn-link text-dark w-100`}
onClick={() => {
setVisibleWidget(true);
setOpenStatus(false);
}}
>
<div className="d-flex flex-row align-items-center">
<BiCode className="mr-2" />
<div>Embed artifact</div>
</div>
</button>
</div>
</div>
</div>
<WidgetModal {...props} visibleWidget={visibleWidget} setOpenStatus={setVisibleWidget} />
</>
);
};
export default MoreActionsButton;

View File

@ -77,7 +77,7 @@
}
.titleWrapper {
margin-right: 200px;
margin-right: 230px;
}
.optsWrapper {

View File

@ -105,7 +105,7 @@ const StarButton = (props: Props) => {
return (
<>
<div className={`d-flex flex-row align-items-center position-relative ${styles.wrapper}`}>
<div className={`ml-auto d-flex flex-row align-items-center position-relative ${styles.wrapper}`}>
<button
data-testid="toggleStarBtn"
className={classnames('btn btn-sm btn-primary px-1 px-md-3', styles.starBtn, {

View File

@ -15,7 +15,7 @@
}
.arrow {
right: 1.25rem !important;
right: 0.3rem !important;
}
.dropdownItem:not(.isDisabled):hover {

View File

@ -2,7 +2,7 @@ import classnames from 'classnames';
import isNull from 'lodash/isNull';
import isUndefined from 'lodash/isUndefined';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { FaCaretDown, FaRegCheckCircle, FaRegCircle } from 'react-icons/fa';
import { FaRegCheckCircle, FaRegCircle } from 'react-icons/fa';
import { MdNotificationsActive, MdNotificationsOff } from 'react-icons/md';
import { useHistory } from 'react-router';
@ -134,7 +134,7 @@ const SubscriptionsButton = (props: Props) => {
}
return (
<div className="d-none d-md-block position-relative ml-3">
<div className="d-none d-md-block position-relative ml-2">
<button
data-testid="subscriptionsBtn"
className="btn p-0 position-relative"
@ -144,26 +144,21 @@ const SubscriptionsButton = (props: Props) => {
setOpenStatus(true);
}}
>
<div className="d-flex flex-row align-items-center justify-content-center">
<div
className={`rounded-circle d-flex align-items-center justify-content-center text-primary iconSubsWrapper ${styles.iconWrapper}`}
>
{isLoading && (
<div className={styles.loading}>
<div className={`spinner-border text-primary ${styles.spinner}`} role="status">
<span className="sr-only">Loading...</span>
</div>
<div
className={`rounded-circle d-flex align-items-center justify-content-center text-primary iconSubsWrapper ${styles.iconWrapper}`}
>
{isLoading && (
<div className={styles.loading}>
<div className={`spinner-border text-primary ${styles.spinner}`} role="status">
<span className="sr-only">Loading...</span>
</div>
)}
{!isUndefined(activeSubscriptions) && activeSubscriptions.length > 0 ? (
<MdNotificationsActive className="rounded-circle" />
) : (
<MdNotificationsOff className="rounded-circle text-muted" />
)}
</div>
<small className="ml-1 text-primary">
<FaCaretDown />
</small>
</div>
)}
{!isUndefined(activeSubscriptions) && activeSubscriptions.length > 0 ? (
<MdNotificationsActive className="rounded-circle" />
) : (
<MdNotificationsOff className="rounded-circle text-muted" />
)}
</div>
</button>

View File

@ -0,0 +1,24 @@
.iconWrapper {
width: 2rem;
height: 2rem;
background-color: var(--white);
border: 1px solid var(--color-1-500);
padding: 2px;
overflow: hidden;
line-height: 1;
font-size: 1.25rem !important;
}
.label {
font-size: 0.875rem;
}
.customControlRightLabel::before {
right: -2.25rem;
left: auto !important;
}
.customControlRightLabel::after {
right: -1.38rem;
left: auto !important;
}

View File

@ -0,0 +1,180 @@
import React, { useEffect, useState } from 'react';
import { FiMoon, FiSun } from 'react-icons/fi';
import { useHistory } from 'react-router-dom';
import { SearchFiltersURL } from '../../types';
import Modal from '../common/Modal';
import CommandBlock from './installation/CommandBlock';
import styles from './WidgetModal.module.css';
interface Props {
packageId: string;
packageName: string;
packageDescription: string;
visibleWidget: boolean;
searchUrlReferer?: SearchFiltersURL;
fromStarredPage?: boolean;
setOpenStatus: React.Dispatch<React.SetStateAction<boolean>>;
}
interface WidgetTheme {
name: string;
icon: JSX.Element;
}
const DEFAULT_THEME = 'light';
const THEMES: WidgetTheme[] = [
{
name: DEFAULT_THEME,
icon: <FiSun />,
},
{ name: 'dark', icon: <FiMoon /> },
];
const WidgetModal = (props: Props) => {
const history = useHistory();
const [theme, setTheme] = useState<string>(DEFAULT_THEME);
const [header, setHeader] = useState<boolean>(true);
const [responsive, setRepsonsive] = useState<boolean>(false);
const compoundWidgetSource = (): string => {
const url = `${window.location.origin}${window.location.pathname}`;
const code = `<div class="artifacthub-widget" data-url="${url}" data-theme="${theme}" data-header="${
!header ? 'false' : 'true'
}" data-responsive="${responsive ? 'true' : 'false'}"><blockquote><p lang="en" dir="ltr"><b>${
props.packageName
}</b>${
props.packageDescription ? `: ${props.packageDescription}` : ''
}</p>&mdash; Open in <a href="${url}">Artifact Hub</a></blockquote></div><script async src="${
window.location.origin
}/artifacthub-widget.js"></script>`;
return code;
};
const [widgetSource, setWidgetSource] = useState<string>(compoundWidgetSource());
const onCloseModal = () => {
props.setOpenStatus(false);
history.replace({
search: '',
state: { searchUrlReferer: props.searchUrlReferer, fromStarredPage: props.fromStarredPage },
});
};
useEffect(() => {
setWidgetSource(compoundWidgetSource());
}, [theme, header, responsive, props.packageId]); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (props.visibleWidget) {
history.replace({
search: '?modal=widget',
state: { searchUrlReferer: props.searchUrlReferer, fromStarredPage: props.fromStarredPage },
});
}
}, [props.visibleWidget]); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<>
{props.visibleWidget && (
<Modal
modalDialogClassName={styles.modalDialog}
header={<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>Widget</div>}
onClose={onCloseModal}
open={props.visibleWidget}
>
<div className="w-100 position-relative">
<label className={`font-weight-bold ${styles.label}`} htmlFor="theme">
Theme
</label>
<div className="d-flex flex-row mb-3">
{THEMES.map((themeOpt: WidgetTheme) => {
return (
<div className="custom-control custom-radio mr-4" key={`radio_theme_${themeOpt.name}`}>
<input
className="custom-control-input"
type="radio"
name="theme"
id={themeOpt.name}
value={themeOpt.name}
checked={theme === themeOpt.name}
required
readOnly
/>
<label
className="text-capitalize custom-control-label"
htmlFor={themeOpt.name}
onClick={() => {
setTheme(themeOpt.name);
}}
>
<div className="d-flex flex-row align-items-center">
{themeOpt.icon}
<span className="ml-1">{themeOpt.name}</span>
</div>
</label>
</div>
);
})}
</div>
<div className="mt-4 mb-3">
<div className="custom-control custom-switch pl-0">
<input
id="header"
type="checkbox"
className="custom-control-input"
value="true"
onChange={() => setHeader(!header)}
checked={header}
/>
<label
htmlFor="header"
className={`custom-control-label font-weight-bold ${styles.label} ${styles.customControlRightLabel}`}
>
Header
</label>
</div>
<small className="form-text text-muted mt-2">
Displays Artifact Hub header at the top of the widget.
</small>
</div>
<div className="mt-4 mb-3">
<div className="custom-control custom-switch pl-0">
<input
id="responsive"
type="checkbox"
className="custom-control-input"
value="true"
onChange={() => setRepsonsive(!responsive)}
checked={responsive}
/>
<label
htmlFor="responsive"
className={`custom-control-label font-weight-bold ${styles.label} ${styles.customControlRightLabel}`}
>
Responsive
</label>
</div>
<small className="form-text text-muted mt-2">
The widget will try to use the width available on the parent container (between 350px and 650px).
</small>
</div>
<div className="mt-5">
<label className={`font-weight-bold ${styles.label}`}>Code</label>
<CommandBlock command={widgetSource} language="javascript" />
</div>
</div>
</Modal>
)}
</>
);
};
export default WidgetModal;

View File

@ -50,6 +50,7 @@ import CustomResourceDefinition from './CustomResourceDefinition';
import Details from './Details';
import InstallationModal from './installation/Modal';
import ModalHeader from './ModalHeader';
import MoreActionsButton from './MoreActionsButton';
import styles from './PackageView.module.css';
import ReadmeWrapper from './readme';
import RecommendedPackages from './RecommendedPackages';
@ -610,6 +611,14 @@ const PackageView = (props: Props) => {
</span>
<StarButton packageId={detail.packageId} />
<SubscriptionsButton packageId={detail.packageId} />
<MoreActionsButton
packageId={detail.packageId}
packageName={detail.displayName || detail.name}
packageDescription={detail.description}
visibleWidget={!isUndefined(props.visibleModal) && props.visibleModal === 'widget'}
searchUrlReferer={props.searchUrlReferer}
fromStarredPage={props.fromStarredPage}
/>
</div>
<div className="row align-items-baseline d-md-none">

16
widget/.eslintrc Normal file
View File

@ -0,0 +1,16 @@
{
"extends": ["react-app", "plugin:prettier/recommended"],
"plugins": ["simple-import-sort"],
"rules": {
"simple-import-sort/sort": "error"
},
"overrides": [
{
"files": ["**/*.ts?(x)"],
"rules": {
"sort-imports": "off",
"import/order": "off"
}
}
]
}

9
widget/.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"singleQuote": true,
"printWidth": 120,
"bracketSpacing": true,
"jsxBracketSameLine": false,
"arrowParens": "always"
}

28
widget/craco.config.js Normal file
View File

@ -0,0 +1,28 @@
module.exports = {
babel: {
presets: [],
plugins: [
["babel-plugin-styled-components", { "displayName": true }]
],
loaderOptions: {},
loaderOptions: (babelLoaderOptions) => { return babelLoaderOptions; }
},
webpack: {
configure: {
output: {
filename: 'static/js/artifacthub-widget.js',
},
optimization: {
runtimeChunk: false,
splitChunks: {
chunks: 'all',
cacheGroups: {
default: false,
vendors: false,
// vendor chunk
},
},
},
}
}
}

63
widget/package.json Normal file
View File

@ -0,0 +1,63 @@
{
"name": "widget",
"version": "0.1.0",
"private": true,
"dependencies": {
"@types/lodash": "^4.14.168",
"@types/react": "^16.9.49",
"@types/react-dom": "^16.9.8",
"@types/styled-components": "^5.1.9",
"lodash": "^4.17.20",
"moment": "^2.27.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"styled-components": "^5.2.3"
},
"devDependencies": {
"@craco/craco": "^5.9.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.0.2",
"@testing-library/user-event": "^12.1.4",
"@types/jest": "^26.0.13",
"@types/node": "^14.6.4",
"babel-plugin-styled-components": "^1.12.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-simple-import-sort": "^5.0.2",
"prettier": "^2.1.1",
"react-scripts": "^3.4.3",
"rewire": "^5.0.0",
"typescript": "^3.9.7",
"webpack-cli": "^4.6.0"
},
"scripts": {
"start": "REACT_APP_HUB_BASE_URL=http://localhost:8000 react-scripts start",
"build": "craco build",
"build:cra": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint --ext .js,.jsx,.ts,.tsx src --color",
"lint:fix": "eslint --ext .js,.jsx,.ts,.tsx src --fix",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"format:diff": "prettier --list-different \"src/**/*.{js,jsx,ts,tsx}\"",
"isready": "yarn format && yarn lint && yarn test --watchAll=false --passWithNoTests --verbose && yarn build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

24
widget/public/index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>Artifact Hub widget</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div class="artifacthub-widget" data-url="http://localhost:8000/packages/helm/hub/artifact-hub"></div>
<div class="artifacthub-widget" data-url="http://localhost:8000/packages/tekton-task/test/send-to-telegram" data-theme="dark" data-responsive="true"></div>
<div class="artifacthub-widget" data-url="https://staging.artifacthub.io/packages/helm/prometheus-community/kube-prometheus-stack" data-theme="light"></div>
<div class="artifacthub-widget" data-url="https://staging.artifacthub.io/packages/helm-plugin/diff/diff" data-theme="dark"></div>
<div class="artifacthub-widget" data-url="https://staging.artifacthub.io/packages/krew/krew-index/cilium" data-theme="dark"></div>
<div class="artifacthub-widget" data-url="https://staging.artifacthub.io/packages/olm/community-operators/elastic-cloud-eck" data-theme="dark"></div>
<div class="artifacthub-widget" data-url="https://staging.artifacthub.io/packages/falco/security-hub/ssh-connections" data-theme="dark"></div>
<div class="artifacthub-widget" data-url="https://staging.artifacthub.io/packages/keda-scaler/keda-scalers-test/apache-kafka" data-theme="dark"></div>
<div class="artifacthub-widget" data-url="https://staging.artifacthub.io/packages/opa/deprek8ion/deprek8ion" data-theme="dark"></div>
<div class="artifacthub-widget" data-url="https://staging.artifacthub.io/packages/tbaction/tinkerbell-community/archive2disk" data-theme="dark"></div>
</body>
</html>

54
widget/src/api/index.ts Normal file
View File

@ -0,0 +1,54 @@
import camelCase from 'lodash/camelCase';
import isArray from 'lodash/isArray';
import isObject from 'lodash/isObject';
import { Package } from '../types';
class API {
private API_BASE_URL = `/api/v1`;
private toCamelCase(r: any): any {
if (isArray(r)) {
return r.map((v) => this.toCamelCase(v));
} else if (isObject(r)) {
return Object.keys(r).reduce(
(result, key) => ({
...result,
[camelCase(key)]: this.toCamelCase((r as any)[key]),
}),
{}
);
}
return r;
}
private async handleContent(res: any) {
if (!res.ok) {
throw new Error('error');
} else {
switch (res.headers.get('Content-Type')) {
case 'text/plain; charset=utf-8':
const text = await res.text();
return text;
case 'application/json':
const json = await res.json();
return this.toCamelCase(json);
default:
return res;
}
}
}
private async apiFetch(url: string) {
return fetch(url)
.then((res) => this.handleContent(res))
.catch((error) => Promise.reject(error));
}
public getPackageInfo(baseUrl: string, pathname: string): Promise<Package> {
return this.apiFetch(`${baseUrl}${this.API_BASE_URL}${pathname}/summary`);
}
}
const APIMethods = new API();
export default APIMethods;

14
widget/src/index.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './layout/App';
const WidgetDivs = document.querySelectorAll('.artifacthub-widget');
WidgetDivs.forEach((div: Element) => {
ReactDOM.render(
<React.StrictMode>
<App element={div} />
</React.StrictMode>,
div
);
});

189
widget/src/layout/App.tsx Normal file
View File

@ -0,0 +1,189 @@
import { isNull, isUndefined } from 'lodash';
import React, { useEffect, useState } from 'react';
import styled, { css, keyframes } from 'styled-components';
import APIMethods from '../api';
import { Package } from '../types';
import Card from './card';
import SVGIcons from './common/SVGIcons';
interface Props {
element: Element;
}
const DEFAULT_THEME = 'light';
const AVAILABLE_THEMES = [DEFAULT_THEME, 'dark'];
const Wrapper = styled('div')`
margin: 0.75rem;
`;
const CardWrapper = styled('div')`
${(props) =>
props.theme === 'dark'
? css`
--color-1-500: #131216;
--color-font: #a3a3a6;
--white: #222529;
--color-1-5: rgba(15, 14, 17, 0.95);
--color-1-10: rgba(15, 14, 17, 0.9);
--color-1-20: rgba(15, 14, 17, 0.8);
--color-2-10: rgba(15, 14, 17, 0.1);
--color-black-25: rgba(255, 255, 255, 0.25);
--light-gray: #e9ecef;
--success: #131216;
--icon-color: #a3a3a6;
`
: css`
--color-1-500: #659dbd;
--color-font: #38383f;
--white: #fff;
--color-1-5: rgba(28, 44, 53, 0.05);
--color-1-10: rgba(28, 44, 53, 0.1);
--color-1-20: rgba(28, 44, 53, 0.2);
--color-2-10: rgba(176, 206, 224, 0.1);
--light-gray: #e9ecef;
--success: #28a745;
--icon-color: #fff;
--color-black-25: rgba(0, 0, 0, 0.25);
`}
font-family: 'Lato', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 1rem;
color: var(--color-font);
background-color: var(--white);
background-clip: padding-box;
border: 3px solid var(--color-1-500);
border-radius: 0.25rem;
overflow: hidden;
width: 350px;
line-height: 1.15rem;
&.responsive {
min-width: 350px;
width: 100%;
max-width: 650px;
}
& *, & *:after, & *:before {
box-sizing: border-box;
}
}
`;
const Link = styled('a')`
text-decoration: inherit;
color: inherit;
`;
const CardHeader = styled('div')`
display: flex;
align-items: center;
background-color: var(--color-1-500);
font-size: 1.25rem;
padding: 0.5rem 0.75rem;
color: #fff;
font-weight: bold;
height: 40px;
`;
const CardBody = styled('div')`
padding: 0.75rem;
color: var(--color-font);
`;
const SpinnerWrapper = styled('div')`
padding: 2rem 3rem;
text-align: center;
`;
const spinnerBorder = keyframes`
to {
transform: rotate(360deg);
}
`;
const Spinner = styled('div')`
display: inline-block;
width: 2rem;
height: 2rem;
vertical-align: text-bottom;
border: 0.25em solid var(--color-1-500);
border-right-color: transparent;
border-radius: 50%;
-webkit-animation: ${spinnerBorder} 0.75s linear infinite;
animation: ${spinnerBorder} 0.75s linear infinite;
`;
const Brand = styled(SVGIcons)`
${(props) =>
props.theme === 'dark' &&
css`
opacity: 0.75;
`}
`;
export default function App(props: Props) {
const { url, theme, header, responsive } = (props.element as HTMLElement).dataset;
const currentTheme = theme && AVAILABLE_THEMES.includes(theme) ? theme : DEFAULT_THEME;
const [urlParams, setUrlParams] = useState<URL | undefined>();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [packageItem, setPackageItem] = useState<Package | undefined | null>(undefined);
useEffect(() => {
async function fetchPackage() {
if (url && urlParams) {
try {
setIsLoading(true);
setPackageItem(await APIMethods.getPackageInfo(urlParams.origin, urlParams.pathname));
setIsLoading(false);
} catch {
setIsLoading(false);
setPackageItem(null);
}
} else {
setPackageItem(null);
}
}
fetchPackage();
}, [urlParams]); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
if (url) {
setUrlParams(new URL(url));
}
}, [url]);
if (isNull(packageItem)) return null;
return (
<Wrapper>
<CardWrapper
theme={currentTheme}
className={!isUndefined(responsive) && responsive === 'true' ? 'responsive' : ''}
>
<Link href={url} rel="noopener noreferrer" target="_blank">
{(isUndefined(header) || header !== 'false') && (
<CardHeader>
<Brand name="logo" theme={currentTheme} />
</CardHeader>
)}
<CardBody>
{isLoading || isUndefined(packageItem) ? (
<SpinnerWrapper>
<Spinner />
</SpinnerWrapper>
) : (
<Card
packageItem={packageItem}
theme={theme || DEFAULT_THEME}
baseUrl={urlParams ? urlParams.origin : undefined}
/>
)}
</CardBody>
</Link>
</CardWrapper>
</Wrapper>
);
}

View File

@ -0,0 +1,223 @@
import { isUndefined } from 'lodash';
import moment from 'moment';
import React from 'react';
import styled from 'styled-components';
import { Package } from '../../types';
import Image from '../common/Image';
import Label from '../common/Label';
import RepositoryIconLabel from '../common/RepositoryIconLabel';
import SVGIcons from '../common/SVGIcons';
interface Props {
packageItem: Package;
theme: string;
baseUrl?: string;
}
const prettifyNumber = (num: number, digits?: number): string | number => {
if (num < 1000) {
return num;
}
const si = [
{ value: 1, symbol: '' },
{ value: 1e3, symbol: 'k' },
{ value: 1e6, symbol: 'M' },
{ value: 1e9, symbol: 'G' },
{ value: 1e12, symbol: 'T' },
{ value: 1e15, symbol: 'P' },
{ value: 1e18, symbol: 'E' },
];
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
let i;
for (i = si.length - 1; i > 0; i--) {
if (num >= si[i].value) {
break;
}
}
return (num / si[i].value).toFixed(digits || 2).replace(rx, '$1') + si[i].symbol;
};
const ImageWrapper = styled('div')`
position: relative;
min-width: 60px;
width: 60px;
height: 60px;
background-color: var(--white);
border: 2px solid var(--color-1-10);
box-shadow: 0px 0px 5px 0px var(--color-1-20);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
`;
const Content = styled('div')`
width: calc(100% - 60px);
padding-left: 0.5rem;
`;
const HeaderWrapper = styled('div')`
display: flex;
align-items: center;
margin-bottom: 1rem;
`;
const TitleWrapper = styled('div')`
display: flex;
justify-content: space-between;
align-items: flex-start;
`;
const TextTruncate = styled('div')`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const Title = styled(TextTruncate)`
font-size: 1.15rem;
padding-bottom: 0.35rem;
margin-right: 0.5rem;
`;
const IconStarsWrapper = styled('div')`
font-size: 75%;
`;
const StarsNumber = styled('div')`
margin-left: 0.25rem;
`;
const BadgesWrapper = styled('div')`
display: flex;
align-items: center;
margin-top: 1rem;
& > * {
margin-right: 0.5rem;
}
`;
const Description = styled('div')`
overflow: hidden;
font-size: 0.9rem;
margin-bottom: 1rem;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
`;
const Legend = styled('span')`
font-size: 80%;
opacity: 0.8;
text-transform: uppercase;
margin-right: 0.25rem;
`;
const ExtraInfo = styled('div')`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
`;
const Version = styled(TextTruncate)`
margin-right: 0.5rem;
font-size: 0.9rem;
`;
const PublishedBy = styled(TextTruncate)`
font-size: 0.9rem;
`;
const Date = styled('small')`
font-size: 70%;
opacity: 0.75;
white-space: nowrap;
`;
const Badge = styled('div')`
font-size: 75%;
text-transform: none;
height: 19px;
background-color: var(--color-1-5);
border: 1px solid var(--color-1-10);
display: flex;
align-items: center;
padding: 0.1em 0.4em;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
border-radius: 50rem;
`;
const Card = (props: Props) => {
if (isUndefined(props.baseUrl)) return null;
return (
<>
<HeaderWrapper>
<ImageWrapper>
{props.baseUrl && (
<Image
baseUrl={props.baseUrl}
imageId={props.packageItem.logoImageId}
alt={`Logo ${props.packageItem.displayName || props.packageItem.name}`}
kind={props.packageItem.repository.kind}
/>
)}
</ImageWrapper>
<Content>
<TitleWrapper>
<Title>{props.packageItem.displayName || props.packageItem.name}</Title>
<Badge>
<IconStarsWrapper>
<SVGIcons name="stars" />
</IconStarsWrapper>
<StarsNumber>{prettifyNumber(props.packageItem.stars || 0)}</StarsNumber>
</Badge>
</TitleWrapper>
<div>
<Version>
<Legend>Version:</Legend>
{props.packageItem.version}
</Version>
</div>
</Content>
</HeaderWrapper>
<ExtraInfo>
<RepositoryIconLabel kind={props.packageItem.repository.kind} baseUrl={props.baseUrl} theme={props.theme} />
<Date>Updated {moment(props.packageItem.ts * 1000).fromNow()}</Date>
</ExtraInfo>
{props.packageItem.description && <Description>{props.packageItem.description}</Description>}
<PublishedBy>
<Legend>Published by:</Legend>
{props.packageItem.repository.userAlias ||
props.packageItem.repository.organizationDisplayName ||
props.packageItem.repository.organizationName}
</PublishedBy>
{(props.packageItem.official ||
props.packageItem.repository.official ||
props.packageItem.repository.verifiedPublisher) && (
<BadgesWrapper>
{(props.packageItem.official || props.packageItem.repository.official) && (
<Label text="Official" type="success" icon={<SVGIcons name="official" />} />
)}
{props.packageItem.repository.verifiedPublisher && (
<Label text="Verified Publisher" icon={<SVGIcons name="verified" />} />
)}
</BadgesWrapper>
)}
</>
);
};
export default Card;

View File

@ -0,0 +1,79 @@
import { isNull, isUndefined } from 'lodash';
import React, { useState } from 'react';
import styled from 'styled-components';
import { RepositoryKind } from '../../types';
interface Props {
baseUrl: string;
imageId?: string | null;
alt: string;
placeholderIcon?: JSX.Element;
kind?: RepositoryKind;
}
const PLACEHOLDER_SRC = '/static/media/package_placeholder.svg';
const StyledImage = styled('img')`
max-width: calc(100% - 8px);
max-height: calc(100% - 8px);
`;
const Image = (props: Props) => {
const [error, setError] = useState(false);
const getSrc = () => {
return `${props.baseUrl}/image/${props.imageId}`;
};
const getPlaceholder = (): string => {
if (isUndefined(props.kind)) {
return PLACEHOLDER_SRC;
} else {
switch (props.kind) {
case RepositoryKind.Helm:
case RepositoryKind.HelmPlugin:
return '/static/media/placeholder_pkg_helm.png';
case RepositoryKind.OLM:
return '/static/media/placeholder_pkg_olm.png';
case RepositoryKind.OPA:
return '/static/media/placeholder_pkg_opa.png';
case RepositoryKind.Falco:
return '/static/media/placeholder_pkg_falco.png';
case RepositoryKind.TBAction:
return '/static/media/placeholder_pkg_tbaction.png';
case RepositoryKind.Krew:
return '/static/media/placeholder_pkg_krew.png';
case RepositoryKind.TektonTask:
return '/static/media/placeholder_pkg_tekton-task.png';
case RepositoryKind.KedaScaler:
return '/static/media/placeholder_pkg_keda-scaler.png';
default:
return PLACEHOLDER_SRC;
}
}
};
return (
<>
{error || isNull(props.imageId) || isUndefined(props.imageId) ? (
<>
{isUndefined(props.placeholderIcon) ? (
<StyledImage alt={props.alt} src={`${props.baseUrl}${getPlaceholder()}`} />
) : (
<>{props.placeholderIcon}</>
)}
</>
) : (
<StyledImage
alt={props.alt}
srcSet={`${getSrc()}@1x 1x, ${getSrc()}@2x 2x, ${getSrc()}@3x 3x, ${getSrc()}@4x 4x`}
src={getSrc()}
onError={() => setError(true)}
/>
)}
</>
);
};
export default Image;

View File

@ -0,0 +1,69 @@
import React from 'react';
import styled from 'styled-components';
const LabelWrapper = styled('div')`
font-size: 0.72rem;
display: flex;
align-items: center;
`;
const LabelText = styled('div')`
background-color: var(--color-1-5);
line-height: 18px;
padding: 0 5px 0 10px;
font-weight: 700;
border: 1px solid var(--color-1-10);
border-radius: 3px;
`;
const IconWrapper = styled('div')`
position: relative;
border: 1px solid var(--color-1-10);
border-radius: 3px;
height: 20px;
line-height: 21px;
color: var(--icon-color);
border-right: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
box-shadow: inset -1px 0 var(--color-black-25);
margin-right: -3px;
padding-left: 3px;
width: 20px;
background-color: var(--color-1-500);
&:before {
content: '';
position: absolute;
right: -3px;
top: calc(50% - 3px);
height: 6px;
width: 6px;
border-radius: 50%;
box-shadow: inset -1px 0 var(--color-black-25);
background-color: var(--color-1-500);
}
&.success {
background-color: var(--success);
&:before {
background-color: var(--success);
}
}
`;
interface Props {
icon: JSX.Element;
text: string;
type?: string;
}
const Label = (props: Props) => (
<LabelWrapper>
<IconWrapper className={props.type}>{props.icon}</IconWrapper>
<LabelText>{props.text}</LabelText>
</LabelWrapper>
);
export default Label;

View File

@ -0,0 +1,30 @@
import React from 'react';
import { RepositoryKind } from '../../types';
import SVGIcons from './SVGIcons';
interface Props {
baseUrl: string;
kind: RepositoryKind;
className?: string;
}
interface IconsList {
[key: number]: JSX.Element;
}
const ICONS: IconsList = {
[RepositoryKind.Helm]: <SVGIcons name="helm" />,
[RepositoryKind.HelmPlugin]: <SVGIcons name="helm-plugin" />,
[RepositoryKind.OPA]: <SVGIcons name="opa" />,
[RepositoryKind.OLM]: <SVGIcons name="olm" />,
[RepositoryKind.Falco]: <SVGIcons name="falco" />,
[RepositoryKind.TektonTask]: <SVGIcons name="tekton" />,
[RepositoryKind.TBAction]: <SVGIcons name="tinkerbell" />,
[RepositoryKind.Krew]: <SVGIcons name="krew" />,
[RepositoryKind.KedaScaler]: <SVGIcons name="keda" />,
};
const RepositoryIcon = (props: Props) => <div className={props.className}>{ICONS[props.kind]}</div>;
export default RepositoryIcon;

View File

@ -0,0 +1,110 @@
import { isUndefined } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { RepositoryKind } from '../../types';
import RepositoryIcon from './RepositoryIcon';
interface Props {
baseUrl: string;
kind: RepositoryKind;
theme: string;
}
export interface RepoKindDef {
kind: RepositoryKind;
name: string;
}
const REPOSITORY_KINDS: RepoKindDef[] = [
{
kind: RepositoryKind.Helm,
name: 'Helm chart',
},
{
kind: RepositoryKind.HelmPlugin,
name: 'Helm plugin',
},
{
kind: RepositoryKind.Falco,
name: 'Falco rules',
},
{
kind: RepositoryKind.OPA,
name: 'OPA policies',
},
{
kind: RepositoryKind.OLM,
name: 'OLM operator',
},
{
kind: RepositoryKind.TBAction,
name: 'Tinkerbell action',
},
{
kind: RepositoryKind.Krew,
name: 'Krew kubectl plugin',
},
{
kind: RepositoryKind.TektonTask,
name: 'Tekton task',
},
{
kind: RepositoryKind.KedaScaler,
name: 'KEDA scaler',
},
];
const Wrapper = styled('span')`
background-color: var(--color-1-5);
border: 1px solid var(--color-1-10);
border-radius: 50rem;
display: flex;
align-items: center;
padding: 0.1em 0.4em;
font-size: 75%;
display: flex;
align-items: center;
font-weight: 700;
line-height: 1;
white-space: nowrap;
`;
const RepoName = styled('div')`
margin-left: 0.25rem;
`;
const Icon = styled(RepositoryIcon)`
height: 15px;
& svg:not(:root) {
width: auto;
height: 100%;
}
&.grayedOut {
filter: grayscale(1) brightness(1.5);
}
`;
const RepositoryIconLabel = (props: Props) => {
const repo = REPOSITORY_KINDS.find((repoKind: RepoKindDef) => repoKind.kind === props.kind);
if (isUndefined(repo)) return null;
return (
<Wrapper>
<div>
<Icon
kind={props.kind}
baseUrl={props.baseUrl}
theme={props.theme}
className={props.theme === 'dark' ? 'grayedOut' : ''}
/>
</div>
<RepoName>{repo.name}</RepoName>
</Wrapper>
);
};
export default RepositoryIconLabel;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,33 @@
.badge {
font-size: 75%;
text-transform: none;
height: 19px;
background-color: var(--color-1-5);
border: 1px solid var(--color-1-10);
display: flex;
align-items: center;
padding: 0.1em 0.4em;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
border-radius: 50rem;
}
.icon {
height: 12px;
width: 12px;
top: -1px;
margin-right: 0.25rem;
}
.icon img {
max-width: 100%;
max-width: 100%;
}
.textTruncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

1
widget/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

5
widget/src/setupTests.ts Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

34
widget/src/types.ts Normal file
View File

@ -0,0 +1,34 @@
export interface Package {
packageId: string;
name: string;
displayName?: string;
normalizedName: string;
description: string;
logoImageId?: string;
repository: Repository;
version: string;
stars?: number | null;
ts: number;
official?: boolean;
}
export interface Repository {
kind: RepositoryKind;
verifiedPublisher?: boolean;
official?: boolean;
organizationName?: string;
organizationDisplayName?: string;
userAlias?: string;
}
export enum RepositoryKind {
Helm = 0,
Falco,
OPA,
OLM,
TBAction,
Krew,
HelmPlugin,
TektonTask,
KedaScaler,
}

25
widget/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"src"
]
}

11538
widget/yarn.lock Normal file

File diff suppressed because it is too large Load Diff