Projects STRLCPY gradejs Commits f0d57cc4
🤬
  • wip: add loading bar, skeletons, mobile filters and error page (#60)

    * wip: rebase recent initial-redesign
    
    * fix: add repeat helper function to avoid repetition with skeletons
    
    * wip: update error page, set loading demo to 60 seconds
    
    * fix: props typing errors
    
    * fix: remove unused methods
    
    * fix: replace dummy text from skeleton with width/height instead, repeat util fix due to review, more repeat usage in appropriate places
    
    * wip: add mobile filters
    
    * wip: add skeletons for mobile filters
    
    * fix: restructure Skeletons into separate files alongside components
    
    * fix: refactor Header component, add multiple todos from code review for future refactoring
    
    * wip: replace off-canvas wrapper with Modal component created by react portal, add more todos
    
    * wip: refactor sidebar filters
    
    * fix: use skeletons on parent level
    
    * fix: typing errors by removing old code for error page
    
    * fix: remove image placeholder prop for keywords list
    
    * fix: wrap card list skeletons in grid
  • Loading...
  • Dmitry Shakun committed with GitHub 2 years ago
    f0d57cc4
    1 parent 4ef627d5
  • ■ ■ ■ ■ ■ ■
    packages/web/.storybook/preview-body.html
     1 +<div id="modal-root"></div>
     2 + 
  • ■ ■ ■ ■ ■
    packages/web/index.html
    skipped 29 lines
    30 30   </noscript>
    31 31   
    32 32   <div id="app"></div>
     33 + <div id="modal-root"></div>
    33 34   </body>
    34 35  </html>
    35 36   
  • ■ ■ ■ ■ ■
    packages/web/package.json
    skipped 75 lines
    76 76   "react-hook-form": "^7.15.3",
    77 77   "react-redux": "^8.0.2",
    78 78   "react-router-dom": "^6.3.0",
     79 + "react-top-loading-bar": "^2.3.1",
    79 80   "react-transition-group": "^4.4.5",
    80 81   "semver": "^7.3.7"
    81 82   }
    skipped 2 lines
  • packages/web/src/assets/icons/sprite/arrow-back.svg
  • packages/web/src/assets/icons/sprite/filters.svg
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/layouts/Error/Error.module.scss
     1 +@import '~styles/_vars.scss';
    1 2  @import '~styles/responsive.scss';
    2 3   
    3  -.heading {
    4  - font-size: 44px;
    5  - margin: 0 0 32px;
     4 +.errorPage {
     5 + padding-top: 36px;
     6 + padding-bottom: 20px;
    6 7   
    7  - @include mobile {
    8  - font-size: 28px;
     8 + @include mobile-and-tablet {
     9 + padding-top: 16px;
     10 + padding-bottom: 8px;
    9 11   }
    10 12  }
    11 13   
    12  -.primary {
    13  - margin: 0 0 16px;
    14  - font-size: 20px;
    15  - line-height: 135%;
    16  - color: #0f0f0f;
     14 +.textContent {
     15 + max-width: 786px;
     16 + margin-bottom: 36px;
     17 + 
     18 + @include mobile-and-tablet {
     19 + margin-bottom: 24px;
     20 + }
    17 21  }
    18 22   
    19  -.secondary {
    20  - font-size: 14px;
    21  - line-height: 140%;
    22  - color: #666666;
    23  - margin: 0 0 24px;
     23 +.searchWrapper {
     24 + max-width: 884px;
     25 + width: 100%;
    24 26  }
    25 27   
    26  -.button {
    27  - width: 212px;
    28  - margin-left: 12px;
     28 +.host {
     29 + color: $gray-text;
     30 + margin-bottom: 12px;
     31 +}
    29 32   
    30  - @include mobile {
    31  - margin-top: 12px;
    32  - margin-left: 0;
    33  - width: 100%;
    34  - }
     33 +.heading {
     34 + margin-bottom: 16px;
    35 35  }
    36 36   
    37  -.retry {
    38  - @include mobile {
    39  - width: 100%;
    40  - text-align: left;
     37 +.desc {
     38 + font-size: 19px;
     39 + 
     40 + @include mobile-and-tablet {
     41 + font-size: 14px;
    41 42   }
    42 43  }
    43 44   
  • ■ ■ ■ ■ ■
    packages/web/src/components/layouts/Error/Error.tsx
    1  -/* eslint-disable react/button-has-type */
    2 1  import React from 'react';
    3  -import { Button, Header, Section } from 'components/ui';
    4 2  import styles from './Error.module.scss';
     3 +import Container from 'components/ui/Container/Container';
     4 +import SearchBar from '../../ui/SearchBar/SearchBar';
     5 +import { CardProps } from '../../ui/Card/Card';
     6 +import CardGroup from '../../ui/CardGroup/CardGroup';
     7 +import CardList from '../../ui/CardList/CardList';
     8 +import CardGroups from '../../ui/CardGroups/CardGroups';
     9 +import Footer from '../../ui/Footer/Footer';
     10 +import ErrorHeader from 'components/ui/Header/ErrorHeader';
    5 11   
    6 12  export type Props = {
    7 13   host: string;
    8 14   message?: string;
    9 15   action?: string;
    10 16   actionTitle?: string;
    11  - onRetryClick: () => unknown;
    12  - onReportClick: () => unknown;
    13 17  };
    14 18   
    15 19  export default function Error({
    16 20   host,
    17  - onRetryClick,
    18  - onReportClick,
    19  - message = 'Unfortunately, something went wrong.',
    20  - action = 'Would you like to retry or report an issue?',
    21  - actionTitle = 'Try again',
     21 + message = 'It looks like the entered website is not built with Webpack',
     22 + action = 'GradeJS will analyze production JavaScript files and match webpack bundled modules to 1,826 indexed NPM libraries over 54,735 releases',
     23 + actionTitle,
    22 24  }: Props) {
     25 + // TODO: mock data, remove later
     26 + const similarCards: CardProps[] = [
     27 + {
     28 + id: 'uExBVGuF',
     29 + title: 'github.com',
     30 + icon: 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     31 + packageTags: {
     32 + featuredPackages: ['mdast-util-from-markdown', 'react', 'react-dom'],
     33 + restPackages: 45,
     34 + },
     35 + },
     36 + {
     37 + id: '1EkL1u5g',
     38 + title: 'fingerprint.com',
     39 + icon: 'https://avatars.githubusercontent.com/u/67208791?s=200&v=4',
     40 + packageTags: {
     41 + featuredPackages: ['mdast-util-from-markdown', 'react', 'react-dom'],
     42 + restPackages: 45,
     43 + },
     44 + },
     45 + {
     46 + id: 'mhwO2bPM',
     47 + title: 'facebook.com',
     48 + icon: 'https://avatars.githubusercontent.com/u/69631?s=200&v=4',
     49 + packageTags: {
     50 + featuredPackages: ['react'],
     51 + restPackages: 45,
     52 + },
     53 + },
     54 + ];
     55 + 
     56 + // TODO: mock data, remove later
     57 + const popularPackages: CardProps[] = [
     58 + {
     59 + id: 'FPsBcl8R',
     60 + title: '@team-griffin/react-heading-section',
     61 + description: "This package's job is to automatically determine...",
     62 + featuredSites: {
     63 + iconList: [
     64 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     65 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     66 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     67 + ],
     68 + numberOfUses: 5265,
     69 + },
     70 + },
     71 + {
     72 + id: 'emtYcsUh',
     73 + title: 'unist-util-generated',
     74 + description: 'unist utility to check if a node is generated',
     75 + featuredSites: {
     76 + iconList: [
     77 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     78 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     79 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     80 + ],
     81 + numberOfUses: 5265,
     82 + },
     83 + },
     84 + {
     85 + id: 'TYIwvAfy',
     86 + title: 'react-smooth',
     87 + description: 'is a animation library work on React',
     88 + featuredSites: {
     89 + iconList: [
     90 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     91 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     92 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     93 + ],
     94 + numberOfUses: 5265,
     95 + },
     96 + },
     97 + {
     98 + id: 'Lq1pEEX7',
     99 + title: 'unist-util-position',
     100 + description: 'unist utility to get the positional info of nodes',
     101 + featuredSites: {
     102 + iconList: [
     103 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     104 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     105 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     106 + ],
     107 + numberOfUses: 5265,
     108 + },
     109 + },
     110 + {
     111 + id: 'cWOgIbmp',
     112 + title: 'vfile-message',
     113 + description: 'Create vfile messages',
     114 + featuredSites: {
     115 + iconList: [
     116 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     117 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     118 + 'https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg',
     119 + ],
     120 + numberOfUses: 5265,
     121 + },
     122 + },
     123 + {
     124 + id: 'UT97Vpoi',
     125 + title: 'Go to all Popular packages',
     126 + variant: 'toAll',
     127 + },
     128 + ];
     129 + 
    23 130   return (
    24 131   <>
    25  - <Header />
    26  - <Section>
    27  - <h1 className={styles.heading}>{host}</h1>
    28  - <p className={styles.primary}>{message}</p>
    29  - <p className={styles.secondary}>{action}</p>
    30  - <Button onClick={onRetryClick} className={styles.retry}>
    31  - {actionTitle}
    32  - </Button>
    33  - <a
    34  - href='https://github.com/gradejs/gradejs/issues'
    35  - target='_blank'
    36  - rel='noreferrer'
    37  - onClick={onReportClick}
    38  - >
    39  - <Button className={styles.button} variant='action'>
    40  - Report an issue
    41  - </Button>
    42  - </a>
    43  - </Section>
     132 + <ErrorHeader />
     133 + 
     134 + <Container>
     135 + <section className={styles.errorPage}>
     136 + <div className={styles.textContent}>
     137 + <p className={styles.host}>{host}</p>
     138 + <h2 className={styles.heading}>{message}</h2>
     139 + {action && <p className={styles.desc}>{action}</p>}
     140 + </div>
     141 + 
     142 + <div className={styles.searchWrapper}>
     143 + <SearchBar size='large' placeholder={actionTitle} />
     144 + </div>
     145 + </section>
     146 + 
     147 + {/* TODO: Trying to fit separate domain entities within a single component seems like burden.
     148 + Feels like these <CardList/>'s should be separate components. */}
     149 + <CardGroups>
     150 + <CardGroup title='But we have'>
     151 + <CardList cards={similarCards} />
     152 + </CardGroup>
     153 + 
     154 + <CardGroup title='Popular packages'>
     155 + <CardList cards={popularPackages} />
     156 + </CardGroup>
     157 + </CardGroups>
     158 + </Container>
     159 + 
     160 + <Footer />
    44 161   </>
    45 162   );
    46 163  }
    skipped 1 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/layouts/Home/Home.tsx
    skipped 152 lines
    153 153   <Hero suggestions={suggestions} onSubmit={onSubmit} loading={loading} />
    154 154   
    155 155   <Container>
     156 + {/* TODO: Trying to fit separate domain entities within a single component seems like burden.
     157 + Feels like these <CardList/>'s should be separate components. */}
    156 158   <CardGroups>
    157 159   <CardGroup title='Popular search queries'>
    158 160   <CardList cards={popularCards} />
    skipped 21 lines
  • ■ ■ ■ ■ ■
    packages/web/src/components/layouts/SearchResults/SearchResults.module.scss
    skipped 34 lines
    35 35   }
    36 36  }
    37 37   
    38  -.packages {
     38 +.searchResultsResource {
    39 39   grid-column: 2 / -1;
    40  - display: grid;
    41  - grid-gap: 16px;
    42  - align-items: start;
    43  - grid-template-rows: min-content;
    44  - grid-template-columns: minmax(0, 1fr);
    45 40   
    46 41   @include mobile-and-tablet {
    47 42   grid-column: 1;
    48  - margin-left: -20px;
    49  - margin-right: -20px;
    50 43   }
    51 44  }
    52 45   
    53  -.sidebar {
     46 +.searchResultsSidebar {
    54 47   grid-column: 1 / 2;
    55 48   grid-row: 1 / 3;
    56  - display: grid;
    57  - grid-gap: 24px;
    58 49   align-self: start;
    59 50   
    60 51   @include mobile-and-tablet {
    skipped 2 lines
    63 54   }
    64 55  }
    65 56   
    66  -.sidebarItem {
    67  - &:not(:first-child) {
    68  - padding-top: 24px;
    69  - border-top: 1px solid $gray-border;
    70  - }
    71  -}
    72  - 
    73  -.meta {
     57 +.packages {
     58 + grid-column: 2 / -1;
    74 59   display: grid;
    75  - grid-gap: 20px;
    76  - 
    77  - // FIXME: not sure that mobile spacing should be higher than desktop one
    78  - @include mobile-and-tablet {
    79  - grid-gap: 24px;
    80  - }
    81  -}
    82  - 
    83  -.metaItem {
    84  - display: flex;
    85  - align-items: center;
    86  - 
    87  - @include mobile-and-tablet {
    88  - line-height: 24px;
    89  - }
    90  -}
    91  - 
    92  -.metaIcon {
    93  - display: flex;
    94  - flex-shrink: 0;
    95  - margin-right: 20px;
     60 + grid-gap: 16px;
     61 + align-items: start;
     62 + grid-template-rows: min-content;
     63 + grid-template-columns: minmax(0, 1fr);
    96 64   
    97 65   @include mobile-and-tablet {
    98  - margin-right: 16px;
     66 + grid-column: 1;
     67 + margin-left: -20px;
     68 + margin-right: -20px;
    99 69   }
    100 70  }
    101 71   
    102  -.metaText {
    103  - font-weight: 500;
    104  -}
    105  - 
  • ■ ■ ■ ■ ■
    packages/web/src/components/layouts/SearchResults/SearchResults.stories.tsx
    skipped 9 lines
    10 10   },
    11 11  } as ComponentMeta<typeof SearchResults>;
    12 12   
    13  -const Template: ComponentStory<typeof SearchResults> = () => <SearchResults />;
     13 +const Template: ComponentStory<typeof SearchResults> = (args) => <SearchResults {...args} />;
    14 14   
    15 15  export const Default = Template.bind({});
    16 16   
     17 +export const Loading = Template.bind({});
     18 +Loading.args = {
     19 + pageLoading: true,
     20 +};
     21 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/layouts/SearchResults/SearchResults.tsx
    1  -import React, { useState } from 'react';
     1 +import React, { useEffect, useRef, useState } from 'react';
    2 2  import styles from './SearchResults.module.scss';
    3  -import Header from 'components/ui/Header/Header';
    4 3  import Footer from 'components/ui/Footer/Footer';
    5 4  import Container from 'components/ui/Container/Container';
    6 5  import { Icon } from '../../ui/Icon/Icon';
    7 6  import PackagePreview from '../../ui/PackagePreview/PackagePreview';
    8  -import SearchBar from '../../ui/SearchBar/SearchBar';
    9 7  import SearchedResource from '../../ui/SearchedResource/SearchedResource';
    10 8  import { CardProps } from '../../ui/Card/Card';
    11 9  import CardGroup from '../../ui/CardGroup/CardGroup';
    12 10  import CardList from '../../ui/CardList/CardList';
    13 11  import CardGroups from 'components/ui/CardGroups/CardGroups';
    14  -import SidebarCategory from '../../ui/SidebarCategory/SidebarCategory';
    15  -import { Button } from '../../ui';
     12 +import LoadingBar, { LoadingBarRef } from 'react-top-loading-bar';
     13 +import DefaultHeader from '../../ui/Header/DefaultHeader';
     14 +import SearchResultsSidebar from 'components/ui/SearchResultsSidebar/SearchResultsSidebar';
     15 +import { SearchedResourceSkeleton } from '../../ui/SearchedResource/SearchedResourceSkeleton';
     16 +import { PackagePreviewSkeleton } from '../../ui/PackagePreview/PackagePreviewSkeleton';
     17 +import { CardListSkeleton } from '../../ui/CardList/CardListSkeleton';
     18 + 
     19 +type Props = {
     20 + pageLoading?: boolean;
     21 +};
     22 + 
     23 +export default function SearchResults({ pageLoading = false }: Props) {
     24 + const [loading, setLoading] = useState<boolean>(pageLoading);
     25 + 
     26 + const loadingRef = useRef<LoadingBarRef>(null);
    16 27   
    17  -export default function SearchResults() {
     28 + // FIXME: just for demo purposes to show how loading bar works
     29 + // Documentation: https://github.com/klendi/react-top-loading-bar
     30 + // Starts the loading indicator with a random starting value between 20-30 (or startingValue),
     31 + // then repetitively after an refreshRate (in milliseconds), increases it by a random value
     32 + // between 2-10. This continues until it reaches 90% of the indicator's width.
     33 + useEffect(() => {
     34 + loadingRef?.current?.continuousStart(10, 5000);
     35 + 
     36 + // After 10 seconds makes the loading indicator reach 100% of his width and then fade.
     37 + setTimeout(() => {
     38 + loadingRef?.current?.complete();
     39 + setLoading(false);
     40 + }, 60000);
     41 + }, []);
     42 + 
    18 43   // TODO: mock data, remove later
    19 44   const similarCards: CardProps[] = [
    20 45   {
    skipped 99 lines
    120 145   },
    121 146   ];
    122 147   
     148 + const metaItems = [
     149 + {
     150 + icon: <Icon kind='weight' width={24} height={24} />,
     151 + text: '159 kb webpack bundle size',
     152 + },
     153 + {
     154 + icon: <Icon kind='search' width={24} height={24} color='#212121' />,
     155 + text: '50 scripts found',
     156 + },
     157 + {
     158 + icon: <Icon kind='bug' width={24} height={24} color='#F3512E' />,
     159 + text: '6 vulnerabilities in 4 packages',
     160 + },
     161 + {
     162 + icon: <Icon kind='duplicate' color='#F3812E' width={24} height={24} />,
     163 + text: '12 duplicate packages',
     164 + },
     165 + {
     166 + icon: <Icon kind='outdated' color='#F1CE61' stroke='white' width={24} height={24} />,
     167 + text: '18 outdated packages',
     168 + },
     169 + ];
     170 + 
    123 171   // TODO: mock data, remove later
    124 172   const keyWords = ['#moment', '#date', '#react', '#parse', '#fb', '#angular', '#vue', '#ember'];
    125 173   
    skipped 3 lines
    129 177   // TODO: mock data, remove later
    130 178   const authors = ['gaearon', 'acdlite', 'sophiebits', 'sebmarkbage', 'zpao', 'trueadm', 'bvaughn'];
    131 179   
    132  - const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
    133  - const [selectedProblems, setSelectedProblems] = useState<string[]>([]);
    134  - const [selectedAuthors, setSelectedAuthors] = useState<string[]>([]);
    135  - 
    136  - const handleFiltersChange = (
    137  - name: string,
    138  - state: string[],
    139  - setState: React.SetStateAction<any>
    140  - ) => {
    141  - const temp = [...state];
    142  - 
    143  - if (temp.includes(name)) {
    144  - const filtered = temp.filter((item) => item !== name);
    145  - setState(filtered);
    146  - } else {
    147  - temp.push(name);
    148  - setState(temp);
    149  - }
    150  - };
    151  - 
    152  - const handleKeywordsChange = (name: string) => {
    153  - handleFiltersChange(name, selectedKeywords, setSelectedKeywords);
    154  - };
    155  - 
    156  - const handleProblemsChange = (name: string) => {
    157  - handleFiltersChange(name, selectedProblems, setSelectedProblems);
    158  - };
    159  - 
    160  - const handleAuthorsChange = (name: string) => {
    161  - handleFiltersChange(name, selectedAuthors, setSelectedAuthors);
    162  - };
    163  - 
    164  - const resetFilters = () => {
    165  - setSelectedKeywords([]);
    166  - setSelectedProblems([]);
    167  - setSelectedAuthors([]);
    168  - };
    169  - 
    170  - const isChanged =
    171  - selectedKeywords.length > 0 || selectedProblems.length > 0 || selectedAuthors.length > 0;
    172  - 
    173 180   return (
    174 181   <>
    175  - <Header>
    176  - <SearchBar value='pinterest.com/blog/%D0%92%D092%D092%D092%/dFD092fg092%D092%/dFD092/blog/%D0%92%D092%D092%D092%/dFD092fg092%D092%/dFD092f' />
    177  - </Header>
     182 + {pageLoading && (
     183 + <LoadingBar
     184 + ref={loadingRef}
     185 + color='linear-gradient(90deg, #2638D9 0%, #B22AF2 100%)'
     186 + height={4}
     187 + shadow={false}
     188 + transitionTime={600}
     189 + loaderSpeed={600}
     190 + />
     191 + )}
     192 + 
     193 + <DefaultHeader showSearch />
    178 194   
    179 195   <Container>
    180 196   <div className={styles.searchResults}>
    181  - <SearchedResource
    182  - image='https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg'
    183  - name='pinterest.com'
    184  - totalPackages={6}
    185  - lastScanDate='21 feb in 21:30'
    186  - />
    187  - 
    188  - <aside className={styles.sidebar}>
    189  - <div className={styles.sidebarItem}>
    190  - <div className={styles.meta}>
    191  - <div className={styles.metaItem}>
    192  - <span className={styles.metaIcon}>
    193  - <Icon kind='weight' width={24} height={24} />
    194  - </span>
    195  - <span className={styles.metaText}>159 kb webpack bundle size</span>
    196  - </div>
    197  - <div className={styles.metaItem}>
    198  - <span className={styles.metaIcon}>
    199  - <Icon kind='search' width={24} height={24} color='#212121' />
    200  - </span>
    201  - <span className={styles.metaText}>50 scripts found</span>
    202  - </div>
    203  - <div className={styles.metaItem}>
    204  - <span className={styles.metaIcon}>
    205  - <Icon kind='bug' width={24} height={24} color='#F3512E' />
    206  - </span>
    207  - <span className={styles.metaText}>6 vulnerabilities in 4&nbsp;packages</span>
    208  - </div>
    209  - <div className={styles.metaItem}>
    210  - <span className={styles.metaIcon}>
    211  - <Icon kind='duplicate' color='#F3812E' width={24} height={24} />
    212  - </span>
    213  - <span className={styles.metaText}>12 duplicate packages</span>
    214  - </div>
    215  - <div className={styles.metaItem}>
    216  - <span className={styles.metaIcon}>
    217  - <Icon kind='outdated' color='#F1CE61' stroke='white' width={24} height={24} />
    218  - </span>
    219  - <span className={styles.metaText}>18 outdated packages</span>
    220  - </div>
    221  - </div>
    222  - </div>
    223  - 
    224  - <div className={styles.sidebarItem}>
    225  - <SidebarCategory
    226  - keywordsList={keyWords}
    227  - selectedKeywords={selectedKeywords}
    228  - selectHandler={handleKeywordsChange}
    229  - renderComponent='chip'
    230  - searchable
     197 + <div className={styles.searchResultsResource}>
     198 + {loading ? (
     199 + <SearchedResourceSkeleton
     200 + image='https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg'
     201 + name='pinterest.com'
    231 202   />
    232  - </div>
    233  - 
    234  - <div className={styles.sidebarItem}>
    235  - <SidebarCategory
    236  - keywordsList={vulnerabilities}
    237  - selectedKeywords={selectedProblems}
    238  - selectHandler={handleProblemsChange}
    239  - renderComponent='checkbox'
     203 + ) : (
     204 + <SearchedResource
     205 + image='https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg'
     206 + name='pinterest.com'
     207 + totalPackages={6}
     208 + lastScanDate='21 feb in 21:30'
    240 209   />
    241  - </div>
     210 + )}
     211 + </div>
    242 212   
    243  - <div className={styles.sidebarItem}>
    244  - <SidebarCategory
    245  - keywordsList={authors}
    246  - selectedKeywords={selectedAuthors}
    247  - selectHandler={handleAuthorsChange}
    248  - renderComponent='person'
    249  - searchable
    250  - />
    251  - </div>
     213 + <div className={styles.searchResultsSidebar}>
     214 + <SearchResultsSidebar
     215 + metaItems={metaItems}
     216 + keyWords={keyWords}
     217 + vulnerabilities={vulnerabilities}
     218 + authors={authors}
     219 + loading={loading}
     220 + />
     221 + </div>
    252 222   
    253  - {isChanged && (
    254  - <div className={styles.sidebarItem}>
    255  - <Button variant='secondary' size='small' onClick={resetFilters}>
    256  - Reset filters
    257  - </Button>
    258  - </div>
     223 + <div className={styles.packages}>
     224 + {loading ? (
     225 + <PackagePreviewSkeleton />
     226 + ) : (
     227 + <PackagePreview name='@team-griffin/react-heading-section' version='3.0.0 - 4.16.4' />
    259 228   )}
    260  - </aside>
    261 229   
    262  - <div className={styles.packages}>
    263  - <PackagePreview name='@team-griffin/react-heading-section' version='3.0.0 - 4.16.4' />
    264  - <PackagePreview
    265  - name='@team-griffin/react-heading-section@team-griffin/react-heading-section'
    266  - version='3.0.0 - 4.16.4'
    267  - />
     230 + {loading ? (
     231 + <PackagePreviewSkeleton />
     232 + ) : (
     233 + <PackagePreview name='@team-griffin/react-heading-section' version='3.0.0 - 4.16.4' />
     234 + )}
    268 235   </div>
    269 236   </div>
    270 237   
     238 + {/* TODO: Trying to fit separate domain entities within a single component seems like burden.
     239 + Feels like these <CardList/>'s should be separate components. */}
    271 240   <CardGroups>
    272 241   <CardGroup title='Similar sites'>
    273  - <CardList cards={similarCards} />
     242 + {loading ? <CardListSkeleton /> : <CardList cards={similarCards} />}
    274 243   </CardGroup>
    275 244   
    276 245   <CardGroup title='Popular packages'>
    277  - <CardList cards={popularPackages} />
     246 + {loading ? <CardListSkeleton /> : <CardList cards={popularPackages} />}
    278 247   </CardGroup>
    279 248   </CardGroups>
    280 249   </Container>
    skipped 6 lines
  • ■ ■ ■ ■ ■
    packages/web/src/components/layouts/Website/Website.tsx
    skipped 1 lines
    2 2  import React, { useState } from 'react';
    3 3  import clsx from 'clsx';
    4 4  import { SubmitHandler } from 'react-hook-form';
    5  -import { Header, Package, Section, PackageSkeleton } from 'components/ui';
     5 +import { Package, Section, PackageSkeleton } from 'components/ui';
    6 6  import styles from './Website.module.scss';
    7 7  import Filters, { FiltersState } from '../Filters/Filters';
    8 8  import TagBadge from '../../ui/TagBadge/TagBadge';
    9 9  import { trackCustomEvent } from '../../../services/analytics';
    10 10  import { ClientApi } from '../../../services/apiClient';
    11 11  import { Icon } from '../../ui/Icon/Icon';
     12 +import DefaultHeader from '../../ui/Header/DefaultHeader';
    12 13   
    13 14  // TODO: Add plashechka
    14 15  export type Props = {
    skipped 22 lines
    37 38   
    38 39   return (
    39 40   <>
    40  - <Header />
     41 + <DefaultHeader />
    41 42   <Section>
    42 43   <h1 className={styles.heading}>{host}</h1>
    43 44   
    skipped 102 lines
  • ■ ■ ■ ■ ■
    packages/web/src/components/pages/Home.tsx
    1 1  import React, { useCallback, useEffect } from 'react';
    2 2  import { Error, Home } from 'components/layouts';
    3 3  import { trackCustomEvent } from '../../services/analytics';
    4  -import {
    5  - useAppDispatch,
    6  - parseWebsite,
    7  - resetError,
    8  - useAppSelector,
    9  - homeDefaultSelector,
    10  -} from '../../store';
     4 +import { useAppDispatch, parseWebsite, useAppSelector, homeDefaultSelector } from '../../store';
    11 5  import { useNavigate } from 'react-router-dom';
    12 6   
    13 7  export function HomePage() {
    skipped 18 lines
    32 26   }, []);
    33 27   
    34 28   if (state.isFailed) {
    35  - return (
    36  - <Error
    37  - host={state.hostname}
    38  - onReportClick={() => {
    39  - trackCustomEvent('HomePage', 'ClickReport');
    40  - }}
    41  - onRetryClick={() => {
    42  - trackCustomEvent('HomePage', 'ClickRetry');
    43  - dispatch(resetError());
    44  - }}
    45  - />
    46  - );
     29 + return <Error host={state.hostname} />;
    47 30   }
    48 31   
    49 32   return <Home onSubmit={handleDetectStart} loading={state.isLoading} />;
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/pages/WebsiteResults.tsx
    skipped 55 lines
    56 56   action='Would you like to try another URL or report an issue?'
    57 57   actionTitle='Try another URL'
    58 58   host={hostname ?? ''}
    59  - onRetryClick={() => {
    60  - trackCustomEvent('HostnamePage', 'ClickRetry_Protected');
    61  - navigate('/', { replace: false });
    62  - }}
    63  - onReportClick={() => {
    64  - trackCustomEvent('HostnamePage', 'ClickReport_Protected');
    65  - }}
    66 59   />
    67 60   );
    68 61   }
    skipped 7 lines
    76 69   action='Would you like to try another URL or report an issue?'
    77 70   actionTitle='Try another URL'
    78 71   host={hostname ?? ''}
    79  - onRetryClick={() => {
    80  - trackCustomEvent('HostnamePage', 'ClickRetry_Invalid');
    81  - navigate('/', { replace: false });
    82  - }}
    83  - onReportClick={() => {
    84  - trackCustomEvent('HostnamePage', 'ClickReport_Invalid');
    85  - }}
    86 72   />
    87 73   );
    88 74   }
    skipped 27 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Badge/Badge.tsx
    1 1  import React from 'react';
    2 2  import styles from './Badge.module.scss';
     3 +import clsx from 'clsx';
    3 4   
    4 5  type Props = {
    5 6   content: string | number;
     7 + className?: string;
    6 8  };
    7 9   
    8  -export default function Badge({ content }: Props) {
    9  - return <span className={styles.badge}>{content}</span>;
     10 +export default function Badge({ content, className }: Props) {
     11 + return <span className={clsx(styles.badge, className)}>{content}</span>;
    10 12  }
    11 13   
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Button/Button.module.scss
    skipped 32 lines
    33 33   align-items: center;
    34 34   justify-content: center;
    35 35   margin-left: 20px;
     36 + 
     37 + @include mobile-and-tablet {
     38 + width: 42px;
     39 + height: 42px;
     40 + }
    36 41  }
    37 42   
    38 43  .arrowIconBackground {
    skipped 37 lines
    76 81   opacity: 1;
    77 82   animation: reveal $transition-duration $transition-timing-function forwards;
    78 83   }
     84 + }
     85 + 
     86 + @include mobile-and-tablet {
     87 + font-size: 14px;
     88 + line-height: 22px;
     89 + padding-right: 65px;
    79 90   }
    80 91  }
    81 92   
    skipped 27 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/CardList/CardListSkeleton.tsx
     1 +import React from 'react';
     2 +import styles from './CardList.module.scss';
     3 +import { repeat } from '../../../utils/helpers';
     4 +import Skeleton from '../Skeleton/Skeleton';
     5 +import Card from '../Card/Card';
     6 + 
     7 +export const CardListSkeleton = () => (
     8 + <div className={styles.grid}>
     9 + {repeat(
     10 + 3,
     11 + <Skeleton width='100%' variant='rounded'>
     12 + <Card id='id1' title='title' />
     13 + </Skeleton>
     14 + )}
     15 + </div>
     16 +);
     17 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/ChipGroup/ChipGroup.tsx
    1 1  import React from 'react';
    2 2  import styles from './ChipGroup.module.scss';
    3 3  import Chip, { ChipProps } from '../Chip/Chip';
     4 +import { ChipGroupSkeleton } from './ChipGroupSkeleton';
    4 5   
    5 6  type Props = {
    6 7   chips: string[];
    skipped 1 lines
    8 9   size?: ChipProps['size'];
    9 10   font?: ChipProps['font'];
    10 11   fontSize?: ChipProps['fontSize'];
     12 + loading?: boolean;
    11 13  };
    12 14   
    13 15  export default function ChipGroup({
    skipped 2 lines
    16 18   size = 'medium',
    17 19   font = 'monospace',
    18 20   fontSize = 'regular',
     21 + loading,
    19 22  }: Props) {
    20 23   return (
    21 24   <div className={styles.chipsWrapper}>
    22 25   <div className={styles.chips}>
    23  - {chips.map((chip) => (
    24  - <Chip key={chip} className={styles.chip} size={size} font={font} fontSize={fontSize}>
    25  - {chip}
    26  - </Chip>
    27  - ))}
     26 + {loading ? (
     27 + <ChipGroupSkeleton />
     28 + ) : (
     29 + chips.map((chip) => (
     30 + <Chip key={chip} className={styles.chip} size={size} font={font} fontSize={fontSize}>
     31 + {chip}
     32 + </Chip>
     33 + ))
     34 + )}
    28 35   
    29 36   {children}
    30 37   </div>
    skipped 4 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/ChipGroup/ChipGroupSkeleton.tsx
     1 +import React from 'react';
     2 +import { repeat } from '../../../utils/helpers';
     3 +import Skeleton from '../Skeleton/Skeleton';
     4 +import styles from './ChipGroup.module.scss';
     5 + 
     6 +export const ChipGroupSkeleton = () => (
     7 + <>{repeat(4, <Skeleton variant='rounded' width={108} height={36} className={styles.chip} />)}</>
     8 +);
     9 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Header/DefaultHeader.tsx
     1 +import React, { useMemo } from 'react';
     2 +import styles from './Header.module.scss';
     3 +import { Icon } from '../Icon/Icon';
     4 +import Header, { Props } from './Header';
     5 +import { trackCustomEvent } from '../../../services/analytics';
     6 + 
     7 +export default function DefaultHeader(props: Props) {
     8 + const trackAboutClick = useMemo(() => {
     9 + return () => trackCustomEvent('ClickExternalLink', 'About');
     10 + }, []);
     11 + 
     12 + const trackAboutCommunity = useMemo(() => {
     13 + return () => trackCustomEvent('ClickExternalLink', 'Community');
     14 + }, []);
     15 + 
     16 + const trackAboutSourceCode = useMemo(() => {
     17 + return () => trackCustomEvent('ClickExternalLink', 'SourceCode');
     18 + }, []);
     19 + 
     20 + return (
     21 + <Header {...props}>
     22 + <a
     23 + href='https://github.com/gradejs/gradejs/discussions/6'
     24 + target='_blank'
     25 + rel='noreferrer'
     26 + className={styles.navLink}
     27 + onClick={trackAboutClick}
     28 + >
     29 + About
     30 + </a>
     31 + <a
     32 + href='https://github.com/gradejs/gradejs/discussions'
     33 + target='_blank'
     34 + rel='noreferrer'
     35 + className={styles.navLink}
     36 + onClick={trackAboutCommunity}
     37 + >
     38 + Community
     39 + </a>
     40 + <a
     41 + href='https://github.com/gradejs/gradejs'
     42 + target='_blank'
     43 + rel='noreferrer'
     44 + className={styles.navLink}
     45 + onClick={trackAboutSourceCode}
     46 + >
     47 + <Icon
     48 + kind='githubLogo'
     49 + className={styles.githubIcon}
     50 + width={32}
     51 + height={32}
     52 + color={props.variant === 'light' ? 'white' : '#212121'}
     53 + />
     54 + </a>
     55 + </Header>
     56 + );
     57 +}
     58 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Header/ErrorHeader.tsx
     1 +import React, { useMemo } from 'react';
     2 +import clsx from 'clsx';
     3 +import styles from './Header.module.scss';
     4 +import Header, { Props } from './Header';
     5 +import { trackCustomEvent } from '../../../services/analytics';
     6 + 
     7 +export default function ErrorHeader(props: Props) {
     8 + const trackIssuesClick = useMemo(() => {
     9 + return () => trackCustomEvent('ClickExternalLink', 'Issues');
     10 + }, []);
     11 + 
     12 + return (
     13 + <Header {...props}>
     14 + <a
     15 + href='https://github.com/gradejs/gradejs/issues'
     16 + target='_blank'
     17 + rel='noreferrer'
     18 + className={clsx(styles.navLink, styles.navLinkIssues)}
     19 + onClick={trackIssuesClick}
     20 + >
     21 + Report an issue
     22 + </a>
     23 + </Header>
     24 + );
     25 +}
     26 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Header/Header.module.scss
    skipped 4 lines
    5 5   display: flex;
    6 6   align-items: center;
    7 7   justify-content: space-between;
    8  - padding: 20px 0;
     8 + min-height: 96px;
    9 9   
    10 10   @include mobile-and-tablet {
     11 + min-height: 72px;
     12 + }
     13 +}
     14 + 
     15 +.default {
     16 + color: $black;
     17 +}
     18 + 
     19 +.light {
     20 + color: $white;
     21 +}
     22 + 
     23 +.showSearch {
     24 + @include mobile-and-tablet {
     25 + padding: 20px 0;
     26 + min-height: initial;
    11 27   display: grid;
    12 28   grid-row-gap: 22px;
    13 29   grid-template-columns: 1fr 2fr;
    skipped 37 lines
    51 67   
    52 68  .navLink {
    53 69   display: flex;
    54  - color: $black;
     70 + color: inherit;
    55 71  }
    56 72   
    57 73  .githubIcon {
    skipped 6 lines
    64 80   }
    65 81  }
    66 82   
    67  -.homepage {
    68  - padding: 44px 0;
    69  - 
    70  - @include mobile-and-tablet {
    71  - padding: 22px 0;
    72  - }
    73  - 
    74  - .navLink {
    75  - color: $white;
    76  - }
    77  -}
    78  - 
  • ■ ■ ■ ■ ■
    packages/web/src/components/ui/Header/Header.tsx
    1  -import React, { useMemo } from 'react';
     1 +import React from 'react';
    2 2  import styles from './Header.module.scss';
    3 3  import Container from '../Container/Container';
    4 4  import clsx from 'clsx';
    5 5  import { Icon } from '../Icon/Icon';
    6  -import { trackCustomEvent } from '../../../services/analytics';
     6 +import SearchBar from '../SearchBar/SearchBar';
    7 7   
    8  -type Props = {
    9  - variant?: 'default' | 'homepage';
     8 +export type Props = {
     9 + variant?: 'default' | 'light';
     10 + showSearch?: boolean;
    10 11   children?: React.ReactNode;
    11 12  };
    12 13   
    13  -export default function Header({ variant = 'default', children }: Props) {
    14  - const trackAboutClick = useMemo(() => {
    15  - return () => trackCustomEvent('ClickExternalLink', 'About');
    16  - }, []);
    17  - 
    18  - const trackAboutCommunity = useMemo(() => {
    19  - return () => trackCustomEvent('ClickExternalLink', 'Community');
    20  - }, []);
    21  - 
    22  - const trackAboutSourceCode = useMemo(() => {
    23  - return () => trackCustomEvent('ClickExternalLink', 'SourceCode');
    24  - }, []);
    25  - 
     14 +export default function Header({ variant = 'default', showSearch = false, children }: Props) {
    26 15   return (
    27 16   <Container>
    28  - <header className={clsx(styles.header, styles[variant])}>
     17 + <header className={clsx(styles.header, showSearch && styles.showSearch, styles[variant])}>
     18 + {/* TODO: add Link from react router */}
    29 19   <a href='/' className={styles.logo}>
    30 20   <Icon
    31 21   kind='logo'
    32 22   width={129}
    33 23   height={25}
    34  - color={variant === 'default' ? '#212121' : 'white'}
     24 + color={variant === 'light' ? 'white' : '#212121'}
    35 25   />
    36 26   </a>
    37 27   
    38  - <div className={styles.searchWrapper}>{children}</div>
     28 + {showSearch && (
     29 + <div className={styles.searchWrapper}>
     30 + <SearchBar />
     31 + </div>
     32 + )}
    39 33   
    40  - <div className={styles.nav}>
    41  - <a
    42  - href='https://github.com/gradejs/gradejs/discussions/6'
    43  - target='_blank'
    44  - rel='noreferrer'
    45  - className={styles.navLink}
    46  - onClick={trackAboutClick}
    47  - >
    48  - About
    49  - </a>
    50  - <a
    51  - href='https://github.com/gradejs/gradejs/discussions'
    52  - target='_blank'
    53  - rel='noreferrer'
    54  - className={styles.navLink}
    55  - onClick={trackAboutCommunity}
    56  - >
    57  - Community
    58  - </a>
    59  - <a
    60  - href='https://github.com/gradejs/gradejs'
    61  - target='_blank'
    62  - rel='noreferrer'
    63  - className={styles.navLink}
    64  - onClick={trackAboutSourceCode}
    65  - >
    66  - <Icon
    67  - kind='githubLogo'
    68  - className={styles.githubIcon}
    69  - width={32}
    70  - height={32}
    71  - color={variant === 'default' ? '#212121' : 'white'}
    72  - />
    73  - </a>
    74  - </div>
     34 + <div className={styles.nav}>{children}</div>
    75 35   </header>
    76 36   </Container>
    77 37   );
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Header/HeaderHomePage.stories.tsx
    1 1  import React from 'react';
    2 2  import { ComponentMeta, ComponentStory } from '@storybook/react';
    3  -import Header from './Header';
     3 +import DefaultHeader from './DefaultHeader';
    4 4   
    5 5  export default {
    6 6   title: 'Interface / Header Homepage',
    7  - component: Header,
     7 + component: DefaultHeader,
    8 8   parameters: {
    9 9   layout: 'fullscreen',
    10 10   backgrounds: {
    skipped 6 lines
    17 17   control: { type: 'radio' },
    18 18   },
    19 19   },
    20  -} as ComponentMeta<typeof Header>;
     20 +} as ComponentMeta<typeof DefaultHeader>;
    21 21   
    22  -export const HomePage: ComponentStory<typeof Header> = () => <Header variant='homepage' />;
     22 +export const HomePage: ComponentStory<typeof DefaultHeader> = () => (
     23 + <DefaultHeader variant='light' />
     24 +);
    23 25   
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Header/HeaderInner.stories.tsx
    1 1  import React from 'react';
    2 2  import { ComponentMeta, ComponentStory } from '@storybook/react';
    3  -import Header from './Header';
    4  -import SearchBar from '../SearchBar/SearchBar';
     3 +import DefaultHeader from './DefaultHeader';
    5 4   
    6 5  export default {
    7 6   title: 'Interface / Header Inner page',
    8  - component: Header,
     7 + component: DefaultHeader,
    9 8   parameters: {
    10 9   layout: 'fullscreen',
    11 10   },
    12  -} as ComponentMeta<typeof Header>;
     11 +} as ComponentMeta<typeof DefaultHeader>;
    13 12   
    14  -export const InnerPage: ComponentStory<typeof Header> = () => (
    15  - <Header>
    16  - <SearchBar value='pinterest.com/blog/%D0%92%D092%D092%D092%/dFD092fg092%D092%/dFD092/blog/%D0%92%D092%D092%D092%/dFD092fg092%D092%/dFD092f' />
    17  - </Header>
    18  -);
     13 +export const InnerPage: ComponentStory<typeof DefaultHeader> = () => <DefaultHeader showSearch />;
    19 14   
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Hero/Hero.module.scss
    skipped 16 lines
    17 17   }
    18 18  }
    19 19   
     20 +.headerWrapper {
     21 + padding: 12px 0;
     22 + 
     23 + @include mobile-and-tablet {
     24 + padding: 0;
     25 + }
     26 +}
     27 + 
    20 28  .content {
    21 29   padding-top: 24px;
    22 30   padding-bottom: 100px;
    skipped 164 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Hero/Hero.tsx
    skipped 1 lines
    2 2  import styles from './Hero.module.scss';
    3 3  import Container from '../Container/Container';
    4 4  import Chip from '../Chip/Chip';
    5  -import Header from '../Header/Header';
     5 +import DefaultHeader from '../Header/DefaultHeader';
    6 6  import { Icon } from '../Icon/Icon';
    7 7   
    8 8  export type HeroProps = {
    skipped 4 lines
    13 13   
    14 14  export default function Hero({ suggestions, onSubmit = () => {}, loading = false }: HeroProps) {
    15 15   const [inputText, setInputText] = useState('');
     16 + 
    16 17   return (
    17 18   <section className={styles.hero}>
    18  - <Header variant='homepage' />
     19 + <div className={styles.headerWrapper}>
     20 + <DefaultHeader variant='light' />
     21 + </div>
    19 22   
    20 23   <Container>
    21 24   <div className={styles.content}>
    skipped 53 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Icon/Icon.tsx
    skipped 22 lines
    23 23  import graph from '../../../assets/icons/sprite/graph.svg';
    24 24  import ratingArrow from '../../../assets/icons/sprite/rating-arrow.svg';
    25 25  import check from '../../../assets/icons/sprite/check.svg';
     26 +import arrowBack from '../../../assets/icons/sprite/arrow-back.svg';
     27 +import filters from '../../../assets/icons/sprite/filters.svg';
    26 28   
    27 29  const icons = {
    28 30   githubLogo,
    skipped 20 lines
    49 51   graph,
    50 52   ratingArrow,
    51 53   check,
     54 + arrowBack,
     55 + filters,
    52 56  };
    53 57   
    54 58  export type IconProps = {
    skipped 35 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/KeywordsList/KeywordsList.module.scss
     1 +.chipSkeletonWrapper {
     2 + display: flex;
     3 + flex-wrap: wrap;
     4 +}
     5 + 
     6 +.chipSkeleton {
     7 + display: inline-flex;
     8 + margin-bottom: 8px;
     9 + 
     10 + &:not(:last-child) {
     11 + margin-right: 8px;
     12 + }
     13 +}
     14 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/KeywordsList/KeywordsList.tsx
     1 +import React from 'react';
     2 +import clsx from 'clsx';
     3 +import styles from '../SidebarCategory/SidebarCategory.module.scss';
     4 +import Chip from '../Chip/Chip';
     5 + 
     6 +type Props = {
     7 + keywordsList: string[];
     8 + selectedKeywords: string[];
     9 + selectHandler: (keyword: string) => void;
     10 +};
     11 + 
     12 +export default function KeywordsList({ keywordsList, selectedKeywords, selectHandler }: Props) {
     13 + return (
     14 + <>
     15 + {keywordsList.slice(0, 6).map((keyword) => (
     16 + <Chip
     17 + key={keyword}
     18 + className={clsx(
     19 + styles.sidebarChip,
     20 + selectedKeywords.includes(keyword) && styles.sidebarChipActive
     21 + )}
     22 + onClick={() => selectHandler(keyword)}
     23 + size='medium'
     24 + font='monospace'
     25 + >
     26 + {keyword}
     27 + </Chip>
     28 + ))}
     29 + </>
     30 + );
     31 +}
     32 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/KeywordsList/KeywordsListSkeleton.tsx
     1 +import React from 'react';
     2 +import styles from './KeywordsList.module.scss';
     3 +import Skeleton from '../Skeleton/Skeleton';
     4 +import { repeat } from '../../../utils/helpers';
     5 + 
     6 +export const KeywordsListSkeleton = () => (
     7 + <div className={styles.chipSkeletonWrapper}>
     8 + {repeat(
     9 + 6,
     10 + <Skeleton variant='rounded' width={69} height={36} className={styles.chipSkeleton} />
     11 + )}
     12 + </div>
     13 +);
     14 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Modal/Modal.module.scss
     1 +@import '~styles/_vars.scss';
     2 +@import '~styles/responsive.scss';
     3 + 
     4 +.modal {
     5 + position: fixed;
     6 + z-index: 3;
     7 + width: 100%;
     8 + height: 100%;
     9 + max-height: none;
     10 + overflow-y: auto;
     11 + backface-visibility: hidden;
     12 + display: flex;
     13 + flex-direction: column;
     14 + justify-content: flex-end;
     15 + pointer-events: none;
     16 +}
     17 + 
     18 +.modalBackdrop {
     19 + position: fixed;
     20 + z-index: 2;
     21 + top: 0;
     22 + left: 0;
     23 + width: 100%;
     24 + height: 100%;
     25 + background: rgba($black, 0.16);
     26 + opacity: 0;
     27 +}
     28 + 
     29 +.modalContent {
     30 + pointer-events: auto;
     31 + border-radius: 20px 20px 0 0;
     32 + background-color: #fff;
     33 + padding: 20px 24px;
     34 +}
     35 + 
     36 +.modalContentWrapper {
     37 + padding-bottom: 80px;
     38 +}
     39 + 
     40 +.modalAction {
     41 + position: fixed;
     42 + bottom: 20px;
     43 + left: 0;
     44 + width: 100%;
     45 + display: flex;
     46 + justify-content: center;
     47 +}
     48 + 
     49 +.modalEnter {
     50 + opacity: 0;
     51 +}
     52 + 
     53 +.modalEnterActive {
     54 + opacity: 1;
     55 + transition: opacity $transition-duration $transition-timing-function;
     56 +}
     57 + 
     58 +.modalEnterDone {
     59 + opacity: 1;
     60 +}
     61 + 
     62 +.modalExit {
     63 + opacity: 1;
     64 +}
     65 + 
     66 +.modalExitActive {
     67 + opacity: 0;
     68 + transition: opacity $transition-duration ease;
     69 +}
     70 + 
     71 +.modalExitDone {
     72 + opacity: 0;
     73 +}
     74 + 
     75 +.contentEnter {
     76 + transform: translateY(100%);
     77 +}
     78 + 
     79 +.contentEnterActive {
     80 + transform: translateY(0);
     81 + transition: transform $transition-duration $transition-timing-function;
     82 +}
     83 + 
     84 +.contentEnterDone {
     85 + transform: translateY(0);
     86 +}
     87 + 
     88 +.contentExit {
     89 + transform: translateY(0);
     90 +}
     91 + 
     92 +.contentExitActive {
     93 + transform: translateY(100%);
     94 + transition: transform $transition-duration ease;
     95 +}
     96 + 
     97 +.contentExitDone {
     98 + transform: translateY(100%);
     99 +}
     100 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Modal/Modal.tsx
     1 +import React from 'react';
     2 +import PortalModal from '../ReactPortal/ReactPortal';
     3 +import styles from './Modal.module.scss';
     4 +import { CSSTransition } from 'react-transition-group';
     5 +import { Button } from '../index';
     6 + 
     7 +type Props = {
     8 + show: boolean;
     9 + setShow: (value: boolean) => void;
     10 + children?: React.ReactNode;
     11 +};
     12 + 
     13 +// TODO: block <body> scroll when modal is open
     14 +export default function Modal({ children, show, setShow }: Props) {
     15 + const closeHandler = () => {
     16 + setShow(false);
     17 + };
     18 + 
     19 + return (
     20 + <PortalModal wrapperId='modal-root'>
     21 + {/* TODO: simplify transition classes */}
     22 + <CSSTransition
     23 + in={show}
     24 + timeout={600}
     25 + classNames={{
     26 + enter: styles.modalEnter,
     27 + enterActive: styles.modalEnterActive,
     28 + enterDone: styles.modalEnterDone,
     29 + exit: styles.modalExit,
     30 + exitActive: styles.modalExitActive,
     31 + exitDone: styles.modalExitDone,
     32 + }}
     33 + unmountOnExit
     34 + >
     35 + <div className={styles.modalBackdrop} onClick={closeHandler} />
     36 + </CSSTransition>
     37 + 
     38 + {/* TODO: simplify transition classes */}
     39 + <CSSTransition
     40 + in={show}
     41 + timeout={600}
     42 + classNames={{
     43 + enter: styles.contentEnter,
     44 + enterActive: styles.contentEnterActive,
     45 + enterDone: styles.contentEnterDone,
     46 + exit: styles.contentExit,
     47 + exitActive: styles.contentExitActive,
     48 + exitDone: styles.contentExitDone,
     49 + }}
     50 + unmountOnExit
     51 + >
     52 + <div className={styles.modal}>
     53 + <div className={styles.modalContent}>
     54 + {children}
     55 + 
     56 + <div className={styles.modalAction}>
     57 + <Button variant='arrow' onClick={closeHandler}>
     58 + Apply
     59 + </Button>
     60 + </div>
     61 + </div>
     62 + </div>
     63 + </CSSTransition>
     64 + </PortalModal>
     65 + );
     66 +}
     67 + 
  • ■ ■ ■ ■ ■
    packages/web/src/components/ui/PackagePreview/PackagePreview.module.scss
    1 1  @import '~styles/_vars.scss';
    2 2  @import '~styles/responsive.scss';
    3 3   
     4 +.packageSkeleton {
     5 + border-radius: 24px;
     6 + min-height: 224px;
     7 + transform: scale(1);
     8 +}
     9 + 
    4 10  .package {
     11 + width: 100%;
    5 12   padding: 24px;
    6 13   border-radius: 24px;
    7 14   background: $white;
    skipped 250 lines
    258 265   margin-top: 16px;
    259 266   display: grid;
    260 267   grid-template-columns: repeat(6, 1fr);
    261  - grid-gap: 11px;
     268 + grid-column-gap: 11px;
    262 269   overflow-x: auto;
    263 270   
    264 271   &::-webkit-scrollbar {
    skipped 6 lines
    271 278   display: flex;
    272 279   flex-wrap: nowrap;
    273 280   }
    274  -}
    275  - 
    276  -.popularityItemWrapper {
    277 281  }
    278 282   
    279 283  .popularityItem {
    skipped 21 lines
    301 305   padding-bottom: 14px;
    302 306  }
    303 307   
     308 +.popularityFillSkeleton {
     309 + padding-bottom: 0;
     310 +}
     311 + 
     312 +.popularitySkeleton {
     313 + padding-bottom: 0;
     314 + transform: none;
     315 +}
     316 + 
    304 317  .popularityFillAccent {
    305 318   background: $blue-accent;
    306 319   color: #ffffff;
    skipped 6 lines
    313 326   margin-top: 16px;
    314 327   font-family: $font-monospace;
    315 328   font-weight: 500;
     329 +}
     330 + 
     331 +.popularityVersionSkeleton {
     332 + margin: 15px auto 0;
    316 333  }
    317 334   
    318 335  .popularityVersionIcon {
    skipped 142 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/PackagePreview/PackgePreview.stories.tsx packages/web/src/components/ui/PackagePreview/PackagePreview.stories.tsx
    1 1  import React from 'react';
    2 2  import { ComponentStory, ComponentMeta } from '@storybook/react';
    3 3  import PackagePreview from './PackagePreview';
     4 +import { PackagePreviewSkeleton } from './PackagePreviewSkeleton';
    4 5   
    5 6  export default {
    6 7   title: 'Interface / PackagePreview',
    skipped 3 lines
    10 11   },
    11 12  } as ComponentMeta<typeof PackagePreview>;
    12 13   
     14 +export const ClosedLoading: ComponentStory<typeof PackagePreview> = () => (
     15 + <PackagePreviewSkeleton />
     16 +);
    13 17  export const Closed: ComponentStory<typeof PackagePreview> = () => (
    14 18   <PackagePreview name='name' version='1.0.0' />
     19 +);
     20 + 
     21 +export const OpenedLoading: ComponentStory<typeof PackagePreview> = () => (
     22 + <PackagePreview name='name' version='1.0.0' opened detailsLoading />
    15 23  );
    16 24   
    17 25  export const Opened: ComponentStory<typeof PackagePreview> = () => (
    skipped 3 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/PackagePreview/PackagePreview.tsx
    skipped 6 lines
    7 7  import SitesList, { Site } from '../SitesList/SitesList';
    8 8  import { CSSTransition } from 'react-transition-group';
    9 9  import Button from '../Button/Button';
     10 +import { formatNumber } from 'utils/helpers';
     11 +import {
     12 + LicenceSkeleton,
     13 + LinksSkeleton,
     14 + PopularitySkeleton,
     15 + PopularityVersionSkeleton,
     16 + RatingSkeleton,
     17 + ScriptSkeleton,
     18 +} from './PackagePreviewSkeleton';
    10 19   
    11 20  type Props = {
    12 21   name: string;
    13 22   version: string;
    14 23   opened?: boolean;
     24 + detailsLoading?: boolean;
    15 25  };
    16 26   
    17  -export default function PackagePreview({ name, version, opened }: Props) {
     27 +// TODO: refactor this (decomposition, props, memoization, etc)
     28 +export default function PackagePreview({ name, version, opened, detailsLoading = false }: Props) {
    18 29   const [open, setOpen] = useState<boolean>(opened ?? false);
     30 + const [packageDetailsLoading, setPackageDetailsLoading] = useState<boolean>(detailsLoading);
    19 31   
    20 32   // TODO: mock data, remove later
    21 33   const sites: Site[] = [
    skipped 41 lines
    63 75   },
    64 76   ];
    65 77   
     78 + // TODO: mock data, remove later
     79 + const modules = [
     80 + {
     81 + fill: '100%',
     82 + uses: 89912,
     83 + moduleVersion: '21.3.0',
     84 + },
     85 + {
     86 + fill: '90%',
     87 + uses: 67111,
     88 + moduleVersion: '18.2.0',
     89 + },
     90 + {
     91 + fill: '80%',
     92 + uses: 44212,
     93 + moduleVersion: '20.1.0',
     94 + },
     95 + {
     96 + fill: '70%',
     97 + uses: 41129,
     98 + moduleVersion: '18.0.0',
     99 + },
     100 + {
     101 + fill: '60%',
     102 + uses: 40465,
     103 + moduleVersion: '19.11.2',
     104 + },
     105 + {
     106 + fill: '50%',
     107 + uses: 38907,
     108 + moduleVersion: '8.1.2',
     109 + bug: true,
     110 + },
     111 + ];
     112 + 
    66 113   const toggleOpen = () => {
    67  - setOpen(!open);
     114 + if (open) {
     115 + setOpen(false);
     116 + } else {
     117 + setOpen(true);
     118 + setPackageDetailsLoading(true);
     119 + 
     120 + // FIXME: just for demo purposes
     121 + setTimeout(() => setPackageDetailsLoading(false), 60000);
     122 + }
    68 123   };
    69 124   
    70 125   return (
    skipped 57 lines
    128 183   <Icon kind='script' color='#8E8AA0' className={styles.statIcon} />
    129 184   Script
    130 185   </div>
    131  - <a href='#' className={styles.statLink} target='_blank' rel='noreferrer'>
    132  - /rsrc.php/v3id044/yu/l/en_US/yD2XaVkWQHO.js?_nc_x=Ij3Wp8lg5Kz
    133  - </a>
     186 + {packageDetailsLoading ? (
     187 + <ScriptSkeleton />
     188 + ) : (
     189 + <a href='#' className={styles.statLink} target='_blank' rel='noreferrer'>
     190 + /rsrc.php/v3id044/yu/l/en_US/yD2XaVkWQHO.js?_nc_x=Ij3Wp8lg5Kz
     191 + </a>
     192 + )}
    134 193   </div>
    135 194   
    136 195   <div className={styles.statList}>
    skipped 2 lines
    139 198   <Icon kind='license' color='#8E8AA0' className={styles.statIcon} />
    140 199   License
    141 200   </div>
    142  - <div className={styles.statTitle}>MIT license</div>
    143  - <div className={styles.statSubtitle}>freely distributable</div>
     201 + {packageDetailsLoading ? (
     202 + <LicenceSkeleton />
     203 + ) : (
     204 + <>
     205 + <div className={styles.statTitle}>MIT license</div>
     206 + <div className={styles.statSubtitle}>freely distributable</div>
     207 + </>
     208 + )}
    144 209   </div>
    145 210   
    146 211   <div className={clsx(styles.stat, styles.statListItemSmall)}>
    skipped 1 lines
    148 213   <Icon kind='rating' color='#8E8AA0' className={styles.statIcon} />
    149 214   Rating
    150 215   </div>
    151  - <div className={styles.statTitle}>
    152  - 385
    153  - {/* or: <div className={clsx(styles.statRating, styles.statRatingRed)}> */}
    154  - <div className={clsx(styles.statRating, styles.statRatingGreen)}>
    155  - <Icon
    156  - kind='ratingArrow'
    157  - width={12}
    158  - height={12}
    159  - className={styles.statRatingArrow}
    160  - />
    161  - +4
    162  - </div>
    163  - </div>
    164  - <div className={styles.statSubtitle}>out of 12 842</div>
     216 + {packageDetailsLoading ? (
     217 + <RatingSkeleton />
     218 + ) : (
     219 + // TODO: What about adding a rankingDelta prop and deciding on class name based on number's sign?
     220 + <>
     221 + <div className={styles.statTitle}>
     222 + 385
     223 + {/* or: <div className={clsx(styles.statRating, styles.statRatingRed)}> */}
     224 + <div className={clsx(styles.statRating, styles.statRatingGreen)}>
     225 + <Icon
     226 + kind='ratingArrow'
     227 + width={12}
     228 + height={12}
     229 + className={styles.statRatingArrow}
     230 + />
     231 + +4
     232 + </div>
     233 + </div>
     234 + <div className={styles.statSubtitle}>out of 12 842</div>
     235 + </>
     236 + )}
    165 237   </div>
    166 238   
    167 239   <div className={clsx(styles.stat, styles.statListItemLarge)}>
    168 240   <div className={styles.statHeader}>
    169  - <Icon kind='dependency' color='#8E8AA0' className={styles.statIcon} />4 Dependency
     241 + <Icon kind='dependency' color='#8E8AA0' className={styles.statIcon} />
     242 + {!packageDetailsLoading && 4} Dependency
    170 243   </div>
    171 244   <ChipGroup
    172 245   chips={['art', 'create-react-class', 'loose-envify', 'scheduler']}
    173 246   fontSize='small'
     247 + loading={packageDetailsLoading}
    174 248   />
    175 249   </div>
    176 250   </div>
    skipped 5 lines
    182 256   </div>
    183 257   
    184 258   <div className={styles.popularity}>
    185  - <div className={styles.popularityItemWrapper}>
    186  - <div className={styles.popularityItem}>
    187  - <div className={styles.popularityFill} style={{ height: '100%' }}>
    188  - 89 912
    189  - </div>
    190  - </div>
    191  - 
    192  - <div className={styles.popularityVersion}>21.3.0</div>
    193  - </div>
    194  - 
    195  - <div className={styles.popularityItemWrapper}>
    196  - <div className={styles.popularityItem}>
    197  - <div
    198  - className={clsx(styles.popularityFill, styles.popularityFillAccent)}
    199  - style={{ height: '90%' }}
    200  - >
    201  - 67 111
    202  - </div>
    203  - </div>
    204  - 
    205  - <div className={styles.popularityVersion}>18.2.0</div>
    206  - </div>
    207  - 
    208  - <div className={styles.popularityItemWrapper}>
    209  - <div className={styles.popularityItem}>
    210  - <div className={styles.popularityFill} style={{ height: '80%' }}>
    211  - 44 212
    212  - </div>
    213  - </div>
    214  - 
    215  - <div className={styles.popularityVersion}>20.1.0</div>
    216  - </div>
    217  - 
    218  - <div className={styles.popularityItemWrapper}>
    219  - <div className={styles.popularityItem}>
    220  - <div className={styles.popularityFill} style={{ height: '70%' }}>
    221  - 41 129
     259 + {modules.map(({ fill, uses, moduleVersion, bug }) => (
     260 + <div className={styles.popularityItemWrapper}>
     261 + <div className={styles.popularityItem}>
     262 + {packageDetailsLoading ? (
     263 + // TODO: We should be on lookout for these deoptimizations,
     264 + // this should definitely be a component / top-level const.
     265 + <div
     266 + className={clsx(styles.popularityFill, styles.popularityFillSkeleton)}
     267 + style={{ height: fill }}
     268 + >
     269 + <PopularitySkeleton />
     270 + </div>
     271 + ) : (
     272 + <div className={styles.popularityFill} style={{ height: fill }}>
     273 + {formatNumber(uses)}
     274 + </div>
     275 + )}
    222 276   </div>
    223  - </div>
    224 277   
    225  - <div className={styles.popularityVersion}>18.0.0</div>
    226  - </div>
    227  - 
    228  - <div className={styles.popularityItemWrapper}>
    229  - <div className={styles.popularityItem}>
    230  - <div className={styles.popularityFill} style={{ height: '60%' }}>
    231  - 40 465
    232  - </div>
     278 + {packageDetailsLoading ? (
     279 + <PopularityVersionSkeleton />
     280 + ) : (
     281 + <div className={styles.popularityVersion}>
     282 + {moduleVersion}
     283 + {bug && (
     284 + <Icon
     285 + kind='bugOutlined'
     286 + color='#212121'
     287 + className={styles.popularityVersionIcon}
     288 + />
     289 + )}
     290 + </div>
     291 + )}
    233 292   </div>
    234  - 
    235  - <div className={styles.popularityVersion}>19.11.2</div>
    236  - </div>
    237  - 
    238  - <div className={styles.popularityItemWrapper}>
    239  - <div className={styles.popularityItem}>
    240  - <div className={styles.popularityFill} style={{ height: '50%' }}>
    241  - 38 907
    242  - </div>
    243  - </div>
    244  - 
    245  - <div className={styles.popularityVersion}>
    246  - 8.1.2
    247  - <Icon
    248  - kind='bugOutlined'
    249  - color='#212121'
    250  - className={styles.popularityVersionIcon}
    251  - />
    252  - </div>
    253  - </div>
     293 + ))}
    254 294   </div>
    255 295   </div>
    256 296   
    skipped 2 lines
    259 299   <div className={styles.stat}>
    260 300   <div className={styles.statHeader}>Used on</div>
    261 301   
    262  - <SitesList sites={sites} className={styles.usedOnList} />
     302 + <SitesList
     303 + sites={sites}
     304 + className={styles.usedOnList}
     305 + loading={packageDetailsLoading}
     306 + />
    263 307   </div>
    264 308   
    265 309   <div className={styles.actions}>
    266 310   <div className={styles.links}>
    267  - <a href='#' className={styles.link} target='_blank' rel='noreferrer'>
    268  - <Icon kind='repository' color='#212121' className={styles.linkIcon} />
    269  - Repository
    270  - </a>
     311 + {packageDetailsLoading ? (
     312 + <LinksSkeleton />
     313 + ) : (
     314 + <>
     315 + <a href='#' className={styles.link} target='_blank' rel='noreferrer'>
     316 + <Icon kind='repository' color='#212121' className={styles.linkIcon} />
     317 + Repository
     318 + </a>
    271 319   
    272  - <a href='#' className={styles.link} target='_blank' rel='noreferrer'>
    273  - <Icon kind='link' color='#212121' className={styles.linkIcon} />
    274  - Homepage
    275  - </a>
     320 + <a href='#' className={styles.link} target='_blank' rel='noreferrer'>
     321 + <Icon kind='link' color='#212121' className={styles.linkIcon} />
     322 + Homepage
     323 + </a>
    276 324   
    277  - <a href='#' className={styles.link} target='_blank' rel='noreferrer'>
    278  - <Icon
    279  - kind='npm'
    280  - width={32}
    281  - height={32}
    282  - color='#212121'
    283  - className={styles.linkIcon}
    284  - />
    285  - </a>
     325 + <a href='#' className={styles.link} target='_blank' rel='noreferrer'>
     326 + <Icon
     327 + kind='npm'
     328 + width={32}
     329 + height={32}
     330 + color='#212121'
     331 + className={styles.linkIcon}
     332 + />
     333 + </a>
     334 + </>
     335 + )}
    286 336   </div>
    287 337   
    288 338   <Button variant='arrow'>Details</Button>
    skipped 36 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/PackagePreview/PackagePreviewSkeleton.tsx
     1 +import React from 'react';
     2 +import styles from './PackagePreview.module.scss';
     3 +import Skeleton from '../Skeleton/Skeleton';
     4 +import { repeat } from '../../../utils/helpers';
     5 + 
     6 +export const PackagePreviewSkeleton = () => <Skeleton className={styles.packageSkeleton} />;
     7 + 
     8 +export const ScriptSkeleton = () => <Skeleton width={354} />;
     9 + 
     10 +export const LicenceSkeleton = () => (
     11 + <>
     12 + <Skeleton width={103} height={26} variant='rectangular' className={styles.statHeader} />
     13 + <Skeleton width={135} />
     14 + </>
     15 +);
     16 + 
     17 +export const RatingSkeleton = () => (
     18 + <>
     19 + <Skeleton width={62} height={26} variant='rectangular' className={styles.statHeader} />
     20 + <Skeleton width={102} />
     21 + </>
     22 +);
     23 + 
     24 +export const PopularitySkeleton = () => (
     25 + <Skeleton width='100%' height='100%' className={styles.popularitySkeleton} />
     26 +);
     27 + 
     28 +export const PopularityVersionSkeleton = () => (
     29 + <Skeleton width={64} className={styles.popularityVersionSkeleton} />
     30 +);
     31 + 
     32 +export const LinksSkeleton = () => (
     33 + <>
     34 + {repeat(
     35 + 3,
     36 + <div className={styles.link}>
     37 + <Skeleton width={18} height={18} className={styles.linkIcon} />
     38 + <Skeleton width={80} />
     39 + </div>
     40 + )}
     41 + </>
     42 +);
     43 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/PeopleList/PeopleList.module.scss
     1 +.authors {
     2 + display: flex;
     3 + flex-wrap: wrap;
     4 +}
     5 + 
     6 +.personSkeleton {
     7 + display: inline-flex;
     8 + align-items: center;
     9 + margin-bottom: 10px;
     10 + 
     11 + &:not(:last-child) {
     12 + margin-right: 16px;
     13 + }
     14 +}
     15 + 
     16 +.personSkeletonImage {
     17 + flex-shrink: 0;
     18 + margin-right: 6px;
     19 +}
     20 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/PeopleList/PeopleList.tsx
     1 +import React from 'react';
     2 +import styles from './PeopleList.module.scss';
     3 +import Person from '../Person/Person';
     4 + 
     5 +type Props = {
     6 + keywordsList: string[];
     7 + selectedKeywords: string[];
     8 + selectHandler: (keyword: string) => void;
     9 +};
     10 + 
     11 +export default function PeopleList({ keywordsList, selectedKeywords, selectHandler }: Props) {
     12 + return (
     13 + <div className={styles.authors}>
     14 + {keywordsList.slice(0, 4).map((person) => (
     15 + <Person
     16 + key={person}
     17 + name={person}
     18 + image='https://via.placeholder.com/36'
     19 + checked={selectedKeywords.includes(person)}
     20 + onClick={() => selectHandler(person)}
     21 + />
     22 + ))}
     23 + </div>
     24 + );
     25 +}
     26 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/PeopleList/PeopleListSkeleton.tsx
     1 +import React from 'react';
     2 +import styles from './PeopleList.module.scss';
     3 +import Skeleton from '../Skeleton/Skeleton';
     4 +import { repeat } from '../../../utils/helpers';
     5 + 
     6 +export const PeopleListSkeleton = () => (
     7 + <>
     8 + {repeat(
     9 + 4,
     10 + <div className={styles.personSkeleton}>
     11 + <Skeleton
     12 + width={36}
     13 + height={36}
     14 + variant='circular'
     15 + className={styles.personSkeletonImage}
     16 + />
     17 + <Skeleton width={56} />
     18 + </div>
     19 + )}
     20 + </>
     21 +);
     22 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/ProblemsList/ProblemsList.module.scss
     1 +.checkboxGroup {
     2 + display: grid;
     3 + grid-gap: 18px;
     4 +}
     5 + 
     6 +.checkboxSkeleton {
     7 + display: flex;
     8 + align-items: center;
     9 +}
     10 + 
     11 +.checkboxSkeletonCheck {
     12 + flex-shrink: 0;
     13 + margin-right: 14px;
     14 +}
     15 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/ProblemsList/ProblemsList.tsx
     1 +import React from 'react';
     2 +import styles from './ProblemsList.module.scss';
     3 +import Checkbox from '../Checkbox/Checkbox';
     4 + 
     5 +type Props = {
     6 + keywordsList: string[];
     7 + selectedKeywords: string[];
     8 + selectHandler: (keyword: string) => void;
     9 +};
     10 + 
     11 +export default function ProblemsList({ keywordsList, selectedKeywords, selectHandler }: Props) {
     12 + return (
     13 + <div className={styles.checkboxGroup}>
     14 + {keywordsList?.map((name) => (
     15 + <Checkbox
     16 + key={name}
     17 + label={name}
     18 + checked={selectedKeywords.includes(name)}
     19 + onChange={() => selectHandler(name)}
     20 + />
     21 + ))}
     22 + </div>
     23 + );
     24 +}
     25 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/ProblemsList/ProblemsListSkeleton.tsx
     1 +import React from 'react';
     2 +import styles from './ProblemsList.module.scss';
     3 +import Skeleton from '../Skeleton/Skeleton';
     4 +import { repeat } from '../../../utils/helpers';
     5 + 
     6 +export const ProblemsListSkeleton = () => (
     7 + <div className={styles.checkboxGroup}>
     8 + {repeat(
     9 + 3,
     10 + <div className={styles.checkboxSkeleton}>
     11 + <Skeleton width={20} height={20} className={styles.checkboxSkeletonCheck} />
     12 + <Skeleton width={100} />
     13 + </div>
     14 + )}
     15 + </div>
     16 +);
     17 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/ReactPortal/ReactPortal.tsx
     1 +import React, { useState, useLayoutEffect } from 'react';
     2 +import { createPortal } from 'react-dom';
     3 + 
     4 +interface Props {
     5 + children: React.ReactNode;
     6 + wrapperId: string;
     7 +}
     8 + 
     9 +export default function PortalModal({ children, wrapperId }: Props) {
     10 + const [portalElement, setPortalElement] = useState<null | HTMLElement>(null);
     11 + 
     12 + const createWrapperAndAppendToBody = (elementId: string) => {
     13 + const element = document.createElement('div');
     14 + element.setAttribute('id', elementId);
     15 + document.body.appendChild(element);
     16 + return element;
     17 + };
     18 + 
     19 + useLayoutEffect(() => {
     20 + let element = document.getElementById(wrapperId) as HTMLElement;
     21 + let portalCreated = false;
     22 + // if element is not found with wrapperId or wrapperId is not provided,
     23 + // create and append to body
     24 + if (!element) {
     25 + element = createWrapperAndAppendToBody(wrapperId);
     26 + portalCreated = true;
     27 + }
     28 + 
     29 + setPortalElement(element);
     30 + 
     31 + // cleaning up the portal element
     32 + return () => {
     33 + // delete the programmatically created element
     34 + if (portalCreated && element.parentNode) {
     35 + element.parentNode.removeChild(element);
     36 + }
     37 + };
     38 + }, [wrapperId]);
     39 + 
     40 + // portalElement state will be null on the very first render.
     41 + if (!portalElement) return null;
     42 + 
     43 + return createPortal(children, portalElement);
     44 +}
     45 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SearchBar/SearchBar.module.scss
    skipped 56 lines
    57 57   }
    58 58  }
    59 59   
     60 +.large {
     61 + .input {
     62 + font-size: 26px;
     63 + line-height: 40px;
     64 + border-radius: 70px;
     65 + padding: 30px 100px 30px 44px;
     66 + 
     67 + @include mobile-and-tablet {
     68 + font-size: 20px;
     69 + line-height: 22px;
     70 + padding: 21px 28px;
     71 + }
     72 + }
     73 + 
     74 + .submit,
     75 + .clear {
     76 + top: 34px;
     77 + right: 40px;
     78 + width: 32px;
     79 + height: 32px;
     80 + 
     81 + @include mobile-and-tablet {
     82 + width: 24px;
     83 + height: 24px;
     84 + top: 20px;
     85 + right: 22px;
     86 + 
     87 + svg {
     88 + width: inherit;
     89 + height: inherit;
     90 + }
     91 + }
     92 + }
     93 +}
     94 + 
  • ■ ■ ■ ■ ■
    packages/web/src/components/ui/SearchBar/SearchBar.tsx
    1 1  import React, { useState } from 'react';
    2 2  import styles from './SearchBar.module.scss';
    3 3  import { Icon } from '../Icon/Icon';
     4 +import clsx from 'clsx';
    4 5   
    5 6  type Props = {
    6 7   value?: string;
     8 + size?: 'default' | 'large';
     9 + placeholder?: string;
    7 10  };
    8 11   
    9  -export default function SearchBar({ value }: Props) {
    10  - // FIXME: not sure that this is legal
    11  - const [inputText, setInputText] = useState<string | undefined>(value);
     12 +// TODO: connect search to redux and get/update with it
     13 +export default function SearchBar({
     14 + value = 'pinterest.com',
     15 + size = 'default',
     16 + placeholder = 'Start analyzing...',
     17 +}: Props) {
     18 + const [inputText, setInputText] = useState<string>(value);
    12 19   
    13 20   const changeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    14 21   setInputText(e.target.value);
    skipped 4 lines
    19 26   };
    20 27   
    21 28   return (
    22  - <div className={styles.searchBar}>
     29 + <div className={clsx(styles.searchBar, styles[size])}>
    23 30   <input
    24 31   type='text'
    25 32   className={styles.input}
    26 33   value={inputText}
    27 34   onChange={changeHandler}
    28  - placeholder='Start analyzing...'
     35 + placeholder={placeholder}
    29 36   />
    30 37   {inputText ? (
    31 38   <button type='button' className={styles.clear} onClick={clearHandler}>
    32  - <Icon kind='cross' width={24} height={24} color='#8E8AA0' />
     39 + {size === 'large' ? (
     40 + <Icon kind='cross' width={32} height={32} color='#8E8AA0' />
     41 + ) : (
     42 + <Icon kind='cross' width={24} height={24} color='#8E8AA0' />
     43 + )}
    33 44   </button>
    34 45   ) : (
    35 46   <button type='submit' className={styles.submit}>
    36  - <Icon kind='arrow' width={9} height={18} stroke='#8E8AA0' />
     47 + {size === 'large' ? (
     48 + <Icon kind='arrow' width={17} height={30} stroke='#8E8AA0' />
     49 + ) : (
     50 + <Icon kind='arrow' width={9} height={18} stroke='#8E8AA0' />
     51 + )}
    37 52   </button>
    38 53   )}
    39 54   </div>
    skipped 3 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SearchResultsSidebar/SearchResultsSidebar.module.scss
     1 +@import '~styles/_vars.scss';
     2 +@import '~styles/responsive.scss';
     3 + 
     4 +.sidebar {
     5 + display: grid;
     6 + grid-gap: 24px;
     7 +}
     8 + 
     9 +.sidebarItem {
     10 + &:not(:first-child) {
     11 + padding-top: 24px;
     12 + border-top: 1px solid $gray-border;
     13 + }
     14 +}
     15 + 
     16 +.sidebarItemFilter {
     17 + @include mobile-and-tablet {
     18 + display: none;
     19 + }
     20 +}
     21 + 
     22 +.sidebarItemMobileFilter {
     23 + display: none;
     24 + 
     25 + @include mobile-and-tablet {
     26 + display: block;
     27 + }
     28 +}
     29 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SearchResultsSidebar/SearchResultsSidebar.tsx
     1 +import React, { useState } from 'react';
     2 +import styles from './SearchResultsSidebar.module.scss';
     3 +import modalStyles from '../Modal/Modal.module.scss';
     4 +import clsx from 'clsx';
     5 +import Modal from '../Modal/Modal';
     6 +import SidebarMeta from '../SidebarMeta/SidebarMeta';
     7 +import SidebarMobileFilter from '../SidebarMobileFilter/SidebarMobileFilter';
     8 +import SidebarCategory from '../SidebarCategory/SidebarCategory';
     9 +import SidebarCategoryHeaderSkeleton from '../SidebarCategory/SidebarCategoryHeaderSkeleton';
     10 +import SidebarCategoryWithSearch from '../SidebarCategory/SidebarCategoryWithSearch';
     11 +import KeywordsList from '../KeywordsList/KeywordsList';
     12 +import { KeywordsListSkeleton } from '../KeywordsList/KeywordsListSkeleton';
     13 +import ProblemsList from '../ProblemsList/ProblemsList';
     14 +import { ProblemsListSkeleton } from '../ProblemsList/ProblemsListSkeleton';
     15 +import PeopleList from '../PeopleList/PeopleList';
     16 +import { PeopleListSkeleton } from '../PeopleList/PeopleListSkeleton';
     17 +import { Button } from '../index';
     18 +import { IconProps } from '../Icon/Icon';
     19 +import { SidebarMetaSkeleton } from '../SidebarMeta/SidebarMetaSkeleton';
     20 +import { SidebarMobileFilterSkeleton } from '../SidebarMobileFilter/SidebarMobileFilterSkeleton';
     21 + 
     22 +type MetaItemProps = {
     23 + icon: React.ReactElement<IconProps>;
     24 + text: string;
     25 +};
     26 + 
     27 +type Props = {
     28 + metaItems: MetaItemProps[];
     29 + keyWords: string[];
     30 + vulnerabilities: string[];
     31 + authors: string[];
     32 + loading: boolean;
     33 +};
     34 + 
     35 +export default function SearchResultsSidebar({
     36 + metaItems,
     37 + keyWords,
     38 + vulnerabilities,
     39 + authors,
     40 + loading,
     41 +}: Props) {
     42 + const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
     43 + const [selectedProblems, setSelectedProblems] = useState<string[]>([]);
     44 + const [selectedAuthors, setSelectedAuthors] = useState<string[]>([]);
     45 + 
     46 + const [modalKeywordsOpen, setModalKeywordsOpen] = useState<boolean>(false);
     47 + const [modalProblemsOpen, setModalProblemsOpen] = useState<boolean>(false);
     48 + const [modalAuthorsOpen, setModalAuthorsOpen] = useState<boolean>(false);
     49 + 
     50 + const handleFiltersChange = (
     51 + name: string,
     52 + state: string[],
     53 + setState: React.SetStateAction<any>
     54 + ) => {
     55 + const temp = [...state];
     56 + 
     57 + if (temp.includes(name)) {
     58 + const filtered = temp.filter((item) => item !== name);
     59 + setState(filtered);
     60 + } else {
     61 + temp.push(name);
     62 + setState(temp);
     63 + }
     64 + };
     65 + 
     66 + const handleKeywordsChange = (name: string) => {
     67 + handleFiltersChange(name, selectedKeywords, setSelectedKeywords);
     68 + };
     69 + 
     70 + const handleProblemsChange = (name: string) => {
     71 + handleFiltersChange(name, selectedProblems, setSelectedProblems);
     72 + };
     73 + 
     74 + const handleAuthorsChange = (name: string) => {
     75 + handleFiltersChange(name, selectedAuthors, setSelectedAuthors);
     76 + };
     77 + 
     78 + const resetFilters = () => {
     79 + setSelectedKeywords([]);
     80 + setSelectedProblems([]);
     81 + setSelectedAuthors([]);
     82 + };
     83 + 
     84 + const filterTriggers = [
     85 + { name: 'keywords', state: selectedKeywords, openModal: () => setModalKeywordsOpen(true) },
     86 + { name: 'problems', state: selectedProblems, openModal: () => setModalProblemsOpen(true) },
     87 + { name: 'authors', state: selectedAuthors, openModal: () => setModalAuthorsOpen(true) },
     88 + ];
     89 + 
     90 + const isChanged =
     91 + selectedKeywords.length > 0 || selectedProblems.length > 0 || selectedAuthors.length > 0;
     92 + 
     93 + return (
     94 + <>
     95 + <Modal show={modalKeywordsOpen} setShow={setModalKeywordsOpen}>
     96 + <SidebarCategoryWithSearch
     97 + categoryName='Keywords'
     98 + keywordsList={keyWords}
     99 + selectedKeywords={selectedKeywords}
     100 + selectHandler={handleKeywordsChange}
     101 + returnButton={() => setModalKeywordsOpen(false)}
     102 + resetGroup={() => setSelectedKeywords([])}
     103 + searchOpen
     104 + />
     105 + </Modal>
     106 + 
     107 + <Modal show={modalProblemsOpen} setShow={setModalProblemsOpen}>
     108 + <div className={modalStyles.modalContentWrapper}>
     109 + <SidebarCategory
     110 + categoryName='Problems'
     111 + selectedKeywords={selectedProblems}
     112 + returnButton={() => setModalProblemsOpen(false)}
     113 + resetGroup={() => setSelectedProblems([])}
     114 + >
     115 + <ProblemsList
     116 + keywordsList={vulnerabilities}
     117 + selectedKeywords={selectedProblems}
     118 + selectHandler={handleProblemsChange}
     119 + />
     120 + </SidebarCategory>
     121 + </div>
     122 + </Modal>
     123 + 
     124 + <Modal show={modalAuthorsOpen} setShow={setModalAuthorsOpen}>
     125 + <SidebarCategoryWithSearch
     126 + categoryName='Authors'
     127 + keywordsList={authors}
     128 + selectedKeywords={selectedAuthors}
     129 + selectHandler={handleAuthorsChange}
     130 + returnButton={() => setModalAuthorsOpen(false)}
     131 + resetGroup={() => setSelectedAuthors([])}
     132 + itemsWithImage
     133 + searchOpen
     134 + />
     135 + </Modal>
     136 + 
     137 + <aside className={styles.sidebar}>
     138 + <div className={styles.sidebarItem}>
     139 + {loading ? <SidebarMetaSkeleton /> : <SidebarMeta meta={metaItems} />}
     140 + </div>
     141 + 
     142 + <div className={clsx(styles.sidebarItem, styles.sidebarItemMobileFilter)}>
     143 + {loading ? (
     144 + <SidebarMobileFilterSkeleton />
     145 + ) : (
     146 + <SidebarMobileFilter
     147 + isChanged={isChanged}
     148 + resetFilters={resetFilters}
     149 + filterTriggers={filterTriggers}
     150 + />
     151 + )}
     152 + </div>
     153 + 
     154 + <div className={clsx(styles.sidebarItem, styles.sidebarItemFilter)}>
     155 + {loading ? (
     156 + <>
     157 + <SidebarCategoryHeaderSkeleton search />
     158 + <KeywordsListSkeleton />
     159 + </>
     160 + ) : (
     161 + <SidebarCategoryWithSearch
     162 + categoryName='Keywords'
     163 + keywordsList={keyWords}
     164 + selectedKeywords={selectedKeywords}
     165 + selectHandler={handleKeywordsChange}
     166 + >
     167 + <KeywordsList
     168 + keywordsList={keyWords}
     169 + selectedKeywords={selectedKeywords}
     170 + selectHandler={handleKeywordsChange}
     171 + />
     172 + </SidebarCategoryWithSearch>
     173 + )}
     174 + </div>
     175 + 
     176 + <div className={clsx(styles.sidebarItem, styles.sidebarItemFilter)}>
     177 + {loading ? (
     178 + <>
     179 + <SidebarCategoryHeaderSkeleton />
     180 + <ProblemsListSkeleton />
     181 + </>
     182 + ) : (
     183 + <SidebarCategory categoryName='Problems' selectedKeywords={selectedProblems}>
     184 + <ProblemsList
     185 + keywordsList={vulnerabilities}
     186 + selectedKeywords={selectedProblems}
     187 + selectHandler={handleProblemsChange}
     188 + />
     189 + </SidebarCategory>
     190 + )}
     191 + </div>
     192 + 
     193 + <div className={clsx(styles.sidebarItem, styles.sidebarItemFilter)}>
     194 + {loading ? (
     195 + <>
     196 + <SidebarCategoryHeaderSkeleton search />
     197 + <PeopleListSkeleton />
     198 + </>
     199 + ) : (
     200 + <SidebarCategoryWithSearch
     201 + categoryName='Authors'
     202 + keywordsList={authors}
     203 + selectedKeywords={selectedAuthors}
     204 + selectHandler={handleAuthorsChange}
     205 + itemsWithImage
     206 + >
     207 + <PeopleList
     208 + keywordsList={authors}
     209 + selectedKeywords={selectedAuthors}
     210 + selectHandler={handleAuthorsChange}
     211 + />
     212 + </SidebarCategoryWithSearch>
     213 + )}
     214 + </div>
     215 + 
     216 + {isChanged && (
     217 + <div className={clsx(styles.sidebarItem, styles.sidebarItemFilter)}>
     218 + <Button variant='secondary' size='small' onClick={resetFilters}>
     219 + Reset filters
     220 + </Button>
     221 + </div>
     222 + )}
     223 + </aside>
     224 + </>
     225 + );
     226 +}
     227 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SearchedResource/SearchedResource.module.scss
    skipped 1 lines
    2 2  @import '~styles/responsive.scss';
    3 3   
    4 4  .searchedResource {
    5  - grid-column: 2 / -1;
    6 5   display: flex;
    7 6   align-items: flex-start;
    8 7   margin-bottom: 24px;
    skipped 4 lines
    13 12   
    14 13   @include mobile-and-tablet {
    15 14   flex-direction: column;
    16  - grid-column: 1;
    17 15   margin-bottom: 0;
    18 16   }
    19 17  }
    skipped 35 lines
    55 53   
    56 54  .searchedResourceHighlight {
    57 55   color: $gray-text;
     56 +}
     57 + 
     58 +.searchedResourceHighlightSkeleton {
     59 + display: inline-block;
     60 + vertical-align: middle;
    58 61  }
    59 62   
    60 63  .searchedResourceSubtitle {
    skipped 3 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SearchedResource/SearchedResourceSkeleton.tsx
     1 +import React from 'react';
     2 +import styles from './SearchedResource.module.scss';
     3 +import Skeleton from '../Skeleton/Skeleton';
     4 + 
     5 +type Props = {
     6 + image: string;
     7 + name: string;
     8 +};
     9 + 
     10 +export const SearchedResourceSkeleton = ({ image, name }: Props) => (
     11 + <div className={styles.searchedResource}>
     12 + <div className={styles.searchedResourceImageWrapper}>
     13 + <img className={styles.searchedResourceImage} src={image} alt='' />
     14 + </div>
     15 + <div className={styles.searchedResourceContent}>
     16 + <h3 className={styles.searchedResourceTitle}>
     17 + {name} <Skeleton width={175} className={styles.searchedResourceHighlightSkeleton} />
     18 + </h3>
     19 + <div className={styles.searchedResourceSubtitle}>
     20 + <Skeleton width={213} />
     21 + </div>
     22 + </div>
     23 + </div>
     24 +);
     25 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarCategory/SidebarCategory.module.scss
    1 1  @import '~styles/_vars.scss';
     2 +@import '~styles/responsive.scss';
    2 3   
    3  -.category {
     4 +.arrowBack {
     5 + width: 24px;
     6 + height: 24px;
     7 + border: none;
     8 + background: none;
     9 + display: inline-flex;
     10 + vertical-align: middle;
     11 + align-items: center;
     12 + justify-content: center;
     13 + margin-right: 8px;
     14 + padding: 0;
    4 15  }
    5 16   
    6 17  .sidebarItemTop {
    skipped 7 lines
    14 25   font-weight: 500;
    15 26   font-size: 19px;
    16 27   line-height: 26px;
     28 + display: flex;
     29 + align-items: center;
     30 + width: 100%;
    17 31  }
    18 32   
    19 33  .sidebarItemCounter {
    skipped 5 lines
    25 39   display: flex;
    26 40  }
    27 41   
     42 +.localReset {
     43 + flex-grow: 1;
     44 + display: flex;
     45 + justify-content: flex-end;
     46 + opacity: 0;
     47 + visibility: hidden;
     48 +}
     49 + 
     50 +.localResetVisible {
     51 + opacity: 1;
     52 + visibility: visible;
     53 +}
     54 + 
    28 55  .selectedCounter {
    29 56   margin-left: 8px;
    30 57  }
    skipped 8 lines
    39 66   
    40 67   &:hover {
    41 68   opacity: 0.6;
     69 + }
     70 + 
     71 + @include mobile-and-tablet {
     72 + display: none;
    42 73   }
    43 74  }
    44 75   
    skipped 14 lines
    59 90   }
    60 91  }
    61 92   
    62  -.authors {
    63  - display: flex;
    64  - flex-wrap: wrap;
    65  -}
    66  - 
    67  -.checkboxGroup {
    68  - display: grid;
    69  - grid-gap: 18px;
    70  -}
    71  - 
  • ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarCategory/SidebarCategory.tsx
    1  -import React, { useState, useEffect } from 'react';
    2  -import styles from './SidebarCategory.module.scss';
    3  -import { Icon } from '../Icon/Icon';
    4  -import clsx from 'clsx';
    5  -import Badge from '../Badge/Badge';
    6  -import Chip from '../Chip/Chip';
    7  -import Person from '../Person/Person';
    8  -import Checkbox from '../Checkbox/Checkbox';
    9  -import SidebarCategorySearch from '../SidebarCategorySearch/SidebarCategorySearch';
    10  - 
    11  -type GroupItem = {
    12  - group: string;
    13  - children: string[];
    14  -};
    15  - 
    16  -type Group = {
    17  - [key: string]: GroupItem;
    18  -};
     1 +import React from 'react';
     2 +import SidebarCategoryHeader from './SidebarCategoryHeader';
    19 3   
    20 4  type Props = {
    21  - keywordsList: string[];
     5 + categoryName: string;
    22 6   selectedKeywords: string[];
    23  - selectHandler: (name: string) => void;
    24  - renderComponent: 'chip' | 'checkbox' | 'person';
    25  - searchable?: boolean;
     7 + keywordsList?: string[];
     8 + returnButton?: () => void;
     9 + resetGroup?: () => void;
     10 + children?: React.ReactNode;
    26 11  };
    27 12   
    28 13  export default function SidebarCategory({
    29  - keywordsList,
     14 + categoryName,
    30 15   selectedKeywords,
    31  - selectHandler,
    32  - renderComponent,
    33  - searchable,
     16 + returnButton,
     17 + resetGroup,
     18 + children,
    34 19  }: Props) {
    35  - const [open, setOpen] = useState<boolean>(false);
    36  - const [searchValue, setSearchValue] = useState<string>('');
    37  - const [list, setList] = useState<GroupItem[]>([]);
    38  - 
    39  - const sortAndGroupList = (unorderedList: string[], value: string): GroupItem[] => {
    40  - const filteredList = unorderedList.filter((item) => item.includes(value));
    41  - const sortedList = filteredList.sort((a: string, b: string) => a.localeCompare(b));
    42  - 
    43  - const groups = sortedList.reduce((r: Group, e) => {
    44  - const group = e.includes('#') ? e[1] : e[0];
    45  - if (!r[group]) r[group] = { group, children: [e] };
    46  - else r[group].children.push(e);
    47  - return r;
    48  - }, {});
    49  - 
    50  - return Object.values(groups);
    51  - };
    52  - 
    53  - const toggleOpen = () => {
    54  - setOpen(!open);
    55  - };
    56  - 
    57  - const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    58  - setSearchValue(e.target.value);
    59  - };
    60  - 
    61  - const clearInput = () => {
    62  - setSearchValue('');
    63  - };
    64  - 
    65  - let combinedList, chips, checkboxes, people;
    66  - if (searchable) {
    67  - // FIXME: not sure that this is optimal UX, because when we're selecting item from featured list,
    68  - // it jumps to first half of the list with other previously selected items, maybe it's fine though
    69  - combinedList = [...new Set([...selectedKeywords, ...keywordsList])];
    70  - 
    71  - useEffect(() => {
    72  - const filteredList = sortAndGroupList(keywordsList, searchValue);
    73  - setList(filteredList);
    74  - }, [searchValue]);
    75  - 
    76  - // Show only first 6 element from list
    77  - chips = combinedList.slice(0, 6).map((chip) => (
    78  - <Chip
    79  - key={chip}
    80  - className={clsx(
    81  - styles.sidebarChip,
    82  - selectedKeywords.includes(chip) && styles.sidebarChipActive
    83  - )}
    84  - onClick={() => selectHandler(chip)}
    85  - size='medium'
    86  - font='monospace'
    87  - >
    88  - {chip}
    89  - </Chip>
    90  - ));
    91  - 
    92  - // Show only first 4 element from list
    93  - people = (
    94  - <div className={styles.authors}>
    95  - {combinedList.slice(0, 4).map((person) => (
    96  - <Person
    97  - key={person}
    98  - name={person}
    99  - image='https://via.placeholder.com/36'
    100  - checked={selectedKeywords.includes(person)}
    101  - onClick={() => selectHandler(person)}
    102  - />
    103  - ))}
    104  - </div>
    105  - );
    106  - } else {
    107  - checkboxes = (
    108  - <div className={styles.checkboxGroup}>
    109  - {keywordsList?.map((name) => (
    110  - <Checkbox
    111  - key={name}
    112  - label={name}
    113  - checked={selectedKeywords.includes(name)}
    114  - onChange={() => selectHandler(name)}
    115  - />
    116  - ))}
    117  - </div>
    118  - );
    119  - }
    120  - 
    121  - let renderedList;
    122  - 
    123  - switch (renderComponent) {
    124  - case 'chip':
    125  - renderedList = chips;
    126  - break;
    127  - case 'checkbox':
    128  - renderedList = checkboxes;
    129  - break;
    130  - case 'person':
    131  - renderedList = people;
    132  - break;
    133  - }
    134  - 
    135 20   return (
    136  - <div className={styles.category}>
    137  - <div className={styles.sidebarItemTop}>
    138  - <div className={styles.sidebarItemTitle}>
    139  - Keywords
    140  - {selectedKeywords.length > 0 && (
    141  - <span className={styles.selectedCounter}>
    142  - <Badge content={selectedKeywords.length} />
    143  - </span>
    144  - )}
    145  - </div>
    146  - {!open && searchable && (
    147  - <div className={styles.sidebarItemAction} onClick={toggleOpen}>
    148  - <Icon kind='search' width={24} height={24} />
    149  - </div>
    150  - )}
    151  - </div>
     21 + <>
     22 + <SidebarCategoryHeader
     23 + returnButton={returnButton}
     24 + categoryName={categoryName}
     25 + selectedKeywords={selectedKeywords}
     26 + resetGroup={resetGroup}
     27 + />
    152 28   
    153  - {open && searchable ? (
    154  - <SidebarCategorySearch
    155  - searchValue={searchValue}
    156  - selectHandler={selectHandler}
    157  - searchChangeHandler={searchChangeHandler}
    158  - renderComponent={renderComponent}
    159  - clearInput={clearInput}
    160  - selectedItems={selectedKeywords}
    161  - alphabeticalGroups={list}
    162  - />
    163  - ) : (
    164  - renderedList
    165  - )}
    166  - 
    167  - {searchable && (
    168  - <span role='button' className={styles.toggleView} onClick={toggleOpen}>
    169  - {open ? 'Hide' : 'View All'}
    170  - </span>
    171  - )}
    172  - </div>
     29 + {children}
     30 + </>
    173 31   );
    174 32  }
    175 33   
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarCategory/SidebarCategoryHeader.tsx
     1 +import React from 'react';
     2 +import styles from './SidebarCategory.module.scss';
     3 +import clsx from 'clsx';
     4 +import { Icon } from '../Icon/Icon';
     5 +import Badge from '../Badge/Badge';
     6 +import Button from '../Button/Button';
     7 + 
     8 +type Props = {
     9 + returnButton?: () => void;
     10 + categoryName: string;
     11 + selectedKeywords: string[];
     12 + resetGroup?: () => void;
     13 + children?: React.ReactNode;
     14 +};
     15 + 
     16 +export default function SidebarCategoryHeader({
     17 + returnButton,
     18 + categoryName,
     19 + selectedKeywords,
     20 + resetGroup,
     21 + children,
     22 +}: Props) {
     23 + return (
     24 + <div className={styles.sidebarItemTop}>
     25 + <div className={styles.sidebarItemTitle}>
     26 + {returnButton && (
     27 + <button className={styles.arrowBack} onClick={returnButton}>
     28 + <Icon kind='arrowBack' width={18} height={16} color='#212121' />
     29 + </button>
     30 + )}
     31 + {categoryName}
     32 + {selectedKeywords.length > 0 && (
     33 + <span className={styles.selectedCounter}>
     34 + <Badge content={selectedKeywords.length} />
     35 + </span>
     36 + )}
     37 + {resetGroup && (
     38 + <div
     39 + className={clsx(
     40 + styles.localReset,
     41 + selectedKeywords.length > 0 && styles.localResetVisible
     42 + )}
     43 + >
     44 + <Button variant='secondary' size='small' onClick={resetGroup}>
     45 + Reset
     46 + </Button>
     47 + </div>
     48 + )}
     49 + </div>
     50 + 
     51 + {children}
     52 + </div>
     53 + );
     54 +}
     55 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarCategory/SidebarCategoryHeaderSkeleton.tsx
     1 +import React from 'react';
     2 +import styles from './SidebarCategory.module.scss';
     3 +import Skeleton from '../Skeleton/Skeleton';
     4 + 
     5 +type Props = {
     6 + search?: boolean;
     7 +};
     8 + 
     9 +export default function SidebarCategoryHeaderSkeleton({ search = false }: Props) {
     10 + return (
     11 + <div className={styles.sidebarItemTop}>
     12 + <div className={styles.sidebarItemTitle}>
     13 + <Skeleton width={100} />
     14 + </div>
     15 + 
     16 + {search && <Skeleton width={24} height={24} className={styles.sidebarItemAction} />}
     17 + </div>
     18 + );
     19 +}
     20 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarCategory/SidebarCategoryWithSearch.tsx
     1 +import React, { useState, useEffect } from 'react';
     2 +import styles from './SidebarCategory.module.scss';
     3 +import SidebarCategorySearch from '../SidebarCategorySearch/SidebarCategorySearch';
     4 +import SidebarCategoryHeader from './SidebarCategoryHeader';
     5 + 
     6 +type GroupItem = {
     7 + group: string;
     8 + children: string[];
     9 +};
     10 + 
     11 +type Group = {
     12 + [key: string]: GroupItem;
     13 +};
     14 + 
     15 +type Props = {
     16 + categoryName: string;
     17 + selectedKeywords: string[];
     18 + keywordsList: string[];
     19 + selectHandler: (name: string) => void;
     20 + itemsWithImage?: boolean;
     21 + searchOpen?: boolean;
     22 + returnButton?: () => void;
     23 + resetGroup?: () => void;
     24 + children?: React.ReactNode;
     25 +};
     26 + 
     27 +export default function SidebarCategoryWithSearch({
     28 + categoryName,
     29 + selectedKeywords,
     30 + keywordsList,
     31 + selectHandler,
     32 + itemsWithImage,
     33 + searchOpen = false,
     34 + returnButton,
     35 + resetGroup,
     36 + children,
     37 +}: Props) {
     38 + const [open, setOpen] = useState<boolean>(searchOpen);
     39 + const [searchValue, setSearchValue] = useState<string>('');
     40 + const [list, setList] = useState<GroupItem[]>([]);
     41 + 
     42 + const sortAndGroupList = (unorderedList: string[], value: string): GroupItem[] => {
     43 + const filteredList = unorderedList.filter((item) => item.includes(value));
     44 + const sortedList = filteredList.sort((a: string, b: string) => a.localeCompare(b));
     45 + 
     46 + const groups = sortedList.reduce((r: Group, e) => {
     47 + const group = e.includes('#') ? e[1] : e[0];
     48 + if (!r[group]) r[group] = { group, children: [e] };
     49 + else r[group].children.push(e);
     50 + return r;
     51 + }, {});
     52 + 
     53 + return Object.values(groups);
     54 + };
     55 + 
     56 + const toggleOpen = () => {
     57 + setOpen(!open);
     58 + };
     59 + 
     60 + const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
     61 + setSearchValue(e.target.value);
     62 + };
     63 + 
     64 + const clearInput = () => {
     65 + setSearchValue('');
     66 + };
     67 + 
     68 + useEffect(() => {
     69 + const filteredList = sortAndGroupList(keywordsList, searchValue);
     70 + setList(filteredList);
     71 + }, [searchValue]);
     72 + 
     73 + return (
     74 + <>
     75 + <SidebarCategoryHeader
     76 + returnButton={returnButton}
     77 + categoryName={categoryName}
     78 + selectedKeywords={selectedKeywords}
     79 + resetGroup={resetGroup}
     80 + />
     81 + 
     82 + {open ? (
     83 + <SidebarCategorySearch
     84 + searchValue={searchValue}
     85 + selectHandler={selectHandler}
     86 + searchChangeHandler={searchChangeHandler}
     87 + itemsWithImage={itemsWithImage}
     88 + clearInput={clearInput}
     89 + selectedItems={selectedKeywords}
     90 + alphabeticalGroups={list}
     91 + />
     92 + ) : (
     93 + children
     94 + )}
     95 + 
     96 + <span role='button' className={styles.toggleView} onClick={toggleOpen}>
     97 + {open ? 'Hide' : 'View All'}
     98 + </span>
     99 + </>
     100 + );
     101 +}
     102 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarCategorySearch/SidebarCategorySearch.module.scss
    1 1  @import '~styles/_vars.scss';
     2 +@import '~styles/responsive.scss';
    2 3   
    3 4  .searchWrapper {
    4 5   position: relative;
    skipped 48 lines
    53 54   overflow-y: auto;
    54 55   margin-bottom: 12px;
    55 56   padding-right: 16px;
     57 + 
     58 + @include mobile-and-tablet {
     59 + max-height: calc(100vh - 184px);
     60 + min-height: calc(100vh - 184px);
     61 + margin-bottom: 0;
     62 + padding-bottom: 48px;
     63 + }
    56 64   
    57 65   &::-webkit-scrollbar {
    58 66   width: 6px;
    skipped 60 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarCategorySearch/SidebarCategorySearch.tsx
    skipped 11 lines
    12 12   item: string;
    13 13   selectedItems: string[];
    14 14   selectHandler: (name: string) => void;
    15  - renderComponent: string;
     15 + itemsWithImage?: boolean;
    16 16  };
    17 17   
    18  -function SearchItem({ item, selectedItems, selectHandler, renderComponent }: SearchItem) {
     18 +function SearchItem({ item, selectedItems, selectHandler, itemsWithImage }: SearchItem) {
    19 19   return (
    20 20   <div
    21 21   className={clsx(styles.groupItem, selectedItems.includes(item) && styles.groupItemActive)}
    22 22   onClick={() => selectHandler(item)}
    23 23   >
    24  - {renderComponent === 'person' && (
     24 + {itemsWithImage && (
    25 25   // TODO: pass actual person image here
    26 26   <img src='https://via.placeholder.com/36' className={styles.groupItemImage} alt='' />
    27 27   )}
    skipped 17 lines
    45 45   clearInput: () => void;
    46 46   alphabeticalGroups: GroupItem[];
    47 47   selectedItems: string[];
    48  - renderComponent: string;
     48 + itemsWithImage?: boolean;
    49 49   selectHandler: (name: string) => void;
    50 50  };
    51 51   
    skipped 3 lines
    55 55   clearInput,
    56 56   alphabeticalGroups,
    57 57   selectedItems,
    58  - renderComponent,
     58 + itemsWithImage,
    59 59   selectHandler,
    60 60  }: Props) {
    61 61   return (
    skipped 33 lines
    95 95   <div className={styles.groupList}>
    96 96   {children.map((item) => (
    97 97   <SearchItem
     98 + key={item}
    98 99   item={item}
    99 100   selectedItems={selectedItems}
    100 101   selectHandler={selectHandler}
    101  - renderComponent={renderComponent}
     102 + itemsWithImage={itemsWithImage}
    102 103   />
    103 104   ))}
    104 105   </div>
    skipped 7 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarMeta/SidebarMeta.module.scss
     1 +@import '~styles/responsive.scss';
     2 + 
     3 +.meta {
     4 + display: grid;
     5 + grid-gap: 20px;
     6 + 
     7 + // FIXME: not sure that mobile spacing should be higher than desktop one
     8 + @include mobile-and-tablet {
     9 + grid-gap: 24px;
     10 + }
     11 +}
     12 + 
     13 +.metaItem {
     14 + display: flex;
     15 + align-items: center;
     16 + line-height: 24px;
     17 +}
     18 + 
     19 +.metaIcon {
     20 + display: flex;
     21 + flex-shrink: 0;
     22 + margin-right: 20px;
     23 + 
     24 + @include mobile-and-tablet {
     25 + margin-right: 16px;
     26 + }
     27 +}
     28 + 
     29 +.metaText {
     30 + font-weight: 500;
     31 +}
     32 + 
     33 +.skeleton {
     34 + display: flex;
     35 + align-items: center;
     36 +}
     37 + 
     38 +.skeletonIcon {
     39 + flex-shrink: 0;
     40 + margin-right: 23px;
     41 +}
     42 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarMeta/SidebarMeta.tsx
     1 +import React from 'react';
     2 +import styles from './SidebarMeta.module.scss';
     3 +import { IconProps } from '../Icon/Icon';
     4 + 
     5 +type MetaItemProps = {
     6 + icon: React.ReactElement<IconProps>;
     7 + text: string;
     8 +};
     9 + 
     10 +type Props = {
     11 + meta: MetaItemProps[];
     12 +};
     13 + 
     14 +function MetaItem({ icon, text }: MetaItemProps) {
     15 + return (
     16 + <div className={styles.metaItem}>
     17 + <span className={styles.metaIcon}>{icon}</span>
     18 + <span className={styles.metaText}>{text}</span>
     19 + </div>
     20 + );
     21 +}
     22 + 
     23 +export default function SidebarMeta({ meta }: Props) {
     24 + return (
     25 + <div className={styles.meta}>
     26 + {meta.map((metaItem) => (
     27 + <MetaItem key={metaItem.text} icon={metaItem.icon} text={metaItem.text} />
     28 + ))}
     29 + </div>
     30 + );
     31 +}
     32 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarMeta/SidebarMetaSkeleton.tsx
     1 +import React from 'react';
     2 +import styles from './SidebarMeta.module.scss';
     3 +import { repeat } from '../../../utils/helpers';
     4 +import Skeleton from '../Skeleton/Skeleton';
     5 + 
     6 +export const SidebarMetaSkeleton = () => (
     7 + <div className={styles.meta}>
     8 + {repeat(
     9 + 5,
     10 + <div className={styles.skeleton}>
     11 + <Skeleton width={18} height={18} variant='circular' className={styles.skeletonIcon} />
     12 + <Skeleton width='100%' />
     13 + </div>
     14 + )}
     15 + </div>
     16 +);
     17 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarMobileFilter/SidebarMobileFilter.module.scss
     1 +@import '~styles/_vars.scss';
     2 +@import '~styles/responsive.scss';
     3 + 
     4 +.mobileSelectedCounter {
     5 + margin-right: 6px;
     6 +}
     7 + 
     8 +.mobileFilterToggle {
     9 + display: inline-flex;
     10 + margin-right: 8px;
     11 + margin-bottom: 8px;
     12 + 
     13 + &:last-child {
     14 + margin-right: 0;
     15 + }
     16 +}
     17 + 
     18 +.mobileFiltersTop {
     19 + display: flex;
     20 + align-items: center;
     21 + justify-content: space-between;
     22 + margin-bottom: 12px;
     23 + line-height: 22px;
     24 +}
     25 + 
     26 +.mobileFiltersTitle {
     27 + display: flex;
     28 + align-items: center;
     29 + font-weight: 500;
     30 + color: $gray-text;
     31 +}
     32 + 
     33 +.mobileFiltersIcon {
     34 + width: 24px;
     35 + height: 24px;
     36 + display: flex;
     37 + align-items: center;
     38 + justify-content: center;
     39 + margin-right: 7px;
     40 +}
     41 + 
     42 +.mobileFiltersReset {
     43 + font-weight: 500;
     44 +}
     45 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarMobileFilter/SidebarMobileFilter.tsx
     1 +import React from 'react';
     2 +import styles from './SidebarMobileFilter.module.scss';
     3 +import { Icon } from '../Icon/Icon';
     4 +import { Button } from '../index';
     5 +import Badge from '../Badge/Badge';
     6 + 
     7 +type toggleList = {
     8 + name: string;
     9 + state: [];
     10 + openModal: () => void;
     11 +};
     12 + 
     13 +type Props = {
     14 + isChanged: boolean;
     15 + resetFilters: () => void;
     16 + filterTriggers: any[];
     17 +};
     18 + 
     19 +export default function SidebarMobileFilter({ isChanged, resetFilters, filterTriggers }: Props) {
     20 + return (
     21 + <>
     22 + <div className={styles.mobileFiltersTop}>
     23 + <div className={styles.mobileFiltersTitle}>
     24 + <span className={styles.mobileFiltersIcon}>
     25 + <Icon kind='filters' width={16} height={16} color='#8E8AA0' />
     26 + </span>
     27 + Filters
     28 + </div>
     29 + 
     30 + {isChanged && (
     31 + <div className={styles.mobileFiltersResetWrapper}>
     32 + <span className={styles.mobileFiltersReset} onClick={resetFilters}>
     33 + Reset
     34 + </span>
     35 + </div>
     36 + )}
     37 + </div>
     38 + 
     39 + {filterTriggers.map(({ name, state, openModal }: toggleList) => (
     40 + <Button
     41 + key={name}
     42 + variant='secondary'
     43 + size='small'
     44 + className={styles.mobileFilterToggle}
     45 + onClick={openModal}
     46 + >
     47 + {state.length > 0 && (
     48 + <Badge content={state.length} className={styles.mobileSelectedCounter} />
     49 + )}
     50 + {name[0].toUpperCase() + name.slice(1)}
     51 + </Button>
     52 + ))}
     53 + </>
     54 + );
     55 +}
     56 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SidebarMobileFilter/SidebarMobileFilterSkeleton.tsx
     1 +import styles from '../../layouts/SearchResults/SearchResults.module.scss';
     2 +import Skeleton from '../Skeleton/Skeleton';
     3 +import { repeat } from '../../../utils/helpers';
     4 +import React from 'react';
     5 + 
     6 +export const SidebarMobileFilterSkeleton = () => (
     7 + <>
     8 + <div className={styles.mobileFiltersTop}>
     9 + <div className={styles.mobileFiltersTitle}>
     10 + <span className={styles.mobileFiltersIcon}>
     11 + <Skeleton width={16} height={16} variant='circular' />
     12 + </span>
     13 + <Skeleton width={50} />
     14 + </div>
     15 + </div>
     16 + 
     17 + {repeat(
     18 + 3,
     19 + <Skeleton width={100} height={40} variant='rounded' className={styles.mobileFilterToggle} />
     20 + )}
     21 + </>
     22 +);
     23 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SitesList/SitesList.module.scss
    skipped 50 lines
    51 51   object-fit: cover;
    52 52  }
    53 53   
     54 +.content {
     55 + white-space: nowrap;
     56 +}
     57 + 
    54 58  .title {
    55 59   font-weight: 500;
    56 60  }
    skipped 7 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SitesList/SitesList.tsx
    1 1  import React from 'react';
    2 2  import styles from './SitesList.module.scss';
    3 3  import clsx from 'clsx';
     4 +import { SitesListSkeleton } from './SitesListSkeleton';
    4 5   
    5 6  export type Site = {
    6  - id: string;
    7  - image: string;
    8  - name: string;
    9  - packagesCount: number;
     7 + id?: string;
     8 + image?: string;
     9 + name?: string;
     10 + packagesCount?: number;
     11 + loading?: boolean;
    10 12  };
    11 13   
    12 14  type Props = {
    13 15   sites: Site[];
    14 16   className?: string;
     17 + loading?: boolean;
    15 18  };
    16 19   
    17 20  function Site({ image, name, packagesCount }: Site) {
    skipped 10 lines
    28 31   );
    29 32  }
    30 33   
    31  -export default function SitesList({ sites, className }: Props) {
     34 +export default function SitesList({ sites, className, loading }: Props) {
    32 35   return (
    33 36   <div className={styles.sitesListWrapper}>
    34 37   <div className={clsx(styles.sitesList, className)}>
    35  - {sites.map((site) => (
    36  - <Site key={site.id} {...site} />
    37  - ))}
     38 + {loading ? <SitesListSkeleton /> : sites.map((site) => <Site key={site.id} {...site} />)}
    38 39   </div>
    39 40   </div>
    40 41   );
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/SitesList/SitesListSkeleton.tsx
     1 +import React from 'react';
     2 +import styles from './SitesList.module.scss';
     3 +import Skeleton from '../Skeleton/Skeleton';
     4 +import { repeat } from '../../../utils/helpers';
     5 + 
     6 +export const SiteSkeleton = () => (
     7 + <div className={styles.site}>
     8 + <div className={styles.imageWrapper}>
     9 + <Skeleton width={36} height={36} variant='circular' />
     10 + </div>
     11 + <div className={styles.content}>
     12 + <Skeleton width={116} />
     13 + <Skeleton width={86} />
     14 + </div>
     15 + </div>
     16 +);
     17 + 
     18 +export const SitesListSkeleton = () => <>{repeat(4, <SiteSkeleton />)}</>;
     19 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Skeleton/Skeleton.module.scss
     1 +@import '~styles/_vars.scss';
     2 + 
     3 +.skeleton {
     4 + display: block;
     5 + background: $gray-surface;
     6 + border-radius: 4px;
     7 + height: 1.35rem;
     8 + animation: 1.5s ease-in-out 0.5s infinite normal none running pulse;
     9 +}
     10 + 
     11 +.text {
     12 + transform-origin: 0 55%;
     13 + transform: scale(1, 0.75);
     14 + 
     15 + &:empty::before {
     16 + content: '\00a0';
     17 + }
     18 +}
     19 + 
     20 +.rounded {
     21 + border-radius: 24px;
     22 +}
     23 + 
     24 +.circular {
     25 + border-radius: 50%;
     26 +}
     27 + 
     28 +.rectangular {
     29 + border-radius: 4px;
     30 +}
     31 + 
     32 +.hasChildren {
     33 + & > * {
     34 + visibility: hidden;
     35 + }
     36 +}
     37 + 
     38 +.hasChildrenNoHeight {
     39 + height: auto;
     40 +}
     41 + 
     42 +.hasChildrenNoWidth {
     43 + max-width: fit-content;
     44 +}
     45 + 
     46 +@keyframes pulse {
     47 + 0% {
     48 + opacity: 1;
     49 + }
     50 + 50% {
     51 + opacity: 0.4;
     52 + }
     53 + 100% {
     54 + opacity: 1;
     55 + }
     56 +}
     57 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Skeleton/Skeleton.tsx
     1 +import React from 'react';
     2 +import styles from './Skeleton.module.scss';
     3 +import clsx from 'clsx';
     4 + 
     5 +type Props = {
     6 + width?: number | string;
     7 + height?: number | string;
     8 + variant?: 'text' | 'circular' | 'rounded' | 'rectangular';
     9 + className?: string;
     10 + children?: React.ReactNode;
     11 +};
     12 + 
     13 +export default function Skeleton({ variant = 'text', className, width, height, children }: Props) {
     14 + return (
     15 + <span
     16 + className={clsx(
     17 + styles.skeleton,
     18 + styles[variant],
     19 + children && styles.hasChildren,
     20 + children && !width && styles.hasChildrenNoWidth,
     21 + children && !height && styles.hasChildrenNoHeight,
     22 + className
     23 + )}
     24 + style={{ width: width, height: height }}
     25 + >
     26 + {children && <span>{children}</span>}
     27 + </span>
     28 + );
     29 +}
     30 + 
  • ■ ■ ■ ■ ■
    packages/web/src/styles/global.scss
    skipped 136 lines
    137 137  }
    138 138   
    139 139  #root,
    140  -#app,
    141  -html,
    142  -body {
     140 +#app {
     141 + width: 100%;
    143 142   height: 100%;
    144 143  }
    145 144   
  • ■ ■ ■ ■ ■ ■
    packages/web/src/utils/helpers.tsx
     1 +import React from 'react';
     2 + 
    1 3  export function formatNumber(x: number) {
    2 4   return x.toLocaleString();
    3 5  }
    4 6   
     7 +export function repeat(times: number, children: React.ReactNode) {
     8 + return new Array(times)
     9 + .fill(undefined)
     10 + .map((_, idx) => <React.Fragment key={idx}>{children}</React.Fragment>);
     11 +}
     12 + 
  • ■ ■ ■ ■ ■
    yarn.lock
    skipped 12646 lines
    12647 12647   use-composed-ref "^1.0.0"
    12648 12648   use-latest "^1.0.0"
    12649 12649   
     12650 +react-top-loading-bar@^2.3.1:
     12651 + version "2.3.1"
     12652 + resolved "https://registry.yarnpkg.com/react-top-loading-bar/-/react-top-loading-bar-2.3.1.tgz#d727eb6aaa412eae52a990e5de9f33e9136ac714"
     12653 + integrity sha512-rQk2Nm+TOBrM1C4E3e6KwT65iXyRSgBHjCkr2FNja1S51WaPulRA5nKj/xazuQ3x89wDDdGsrqkqy0RBIfd0xg==
     12654 + 
    12650 12655  react-transition-group@^4.4.5:
    12651 12656   version "4.4.5"
    12652 12657   resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"
    skipped 3083 lines
Please wait...
Page is in error, reload to recover