├── 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 |
9 |
10 |
11 |
12 |
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 |
159 |
160 |
161 | 165 | 168 | setInputValue(event.target.value)} 173 | placeholder="Search for photos" 174 | /> 175 |
176 | 177 |
178 | 179 |
180 | 197 | 198 | 217 |
218 |
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 | 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 | 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 | {image.description 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 |
39 |
40 |
41 |
42 |
43 |
44 |
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 | 184 |
185 | 186 | {isFullscreen && ( 187 |
188 | 225 |
226 | )} 227 | 228 |
236 | 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 | 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 | {image.description 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 | {image.description 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 | {image.description 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 | --------------------------------------------------------------------------------