{"id":24024934,"url":"https://github.com/tigerabrodi/hinata","last_synced_at":"2025-05-07T14:09:04.637Z","repository":{"id":269187558,"uuid":"906672063","full_name":"tigerabrodi/hinata","owner":"tigerabrodi","description":"Search and download images. Built with Unsplash API.","archived":false,"fork":false,"pushed_at":"2024-12-26T11:34:32.000Z","size":150,"stargazers_count":64,"open_issues_count":0,"forks_count":6,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-05-07T14:08:55.329Z","etag":null,"topics":["images","react","react-query","typescript","unsplash"],"latest_commit_sha":null,"homepage":"https://hinata-search.vercel.app/","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tigerabrodi.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2024-12-21T15:11:56.000Z","updated_at":"2025-04-24T13:46:55.000Z","dependencies_parsed_at":null,"dependency_job_id":"17928e70-c2d7-4f8e-b8f6-13b3baee90ce","html_url":"https://github.com/tigerabrodi/hinata","commit_stats":null,"previous_names":["tigerabrodi/hinata"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Fhinata","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Fhinata/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Fhinata/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Fhinata/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tigerabrodi","download_url":"https://codeload.github.com/tigerabrodi/hinata/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":252892503,"owners_count":21820648,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["images","react","react-query","typescript","unsplash"],"created_at":"2025-01-08T15:36:19.590Z","updated_at":"2025-05-07T14:09:04.616Z","avatar_url":"https://github.com/tigerabrodi.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Hinata 🔍\n\nBuilt with Unsplash API. A site where you can search and download images.\n\nHad a lot of fun geeking out on performance.\n\nhttps://github.com/user-attachments/assets/d489615c-454b-4352-af7a-f43c5ea487ee\n\n# PS...\n\nIf it isn't working, the rate limiting has been hit.\n\n# Get it up and running\n\nFirst, clone the repo and install the dependencies:\n\n```bash\npnpm install\n```\n\nCreate a `.env.local` file and add the following:\n\n```bash\nVITE_UNSPLASH_ACCESS_KEY=\u003cyour-unsplash-access-key\u003e\n```\n\nRun the development server:\n\n```bash\npnpm dev\n```\n\n# Explanations\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Image performance\u003c/summary\u003e\n\n---\n\n# Quick snippet\n\n```jsx\n\u003cimg\n  srcSet={`\n            ${image.urls.small} 400w,\n            ${image.urls.regular} 1080w\n          `}\n  sizes=\"(min-width: 1024px) 33vw,\n         (min-width: 768px) 50vw,\n         100vw\"\n  src={image.urls.small}\n  alt={image.description || `Photo by ${image.user.name}`}\n  className=\"absolute inset-0 h-full w-full object-cover\"\n  loading={shouldLazyLoad ? 'lazy' : 'eager'}\n  decoding=\"async\"\n  fetchPriority={!shouldLazyLoad ? 'high' : 'auto'}\n/\u003e\n```\n\nYou may look at this and go wow, I don't understand what's happening here besides the src and alt tag.\n\nLet's dig into the details.\n\n# srcSet and sizes\n\nWith `srcSet`, we tell the browser which image to use based on the screen width. If you look at the example above, `small` will be used if the screen width is less than 400px. Otherwise, `regular` will be used. Small and regular in this case are different sizes of the same image.\n\nOn bigger screens, to keep it crisp, you want to use a bigger image.\n\n`sizes` is used to tell the browser roughly the width of the image depending on the screen width.\n\nThis however, isn't the entire story. There is something called Device Pixel Ratio. To explain this in simple words, the higher the DPR, the more physical pixels there are on the screen. If DPR is 2, it means for every pixel, there are 2 physical pixels.\n\nThat's why modern screens are so crisp.\n\nSummary: sizes and srcSet help us use the right image for the right screen size.\n\n# Lazy loading\n\nWhen you load an image, you need to request, download and decode it. This is work for the browser. There is no need to do this work and interfere with more important work if the image isn't needed.\n\nIf the user must scroll or interact (e.g. carousel) for the image to become visible, it should be lazy loaded.\n\nWhen the image becomes visible, the browser will load the image.\n\nUnder the hood, it uses intersection observer to detect when the image is visible.\n\n# Fetch priority\n\n`fetchPriority` is used to tell the browser the priority of the image.\n\nIf the image is immediately visible (think hero section), it should be high priority. Other images should not just be lazy loaded, but also low priority.\n\nLow priority images is like telling the browser \"load this image when you have time, otherwise leave it for later\".\n\nWhat you don't want to happen is high priority images taking longer because low priority images are also being fetched and decoded.\n\n# Decoding\n\n`decoding=\"async\"` is used to tell the browser to decode the image asynchronously. This means the image will be decoded in the background while the main thread is doing other things.\n\nYou might wonder, what's decoding?\n\nWhen the browser loads an image, it gets the image as a compressed file. Decoding is the process of decompressing the image and turning it into a bitmap. A bitmap is a map of pixels where each pixel has a color and a position. This is necessary so the browser can display the image.\n\n# Preloading images\n\nHave you ever wondered why despite having fetched the data, the image still takes a while to load?\n\nWhen the browser sees the image tag, it needs to:\n\n1. Fetch the image\n2. Download the image\n3. Decode the image\n\nWe can do this work ahead of time by using `new Image()` and setting the `src` to the image URL.\n\n```js\nconst image = new Image()\nimage.src = {image url}\n```\n\n`new Image()` is a way to create a new image object. It doesn't do anything else.\n\nWhen you do `image.src = {image url}`, the browser will fetch, download and decode the image.\n\nWhen the browser then sees the image tag, it can get it directly from the cache instead!\n\nYou can listen to `image.onload` to know when the image is ready to be used. In react, this would be `onLoad`.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Blur hash\u003c/summary\u003e\n\n---\n\nIf you dig into the code, you'll see that I'm using blur hash if the image hasn't loaded yet.\n\n```jsx\n\u003cLink\n  to={generatePath(ROUTES.photoDetail, { id: image.id })}\n  state={{ background: location }}\n  className=\"relative block w-full\"\n  style={{ paddingBottom }}\n\u003e\n  {image.blur_hash ? (\n    \u003cdiv className=\"absolute inset-0\"\u003e\n      \u003cBlurhash hash={image.blur_hash} width=\"100%\" height=\"100%\" /\u003e\n    \u003c/div\u003e\n  ) : (\n    \u003cdiv className=\"absolute inset-0 bg-gray-200\" /\u003e\n  )}\n\n  \u003cimg\n    srcSet={`\n            ${image.urls.small} 400w,\n            ${image.urls.regular} 1080w\n          `}\n    sizes=\"(min-width: 1024px) 33vw,\n         (min-width: 768px) 50vw,\n         100vw\"\n    src={image.urls.small}\n    alt={image.description || `Photo by ${image.user.name}`}\n    className={cn(\n      'absolute inset-0 h-full w-full object-cover opacity-0 transition-opacity duration-300 ease-in-out',\n      {\n        'opacity-100': isImageLoaded,\n      }\n    )}\n    loading={shouldLazyLoad ? 'lazy' : 'eager'}\n    decoding=\"async\"\n    fetchPriority={!shouldLazyLoad ? 'high' : 'auto'}\n    onLoad={() =\u003e setIsImageLoaded(true)}\n  /\u003e\n\u003c/Link\u003e\n```\n\nBlur hash is a hash of the image that is used to display a blurred version of the image while the image is loading. This is given to use from the server.\n\nThe server generates the blur hash by using an encoding algorithm. This encoder turns the image into a grid, analyzes the colors and then encodes them into a string using a base83 encoding.\n\nThis takes 20-30 bytes to send compared to the image which is 100s of KBs. This provides a nice UX before the real image is loaded.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Prefetching data\u003c/summary\u003e\n\n---\n\nI'm using React Query to fetch and manage server state.\n\nOne of the cool things you can do to improve the perceived performance of your site is to prefetch data. When a user hovers over a link, you can prefetch the data for the link they are hovering over.\n\nThis way, when they navigate to the next page, the data is already ready to be used.\n\nWith React Query, we prefetch the data and store it in the cache.\n\nAn example:\n\n```js\nfunction prefetchData() {\n  void queryClient.prefetchQuery({\n    queryKey: photoKeys.detail(image.id),\n    queryFn: () =\u003e api.getPhotoDetail(image.id),\n  })\n\n  void queryClient.prefetchQuery({\n    queryKey: userKeys.detail(image.user.username),\n    queryFn: () =\u003e api.getUser(image.user.username),\n  })\n\n  void queryClient.prefetchQuery({\n    queryKey: userKeys.photos(image.user.username),\n    queryFn: () =\u003e\n      api.getUserPhotos({\n        username: image.user.username,\n        queryParams: {\n          page: USER_DETAIL_PHOTOS_PAGE_INDEX,\n          perPage: USER_DETAIL_PHOTOS_PER_PAGE,\n        },\n      }),\n  })\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Infinite loading\u003c/summary\u003e\n\n---\n\nHow we manage infinite loading is by using `useInfiniteQuery` hook from React Query.\n\nIt's honestly the first time I use it.\n\nIt's really cool how simple things are:\n\n```ts\nexport function useImageSearch({ params }: { params: SearchParams }) {\n  const query = useInfiniteQuery({\n    queryKey: photoKeys.searchResults({\n      query: params.query,\n      orderBy: params.orderBy,\n      color: params.color,\n      perPage: params.perPage,\n    }),\n    queryFn: ({ pageParam }) =\u003e\n      api.searchPhotos({\n        ...params,\n        page: pageParam,\n      }),\n    initialPageParam: params.page,\n    getNextPageParam: (lastPage, _allPages, lastPageParam) =\u003e {\n      const hasNoMorePages = lastPageParam \u003e= lastPage.total_pages\n      if (hasNoMorePages) {\n        return undefined\n      }\n      return lastPageParam + 1\n    },\n    enabled: !!params.query,\n  })\n\n  useEffect(() =\u003e {\n    if (!query.data || !params.query) return\n\n    const loadUpToInitialPage = async () =\u003e {\n      const loadedPages = query.data.pages.length\n\n      if (loadedPages \u003c params.page) {\n        try {\n          await query.fetchNextPage()\n        } catch (error) {\n          // TODO: handle error\n          console.error('Error loading pages:', error)\n        }\n      }\n    }\n\n    loadUpToInitialPage().catch(console.error)\n  }, [params.page, params.query, query])\n\n  return query\n}\n```\n\nOne thing I had to wrap my head around is that page param is managed by the hook itself.\n\nTo get the initial data if page isn't 1, we need to keep fetching the next page until we get to the initial page.\n\nTo be honest, I couldn't find a better way to do this. I'm still not sure if it's the best way to go about it. But this works.\n\nError handling is still missing for that specific case as you can see. Because it's a side project I just let it be. I guess in the real world this would be a product discussion to have about how we manage this specific edge case.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Geeking out on performance\u003c/summary\u003e\n\n---\n\nI know this has all been about performance. I love it. It's like never ending detective work on how to make things faster and improve the user experience.\n\nIf you look at the image grid, you'll see that really analyzing when and which image to lazy load.\n\nIt's also really fun when you see the network tab and when the images are actually loaded:\n\n```jsx\nimport { DEFAULT_QUERY_PARAM_VALUES } from '@/lib/constants'\nimport { ImageGridItem } from './ImageGridItem'\nimport { Photo } from '@/lib/schemas'\nimport { breakpoints, useMediaQuery } from '@/hooks/useMediaQuery'\n\nexport type ImageWithPageIndex = {\n  image: Photo\n  pageIndex: number\n}\n\nexport function ImageGrid({\n  images,\n}: {\n  images: Array\u003cImageWithPageIndex\u003e | Array\u003cPhoto\u003e\n}) {\n  const isDesktop = useMediaQuery(breakpoints.md)\n\n  return (\n    \u003cdiv className=\"grid grid-cols-1 grid-rows-[0px] gap-[18px] md:grid-cols-2 md:gap-4 lg:grid-cols-3\"\u003e\n      {images.map((data, index) =\u003e {\n        // On mobile we show a single column layout\n        const isImageAmongFirstResults = index \u003c 3\n        const shouldLazyLoadOnMobile = isImageAmongFirstResults \u0026\u0026 !isDesktop\n\n        const isImageWithPageIndex = 'pageIndex' in data\n\n        if (isImageWithPageIndex) {\n          const { image, pageIndex } = data\n\n          const isImageAmongPaginatedResults =\n            pageIndex + 1 !== DEFAULT_QUERY_PARAM_VALUES.page\n\n          // On home page we typically get away with showing a lot of images in the first page\n          const shouldLazyLoadOnDesktop = isImageAmongPaginatedResults\n\n          const shouldLazyLoad =\n            shouldLazyLoadOnMobile || shouldLazyLoadOnDesktop\n\n          return (\n            \u003cImageGridItem\n              key={`${image.id}-${pageIndex}`}\n              image={image}\n              // Optimization to lazy load images that are not the first page\n              shouldLazyLoad={shouldLazyLoad}\n            /\u003e\n          )\n        }\n\n        // On profile page\n        // All images aren't visible directly on desktop\n        // First 6 images are usually visible\n        const shouldLazyLoadOnDesktop = isDesktop \u0026\u0026 index \u003e 5\n\n        const shouldLazyLoad = shouldLazyLoadOnMobile || shouldLazyLoadOnDesktop\n\n        return (\n          \u003cImageGridItem\n            key={data.id}\n            image={data}\n            shouldLazyLoad={shouldLazyLoad}\n          /\u003e\n        )\n      })}\n    \u003c/div\u003e\n  )\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Masonry layout wtf?!\u003c/summary\u003e\n\n---\n\nTo be fair, it isn't real masonry layout.\n\nWhat I'm doing is letting each grid item span a number of rows based on the aspect ratio of the image.\n\nStarting off, on the image grid itself, the one that wraps all the items, I set the grid rows to 0px. This means that the default height of the grid items is 0px.\n\nIt's useful when you want the grid item height to grow depending on the content. Which is exactly what we want here.\n\n```jsx\n\u003cdiv className=\"grid grid-cols-1 grid-rows-[0px] gap-4 md:grid-cols-2 lg:grid-cols-3\" /\u003e\n```\n\nLet's dive into the grid item itself.\n\nThe way we decide to span number of rows (height of grid item) is by doing this:\n\n```js\n// We do height / width to maintain the right proportions for height specifically\n// e.g height 800px and width 1200px\n// 800 / 1200 = 0.66\n// 0.66 means for every 1px of width, there are 0.66px of height\n// 0.66 * 22 = 14.66\n// Math.ceil(14.66) = 15\n// So the image will span 15 rows\nconst rowsToSpanBasedOnAspectRatio = Math.ceil(\n  (image.height / image.width) *\n    MULTIPLIER_TO_TURN_ASPECT_RATIO_INTO_ROWS_TO_SPAN\n)\n```\n\n`MULTIPLIER_TO_TURN_ASPECT_RATIO_INTO_ROWS_TO_SPAN` is a number you can play around with. 22 seems to work well on both mobile and desktop.\n\nNow, this gives us the number of rows to span for the grid item itself.\n\nOne problem we have here is that this isn't totally accurate still. It's a rough calculation. It's off by 2-5px in height a lot when comparing it to the actual aspect ratio.\n\nNow, the grid item itself already has a specified width since it's a grid item.\n\nThe other thing we have to do is to set the height of the actual link which wraps the image. This will be the accurate height. We do this by using padding bottom with percentage. When you use padding bottom with percentage, it's calculated based on the width.\n\n```js\nconst paddingBottom = `${(image.height / image.width) * 100}%`\n```\n\nBy doing this, we can get the accurate height of the grid item.\n\nOne issue here is that the grid item itself is a bit too big. This looks weird with the gap. A trick here is to use `fit-content` on the grid item. This will make the grid item take height necessary, but behave like `min-content`.\n\nThese are the full elements:\n\n```jsx\n\u003cfigure\n  className=\"group relative flex h-fit flex-col gap-3 overflow-hidden rounded-lg\"\n  onMouseOver={prefetchData}\n  style={{\n    gridRow: `span ${rowsToSpanBasedOnAspectRatio}`,\n  }}\n\u003e\n  {mobileHeader}\n\n  {/* Link by default are inline elements that won't span the full width of the parent */}\n  {/* Block span full width of parent and start on new lines */}\n  \u003cLink\n    to={generatePath(ROUTES.photoDetail, { id: image.id })}\n    state={{ background: location }}\n    className=\"relative block w-full\"\n    style={{ paddingBottom }}\n  \u003e\n    {image.blur_hash ? (\n      \u003cdiv className=\"absolute inset-0\"\u003e\n        \u003cBlurhash hash={image.blur_hash} width=\"100%\" height=\"100%\" /\u003e\n      \u003c/div\u003e\n    ) : (\n      \u003cdiv className=\"absolute inset-0 bg-gray-200\" /\u003e\n    )}\n\n    \u003cimg\n      srcSet={`\n            ${image.urls.small} 400w,\n            ${image.urls.regular} 1080w\n          `}\n      sizes=\"(min-width: 1024px) 33vw,\n         (min-width: 768px) 50vw,\n         100vw\"\n      src={image.urls.small}\n      alt={image.description || `Photo by ${image.user.name}`}\n      className={cn(\n        'absolute inset-0 h-full w-full object-cover opacity-0 transition-opacity duration-300 ease-in-out',\n        {\n          'opacity-100': isImageLoaded,\n        }\n      )}\n      loading={shouldLazyLoad ? 'lazy' : 'eager'}\n      decoding=\"async\"\n      fetchPriority={!shouldLazyLoad ? 'high' : 'auto'}\n      onLoad={() =\u003e setIsImageLoaded(true)}\n    /\u003e\n  \u003c/Link\u003e\n\n  {desktopHoverOverlay}\n  \u003cfigcaption className=\"sr-only\"\u003ePhoto by {image.user.name}\u003c/figcaption\u003e\n\n  {mobileFooter}\n\u003c/figure\u003e\n```\n\n\u003c/details\u003e\n\n# Improvements that could be made\n\n- Of course, we could add testing.\n- Let you edit the photo before downloading it.\n- Prefetching data on mobile using intersection observer.\n- Delay before prefetching in case user hovers multiple images very fast, if the cursor is on an image more than 100ms, let's then prefetch, this would be more optimized tbf.\n- Better error handling\n\n# Tech\n\nBuilt with:\n\n- React\n- React Query\n- React Router 7\n- Shadcn UI\n- Tailwind CSS\n- TypeScript\n- Vite\n- Unsplash API\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftigerabrodi%2Fhinata","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftigerabrodi%2Fhinata","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftigerabrodi%2Fhinata/lists"}