├── src
├── vite-env.d.ts
├── lib
│ ├── env.ts
│ ├── constants.ts
│ ├── utils.test.ts
│ ├── queryKeys.ts
│ ├── utils.ts
│ ├── schemas.ts
│ └── api.ts
├── layouts
│ └── root
│ │ ├── RootLayout.tsx
│ │ └── SearchForm.tsx
├── main.tsx
├── pages
│ ├── photo-detail
│ │ ├── usePhotoDetail.ts
│ │ └── index.tsx
│ ├── user-detail
│ │ ├── useGetUser.ts
│ │ ├── useGetUserPhotos.ts
│ │ └── index.tsx
│ └── home
│ │ ├── useImageSearch.ts
│ │ ├── SearchResults.tsx
│ │ └── index.tsx
├── components
│ ├── photos
│ │ ├── ImageGridSkeleton.tsx
│ │ ├── ImageGrid.tsx
│ │ └── ImageGridItem.tsx
│ ├── core
│ │ └── ProfileImage.tsx
│ └── ui
│ │ ├── input.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ └── select.tsx
├── icons
│ ├── ZoomIn.tsx
│ └── ZoomOut.tsx
├── hooks
│ └── useMediaQuery.ts
├── App.tsx
└── index.css
├── vercel.json
├── postcss.config.js
├── tsconfig.json
├── vite.config.ts
├── .gitignore
├── index.html
├── components.json
├── .prettierrc
├── tsconfig.node.json
├── tsconfig.app.json
├── public
└── vite.svg
├── package.json
├── eslint.config.js
├── tailwind.config.js
└── README.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
3 | }
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/src/lib/env.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | const envSchema = z.object({
4 | UNSPLASH_ACCESS_KEY: z.string(),
5 | })
6 |
7 | export const env = envSchema.parse({
8 | UNSPLASH_ACCESS_KEY: import.meta.env.VITE_UNSPLASH_ACCESS_KEY,
9 | })
10 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "@/*": ["./src/*"]
7 | }
8 | },
9 | "references": [
10 | { "path": "./tsconfig.app.json" },
11 | { "path": "./tsconfig.node.json" }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import react from '@vitejs/plugin-react'
3 | import { defineConfig } from 'vite'
4 |
5 | export default defineConfig({
6 | plugins: [react()],
7 | resolve: {
8 | alias: {
9 | '@': path.resolve(__dirname, './src'),
10 | },
11 | },
12 | })
13 |
--------------------------------------------------------------------------------
/src/layouts/root/RootLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from 'react-router'
2 | import { SearchForm } from './SearchForm'
3 |
4 | export function RootLayout() {
5 | return (
6 |
7 |
8 |
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 | import { BrowserRouter } from 'react-router'
6 |
7 | createRoot(document.getElementById('root')!).render(
8 |
9 |
10 |
11 |
12 |
13 | )
14 |
--------------------------------------------------------------------------------
/src/pages/photo-detail/usePhotoDetail.ts:
--------------------------------------------------------------------------------
1 | import { api } from '@/lib/api'
2 | import { photoKeys } from '@/lib/queryKeys'
3 | import { useQuery } from '@tanstack/react-query'
4 |
5 | export function usePhotoDetail(id: string | undefined) {
6 | return useQuery({
7 | queryKey: photoKeys.detail(id),
8 | queryFn: () => api.getPhotoDetail(id),
9 | enabled: !!id,
10 | })
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/user-detail/useGetUser.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 | import { userKeys } from '../../lib/queryKeys'
3 | import { api } from '@/lib/api'
4 |
5 | export function useGetUser(username: string | undefined) {
6 | return useQuery({
7 | queryKey: userKeys.detail(username),
8 | queryFn: () => api.getUser(username),
9 | enabled: !!username,
10 | })
11 | }
12 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Hinata
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "bracketSpacing": true,
4 | "embeddedLanguageFormatting": "auto",
5 | "htmlWhitespaceSensitivity": "css",
6 | "insertPragma": false,
7 | "jsxBracketSameLine": false,
8 | "jsxSingleQuote": false,
9 | "printWidth": 80,
10 | "proseWrap": "preserve",
11 | "quoteProps": "as-needed",
12 | "requirePragma": false,
13 | "semi": false,
14 | "singleQuote": true,
15 | "tabWidth": 2,
16 | "trailingComma": "es5",
17 | "useTabs": false,
18 | "vueIndentScriptAndStyle": false,
19 | "plugins": ["prettier-plugin-tailwindcss"]
20 | }
21 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | const ONE_SECOND_IN_MS = 1000
2 | const ONE_MINUTE_IN_MS = ONE_SECOND_IN_MS * 60
3 | export const FIVE_MINUTES_IN_MS = ONE_MINUTE_IN_MS * 5
4 |
5 | export const DEFAULT_QUERY_PARAM_VALUES = {
6 | page: 1,
7 | perPage: 12,
8 | orderBy: 'relevant',
9 | } as const
10 |
11 | export const QUERY_PARAMS = {
12 | query: 'query',
13 | page: 'page',
14 | color: 'color',
15 | orderBy: 'orderBy',
16 | perPage: 'perPage',
17 | } as const
18 |
19 | export const ROUTES = {
20 | home: '/',
21 | user: '/users/:username',
22 | photoDetail: '/photos/:id',
23 | userDetail: '/users/:username',
24 | } as const
25 |
--------------------------------------------------------------------------------
/src/components/photos/ImageGridSkeleton.tsx:
--------------------------------------------------------------------------------
1 | export function ImageGridSkeleton({ count }: { count: number }) {
2 | return (
3 |
4 | {Array.from({ length: count }).map((_, index) => (
5 |
13 | ))}
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/icons/ZoomIn.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from 'react'
2 |
3 | export function ZoomInIcon(props: ComponentProps<'svg'>) {
4 | return (
5 |
11 |
12 |
13 |
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/pages/user-detail/useGetUserPhotos.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 | import { type SearchParams } from '@/lib/schemas'
3 | import { userKeys } from '@/lib/queryKeys'
4 | import { api } from '@/lib/api'
5 |
6 | type UseGetUserPhotosParams = {
7 | username: string | undefined
8 | queryParams: Pick
9 | }
10 |
11 | export function useGetUserPhotos({
12 | username,
13 | queryParams,
14 | }: UseGetUserPhotosParams) {
15 | return useQuery({
16 | queryKey: userKeys.photos(username),
17 | queryFn: () => api.getUserPhotos({ username, queryParams }),
18 | enabled: !!username,
19 | })
20 | }
21 |
--------------------------------------------------------------------------------
/src/lib/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, it } from 'vitest'
2 | import { formatImageFilename } from './utils'
3 |
4 | it('should format image filename', () => {
5 | expect(formatImageFilename({ imageDescription: 'Hello World' })).toBe(
6 | 'hello_world'
7 | )
8 |
9 | expect(formatImageFilename({ imageDescription: 'Hello World 123' })).toBe(
10 | 'hello_world_123'
11 | )
12 |
13 | expect(
14 | formatImageFilename({ imageDescription: 'Hello...Worldlolok123!!' })
15 | ).toBe('helloworldlolok123')
16 |
17 | expect(
18 | formatImageFilename({ imageDescription: '.ok!-uhm,isthisgonnawork World' })
19 | ).toBe('okuhmisthisgonnawork_world')
20 | })
21 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "incremental": true,
5 | "target": "ES2022",
6 | "lib": ["ES2023"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/src/icons/ZoomOut.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from 'react'
2 |
3 | export function ZoomOutIcon(props: ComponentProps<'svg'>) {
4 | return (
5 |
11 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/core/ProfileImage.tsx:
--------------------------------------------------------------------------------
1 | import { User } from '@/lib/schemas'
2 | import { cn } from '@/lib/utils'
3 | import { ComponentProps } from 'react'
4 |
5 | type ProfileImageProps = ComponentProps<'img'> & {
6 | profileImage: User['profile_image']
7 | }
8 |
9 | export function ProfileImage({
10 | profileImage,
11 | className,
12 | ...props
13 | }: ProfileImageProps) {
14 | return (
15 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "incremental": true,
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "isolatedModules": true,
15 | "moduleDetection": "force",
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "baseUrl": ".",
25 | "paths": {
26 | "@/*": ["./src/*"]
27 | }
28 | },
29 | "include": ["src"]
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = 'Input'
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/src/hooks/useMediaQuery.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 |
3 | export function useMediaQuery(query: string): boolean {
4 | const [isMatching, setIsMatching] = useState(false)
5 |
6 | useEffect(() => {
7 | const mediaQuery = window.matchMedia(query)
8 |
9 | setIsMatching(mediaQuery.matches)
10 |
11 | const handleChange = (event: MediaQueryListEvent) => {
12 | setIsMatching(event.matches)
13 | }
14 |
15 | mediaQuery.addEventListener('change', handleChange)
16 |
17 | return () => {
18 | mediaQuery.removeEventListener('change', handleChange)
19 | }
20 | }, [query])
21 |
22 | return isMatching
23 | }
24 |
25 | export const breakpoints = {
26 | sm: '(min-width: 640px)',
27 | md: '(min-width: 768px)',
28 | lg: '(min-width: 1024px)',
29 | xl: '(min-width: 1280px)',
30 | '2xl': '(min-width: 1536px)',
31 | } as const
32 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
3 | import { HomePage } from './pages/home'
4 | import { Route, Routes } from 'react-router'
5 | import { RootLayout } from './layouts/root/RootLayout'
6 | import { ROUTES } from './lib/constants'
7 | import { PhotoDetailPage } from './pages/photo-detail'
8 | import { UserDetailPage } from './pages/user-detail'
9 |
10 | const queryClient = new QueryClient()
11 |
12 | function App() {
13 | return (
14 |
15 |
16 | }>
17 | } />
18 | } />
19 | } />
20 |
21 |
22 |
23 |
24 | )
25 | }
26 |
27 | export default App
28 |
--------------------------------------------------------------------------------
/src/lib/queryKeys.ts:
--------------------------------------------------------------------------------
1 | import { SearchParams } from './schemas'
2 |
3 | const PLACEHOLDER = 'placeholder'
4 |
5 | export const photoKeys = {
6 | all: ['photos'] as const,
7 | detail: (id: string | undefined) =>
8 | id ? ([...photoKeys.all, id] as const) : ([PLACEHOLDER] as const),
9 | search: () => [...photoKeys.all, 'search'] as const,
10 | searchResults: (params: Omit) => {
11 | if (!params.query) return [PLACEHOLDER] as const
12 |
13 | // order important to not mess up caching
14 | const queryParams = [
15 | params.query,
16 | params.orderBy,
17 | params.color,
18 | params.perPage,
19 | ] as const
20 |
21 | return [...photoKeys.search(), ...queryParams] as const
22 | },
23 | } as const
24 |
25 | export const userKeys = {
26 | all: ['users'] as const,
27 | detail: (username: string | undefined) =>
28 | username
29 | ? ([...userKeys.all, username] as const)
30 | : ([PLACEHOLDER] as const),
31 | photos: (username: string | undefined) =>
32 | username
33 | ? ([...userKeys.all, username, 'photos'] as const)
34 | : ([PLACEHOLDER] as const),
35 | } as const
36 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
13 | secondary:
14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
15 | destructive:
16 | 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
17 | outline: 'text-foreground',
18 | },
19 | },
20 | defaultVariants: {
21 | variant: 'default',
22 | },
23 | }
24 | )
25 |
26 | export type BadgeProps = {} & React.HTMLAttributes &
27 | VariantProps
28 |
29 | function Badge({ className, variant, ...props }: BadgeProps) {
30 | return (
31 |
32 | )
33 | }
34 |
35 | export { Badge, badgeVariants }
36 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: Array) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
8 | export async function handleDownload({
9 | url,
10 | imageDescription,
11 | }: {
12 | url: string
13 | imageDescription: string
14 | }) {
15 | const response = await fetch(url)
16 | const blob = await response.blob()
17 | const objectUrl = URL.createObjectURL(blob)
18 | const formattedFilename = formatImageFilename({ imageDescription })
19 |
20 | const link = document.createElement('a')
21 | link.href = objectUrl
22 | link.download = `${formattedFilename}.jpg`
23 | document.body.appendChild(link)
24 | link.click()
25 | document.body.removeChild(link)
26 | URL.revokeObjectURL(objectUrl)
27 | }
28 |
29 | // Matches one or more whitespace characters (spaces, tabs, newlines)
30 | const WHITESPACE_REGEX = /\s+/g
31 | // Matches any character that is NOT a lowercase letter, number, or underscore
32 | const NON_ALPHANUMERIC_UNDERSCORE_REGEX = /[^a-z0-9_]/g
33 |
34 | export function formatImageFilename({
35 | imageDescription,
36 | }: {
37 | imageDescription: string
38 | }) {
39 | const lowercased = imageDescription.toLowerCase()
40 | const joinedViaUnderscore = lowercased.replace(WHITESPACE_REGEX, '_')
41 | const sanitized = joinedViaUnderscore.replace(
42 | NON_ALPHANUMERIC_UNDERSCORE_REGEX,
43 | ''
44 | )
45 |
46 | return sanitized
47 | }
48 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hinata",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview",
11 | "test": "vitest"
12 | },
13 | "dependencies": {
14 | "@radix-ui/react-select": "^2.1.2",
15 | "@radix-ui/react-slot": "^1.1.0",
16 | "@tanstack/react-query": "^5.61.5",
17 | "blurhash": "^2.0.5",
18 | "class-variance-authority": "^0.7.1",
19 | "clsx": "^2.1.1",
20 | "lucide-react": "^0.462.0",
21 | "react": "^18.3.1",
22 | "react-blurhash": "^0.3.0",
23 | "react-dom": "^18.3.1",
24 | "react-router": "^7.1.0",
25 | "tailwind-merge": "^2.5.5",
26 | "tailwindcss-animate": "^1.0.7",
27 | "zod": "^3.23.8"
28 | },
29 | "devDependencies": {
30 | "@eslint/js": "^9.15.0",
31 | "@tanstack/react-query-devtools": "^5.61.5",
32 | "@types/node": "^22.10.0",
33 | "@types/react": "^18.3.12",
34 | "@types/react-dom": "^18.3.1",
35 | "@vitejs/plugin-react": "^4.3.4",
36 | "autoprefixer": "^10.4.20",
37 | "eslint": "^9.15.0",
38 | "eslint-plugin-react-hooks": "^5.0.0",
39 | "eslint-plugin-react-refresh": "^0.4.14",
40 | "globals": "^15.12.0",
41 | "postcss": "^8.4.49",
42 | "prettier-plugin-tailwindcss": "^0.6.9",
43 | "tailwindcss": "^3.4.15",
44 | "typescript": "~5.6.2",
45 | "typescript-eslint": "^8.15.0",
46 | "vite": "^6.0.1",
47 | "vitest": "^2.1.8"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/pages/home/useImageSearch.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteQuery } from '@tanstack/react-query'
2 | import { type SearchParams } from '@/lib/schemas'
3 | import { photoKeys } from '@/lib/queryKeys'
4 | import { api } from '@/lib/api'
5 | import { useEffect } from 'react'
6 |
7 | export function useImageSearch({ params }: { params: SearchParams }) {
8 | const query = useInfiniteQuery({
9 | queryKey: photoKeys.searchResults({
10 | query: params.query,
11 | orderBy: params.orderBy,
12 | color: params.color,
13 | perPage: params.perPage,
14 | }),
15 | queryFn: ({ pageParam }) =>
16 | api.searchPhotos({
17 | ...params,
18 | page: pageParam,
19 | }),
20 | initialPageParam: params.page,
21 | getNextPageParam: (lastPage, _allPages, lastPageParam) => {
22 | const hasNoMorePages = lastPageParam >= lastPage.total_pages
23 | if (hasNoMorePages) {
24 | return undefined
25 | }
26 | return lastPageParam + 1
27 | },
28 | enabled: !!params.query,
29 | })
30 |
31 | useEffect(() => {
32 | if (!query.data || !params.query) return
33 |
34 | const loadUpToInitialPage = async () => {
35 | const loadedPages = query.data.pages.length
36 |
37 | if (loadedPages < params.page) {
38 | try {
39 | await query.fetchNextPage()
40 | } catch (error) {
41 | // TODO: handle error
42 | console.error('Error loading pages:', error)
43 | }
44 | }
45 | }
46 |
47 | loadUpToInitialPage().catch(console.error)
48 | }, [params.page, params.query, query])
49 |
50 | return query
51 | }
52 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | parser: tseslint.parser,
16 | parserOptions: {
17 | project: './tsconfig.app.json',
18 | ecmaFeatures: {
19 | jsx: true,
20 | },
21 | },
22 | },
23 | plugins: {
24 | 'react-hooks': reactHooks,
25 | 'react-refresh': reactRefresh,
26 | },
27 | rules: {
28 | ...reactHooks.configs.recommended.rules,
29 | 'react-refresh/only-export-components': [
30 | 'warn',
31 | { allowConstantExport: true },
32 | ],
33 | 'no-await-in-loop': 'error',
34 | '@typescript-eslint/array-type': ['error', { default: 'generic' }],
35 | '@typescript-eslint/naming-convention': [
36 | 'error',
37 | {
38 | selector: 'variable',
39 | types: ['boolean'],
40 | format: ['PascalCase'],
41 | prefix: ['is', 'should', 'has', 'are', 'can', 'was'],
42 | },
43 | ],
44 | '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
45 | '@typescript-eslint/no-floating-promises': 'warn',
46 | '@typescript-eslint/no-redeclare': 'error',
47 | },
48 | }
49 | )
50 |
--------------------------------------------------------------------------------
/src/lib/schemas.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const colorSchema = z.enum([
4 | 'black_and_white',
5 | 'black',
6 | 'white',
7 | 'yellow',
8 | 'orange',
9 | 'red',
10 | 'purple',
11 | 'magenta',
12 | 'green',
13 | 'teal',
14 | 'blue',
15 | ])
16 |
17 | export const orderBySchema = z.enum(['latest', 'relevant'])
18 |
19 | export const userSchema = z.object({
20 | name: z.string(),
21 | username: z.string(),
22 | bio: z.string().nullable(),
23 | location: z.string().nullable(),
24 | total_photos: z.number(),
25 | profile_image: z.object({
26 | small: z.string().url(),
27 | medium: z.string().url(),
28 | large: z.string().url(),
29 | }),
30 | })
31 |
32 | export type User = z.infer
33 |
34 | export const photoSchema = z.object({
35 | id: z.string(),
36 | description: z.string().nullable(),
37 | blur_hash: z.string().nullable(),
38 | urls: z.object({
39 | small: z.string().url(),
40 | regular: z.string().url(),
41 | full: z.string().url(),
42 | raw: z.string().url(),
43 | }),
44 | location: z
45 | .object({
46 | city: z.string().nullable(),
47 | country: z.string().nullable(),
48 | })
49 | .optional(),
50 | tags: z.array(z.object({ title: z.string() })).optional(),
51 | width: z.number(),
52 | height: z.number(),
53 | user: userSchema,
54 | })
55 |
56 | export const searchParamsSchema = z.object({
57 | query: z.string().min(1),
58 | page: z.coerce.number().positive(),
59 | orderBy: orderBySchema.default('relevant'),
60 | perPage: z.coerce.number(),
61 | color: colorSchema.optional(),
62 | })
63 |
64 | export type Photo = z.infer
65 | export type SearchParams = z.infer
66 | export type ColorOption = z.infer
67 | export type OrderByOption = z.infer
68 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import * as tailwindAnimate from 'tailwindcss-animate'
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | export default {
5 | darkMode: ['class'],
6 | content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
7 | theme: {
8 | extend: {
9 | borderRadius: {
10 | lg: 'var(--radius)',
11 | md: 'calc(var(--radius) - 2px)',
12 | sm: 'calc(var(--radius) - 4px)',
13 | },
14 | colors: {
15 | background: 'hsl(var(--background))',
16 | foreground: 'hsl(var(--foreground))',
17 | card: {
18 | DEFAULT: 'hsl(var(--card))',
19 | foreground: 'hsl(var(--card-foreground))',
20 | },
21 | popover: {
22 | DEFAULT: 'hsl(var(--popover))',
23 | foreground: 'hsl(var(--popover-foreground))',
24 | },
25 | primary: {
26 | DEFAULT: 'hsl(var(--primary))',
27 | foreground: 'hsl(var(--primary-foreground))',
28 | },
29 | secondary: {
30 | DEFAULT: 'hsl(var(--secondary))',
31 | foreground: 'hsl(var(--secondary-foreground))',
32 | },
33 | muted: {
34 | DEFAULT: 'hsl(var(--muted))',
35 | foreground: 'hsl(var(--muted-foreground))',
36 | },
37 | accent: {
38 | DEFAULT: 'hsl(var(--accent))',
39 | foreground: 'hsl(var(--accent-foreground))',
40 | },
41 | destructive: {
42 | DEFAULT: 'hsl(var(--destructive))',
43 | foreground: 'hsl(var(--destructive-foreground))',
44 | },
45 | border: 'hsl(var(--border))',
46 | input: 'hsl(var(--input))',
47 | ring: 'hsl(var(--ring))',
48 | chart: {
49 | 1: 'hsl(var(--chart-1))',
50 | 2: 'hsl(var(--chart-2))',
51 | 3: 'hsl(var(--chart-3))',
52 | 4: 'hsl(var(--chart-4))',
53 | 5: 'hsl(var(--chart-5))',
54 | },
55 | },
56 | },
57 | },
58 | plugins: [tailwindAnimate],
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Slot } from '@radix-ui/react-slot'
3 | import { cva, type VariantProps } from 'class-variance-authority'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
14 | destructive:
15 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
16 | outline:
17 | 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
18 | secondary:
19 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
20 | ghost: 'hover:bg-accent hover:text-accent-foreground',
21 | link: 'text-primary underline-offset-4 hover:underline',
22 | },
23 | size: {
24 | default: 'h-9 px-4 py-2',
25 | sm: 'h-8 rounded-md px-3 text-xs',
26 | lg: 'h-10 rounded-md px-8',
27 | icon: 'h-9 w-9',
28 | },
29 | },
30 | defaultVariants: {
31 | variant: 'default',
32 | size: 'default',
33 | },
34 | }
35 | )
36 |
37 | export type ButtonProps = {
38 | asChild?: boolean
39 | } & React.ButtonHTMLAttributes &
40 | VariantProps
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : 'button'
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = 'Button'
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/src/components/photos/ImageGrid.tsx:
--------------------------------------------------------------------------------
1 | import { DEFAULT_QUERY_PARAM_VALUES } from '@/lib/constants'
2 | import { ImageGridItem } from './ImageGridItem'
3 | import { Photo } from '@/lib/schemas'
4 | import { breakpoints, useMediaQuery } from '@/hooks/useMediaQuery'
5 |
6 | export type ImageWithPageIndex = {
7 | image: Photo
8 | pageIndex: number
9 | }
10 |
11 | export function ImageGrid({
12 | images,
13 | }: {
14 | images: Array | Array
15 | }) {
16 | const isDesktop = useMediaQuery(breakpoints.md)
17 |
18 | return (
19 |
20 | {images.map((data, index) => {
21 | // On mobile we show a single column layout
22 | const isImageAmongFirstResults = index < 3
23 | const shouldLazyLoadOnMobile = isImageAmongFirstResults && !isDesktop
24 |
25 | const isImageWithPageIndex = 'pageIndex' in data
26 |
27 | if (isImageWithPageIndex) {
28 | const { image, pageIndex } = data
29 |
30 | const isImageAmongPaginatedResults =
31 | pageIndex + 1 !== DEFAULT_QUERY_PARAM_VALUES.page
32 |
33 | // On home page we typically get away with showing a lot of images in the first page
34 | const shouldLazyLoadOnDesktop = isImageAmongPaginatedResults
35 |
36 | const shouldLazyLoad =
37 | shouldLazyLoadOnMobile || shouldLazyLoadOnDesktop
38 |
39 | return (
40 |
46 | )
47 | }
48 |
49 | // On profile page
50 | // All images aren't visible directly on desktop
51 | // First 6 images are usually visible
52 | const shouldLazyLoadOnDesktop = isDesktop && index > 5
53 |
54 | const shouldLazyLoad = shouldLazyLoadOnMobile || shouldLazyLoadOnDesktop
55 |
56 | return (
57 |
62 | )
63 | })}
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body {
7 | height: 100%;
8 | width: 100%;
9 | font-family: 'Roboto', sans-serif;
10 | }
11 |
12 | #root {
13 | min-height: 100%;
14 | width: 100%;
15 | }
16 |
17 | a:focus,
18 | button:focus {
19 | outline: none;
20 | }
21 |
22 | a:focus-visible,
23 | button:focus-visible {
24 | outline: 2px solid #000;
25 | outline-offset: 4px;
26 | }
27 |
28 | @layer base {
29 | :root {
30 | --background: 0 0% 100%;
31 | --foreground: 240 10% 3.9%;
32 | --card: 0 0% 100%;
33 | --card-foreground: 240 10% 3.9%;
34 | --popover: 0 0% 100%;
35 | --popover-foreground: 240 10% 3.9%;
36 | --primary: 240 5.9% 10%;
37 | --primary-foreground: 0 0% 98%;
38 | --secondary: 240 4.8% 95.9%;
39 | --secondary-foreground: 240 5.9% 10%;
40 | --muted: 240 4.8% 95.9%;
41 | --muted-foreground: 240 3.8% 46.1%;
42 | --accent: 240 4.8% 95.9%;
43 | --accent-foreground: 240 5.9% 10%;
44 | --destructive: 0 84.2% 60.2%;
45 | --destructive-foreground: 0 0% 98%;
46 | --border: 240 5.9% 90%;
47 | --input: 240 5.9% 90%;
48 | --ring: 240 10% 3.9%;
49 | --chart-1: 12 76% 61%;
50 | --chart-2: 173 58% 39%;
51 | --chart-3: 197 37% 24%;
52 | --chart-4: 43 74% 66%;
53 | --chart-5: 27 87% 67%;
54 | --radius: 0.5rem;
55 | }
56 | .dark {
57 | --background: 240 10% 3.9%;
58 | --foreground: 0 0% 98%;
59 | --card: 240 10% 3.9%;
60 | --card-foreground: 0 0% 98%;
61 | --popover: 240 10% 3.9%;
62 | --popover-foreground: 0 0% 98%;
63 | --primary: 0 0% 98%;
64 | --primary-foreground: 240 5.9% 10%;
65 | --secondary: 240 3.7% 15.9%;
66 | --secondary-foreground: 0 0% 98%;
67 | --muted: 240 3.7% 15.9%;
68 | --muted-foreground: 240 5% 64.9%;
69 | --accent: 240 3.7% 15.9%;
70 | --accent-foreground: 0 0% 98%;
71 | --destructive: 0 62.8% 30.6%;
72 | --destructive-foreground: 0 0% 98%;
73 | --border: 240 3.7% 15.9%;
74 | --input: 240 3.7% 15.9%;
75 | --ring: 240 4.9% 83.9%;
76 | --chart-1: 220 70% 50%;
77 | --chart-2: 160 60% 45%;
78 | --chart-3: 30 80% 55%;
79 | --chart-4: 280 65% 60%;
80 | --chart-5: 340 75% 55%;
81 | }
82 | }
83 |
84 | @layer base {
85 | * {
86 | @apply border-border;
87 | }
88 | body {
89 | @apply bg-background text-foreground;
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/pages/home/SearchResults.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 | import { ImageGridSkeleton } from '../../components/photos/ImageGridSkeleton'
3 | import { DEFAULT_QUERY_PARAM_VALUES } from '@/lib/constants'
4 | import {
5 | ImageGrid,
6 | ImageWithPageIndex,
7 | } from '../../components/photos/ImageGrid'
8 |
9 | type SearchResultsProps = {
10 | isLoading: boolean
11 | hasResults: boolean
12 | hasNoResults: boolean
13 | hasNoResultsMessage?: string
14 | isError: boolean
15 | itemsCountMessage: string
16 | allImagesWithPages: Array
17 | hasNextPage: boolean
18 | isFetchingNextPage: boolean
19 | handleLoadMore: () => void
20 | }
21 |
22 | export function SearchResults({
23 | isLoading,
24 | hasResults,
25 | hasNoResults,
26 | isError,
27 | itemsCountMessage,
28 | allImagesWithPages,
29 | hasNextPage,
30 | isFetchingNextPage,
31 | handleLoadMore,
32 | hasNoResultsMessage = 'No results',
33 | }: SearchResultsProps) {
34 | const observerRef = useRef(null)
35 |
36 | useEffect(() => {
37 | const node = observerRef.current
38 | if (!node || !hasNextPage || isFetchingNextPage) return
39 |
40 | const observer = new IntersectionObserver(
41 | (entries) => {
42 | if (entries[0].isIntersecting) {
43 | handleLoadMore()
44 | }
45 | },
46 | { threshold: 0.5 }
47 | )
48 |
49 | observer.observe(node)
50 | return () => {
51 | observer.disconnect()
52 | }
53 | }, [handleLoadMore, hasNextPage, isFetchingNextPage])
54 |
55 | if (isLoading) {
56 | return
57 | }
58 |
59 | if (hasResults) {
60 | return (
61 |
62 |
{itemsCountMessage}
63 |
64 | {hasNextPage &&
}
65 |
66 | )
67 | }
68 |
69 | if (hasNoResults) {
70 | return (
71 |
72 |
{hasNoResultsMessage}
73 |
74 | )
75 | }
76 |
77 | if (isError) {
78 | return (
79 |
80 |
81 | An error occurred while fetching images.
82 |
83 |
Please try again later.
84 |
85 | )
86 | }
87 |
88 | return null
89 | }
90 |
--------------------------------------------------------------------------------
/src/pages/home/index.tsx:
--------------------------------------------------------------------------------
1 | import { SearchParams } from '@/lib/schemas'
2 | import { useCallback } from 'react'
3 | import { useImageSearch } from './useImageSearch'
4 | import { DEFAULT_QUERY_PARAM_VALUES, QUERY_PARAMS } from '@/lib/constants'
5 |
6 | import { useSearchParams } from 'react-router'
7 | import { ImageWithPageIndex } from '@/components/photos/ImageGrid'
8 | import { SearchResults } from './SearchResults'
9 |
10 | export function HomePage() {
11 | const [searchParams, setSearchParams] = useSearchParams()
12 |
13 | const currentParams: SearchParams = {
14 | query: searchParams.get(QUERY_PARAMS.query) || '',
15 | page: Number(searchParams.get(QUERY_PARAMS.page)) || 1,
16 | color:
17 | (searchParams.get(QUERY_PARAMS.color) as SearchParams['color']) ||
18 | undefined,
19 | orderBy:
20 | (searchParams.get(QUERY_PARAMS.orderBy) as SearchParams['orderBy']) ||
21 | DEFAULT_QUERY_PARAM_VALUES.orderBy,
22 | perPage: DEFAULT_QUERY_PARAM_VALUES.perPage,
23 | }
24 |
25 | const {
26 | data,
27 | isLoading,
28 | isError,
29 | isFetchingNextPage,
30 | hasNextPage,
31 | fetchNextPage,
32 | } = useImageSearch({
33 | params: currentParams,
34 | })
35 |
36 | const handleLoadMore = useCallback(() => {
37 | fetchNextPage()
38 | .then(() => {
39 | setSearchParams(
40 | (prev) => {
41 | const newParams = new URLSearchParams(prev)
42 | newParams.set(QUERY_PARAMS.page, String(currentParams.page + 1))
43 | return newParams
44 | },
45 | { replace: true }
46 | )
47 | })
48 | .catch((error) => {
49 | console.error('Error fetching next page', error)
50 | })
51 | }, [fetchNextPage, setSearchParams, currentParams.page])
52 |
53 | const allImagesWithPages: Array =
54 | data?.pages.flatMap((page, pageIndex) =>
55 | page.results.map((image) => ({
56 | image,
57 | pageIndex,
58 | }))
59 | ) ?? []
60 |
61 | const hasResults = allImagesWithPages.length > 0
62 | const hasNoResults = currentParams.query !== '' && !hasResults && !isError
63 |
64 | const firstPage = data?.pages[0]
65 | const totalItems = firstPage?.total ?? 0
66 | const loadedItemsCount = allImagesWithPages.length
67 |
68 | const itemsCountMessage = firstPage
69 | ? `Showing ${loadedItemsCount} of ${totalItems} photos`
70 | : ''
71 |
72 | return (
73 |
74 | Unsplash search
75 |
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/src/lib/api.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { env } from './env'
3 | import type { Photo, SearchParams, User } from './schemas'
4 | import { photoSchema, userSchema } from './schemas'
5 |
6 | const BASE_URL = 'https://api.unsplash.com'
7 |
8 | export const searchPhotosResponseSchema = z.object({
9 | total: z.number(),
10 | total_pages: z.number(),
11 | results: z.array(photoSchema),
12 | })
13 |
14 | export type SearchPhotosResponse = z.infer
15 |
16 | export const api = {
17 | searchPhotos: async (params: SearchParams): Promise => {
18 | const queryParams = new URLSearchParams({
19 | query: params.query,
20 | page: String(params.page),
21 | per_page: String(params.perPage),
22 | order_by: params.orderBy,
23 | ...(params.color && { color: params.color }),
24 | })
25 |
26 | const response = await fetch(
27 | `${BASE_URL}/search/photos?${queryParams.toString()}`,
28 | {
29 | headers: {
30 | Authorization: `Client-ID ${env.UNSPLASH_ACCESS_KEY}`,
31 | },
32 | }
33 | )
34 |
35 | if (!response.ok) {
36 | throw new Error('Failed to fetch photos')
37 | }
38 |
39 | const results = await response.json()
40 |
41 | const parsedResult = searchPhotosResponseSchema.safeParse(results)
42 |
43 | if (!parsedResult.success) {
44 | console.error('Validation error:', parsedResult.error)
45 | throw new Error('Invalid API response format')
46 | }
47 |
48 | return parsedResult.data
49 | },
50 |
51 | getPhotoDetail: async (id: string | undefined): Promise => {
52 | if (!id) return null
53 |
54 | const response = await fetch(`${BASE_URL}/photos/${id}`, {
55 | headers: {
56 | Authorization: `Client-ID ${env.UNSPLASH_ACCESS_KEY}`,
57 | },
58 | })
59 |
60 | if (!response.ok) {
61 | throw new Error('Failed to fetch photo detail')
62 | }
63 |
64 | const photoResult = await response.json()
65 |
66 | const parsedResult = photoSchema.safeParse(photoResult)
67 |
68 | if (!parsedResult.success) {
69 | console.error('Validation error:', parsedResult.error)
70 | throw new Error('Invalid API response format')
71 | }
72 |
73 | return parsedResult.data
74 | },
75 |
76 | getUser: async (username: string | undefined): Promise => {
77 | if (!username) return null
78 |
79 | const response = await fetch(`${BASE_URL}/users/${username}`, {
80 | headers: {
81 | Authorization: `Client-ID ${env.UNSPLASH_ACCESS_KEY}`,
82 | },
83 | })
84 |
85 | if (!response.ok) {
86 | throw new Error('Failed to fetch user detail')
87 | }
88 |
89 | const userResult = await response.json()
90 |
91 | const parsedResult = userSchema.safeParse(userResult)
92 |
93 | if (!parsedResult.success) {
94 | console.error('Validation error:', parsedResult.error)
95 | throw new Error('Invalid API response format')
96 | }
97 |
98 | return parsedResult.data
99 | },
100 |
101 | getUserPhotos: async ({
102 | username,
103 | queryParams: { page, perPage },
104 | }: {
105 | username: string | undefined
106 | queryParams: Pick
107 | }): Promise> => {
108 | if (!username) return []
109 |
110 | const queryParams = new URLSearchParams({
111 | page: String(page),
112 | per_page: String(perPage),
113 | })
114 |
115 | const response = await fetch(
116 | `${BASE_URL}/users/${username}/photos?${queryParams.toString()}`,
117 | {
118 | headers: {
119 | Authorization: `Client-ID ${env.UNSPLASH_ACCESS_KEY}`,
120 | },
121 | }
122 | )
123 |
124 | if (!response.ok) {
125 | throw new Error('Failed to fetch user photos')
126 | }
127 |
128 | const photosResults = await response.json()
129 |
130 | console.log('photosResults', photosResults)
131 |
132 | const parsedResult = z.array(photoSchema).safeParse(photosResults)
133 |
134 | if (!parsedResult.success) {
135 | console.error('Validation error:', parsedResult.error)
136 | throw new Error('Invalid API response format')
137 | }
138 |
139 | return parsedResult.data
140 | },
141 | } as const
142 |
--------------------------------------------------------------------------------
/src/pages/user-detail/index.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from 'react-router'
2 | import { useGetUser } from './useGetUser'
3 | import { MapPinIcon } from 'lucide-react'
4 | import { SearchParams } from '@/lib/schemas'
5 | import { useGetUserPhotos } from './useGetUserPhotos'
6 | import { ImageGrid } from '@/components/photos/ImageGrid'
7 | import { ImageGridSkeleton } from '@/components/photos/ImageGridSkeleton'
8 | import { ProfileImage } from '@/components/core/ProfileImage'
9 |
10 | // The reason for hardcoding and not doing what we did in the home page:
11 | // ...Unsplash API doesn't return results as paginated results for `/users/:username/photos` endpoint
12 | // Rather, they simply return an array of photos
13 | // However, we can decide how many photos we want to load
14 | // I KNOW so annoying, like why let me provide a page number if I can't paginate?!
15 | export const USER_DETAIL_PHOTOS_PAGE_INDEX = 1
16 | export const USER_DETAIL_PHOTOS_PER_PAGE = 50
17 |
18 | export function UserDetailPage() {
19 | const { username } = useParams()
20 | return (
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | function UserHeaderSkeleton() {
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
{' '}
36 |
{' '}
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | )
45 | }
46 |
47 | function UserHeader({ username }: { username: string | undefined }) {
48 | const {
49 | isError: isUserError,
50 | isLoading: isUserLoading,
51 | data: user,
52 | } = useGetUser(username)
53 |
54 | if (isUserError)
55 | return (
56 |
57 |
Error fetching user
58 |
59 | )
60 |
61 | if (isUserLoading || !user) return
62 |
63 | return (
64 |
65 |
71 |
72 |
73 |
74 |
{user.name}
75 |
76 | @{user.username}
77 |
78 |
79 |
80 | {user.bio && (
81 |
{user.bio}
82 | )}
83 |
84 |
85 | {user.location && (
86 |
87 |
88 |
89 | {user.location.split(',').join(', ')}
90 |
91 |
92 | )}
93 |
94 | )
95 | }
96 |
97 | function UserPhotos({ username }: { username: string | undefined }) {
98 | const currentParams: Pick = {
99 | page: USER_DETAIL_PHOTOS_PAGE_INDEX,
100 | perPage: USER_DETAIL_PHOTOS_PER_PAGE,
101 | }
102 |
103 | const {
104 | data: photos,
105 | isLoading: isPhotosLoading,
106 | isError: isPhotosError,
107 | } = useGetUserPhotos({
108 | username,
109 | queryParams: currentParams,
110 | })
111 |
112 | if (isPhotosError)
113 | return (
114 |
115 |
Error fetching photos
116 |
117 | )
118 |
119 | if (isPhotosLoading || !photos)
120 | return
121 |
122 | return
123 | }
124 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as SelectPrimitive from '@radix-ui/react-select'
3 | import { Check, ChevronDown, ChevronUp } from 'lucide-react'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const Select = SelectPrimitive.Root
8 |
9 | const SelectGroup = SelectPrimitive.Group
10 |
11 | const SelectValue = SelectPrimitive.Value
12 |
13 | const SelectTrigger = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, children, ...props }, ref) => (
17 | span]:line-clamp-1',
21 | className
22 | )}
23 | {...props}
24 | >
25 | {children}
26 |
27 |
28 |
29 |
30 | ))
31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
32 |
33 | const SelectScrollUpButton = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 |
46 |
47 | ))
48 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
49 |
50 | const SelectScrollDownButton = React.forwardRef<
51 | React.ElementRef,
52 | React.ComponentPropsWithoutRef
53 | >(({ className, ...props }, ref) => (
54 |
62 |
63 |
64 | ))
65 | SelectScrollDownButton.displayName =
66 | SelectPrimitive.ScrollDownButton.displayName
67 |
68 | const SelectContent = React.forwardRef<
69 | React.ElementRef,
70 | React.ComponentPropsWithoutRef
71 | >(({ className, children, position = 'popper', ...props }, ref) => (
72 |
73 |
84 |
85 |
92 | {children}
93 |
94 |
95 |
96 |
97 | ))
98 | SelectContent.displayName = SelectPrimitive.Content.displayName
99 |
100 | const SelectLabel = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
109 | ))
110 | SelectLabel.displayName = SelectPrimitive.Label.displayName
111 |
112 | const SelectItem = React.forwardRef<
113 | React.ElementRef,
114 | React.ComponentPropsWithoutRef
115 | >(({ className, children, ...props }, ref) => (
116 |
124 |
125 |
126 |
127 |
128 |
129 | {children}
130 |
131 | ))
132 | SelectItem.displayName = SelectPrimitive.Item.displayName
133 |
134 | const SelectSeparator = React.forwardRef<
135 | React.ElementRef,
136 | React.ComponentPropsWithoutRef
137 | >(({ className, ...props }, ref) => (
138 |
143 | ))
144 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
145 |
146 | export {
147 | Select,
148 | SelectGroup,
149 | SelectValue,
150 | SelectTrigger,
151 | SelectContent,
152 | SelectLabel,
153 | SelectItem,
154 | SelectSeparator,
155 | SelectScrollUpButton,
156 | SelectScrollDownButton,
157 | }
158 |
--------------------------------------------------------------------------------
/src/layouts/root/SearchForm.tsx:
--------------------------------------------------------------------------------
1 | import { FormEvent, useState, useCallback } from 'react'
2 | import { DEFAULT_QUERY_PARAM_VALUES, QUERY_PARAMS } from '@/lib/constants'
3 | import { useNavigate, useSearchParams } from 'react-router'
4 | import { useIsFetching } from '@tanstack/react-query'
5 | import { photoKeys } from '@/lib/queryKeys'
6 |
7 | import {
8 | orderBySchema,
9 | colorSchema,
10 | SearchParams,
11 | ColorOption,
12 | } from '@/lib/schemas'
13 | import { useId } from 'react'
14 | import { Button } from '@/components/ui/button'
15 | import { Input } from '@/components/ui/input'
16 | import {
17 | Select,
18 | SelectContent,
19 | SelectItem,
20 | SelectTrigger,
21 | SelectValue,
22 | } from '@/components/ui/select'
23 | import { Search as SearchIcon } from 'lucide-react'
24 |
25 | // Observed from the dev tools
26 | export const SEARCH_FORM_HEIGHT = 136
27 |
28 | const SORT_OPTIONS: Array<{ value: SearchParams['orderBy']; label: string }> = [
29 | {
30 | value: 'relevant',
31 | label: 'Relevant',
32 | },
33 | {
34 | value: 'latest',
35 | label: 'Latest',
36 | },
37 | ]
38 |
39 | const COLOR_OPTIONS: Array<{ value: ColorOption; label: string }> = [
40 | {
41 | value: 'black_and_white',
42 | label: 'Black and White',
43 | },
44 | {
45 | value: 'black',
46 | label: 'Black',
47 | },
48 | {
49 | value: 'white',
50 | label: 'White',
51 | },
52 | {
53 | value: 'yellow',
54 | label: 'Yellow',
55 | },
56 | {
57 | value: 'orange',
58 | label: 'Orange',
59 | },
60 | {
61 | value: 'green',
62 | label: 'Green',
63 | },
64 | {
65 | value: 'teal',
66 | label: 'Teal',
67 | },
68 | {
69 | value: 'blue',
70 | label: 'Blue',
71 | },
72 | {
73 | value: 'purple',
74 | label: 'Purple',
75 | },
76 | {
77 | value: 'red',
78 | label: 'Red',
79 | },
80 | {
81 | value: 'magenta',
82 | label: 'Magenta',
83 | },
84 | ]
85 |
86 | export function SearchForm() {
87 | const [searchParams, setSearchParams] = useSearchParams()
88 | const navigate = useNavigate()
89 |
90 | const currentParams: SearchParams = {
91 | query: searchParams.get(QUERY_PARAMS.query) || '',
92 | page: Number(searchParams.get(QUERY_PARAMS.page)) || 1,
93 | color:
94 | (searchParams.get(QUERY_PARAMS.color) as SearchParams['color']) ||
95 | undefined,
96 | orderBy:
97 | (searchParams.get(QUERY_PARAMS.orderBy) as SearchParams['orderBy']) ||
98 | DEFAULT_QUERY_PARAM_VALUES.orderBy,
99 | perPage: DEFAULT_QUERY_PARAM_VALUES.perPage,
100 | }
101 |
102 | const [inputValue, setInputValue] = useState(currentParams.query)
103 | const searchId = useId()
104 |
105 | const handleSubmit = (event: FormEvent) => {
106 | event.preventDefault()
107 | const trimmedValue = inputValue.trim()
108 | if (!trimmedValue || isLoading) return
109 |
110 | const newParams = new URLSearchParams(searchParams)
111 | newParams.set(QUERY_PARAMS.query, trimmedValue)
112 | newParams.set(QUERY_PARAMS.page, DEFAULT_QUERY_PARAM_VALUES.page.toString())
113 |
114 | void navigate(`/?${newParams.toString()}`)
115 | }
116 |
117 | const updateParams = useCallback(
118 | (updates: Partial) => {
119 | setSearchParams((prev) => {
120 | const newParams = new URLSearchParams(prev)
121 |
122 | Object.entries(updates).forEach(([key, value]) => {
123 | if (value) {
124 | newParams.set(key, String(value))
125 | } else {
126 | newParams.delete(key)
127 | }
128 | })
129 |
130 | if ('color' in updates || 'orderBy' in updates) {
131 | newParams.set(
132 | QUERY_PARAMS.page,
133 | DEFAULT_QUERY_PARAM_VALUES.page.toString()
134 | )
135 | }
136 |
137 | return newParams
138 | })
139 | },
140 | [setSearchParams]
141 | )
142 |
143 | const queriesFetchingSearchResults = useIsFetching({
144 | queryKey: photoKeys.searchResults({
145 | orderBy: currentParams.orderBy,
146 | perPage: currentParams.perPage,
147 | color: currentParams.color,
148 | query: currentParams.query,
149 | }),
150 | })
151 |
152 | const isLoading = queriesFetchingSearchResults > 0
153 |
154 | return (
155 |
219 | )
220 | }
221 |
--------------------------------------------------------------------------------
/src/components/photos/ImageGridItem.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { api } from '@/lib/api'
3 | import { ROUTES } from '@/lib/constants'
4 | import { photoKeys, userKeys } from '@/lib/queryKeys'
5 | import { Photo } from '@/lib/schemas'
6 | import { cn, handleDownload } from '@/lib/utils'
7 | import {
8 | USER_DETAIL_PHOTOS_PAGE_INDEX,
9 | USER_DETAIL_PHOTOS_PER_PAGE,
10 | } from '@/pages/user-detail'
11 | import { useQueryClient } from '@tanstack/react-query'
12 | import { DownloadIcon } from 'lucide-react'
13 | import { Blurhash } from 'react-blurhash'
14 | import { generatePath, Link, useLocation } from 'react-router'
15 | import { ProfileImage } from '../core/ProfileImage'
16 | import { useState } from 'react'
17 |
18 | // This is a number you can play around with
19 | // you might even want different ones for desktop vs mobile depending on the images you're serving
20 | // 22 seems to work well on both mobile and desktop
21 | const MULTIPLIER_TO_TURN_ASPECT_RATIO_INTO_ROWS_TO_SPAN = 22
22 |
23 | const FALLBACK_IMAGE_DESCRIPTION = 'Unsplash photo'
24 |
25 | export function ImageGridItem({
26 | image,
27 | shouldLazyLoad,
28 | }: {
29 | image: Photo
30 | shouldLazyLoad: boolean
31 | }) {
32 | const location = useLocation()
33 |
34 | const [isImageLoaded, setIsImageLoaded] = useState(false)
35 |
36 | const queryClient = useQueryClient()
37 |
38 | // We do height / width to maintain the right proportions for height specifically
39 | // e.g height 800px and width 1200px
40 | // 800 / 1200 = 0.66
41 | // 0.66 means for every 1px of width, there are 0.66px of height
42 | // 0.66 * 22 = 14.66
43 | // Math.ceil(14.66) = 15
44 | // So the image will span 15 rows
45 | const rowsToSpanBasedOnAspectRatio = Math.ceil(
46 | (image.height / image.width) *
47 | MULTIPLIER_TO_TURN_ASPECT_RATIO_INTO_ROWS_TO_SPAN
48 | )
49 |
50 | const paddingBottom = `${(image.height / image.width) * 100}%`
51 |
52 | const desktopHoverOverlay = (
53 |
54 |
55 |
56 |
62 |
63 |
64 |
68 | {image.user.name}
69 |
70 |
71 | {image.user.username}
72 |
73 |
74 |
75 |
76 |
79 | handleDownload({
80 | url: image.urls.regular,
81 | imageDescription: image.description || FALLBACK_IMAGE_DESCRIPTION,
82 | })
83 | }
84 | className="pointer-events-auto"
85 | >
86 |
87 | Download
88 |
89 |
90 |
91 | )
92 |
93 | const mobileHeader = (
94 |
95 |
101 |
102 |
103 |
107 | {image.user.name}
108 |
109 |
{image.user.username}
110 |
111 |
112 | )
113 |
114 | const mobileFooter = (
115 |
118 | handleDownload({
119 | url: image.urls.regular,
120 | imageDescription: image.description || FALLBACK_IMAGE_DESCRIPTION,
121 | })
122 | }
123 | className="pointer-events-auto ml-auto md:hidden"
124 | >
125 |
126 | Download
127 |
128 | )
129 |
130 | function prefetchData() {
131 | void queryClient.prefetchQuery({
132 | queryKey: photoKeys.detail(image.id),
133 | queryFn: () => api.getPhotoDetail(image.id),
134 | })
135 |
136 | void queryClient.prefetchQuery({
137 | queryKey: userKeys.detail(image.user.username),
138 | queryFn: () => api.getUser(image.user.username),
139 | })
140 |
141 | void queryClient.prefetchQuery({
142 | queryKey: userKeys.photos(image.user.username),
143 | queryFn: () =>
144 | api.getUserPhotos({
145 | username: image.user.username,
146 | queryParams: {
147 | page: USER_DETAIL_PHOTOS_PAGE_INDEX,
148 | perPage: USER_DETAIL_PHOTOS_PER_PAGE,
149 | },
150 | }),
151 | })
152 | }
153 |
154 | return (
155 |
162 | {mobileHeader}
163 |
164 | {/* Link by default are inline elements that won't span the full width of the parent */}
165 | {/* Block span full width of parent and start on new lines */}
166 |
172 | {image.blur_hash ? (
173 |
174 |
175 |
176 | ) : (
177 |
178 | )}
179 |
180 | setIsImageLoaded(true)}
200 | />
201 |
202 |
203 | {desktopHoverOverlay}
204 | Photo by {image.user.name}
205 |
206 | {mobileFooter}
207 |
208 | )
209 | }
210 |
--------------------------------------------------------------------------------
/src/pages/photo-detail/index.tsx:
--------------------------------------------------------------------------------
1 | import { ROUTES } from '@/lib/constants'
2 | import { generatePath, Link, Navigate, useParams } from 'react-router'
3 | import { usePhotoDetail } from './usePhotoDetail'
4 | import { Button } from '@/components/ui/button'
5 | import { DownloadIcon } from 'lucide-react'
6 | import { SEARCH_FORM_HEIGHT } from '@/layouts/root/SearchForm'
7 | import { Badge } from '@/components/ui/badge'
8 | import { cn, handleDownload } from '@/lib/utils'
9 | import { useMediaQuery } from '@/hooks/useMediaQuery'
10 | import { breakpoints } from '@/hooks/useMediaQuery'
11 | import { useCallback, useEffect, useState } from 'react'
12 | import { ZoomOutIcon } from '@/icons/ZoomOut'
13 | import { ZoomInIcon } from '@/icons/ZoomIn'
14 | import { Blurhash } from 'react-blurhash'
15 | import { api } from '@/lib/api'
16 | import { useQueryClient } from '@tanstack/react-query'
17 | import { userKeys } from '@/lib/queryKeys'
18 | import {
19 | USER_DETAIL_PHOTOS_PAGE_INDEX,
20 | USER_DETAIL_PHOTOS_PER_PAGE,
21 | } from '../user-detail'
22 | import { ProfileImage } from '@/components/core/ProfileImage'
23 |
24 | const getOptimizedFullscreenUrl = (rawUrl: string) => {
25 | // Typical 4K monitor width is 3840px, but 2560px is often sufficient
26 | // dpr=2 for retina displays
27 | // fm=jpg for broad compatibility
28 | // q=85 for good quality/size balance
29 | // fit=max to maintain aspect ratio
30 | // auto=format for browser-appropriate format
31 | return `${rawUrl}&w=2560&dpr=2&fm=jpg&q=85&fit=max&auto=format`
32 | }
33 |
34 | function PhotoDetailSkeleton() {
35 | return (
36 |
37 | {/* Header shimmer */}
38 |
45 |
46 | {/* Image shimmer */}
47 |
48 |
49 | )
50 | }
51 |
52 | export function PhotoDetailPage() {
53 | const { id } = useParams()
54 |
55 | const isDesktop = useMediaQuery(breakpoints.md)
56 | const { data: image, isLoading, isError } = usePhotoDetail(id)
57 |
58 | const [isFullscreen, setIsFullscreen] = useState(false)
59 | const [optimizedFullscreenUrl, setOptimizedFullscreenUrl] = useState<
60 | string | null
61 | >(null)
62 |
63 | const queryClient = useQueryClient()
64 |
65 | useEffect(() => {
66 | if (isFullscreen) {
67 | document.body.style.overflow = 'hidden'
68 | } else {
69 | document.body.style.overflow = 'auto'
70 | }
71 | }, [isFullscreen])
72 |
73 | useEffect(() => {
74 | // This fetch is to work around the unsplash request headers no cache
75 | // In the real world, you only need `new Image()` to fetch, download and then store image in the browser cache
76 | if (isDesktop && image && !optimizedFullscreenUrl) {
77 | fetch(getOptimizedFullscreenUrl(image.urls.raw), {
78 | headers: {
79 | 'Cache-Control': 'max-age=31536000',
80 | Pragma: 'cache',
81 | },
82 | })
83 | .then((response) => response.blob())
84 | .then((blob) => {
85 | const objectUrl = URL.createObjectURL(blob)
86 | setOptimizedFullscreenUrl(objectUrl)
87 | })
88 | .catch((error) => {
89 | console.error('Error fetching optimized fullscreen image', error)
90 | })
91 | }
92 |
93 | return () => {
94 | if (optimizedFullscreenUrl) {
95 | URL.revokeObjectURL(optimizedFullscreenUrl)
96 | }
97 | }
98 | }, [image, isDesktop, optimizedFullscreenUrl])
99 |
100 | const handleFullscreenToggle = useCallback(() => {
101 | if (isDesktop) {
102 | setIsFullscreen((prev) => {
103 | return !prev
104 | })
105 | }
106 | }, [isDesktop])
107 |
108 | if (!id) {
109 | return
110 | }
111 |
112 | if (isLoading || !image) {
113 | return
114 | }
115 |
116 | if (isError) {
117 | return Error
118 | }
119 |
120 | function prefetchUserDetail() {
121 | if (!image) return
122 |
123 | void queryClient.prefetchQuery({
124 | queryKey: userKeys.detail(image.user.username),
125 | queryFn: () => api.getUser(image.user.username),
126 | })
127 |
128 | void queryClient.prefetchQuery({
129 | queryKey: userKeys.photos(image.user.username),
130 | queryFn: () =>
131 | api.getUserPhotos({
132 | username: image.user.username,
133 | queryParams: {
134 | page: USER_DETAIL_PHOTOS_PAGE_INDEX,
135 | perPage: USER_DETAIL_PHOTOS_PER_PAGE,
136 | },
137 | }),
138 | })
139 | }
140 |
141 | return (
142 |
143 |
150 |
155 |
161 |
162 |
163 | {image.user.name}
164 |
165 | {image.user.username}
166 |
167 |
168 |
169 |
170 |
174 | handleDownload({
175 | imageDescription: image.description || 'Unsplash photo',
176 | url: image.urls.regular,
177 | })
178 | }
179 | aria-label={`Download ${image.description || 'unsplash photo'}`}
180 | >
181 |
182 | Download
183 |
184 |
185 |
186 | {isFullscreen && (
187 |
188 |
206 | {image.blur_hash ? (
207 |
208 |
209 |
210 | ) : (
211 |
212 | )}
213 |
214 |
222 |
223 |
224 |
225 |
226 | )}
227 |
228 |
236 |
241 |
242 |
253 |
254 |
255 |
256 |
257 | {/* city and country can still be null */}
258 | {image.location && image.location.city && image.location.country && (
259 |
260 | Location:
261 |
262 | {image.location.city}, {image.location.country}
263 |
264 |
265 | )}
266 |
267 | {image.tags && (
268 |
269 | {image.tags.map((tag) => (
270 |
275 | {tag.title}
276 |
277 | ))}
278 |
279 | )}
280 |
281 |
282 |
287 | handleDownload({
288 | imageDescription: image.description || 'Unsplash photo',
289 | url: image.urls.regular,
290 | })
291 | }
292 | aria-label={`Download ${image.description || 'unsplash photo'}`}
293 | >
294 |
295 |
296 |
297 | )
298 | }
299 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hinata 🔍
2 |
3 | Built with Unsplash API. A site where you can search and download images.
4 |
5 | Had a lot of fun geeking out on performance.
6 |
7 | https://github.com/user-attachments/assets/d489615c-454b-4352-af7a-f43c5ea487ee
8 |
9 | # PS...
10 |
11 | If it isn't working, the rate limiting has been hit.
12 |
13 | # Get it up and running
14 |
15 | First, clone the repo and install the dependencies:
16 |
17 | ```bash
18 | pnpm install
19 | ```
20 |
21 | Create a `.env.local` file and add the following:
22 |
23 | ```bash
24 | VITE_UNSPLASH_ACCESS_KEY=
25 | ```
26 |
27 | Run the development server:
28 |
29 | ```bash
30 | pnpm dev
31 | ```
32 |
33 | # Explanations
34 |
35 |
36 | 🍿 Image performance
37 |
38 | ---
39 |
40 | # Quick snippet
41 |
42 | ```jsx
43 |
58 | ```
59 |
60 | You may look at this and go wow, I don't understand what's happening here besides the src and alt tag.
61 |
62 | Let's dig into the details.
63 |
64 | # srcSet and sizes
65 |
66 | With `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.
67 |
68 | On bigger screens, to keep it crisp, you want to use a bigger image.
69 |
70 | `sizes` is used to tell the browser roughly the width of the image depending on the screen width.
71 |
72 | This 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.
73 |
74 | That's why modern screens are so crisp.
75 |
76 | Summary: sizes and srcSet help us use the right image for the right screen size.
77 |
78 | # Lazy loading
79 |
80 | When 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.
81 |
82 | If the user must scroll or interact (e.g. carousel) for the image to become visible, it should be lazy loaded.
83 |
84 | When the image becomes visible, the browser will load the image.
85 |
86 | Under the hood, it uses intersection observer to detect when the image is visible.
87 |
88 | # Fetch priority
89 |
90 | `fetchPriority` is used to tell the browser the priority of the image.
91 |
92 | If 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.
93 |
94 | Low priority images is like telling the browser "load this image when you have time, otherwise leave it for later".
95 |
96 | What you don't want to happen is high priority images taking longer because low priority images are also being fetched and decoded.
97 |
98 | # Decoding
99 |
100 | `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.
101 |
102 | You might wonder, what's decoding?
103 |
104 | When 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.
105 |
106 | # Preloading images
107 |
108 | Have you ever wondered why despite having fetched the data, the image still takes a while to load?
109 |
110 | When the browser sees the image tag, it needs to:
111 |
112 | 1. Fetch the image
113 | 2. Download the image
114 | 3. Decode the image
115 |
116 | We can do this work ahead of time by using `new Image()` and setting the `src` to the image URL.
117 |
118 | ```js
119 | const image = new Image()
120 | image.src = {image url}
121 | ```
122 |
123 | `new Image()` is a way to create a new image object. It doesn't do anything else.
124 |
125 | When you do `image.src = {image url}`, the browser will fetch, download and decode the image.
126 |
127 | When the browser then sees the image tag, it can get it directly from the cache instead!
128 |
129 | You can listen to `image.onload` to know when the image is ready to be used. In react, this would be `onLoad`.
130 |
131 |
132 |
133 |
134 | 🍿 Blur hash
135 |
136 | ---
137 |
138 | If you dig into the code, you'll see that I'm using blur hash if the image hasn't loaded yet.
139 |
140 | ```jsx
141 |
147 | {image.blur_hash ? (
148 |
149 |
150 |
151 | ) : (
152 |
153 | )}
154 |
155 | setIsImageLoaded(true)}
175 | />
176 |
177 | ```
178 |
179 | Blur 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.
180 |
181 | The 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.
182 |
183 | This 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.
184 |
185 |
186 |
187 |
188 | 🍿 Prefetching data
189 |
190 | ---
191 |
192 | I'm using React Query to fetch and manage server state.
193 |
194 | One 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.
195 |
196 | This way, when they navigate to the next page, the data is already ready to be used.
197 |
198 | With React Query, we prefetch the data and store it in the cache.
199 |
200 | An example:
201 |
202 | ```js
203 | function prefetchData() {
204 | void queryClient.prefetchQuery({
205 | queryKey: photoKeys.detail(image.id),
206 | queryFn: () => api.getPhotoDetail(image.id),
207 | })
208 |
209 | void queryClient.prefetchQuery({
210 | queryKey: userKeys.detail(image.user.username),
211 | queryFn: () => api.getUser(image.user.username),
212 | })
213 |
214 | void queryClient.prefetchQuery({
215 | queryKey: userKeys.photos(image.user.username),
216 | queryFn: () =>
217 | api.getUserPhotos({
218 | username: image.user.username,
219 | queryParams: {
220 | page: USER_DETAIL_PHOTOS_PAGE_INDEX,
221 | perPage: USER_DETAIL_PHOTOS_PER_PAGE,
222 | },
223 | }),
224 | })
225 | }
226 | ```
227 |
228 |
229 |
230 |
231 | 🍿 Infinite loading
232 |
233 | ---
234 |
235 | How we manage infinite loading is by using `useInfiniteQuery` hook from React Query.
236 |
237 | It's honestly the first time I use it.
238 |
239 | It's really cool how simple things are:
240 |
241 | ```ts
242 | export function useImageSearch({ params }: { params: SearchParams }) {
243 | const query = useInfiniteQuery({
244 | queryKey: photoKeys.searchResults({
245 | query: params.query,
246 | orderBy: params.orderBy,
247 | color: params.color,
248 | perPage: params.perPage,
249 | }),
250 | queryFn: ({ pageParam }) =>
251 | api.searchPhotos({
252 | ...params,
253 | page: pageParam,
254 | }),
255 | initialPageParam: params.page,
256 | getNextPageParam: (lastPage, _allPages, lastPageParam) => {
257 | const hasNoMorePages = lastPageParam >= lastPage.total_pages
258 | if (hasNoMorePages) {
259 | return undefined
260 | }
261 | return lastPageParam + 1
262 | },
263 | enabled: !!params.query,
264 | })
265 |
266 | useEffect(() => {
267 | if (!query.data || !params.query) return
268 |
269 | const loadUpToInitialPage = async () => {
270 | const loadedPages = query.data.pages.length
271 |
272 | if (loadedPages < params.page) {
273 | try {
274 | await query.fetchNextPage()
275 | } catch (error) {
276 | // TODO: handle error
277 | console.error('Error loading pages:', error)
278 | }
279 | }
280 | }
281 |
282 | loadUpToInitialPage().catch(console.error)
283 | }, [params.page, params.query, query])
284 |
285 | return query
286 | }
287 | ```
288 |
289 | One thing I had to wrap my head around is that page param is managed by the hook itself.
290 |
291 | To get the initial data if page isn't 1, we need to keep fetching the next page until we get to the initial page.
292 |
293 | To 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.
294 |
295 | Error 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.
296 |
297 |
298 |
299 |
300 | 🍿 Geeking out on performance
301 |
302 | ---
303 |
304 | I 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.
305 |
306 | If you look at the image grid, you'll see that really analyzing when and which image to lazy load.
307 |
308 | It's also really fun when you see the network tab and when the images are actually loaded:
309 |
310 | ```jsx
311 | import { DEFAULT_QUERY_PARAM_VALUES } from '@/lib/constants'
312 | import { ImageGridItem } from './ImageGridItem'
313 | import { Photo } from '@/lib/schemas'
314 | import { breakpoints, useMediaQuery } from '@/hooks/useMediaQuery'
315 |
316 | export type ImageWithPageIndex = {
317 | image: Photo
318 | pageIndex: number
319 | }
320 |
321 | export function ImageGrid({
322 | images,
323 | }: {
324 | images: Array | Array
325 | }) {
326 | const isDesktop = useMediaQuery(breakpoints.md)
327 |
328 | return (
329 |
330 | {images.map((data, index) => {
331 | // On mobile we show a single column layout
332 | const isImageAmongFirstResults = index < 3
333 | const shouldLazyLoadOnMobile = isImageAmongFirstResults && !isDesktop
334 |
335 | const isImageWithPageIndex = 'pageIndex' in data
336 |
337 | if (isImageWithPageIndex) {
338 | const { image, pageIndex } = data
339 |
340 | const isImageAmongPaginatedResults =
341 | pageIndex + 1 !== DEFAULT_QUERY_PARAM_VALUES.page
342 |
343 | // On home page we typically get away with showing a lot of images in the first page
344 | const shouldLazyLoadOnDesktop = isImageAmongPaginatedResults
345 |
346 | const shouldLazyLoad =
347 | shouldLazyLoadOnMobile || shouldLazyLoadOnDesktop
348 |
349 | return (
350 |
356 | )
357 | }
358 |
359 | // On profile page
360 | // All images aren't visible directly on desktop
361 | // First 6 images are usually visible
362 | const shouldLazyLoadOnDesktop = isDesktop && index > 5
363 |
364 | const shouldLazyLoad = shouldLazyLoadOnMobile || shouldLazyLoadOnDesktop
365 |
366 | return (
367 |
372 | )
373 | })}
374 |
375 | )
376 | }
377 | ```
378 |
379 |
380 |
381 |
382 | 🍿 Masonry layout wtf?!
383 |
384 | ---
385 |
386 | To be fair, it isn't real masonry layout.
387 |
388 | What I'm doing is letting each grid item span a number of rows based on the aspect ratio of the image.
389 |
390 | Starting 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.
391 |
392 | It's useful when you want the grid item height to grow depending on the content. Which is exactly what we want here.
393 |
394 | ```jsx
395 |
396 | ```
397 |
398 | Let's dive into the grid item itself.
399 |
400 | The way we decide to span number of rows (height of grid item) is by doing this:
401 |
402 | ```js
403 | // We do height / width to maintain the right proportions for height specifically
404 | // e.g height 800px and width 1200px
405 | // 800 / 1200 = 0.66
406 | // 0.66 means for every 1px of width, there are 0.66px of height
407 | // 0.66 * 22 = 14.66
408 | // Math.ceil(14.66) = 15
409 | // So the image will span 15 rows
410 | const rowsToSpanBasedOnAspectRatio = Math.ceil(
411 | (image.height / image.width) *
412 | MULTIPLIER_TO_TURN_ASPECT_RATIO_INTO_ROWS_TO_SPAN
413 | )
414 | ```
415 |
416 | `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.
417 |
418 | Now, this gives us the number of rows to span for the grid item itself.
419 |
420 | One 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.
421 |
422 | Now, the grid item itself already has a specified width since it's a grid item.
423 |
424 | The 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.
425 |
426 | ```js
427 | const paddingBottom = `${(image.height / image.width) * 100}%`
428 | ```
429 |
430 | By doing this, we can get the accurate height of the grid item.
431 |
432 | One 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`.
433 |
434 | These are the full elements:
435 |
436 | ```jsx
437 |
444 | {mobileHeader}
445 |
446 | {/* Link by default are inline elements that won't span the full width of the parent */}
447 | {/* Block span full width of parent and start on new lines */}
448 |
454 | {image.blur_hash ? (
455 |
456 |
457 |
458 | ) : (
459 |
460 | )}
461 |
462 | setIsImageLoaded(true)}
482 | />
483 |
484 |
485 | {desktopHoverOverlay}
486 | Photo by {image.user.name}
487 |
488 | {mobileFooter}
489 |
490 | ```
491 |
492 |
493 |
494 | # Improvements that could be made
495 |
496 | - Of course, we could add testing.
497 | - Let you edit the photo before downloading it.
498 | - Prefetching data on mobile using intersection observer.
499 | - 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.
500 | - Better error handling
501 |
502 | # Tech
503 |
504 | Built with:
505 |
506 | - React
507 | - React Query
508 | - React Router 7
509 | - Shadcn UI
510 | - Tailwind CSS
511 | - TypeScript
512 | - Vite
513 | - Unsplash API
514 |
--------------------------------------------------------------------------------