Projects STRLCPY gradejs Commits 818751cf
🤬
Revision indexing in progress... (symbol navigation in revisions will be accurate after indexed)
  • ■ ■ ■ ■
    packages/web/src/components/App.tsx
    skipped 38 lines
    39 39   </Helmet>
    40 40   <Routes>
    41 41   <Route index element={<HomePage />} />
    42  - <Route path='/w/:hostname' element={<WebsiteResultsPage />} />
     42 + <Route path='/scan/:address' element={<WebsiteResultsPage />} />
    43 43   <Route path='*' element={<Navigate replace to='/' />} />
    44 44   </Routes>
    45 45   </>
    skipped 3 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/layouts/SearchResults/SearchResults.tsx
    1 1  import React, { useEffect, useRef, useState } from 'react';
     2 +import semver from 'semver';
    2 3  import styles from './SearchResults.module.scss';
    3 4  import Footer from 'components/ui/Footer/Footer';
    4 5  import Container from 'components/ui/Container/Container';
    skipped 10 lines
    15 16  import StickyDefaultHeader from '../../ui/Header/StickyDefaultHeader';
    16 17  import PackagesBySourceCardList from '../../ui/CardList/PackagesBySourceCardList';
    17 18  import PopularPackageCardList from '../../ui/CardList/PopularPackageCardList';
    18  -import { packagesBySourceListData, popularPackageListData } from '../../../mocks/CardListsMocks';
     19 +import { RequestWebPageScanOutput } from '../../../services/apiClient';
     20 +import { SubmitHandler } from 'react-hook-form';
     21 + 
     22 +type FiltersState = {
     23 + filter: 'all' | 'outdated' | 'vulnerable' | 'name';
     24 + sort: 'name' | 'size' | 'severity' | 'importDepth' | 'packagePopularity' | 'confidenceScore';
     25 + filterPackageName?: string;
     26 +};
    19 27   
    20 28  type Props = {
    21  - pageLoading?: boolean;
     29 + searchQuery: string;
     30 + host: string;
     31 + siteFavicon: string;
     32 + isLoading: boolean;
     33 + isPending: boolean;
     34 + scanOutput: RequestWebPageScanOutput;
     35 + onFiltersApply: SubmitHandler<FiltersState>;
    22 36  };
    23 37   
    24 38  export default function SearchResults({ pageLoading = false }: Props) {
    skipped 16 lines
    41 55   }, 60000);
    42 56   }, []);
    43 57   
     58 + // TODO: memoize
     59 + // Corresponding flags are determined by array index (packages[i] <=> flags[i]).
     60 + const flags: Array<{ vulnerable: boolean; duplicate: boolean; outdated: boolean }> = (
     61 + scanOutput.scanResult?.packages ?? []
     62 + ).map((pkg) => ({
     63 + duplicate: false, // TODO
     64 + outdated: !!(
     65 + pkg.registryMetadata && semver.gtr(pkg.registryMetadata.latestVersion, pkg.versionRange)
     66 + ),
     67 + vulnerable: (scanOutput.scanResult?.vulnerabilities[pkg.name]?.length ?? 0) > 0,
     68 + }));
     69 + 
     70 + // TODO memoize
     71 + const outdatedCount = flags.filter((f) => f.outdated).length;
     72 + 
     73 + // TODO memoize
     74 + const keywordsList = [
     75 + ...new Set(
     76 + scanOutput.scanResult?.packages.reduce((acc, pkg) => {
     77 + return acc.concat(pkg.registryMetadata?.keywords ?? []);
     78 + }, [] as string[])
     79 + ),
     80 + ];
     81 + 
     82 + /*
    44 83   // TODO: mock data, remove later
    45 84   const metaItems = [
    46 85   {
    skipped 26 lines
    73 112   
    74 113   // TODO: mock data, remove later
    75 114   const authors = ['gaearon', 'acdlite', 'sophiebits', 'sebmarkbage', 'zpao', 'trueadm', 'bvaughn'];
     115 + */
    76 116   
    77 117   return (
    78 118   <>
    skipped 8 lines
    87 127   />
    88 128   )}
    89 129   
    90  - <StickyDefaultHeader showSearch />
     130 + <StickyDefaultHeader showSearch query={searchQuery} />
    91 131   
    92 132   <Container>
    93 133   <div className={styles.searchResults}>
    skipped 5 lines
    99 139   />
    100 140   ) : (
    101 141   <SearchedResource
    102  - image='https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg'
    103  - name='pinterest.com'
    104  - totalPackages={6}
    105  - lastScanDate='21 feb in 21:30'
     142 + image={siteFavicon}
     143 + name={host}
     144 + totalPackages={scanOutput.scanResult?.packages.length ?? 0}
     145 + lastScanDate={scanOutput.finishedAt}
    106 146   />
    107 147   )}
    108 148   </div>
    109 149   
    110 150   <div className={styles.searchResultsSidebar}>
    111 151   <SearchResultsSidebar
    112  - metaItems={metaItems}
     152 + metaItems={scanOutput.scanResult?.meta}
    113 153   keyWords={keyWords}
    114 154   vulnerabilities={vulnerabilities}
    115  - authors={authors}
     155 + authors={state.fetched.authors}
    116 156   loading={loading}
    117 157   />
    118 158   </div>
    119 159   
    120 160   <div className={styles.packages}>
    121 161   {loading ? (
    122  - <PackagePreviewSkeleton />
     162 + <>
     163 + <PackagePreviewSkeleton />
     164 + <PackagePreviewSkeleton />
     165 + <PackagePreviewSkeleton />
     166 + <PackagePreviewSkeleton />
     167 + <PackagePreviewSkeleton />
     168 + <PackagePreviewSkeleton />
     169 + </>
    123 170   ) : (
    124  - <PackagePreview
    125  - name='@team-griffin/react-heading-section'
    126  - version='3.0.0 - 4.16.4'
    127  - desc='The Lodash library exported as ES modules. Generated using lodash-cli'
    128  - problems={['vulnerabilities']}
    129  - keywords={['#moment', '#date', '#time', '#parse', '#format', '#format', '#format']}
    130  - author={{ name: 'jdalton', image: 'https://via.placeholder.com/36' }}
    131  - />
    132  - )}
    133  - 
    134  - {loading ? (
    135  - <PackagePreviewSkeleton />
    136  - ) : (
    137  - <PackagePreview
    138  - name='@team-griffin/react-heading-section'
    139  - version='3.0.0 - 4.16.4'
    140  - desc='The Lodash library exported as ES modules. Generated using lodash-cli'
    141  - problems={['vulnerabilities', 'duplicate', 'outdated']}
    142  - keywords={['#moment', '#date', '#time', '#parse', '#format']}
    143  - author={{ name: 'jdalton', image: 'https://via.placeholder.com/36' }}
    144  - />
     171 + scanOutput.scanResult?.packages.map((pkg, index) => (
     172 + <PackagePreview // TODO пробрасываем сюда данные
     173 + pkg={pkg}
     174 + flags={flags[index]}
     175 + sites={[] /* TODO */}
     176 + opened={index === 0}
     177 + totalRatedPackages={totalRatedPackages}
     178 + />
     179 + ))
    145 180   )}
    146 181   </div>
    147 182   </div>
    skipped 3 lines
    151 186   {loading ? (
    152 187   <CardListSkeleton />
    153 188   ) : (
    154  - <PackagesBySourceCardList cards={packagesBySourceListData} />
     189 + <PackagesBySourceCardList cards={state.fetched.similarCards} />
    155 190   )}
    156 191   </CardGroup>
    157 192   
    158 193   <CardGroup title='Popular packages'>
    159 194   {loading ? (
    160  - <CardListSkeleton numberOfElements={6} />
     195 + <CardListSkeleton />
    161 196   ) : (
    162  - <PopularPackageCardList cards={popularPackageListData} />
     197 + <PopularPackageCardList cards={state.fetched.popularPackages} />
    163 198   )}
    164 199   </CardGroup>
    165 200   </CardGroups>
    skipped 7 lines
  • ■ ■ ■ ■ ■
    packages/web/src/components/layouts/index.ts
    1 1  export { default as Home } from './Home/Home';
    2 2  export { default as Website } from './Website/Website';
     3 +export { default as SearchResults } from './SearchResults/SearchResults';
    3 4  export { default as Error } from './Error/Error';
    4 5   
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/pages/Home.tsx
    skipped 10 lines
    11 11   
    12 12   // TODO: properly handle history/routing
    13 13   useEffect(() => {
    14  - if (!state.isLoading && !state.isFailed && state.hostname) {
    15  - navigate(`/w/${state.hostname}`, { replace: true });
     14 + if (!state.isLoading && !state.isFailed && state.address) {
     15 + navigate(`/scan/${state.address}`);
    16 16   }
    17 17   });
    18 18   
    19 19   const handleDetectStart = useCallback(async (address: string) => {
    20 20   trackCustomEvent('HomePage', 'WebsiteSubmitted');
    21 21   // TODO: error state of input field, e.g. when empty
    22  - if (!address.startsWith('http://') && !address.startsWith('https://')) {
    23  - address = 'https://' + address;
    24  - }
    25 22   await dispatch(parseWebsite(address));
    26 23   }, []);
    27 24   
    28 25   if (state.isFailed) {
    29  - return <Error host={state.hostname} />;
     26 + return (
     27 + <Error
     28 + host={state.address}
     29 + onReportClick={() => {
     30 + trackCustomEvent('HomePage', 'ClickReport');
     31 + }}
     32 + onRetryClick={() => {
     33 + trackCustomEvent('HomePage', 'ClickRetry');
     34 + dispatch(resetError());
     35 + }}
     36 + />
     37 + );
    30 38   }
    31 39   
    32 40   return <Home onSubmit={handleDetectStart} loading={state.isLoading} />;
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/pages/WebsiteResults.tsx
    1 1  import React, { useEffect } from 'react';
    2 2  import { Helmet } from 'react-helmet';
    3 3  import { useParams, useNavigate } from 'react-router-dom';
    4  -import { Error as ErrorLayout, Website } from 'components/layouts';
     4 +import { Error as ErrorLayout, SearchResults } from 'components/layouts';
    5 5  import { trackCustomEvent } from '../../services/analytics';
    6 6  import {
    7 7   useAppDispatch,
    8 8   useAppSelector,
    9  - applyFilters,
    10 9   getWebsite,
    11 10   websiteResultsSelectors as selectors,
    12 11  } from '../../store';
    13  -import { FiltersState } from '../layouts/Filters/Filters';
    14 12   
    15 13  export function WebsiteResultsPage() {
    16  - const { hostname } = useParams();
     14 + const { address } = useParams();
     15 + const hostname = new URL(address!).hostname;
    17 16   const navigate = useNavigate();
    18 17   const dispatch = useAppDispatch();
    19 18   const { vulnerabilities } = useAppSelector(selectors.default);
    skipped 2 lines
    22 21   const { isProtected, isPending, isLoading, isFailed, isInvalid } = useAppSelector(
    23 22   selectors.stateFlags
    24 23   );
    25  - const setFilters = (filters: FiltersState) => dispatch(applyFilters(filters));
    26 24   
    27 25   // TODO: discuss. Looks ugly
    28 26   // Fetch data for SSR if host is already processed
    29  - if (__isServer__ && hostname) {
    30  - dispatch(getWebsite({ hostname, useRetry: false }));
     27 + if (__isServer__ && address) {
     28 + dispatch(getWebsite({ hostname: address, useRetry: false }));
    31 29   }
    32 30   
    33 31   useEffect(() => {
    34  - if (hostname && isPending && !isFailed) {
    35  - const promise = dispatch(getWebsite({ hostname }));
     32 + if (address && isPending && !isFailed) {
     33 + const promise = dispatch(getWebsite({ hostname: address }));
    36 34   return function cleanup() {
    37 35   promise.abort();
    38 36   };
    39 37   }
    40 38   return () => {};
    41  - }, [hostname, isPending, isFailed]);
     39 + }, [address, isPending, isFailed]);
    42 40   
    43 41   // TODO: properly handle history/routing
    44 42   useEffect(() => {
    45  - if (!hostname || isFailed) {
     43 + if (!address || isFailed) {
    46 44   navigate('/', { replace: true });
    47 45   }
    48  - }, [hostname]);
     46 + }, [address]);
    49 47   
    50 48   if (isProtected) {
    51 49   // TODO: move to tracking middleware?
    skipped 3 lines
    55 53   message='The entered website appears to be protected by a third-party service, such as DDoS prevention, password protection or geolocation restrictions.'
    56 54   action='Would you like to try another URL or report an issue?'
    57 55   actionTitle='Try another URL'
    58  - host={hostname ?? ''}
     56 + host={address ?? ''}
     57 + onRetryClick={() => {
     58 + trackCustomEvent('HostnamePage', 'ClickRetry_Protected');
     59 + navigate('/', { replace: false });
     60 + }}
     61 + onReportClick={() => {
     62 + trackCustomEvent('HostnamePage', 'ClickReport_Protected');
     63 + }}
    59 64   />
    60 65   );
    61 66   }
    skipped 6 lines
    68 73   message='It looks like the entered website is not built with Webpack.'
    69 74   action='Would you like to try another URL or report an issue?'
    70 75   actionTitle='Try another URL'
    71  - host={hostname ?? ''}
     76 + host={address ?? ''}
     77 + onRetryClick={() => {
     78 + trackCustomEvent('HostnamePage', 'ClickRetry_Invalid');
     79 + navigate('/', { replace: false });
     80 + }}
     81 + onReportClick={() => {
     82 + trackCustomEvent('HostnamePage', 'ClickReport_Invalid');
     83 + }}
    72 84   />
    73 85   );
    74 86   }
    75 87   
    76  - const title = `List of NPM packages that are used on ${hostname} - GradeJS`;
     88 + const title = `List of NPM packages that are used on ${address} - GradeJS`;
    77 89   const description =
    78  - `GradeJS has discovered ${packagesStats.total} NPM packages used on ${hostname}` +
     90 + `GradeJS has discovered ${packagesStats.total} NPM packages used on ${address}` +
    79 91   (packagesStats.vulnerable > 0 ? `, ${packagesStats.vulnerable} are vulnerable` : '') +
    80 92   (packagesStats.outdated > 0 ? `, ${packagesStats.outdated} are outdated` : '');
    81 93   
    skipped 5 lines
    87 99   <meta property='og:title' content={title} />
    88 100   <meta property='og:description' content={description} />
    89 101   </Helmet>
    90  - <Website
     102 + <SearchResults
    91 103   isLoading={isLoading}
    92 104   isPending={isPending}
    93  - packages={packagesFiltered ?? []}
    94  - host={hostname ?? ''}
    95  - vulnerabilities={vulnerabilities ?? {}}
    96  - onFiltersApply={setFilters}
     105 + host={address ?? ''}
     106 + scanOutput={}
    97 107   />
    98 108   </>
    99 109   );
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Icon/Icon.tsx
    skipped 61 lines
    62 62   width?: number;
    63 63   height?: number;
    64 64   className?: string;
     65 + style?: React.CSSProperties;
    65 66   color?: string;
    66 67   stroke?: string;
    67 68   onClick?: () => unknown;
    skipped 5 lines
    73 74   color = '#A5A5A5',
    74 75   stroke,
    75 76   className,
     77 + style,
    76 78   kind,
    77 79   onClick,
    78 80  }: IconProps) {
    skipped 6 lines
    85 87   viewBox={icons[kind].viewBox}
    86 88   fill='none'
    87 89   color={color}
     90 + style={style}
    88 91   stroke={stroke}
    89 92   xmlns='http://www.w3.org/2000/svg'
    90 93   >
    skipped 5 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/PackagePreview/PackagePreview.tsx
    skipped 19 lines
    20 20  import BarChartSkeleton from '../BarChart/BarChartSkeleton';
    21 21  import { formatNumber } from 'utils/helpers';
    22 22  import Hint from '../Tooltip/Hint';
     23 +import { useNavigate } from 'react-router-dom';
    23 24   
    24 25  type Problem = 'vulnerabilities' | 'duplicate' | 'outdated';
    25 26   
    skipped 4 lines
    30 31  };
    31 32   
    32 33  type Props = {
     34 + /*
    33 35   name: string;
    34 36   version: string;
    35 37   desc: string;
    skipped 3 lines
    39 41   name: string;
    40 42   image: string;
    41 43   };
     44 + */
    42 45   opened?: boolean;
    43 46   detailsLoading?: boolean;
     47 + sites: Site[];
     48 + flags: {
     49 + vulnerable: boolean;
     50 + duplicate: boolean;
     51 + outdated: boolean;
     52 + };
     53 + pkg: {
     54 + name: string;
     55 + descriptionFull: string;
     56 + containingScriptUrl: string;
     57 + version: string;
     58 + license: string;
     59 + licenseDescription: string;
     60 + rating: number;
     61 + ratingDelta: number;
     62 + deps: string[]; // TODO: probably not just string[]
     63 + repositoryUrl?: string;
     64 + homePageUrl?: string;
     65 + npmUrl?: string;
     66 + keywords: Array<{
     67 + name: string;
     68 + }>;
     69 + author: {
     70 + name: string;
     71 + avatar: string;
     72 + };
     73 + };
     74 + totalRatedPackages: number;
    44 75  };
    45 76   
    46 77  // TODO: refactor this (decomposition, props, memoization, etc)
    47 78  export default function PackagePreview({
    48  - name,
    49  - version,
    50  - desc,
    51  - problems,
    52  - keywords,
    53  - author,
     79 + /* name,
     80 + version,
     81 + desc,
     82 + problems,
     83 + keywords,
     84 + author,
     85 + opened,
     86 + detailsLoading = false,
     87 + */
    54 88   opened,
     89 + sites,
     90 + flags,
     91 + pkg,
     92 + totalRatedPackages,
    55 93   detailsLoading = false,
    56 94  }: Props) {
    57 95   const [open, setOpen] = useState<boolean>(opened ?? false);
    58 96   const [packageDetailsLoading, setPackageDetailsLoading] = useState<boolean>(detailsLoading);
     97 + const navigate = useNavigate();
    59 98   
    60 99   const toggleOpen = () => {
    61 100   if (open) {
    skipped 108 lines
    170 209   links: externalLinks,
    171 210   };
    172 211   
    173  - const { script, license, rating, dependencies, packages, sites, links } = loadedData;
     212 + //const { script, license, rating, dependencies, packages, sites, links } = loadedData;
    174 213   
    175 214   return (
    176 215   <div className={clsx(styles.package, open && styles.open)}>
    skipped 1 lines
    178 217   <div className={styles.top} onClick={toggleOpen}>
    179 218   <div className={styles.title}>
    180 219   <span className={styles.name}>
    181  - {name} <span className={styles.version}>{version}</span>
     220 + {pkg.name} <span className={styles.version}>{pkg.version}</span>
    182 221   </span>
    183 222   
    184  - {problems && (
     223 + {flags && ( // TODO deal with flags
    185 224   <span className={styles.problems}>
    186 225   {problems.map((problem) => (
    187 226   <ProblemBadge key={problem} problem={problem} />
    skipped 3 lines
    191 230   </div>
    192 231   
    193 232   <button type='button' className={styles.arrowWrapper} onClick={toggleOpen}>
    194  - <Icon kind='arrowDown' width={14} height={8} color='#8E8AA0' className={styles.arrow} />
     233 + <Icon
     234 + kind='arrowDown'
     235 + style={{ transform: opened ? 'rotate(180deg)' : 'rotate(0)' }}
     236 + width={14}
     237 + height={8}
     238 + color='#8E8AA0'
     239 + className={styles.arrow}
     240 + />
    195 241   </button>
    196 242   </div>
    197 243   
    198  - <div className={styles.desc}>{desc}</div>
     244 + <div className={styles.desc}>{pkg.descriptionFull}</div>
    199 245   </div>
    200 246   
    201 247   <CSSTransition
    skipped 13 lines
    215 261   {packageDetailsLoading ? (
    216 262   <ScriptSkeleton />
    217 263   ) : (
    218  - <a href='#' className={styles.statLink} target='_blank' rel='noreferrer'>
    219  - {script}
     264 + <a
     265 + href={pkg.containingScriptUrl}
     266 + className={styles.statLink}
     267 + target='_blank'
     268 + rel='noreferrer'
     269 + >
     270 + {pkg.containingScriptUrl}
    220 271   </a>
    221 272   )}
    222 273   </div>
    skipped 8 lines
    231 282   <LicenceSkeleton />
    232 283   ) : (
    233 284   <>
    234  - <div className={styles.statTitle}>{license.title}</div>
    235  - <div className={styles.statSubtitle}>{license.subtitle}</div>
     285 + <div className={styles.statTitle}>{pkg.license}</div>
     286 + <div className={styles.statSubtitle}>{pkg.licenseDescription}</div>
    236 287   </>
    237 288   )}
    238 289   </div>
    skipped 11 lines
    250 301   ) : (
    251 302   <>
    252 303   <div className={styles.statTitle}>
    253  - {rating.place}
    254  - 
     304 + {pkg.rating}
    255 305   <div
    256 306   className={clsx(
    257 307   styles.statRating,
    258  - rating.rankingDelta > 0 ? styles.statRatingGreen : styles.statRatingRed
     308 + pkg.ratingDelta > 0 ? styles.statRatingGreen : styles.statRatingRed
    259 309   )}
    260 310   >
    261 311   <Icon
    skipped 2 lines
    264 314   height={12}
    265 315   className={styles.statRatingArrow}
    266 316   />
    267  - {rating.rankingDelta}
     317 + {pkg.ratingDelta}
    268 318   </div>
    269 319   </div>
    270  - <div className={styles.statSubtitle}>out of {formatNumber(rating.out)}</div>
     320 + <div className={styles.statSubtitle}>
     321 + out of {formatNumber(totalRatedPackages)}
     322 + </div>
    271 323   </>
    272 324   )}
    273 325   </div>
    skipped 2 lines
    276 328   <div className={styles.statHeader}>
    277 329   <Icon kind='dependency' color='#8E8AA0' className={styles.statIcon} />
    278 330   Dependencies
     331 + {packageDetailsLoading ? (
     332 + <ChipGroupSkeleton />
     333 + ) : (
     334 + <ChipGroup>
     335 + {pkg.deps.map((dependency) => (
     336 + <Chip size='medium' fontSize='small' font='monospace'>
     337 + {dependency}
     338 + </Chip>
     339 + ))}
     340 + </ChipGroup>
     341 + )}
    279 342   </div>
    280  - {packageDetailsLoading ? (
    281  - <ChipGroupSkeleton />
    282  - ) : (
    283  - <ChipGroup>
    284  - {dependencies.map((dependency) => (
    285  - <Chip size='medium' fontSize='small' font='monospace'>
    286  - {dependency}
    287  - </Chip>
    288  - ))}
    289  - </ChipGroup>
    290  - )}
    291 343   </div>
    292  - </div>
    293 344   
     345 + {/* TODO: separate component
    294 346   <div className={styles.stat}>
    295 347   <div className={styles.statHeader}>
    296 348   <Icon kind='graph' color='#8E8AA0' className={styles.statIcon} />
    skipped 4 lines
    301 353   {packageDetailsLoading ? <BarChartSkeleton /> : <BarChart bars={packages} />}
    302 354   </div>
    303 355   </div>
    304  - 
    305  - {/* TODO: add Modules treemap here */}
     356 + */}
    306 357   
    307  - <div className={styles.stat}>
    308  - <div className={styles.statHeader}>Used on</div>
     358 + {/* TODO: add Modules treemap here */}
    309 359   
    310  - {packageDetailsLoading ? (
    311  - <SitesListSkeleton className={styles.usedOnList} />
    312  - ) : (
    313  - <SitesList sites={sites} className={styles.usedOnList} />
    314  - )}
    315  - </div>
     360 + <div className={styles.stat}>
     361 + <div className={styles.statHeader}>Used on</div>
    316 362   
    317  - <div className={styles.actions}>
    318  - <div className={styles.links}>
    319 363   {packageDetailsLoading ? (
    320  - <LinksSkeleton />
     364 + <SitesListSkeleton className={styles.usedOnList} />
    321 365   ) : (
    322  - links.map(({ href, kind, linkText }) => (
    323  - <a
    324  - key={href}
    325  - href={href}
    326  - className={styles.link}
    327  - target='_blank'
    328  - rel='noreferrer'
    329  - >
    330  - <Icon
    331  - kind={kind}
    332  - width={kind !== 'npm' ? 16 : 32}
    333  - height={kind !== 'npm' ? 16 : 32}
    334  - color='#212121'
    335  - className={styles.linkIcon}
    336  - />
    337  - {linkText}
    338  - </a>
    339  - ))
     366 + <SitesList sites={sites} className={styles.usedOnList} />
    340 367   )}
    341 368   </div>
    342 369   
    343  - <Button variant='arrow'>Details</Button>
     370 + <div className={styles.actions}>
     371 + <div className={styles.links}>
     372 + {packageDetailsLoading ? (
     373 + <LinksSkeleton />
     374 + ) : (
     375 + <>
     376 + {pkg.repositoryUrl && (
     377 + <a
     378 + href={pkg.repositoryUrl}
     379 + className={styles.link}
     380 + target='_blank'
     381 + rel='noreferrer'
     382 + >
     383 + <Icon kind='repository' color='#212121' className={styles.linkIcon} />
     384 + Repository
     385 + </a>
     386 + )}
     387 + 
     388 + {pkg.homePageUrl && (
     389 + <a
     390 + href={pkg.homePageUrl}
     391 + className={styles.link}
     392 + target='_blank'
     393 + rel='noreferrer'
     394 + >
     395 + <Icon kind='link' color='#212121' className={styles.linkIcon} />
     396 + Homepage
     397 + </a>
     398 + )}
     399 + 
     400 + {pkg.npmUrl && (
     401 + <a
     402 + href={pkg.npmUrl}
     403 + className={styles.link}
     404 + target='_blank'
     405 + rel='noreferrer'
     406 + >
     407 + <Icon
     408 + kind='npm'
     409 + width={32}
     410 + height={32}
     411 + color='#212121'
     412 + className={styles.linkIcon}
     413 + />
     414 + </a>
     415 + )}
     416 + </>
     417 + )}
     418 + </div>
     419 + 
     420 + {/* TODO: should be a <a> link */}
     421 + <Button variant='arrow' onClick={() => navigate('/package/' + pkg.name)}>
     422 + Details
     423 + </Button>
     424 + </div>
    344 425   </div>
    345 426   </div>
    346 427   </div>
    skipped 4 lines
    351 432   {/* TODO: not sure how to conditionally render maximum number of keywords (e.g. 5 for
    352 433   desktop, 3/4 for tablet, 2 for mobile) based on viewport and update rest number
    353 434   of keywords beyond current maximum in Chip */}
    354  - {keywords.slice(0, 5).map((keyword) => (
    355  - <a key={keyword} href='#' className={styles.tag}>
    356  - {keyword}
     435 + {pkg.keywords.slice(6).map((tag) => (
     436 + <a href='#' className={styles.tag}>
     437 + {tag.name}
    357 438   </a>
    358 439   ))}
    359  - {keywords.slice(5).length > 0 && (
     440 + {pkg.keywords.length > 6 && (
    360 441   <Chip variant='info' size='medium' fontWeight='semiBold'>
    361  - +{keywords.slice(5).length}
     442 + +{pkg.keywords.length - 6}
    362 443   </Chip>
    363 444   )}
    364 445   </div>
    365 446   
    366 447   <div className={styles.author}>
    367  - <span className={styles.authorName}>{author.name}</span>
    368  - <img className={styles.authorImage} src={author.image} alt='' />
     448 + <span className={styles.authorName}>{pkg.author.name}</span>
     449 + <img className={styles.authorImage} src={pkg.author.avatar} alt='' />
    369 450   </div>
    370 451   </div>
    371 452   </div>
    skipped 3 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/store/slices/home.ts
    skipped 14 lines
    15 15   initialState: {
    16 16   isLoading: false,
    17 17   isFailed: false,
    18  - hostname: '',
     18 + address: '',
    19 19   loadError: null as SerializedError | null,
    20 20   },
    21 21   reducers: {
    22 22   resetError(state) {
    23  - state.hostname = '';
     23 + state.address = '';
    24 24   state.isFailed = false;
    25 25   },
    26 26   },
    27 27   extraReducers(builder) {
    28 28   builder
    29 29   .addCase(parseWebsite.pending, (state, action) => {
    30  - state.hostname = new URL(action.meta.arg).hostname;
     30 + state.address = new URL(action.meta.arg).toString();
    31 31   state.isLoading = true;
    32 32   state.isFailed = false;
    33 33   state.loadError = null;
    skipped 17 lines
Please wait...
Page is in error, reload to recover