├── .eslintrc.json ├── atoms └── index.ts ├── public ├── favicon.ico ├── vercel.svg └── next.svg ├── .env ├── postcss.config.js ├── lib └── utils.ts ├── next.config.js ├── components ├── provider.tsx ├── ui │ ├── skeleton.tsx │ ├── input.tsx │ ├── button.tsx │ └── dialog.tsx ├── photo-skeleton.tsx ├── layout-content.tsx ├── query-provider.tsx ├── navbar │ ├── mode-toggle.tsx │ └── index.tsx ├── photo-item.tsx ├── search.tsx ├── scroll-top.tsx ├── blur-image.tsx ├── photo-view.tsx └── photo-list.tsx ├── hooks ├── use-debounce.tsx └── use-photos.tsx ├── .gitignore ├── app ├── layout.tsx └── page.tsx ├── tsconfig.json ├── package.json ├── README.md ├── styles └── globals.css └── tailwind.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /atoms/index.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const searchAtom = atom('') -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soheilghanbary/picsearch/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | API_KEY=U7DCc9zu9YT3-NDeVLi9if-SB91zIQFXZQxLvxGTqTA 2 | # API_KEY="uwDEASWyUYEfrZExhb9KdOPoRPlM2V7MFpngJT7Px1I" -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | env: { 4 | API_KEY: process.env.API_KEY, 5 | }, 6 | images: { 7 | unoptimized: true, 8 | domains: ["images.unsplash.com"], 9 | }, 10 | }; 11 | 12 | module.exports = nextConfig; 13 | -------------------------------------------------------------------------------- /components/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ThemeProvider } from "next-themes"; 3 | import { ReactNode } from "react"; 4 | 5 | export default function Provider({ children }: { children: ReactNode }) { 6 | return {children}; 7 | } 8 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /components/photo-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "./ui/skeleton"; 2 | 3 | const ProductSkeleton = () => { 4 | const skeletonCount = 12; 5 | const skeletonArray = Array.from({ length: skeletonCount }, (_, i) => ( 6 | 7 | )); 8 | return <>{skeletonArray}; 9 | }; 10 | 11 | export default ProductSkeleton; -------------------------------------------------------------------------------- /components/layout-content.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import Navbar from "./navbar"; 3 | import Provider from "./provider"; 4 | 5 | export default function LayoutContent({ children }: { children: ReactNode }) { 6 | return ( 7 | 8 |
9 | 10 | {children} 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/query-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 3 | import { ReactNode } from "react"; 4 | 5 | const queryClient = new QueryClient(); 6 | 7 | export default function QueryProvider({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /hooks/use-debounce.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | function useDebounce(value: T, delay?: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value) 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delay || 500) 8 | 9 | return () => { 10 | clearTimeout(timer) 11 | } 12 | }, [value, delay]) 13 | 14 | return debouncedValue 15 | } 16 | 17 | export default useDebounce 18 | -------------------------------------------------------------------------------- /components/navbar/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { MoonIcon } from "lucide-react"; 3 | import { Button } from "../ui/button"; 4 | import { useTheme } from "next-themes"; 5 | 6 | export default function ModeToggle() { 7 | const { theme, setTheme } = useTheme(); 8 | return ( 9 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import LayoutContent from "@/components/layout-content"; 2 | import "@/styles/globals.css"; 3 | import { Inter } from "next/font/google"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata = { 8 | title: "Picsearch | Photo Searcher", 9 | description: "Generated by tony", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | 20 | {children} 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/photo-item.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom, useSetAtom } from "jotai"; 2 | import BlurImage from "./blur-image"; 3 | import { openViewAtom, photoViewAtom } from "./photo-view"; 4 | 5 | export default function PhotoItem(photo: any) { 6 | const [show, setShow] = useAtom(openViewAtom); 7 | const setPhoto = useSetAtom(photoViewAtom) 8 | return ( 9 | <> 10 |
{ 14 | setPhoto(photo) 15 | setShow(!show) 16 | }} 17 | > 18 | 19 |
20 | 21 | ); 22 | } 23 | 24 | 25 | -------------------------------------------------------------------------------- /hooks/use-photos.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | 3 | const API_KEY = process.env.API_KEY; 4 | 5 | export const usePhotos = (query: string) => { 6 | const fetcher = async (query: string) => { 7 | const searchUrl = `https://api.unsplash.com/search/photos?page=1&per_page=8&query=${query}&client_id=${API_KEY}`; 8 | const url = `https://api.unsplash.com/photos?page=1&per_page=8&client_id=${API_KEY}`; 9 | const res = await fetch(query !== "all" ? searchUrl : url); 10 | const photos = await res.json(); 11 | return query !== "all" ? photos.results : photos; 12 | }; 13 | 14 | const { data, isLoading, isValidating } = useSWR(query || "all", fetcher); 15 | 16 | return { photos: data, isLoading, isValidating }; 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["./*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /components/navbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { AirVent, GithubIcon, MoonIcon } from "lucide-react"; 2 | import { buttonVariants } from "../ui/button"; 3 | import ModeToggle from "./mode-toggle"; 4 | import Link from "next/link"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | export default function Navbar() { 8 | return ( 9 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /components/search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import { Button } from "./ui/button"; 4 | import { Input } from "./ui/input"; 5 | import { searchAtom } from "@/atoms"; 6 | import { useAtom } from "jotai"; 7 | import useDebounce from "@/hooks/use-debounce"; 8 | 9 | export default function Search() { 10 | const [searchText, setSearchText] = useState(""); 11 | const [query, setQuery] = useAtom(searchAtom); 12 | 13 | const handleSearch = (e: React.SyntheticEvent) => { 14 | e.preventDefault(); 15 | setQuery(searchText) 16 | }; 17 | 18 | return ( 19 |
23 | setSearchText(e.target.value)} 25 | type="text" 26 | placeholder="search image" 27 | /> 28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "picsearch", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-dialog": "^1.0.4", 13 | "@radix-ui/react-slot": "^1.0.2", 14 | "@tanstack/react-query": "^4.29.12", 15 | "@tanstack/react-query-devtools": "^4.29.12", 16 | "@types/node": "20.2.5", 17 | "@types/react": "18.2.8", 18 | "@types/react-dom": "18.2.4", 19 | "autoprefixer": "10.4.14", 20 | "class-variance-authority": "^0.6.0", 21 | "clsx": "^1.2.1", 22 | "eslint": "8.42.0", 23 | "eslint-config-next": "13.4.4", 24 | "jotai": "^2.1.1", 25 | "lucide-react": "^0.240.0", 26 | "next": "13.4.4", 27 | "next-themes": "^0.2.1", 28 | "postcss": "8.4.24", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-intersection-observer": "^9.4.4", 32 | "sonner": "^0.4.0", 33 | "swr": "^2.1.5", 34 | "tailwind-merge": "^1.13.0", 35 | "tailwindcss": "3.3.2", 36 | "tailwindcss-animate": "^1.0.5", 37 | "typescript": "5.1.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /components/scroll-top.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ArrowUp } from "lucide-react"; 3 | import { useState, useEffect } from "react"; 4 | import { Button } from "./ui/button"; 5 | 6 | export default function ScrollToTop() { 7 | const [isVisible, setIsVisible] = useState(false); 8 | 9 | const toggleVisibility = () => { 10 | if (window.pageYOffset > 300) { 11 | setIsVisible(true); 12 | } else { 13 | setIsVisible(false); 14 | } 15 | }; 16 | 17 | const scrollToTop = () => { 18 | window.scrollTo({ 19 | top: 0, 20 | behavior: "smooth", 21 | }); 22 | }; 23 | 24 | useEffect(() => { 25 | window.addEventListener("scroll", toggleVisibility); 26 | 27 | return () => { 28 | window.removeEventListener("scroll", toggleVisibility); 29 | }; 30 | }, []); 31 | 32 | return ( 33 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import QueryProvider from "@/components/query-provider"; 2 | import RQPhotoList from "@/components/photo-list"; 3 | import ScrollToTop from "@/components/scroll-top"; 4 | import Search from "@/components/search"; 5 | import { buttonVariants } from "@/components/ui/button"; 6 | 7 | export default function homePage() { 8 | return ( 9 |
10 |
11 |
12 | 17 | Follow along on Twitter 18 | 19 |

20 | a Cool photo searcher from all over the world 21 |

22 |

23 | I m building a web app with Next.js 13 and open sourcing everything. 24 | Follow along as we figure this out together. 25 |

26 | 32 | GitHub 33 | 34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/blur-image.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { useState } from "react"; 3 | import { Button } from "./ui/button"; 4 | import { CopyIcon } from "lucide-react"; 5 | import { toast } from "sonner"; 6 | 7 | interface BlurImageProps { 8 | src: string; 9 | alt: string; 10 | rounded?: boolean 11 | } 12 | 13 | export default function BlurImage({ src, alt, rounded }: BlurImageProps) { 14 | const [isLoading, setLoading] = useState(true); 15 | 16 | const handleCopy = async () => { 17 | try { 18 | await navigator.clipboard.writeText(src); 19 | toast.success("Copy Link successfully"); 20 | } catch (err) { 21 | console.error("Failed to copy: ", err); 22 | } 23 | }; 24 | 25 | return ( 26 |
27 | {alt} setLoading(false)} 36 | /> 37 |
38 |

{alt}

39 | 43 |
44 |
45 | ); 46 | } 47 | 48 | 49 | -------------------------------------------------------------------------------- /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 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "underline-offset-4 hover:underline text-primary", 21 | }, 22 | size: { 23 | default: "h-10 py-2 px-4", 24 | sm: "h-9 px-3 rounded-md", 25 | lg: "h-11 px-8 rounded-md", 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: "default", 30 | size: "default", 31 | }, 32 | } 33 | ) 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | asChild?: boolean 39 | } 40 | 41 | const Button = React.forwardRef( 42 | ({ className, variant, size, asChild = false, ...props }, ref) => { 43 | const Comp = asChild ? Slot : "button" 44 | return ( 45 | 50 | ) 51 | } 52 | ) 53 | Button.displayName = "Button" 54 | 55 | export { Button, buttonVariants } 56 | -------------------------------------------------------------------------------- /components/photo-view.tsx: -------------------------------------------------------------------------------- 1 | import { atom, useAtom, useAtomValue } from "jotai"; 2 | import Image from "next/image"; 3 | import { useState } from "react"; 4 | import BlurImage from "./blur-image"; 5 | import { Dialog, DialogContent } from "./ui/dialog"; 6 | 7 | export const photoViewAtom = atom({ 8 | user: { username: "", portfolio_url: "", profile_image: { large: "" } }, 9 | urls: { regular: "" }, 10 | }); 11 | export const openViewAtom = atom(false); 12 | 13 | export default function PhotoView() { 14 | const [show, setShow] = useAtom(openViewAtom); 15 | const photo = useAtomValue(photoViewAtom); 16 | return ( 17 | setShow(false)}> 18 | 19 | 37 | 38 | 39 | ); 40 | } 41 | 42 | function PhotoViewAvatar({ alt, src }: any) { 43 | const [isLoading, setLoading] = useState(true); 44 | 45 | return ( 46 |
47 | setLoading(false)} 54 | alt={alt} 55 | src={src} 56 | /> 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 47.4% 11.2%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 47.4% 11.2%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 47.4% 11.2%; 18 | 19 | --border: 214.3 31.8% 91.4%; 20 | --input: 214.3 31.8% 91.4%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 100% 50%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 224 71% 4%; 41 | --foreground: 213 31% 91%; 42 | 43 | --muted: 223 47% 11%; 44 | --muted-foreground: 215.4 16.3% 56.9%; 45 | 46 | --popover: 224 71% 4%; 47 | --popover-foreground: 215 20.2% 65.1%; 48 | 49 | --card: 224 71% 4%; 50 | --card-foreground: 213 31% 91%; 51 | 52 | --border: 216 34% 17%; 53 | --input: 216 34% 17%; 54 | 55 | --primary: 210 40% 98%; 56 | --primary-foreground: 222.2 47.4% 1.2%; 57 | 58 | --secondary: 222.2 47.4% 11.2%; 59 | --secondary-foreground: 210 40% 98%; 60 | 61 | --accent: 216 34% 17%; 62 | --accent-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 63% 31%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --ring: 216 34% 17%; 68 | 69 | --radius: 0.5rem; 70 | } 71 | } 72 | 73 | @layer base { 74 | * { 75 | @apply border-border; 76 | } 77 | body { 78 | @apply bg-background text-foreground transition-[background]; 79 | font-feature-settings: "rlig" 1, "calt" 1; 80 | } 81 | 82 | .font-heading { 83 | @apply font-bold tracking-tight leading-8; 84 | } 85 | 86 | body::-webkit-scrollbar { 87 | display: none; 88 | } 89 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | ], 9 | theme: { 10 | container: { 11 | center: true, 12 | padding: "2rem", 13 | screens: { 14 | "2xl": "1400px", 15 | }, 16 | }, 17 | extend: { 18 | colors: { 19 | border: "hsl(var(--border))", 20 | input: "hsl(var(--input))", 21 | ring: "hsl(var(--ring))", 22 | background: "hsl(var(--background))", 23 | foreground: "hsl(var(--foreground))", 24 | primary: { 25 | DEFAULT: "hsl(var(--primary))", 26 | foreground: "hsl(var(--primary-foreground))", 27 | }, 28 | secondary: { 29 | DEFAULT: "hsl(var(--secondary))", 30 | foreground: "hsl(var(--secondary-foreground))", 31 | }, 32 | destructive: { 33 | DEFAULT: "hsl(var(--destructive))", 34 | foreground: "hsl(var(--destructive-foreground))", 35 | }, 36 | muted: { 37 | DEFAULT: "hsl(var(--muted))", 38 | foreground: "hsl(var(--muted-foreground))", 39 | }, 40 | accent: { 41 | DEFAULT: "hsl(var(--accent))", 42 | foreground: "hsl(var(--accent-foreground))", 43 | }, 44 | popover: { 45 | DEFAULT: "hsl(var(--popover))", 46 | foreground: "hsl(var(--popover-foreground))", 47 | }, 48 | card: { 49 | DEFAULT: "hsl(var(--card))", 50 | foreground: "hsl(var(--card-foreground))", 51 | }, 52 | }, 53 | borderRadius: { 54 | lg: "var(--radius)", 55 | md: "calc(var(--radius) - 2px)", 56 | sm: "calc(var(--radius) - 4px)", 57 | }, 58 | keyframes: { 59 | "accordion-down": { 60 | from: { height: 0 }, 61 | to: { height: "var(--radix-accordion-content-height)" }, 62 | }, 63 | "accordion-up": { 64 | from: { height: "var(--radix-accordion-content-height)" }, 65 | to: { height: 0 }, 66 | }, 67 | }, 68 | animation: { 69 | "accordion-down": "accordion-down 0.2s ease-out", 70 | "accordion-up": "accordion-up 0.2s ease-out", 71 | }, 72 | }, 73 | }, 74 | plugins: [require("tailwindcss-animate")], 75 | } -------------------------------------------------------------------------------- /components/photo-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { searchAtom } from "@/atoms"; 3 | import { useInfiniteQuery } from "@tanstack/react-query"; 4 | import { useAtomValue } from "jotai"; 5 | import { useInView } from "react-intersection-observer"; 6 | import { Toaster } from "sonner"; 7 | import ProductSkeleton from "./photo-skeleton"; 8 | import { useEffect } from "react"; 9 | import PhotoItem from "./photo-item"; 10 | import PhotoView from "./photo-view"; 11 | 12 | const API_KEY = process.env.API_KEY; 13 | 14 | const fetcher = async (page: number, query: string) => { 15 | if (query.length) { 16 | const response = await fetch( 17 | `https://api.unsplash.com/search/photos?page=${page}&per_page=12&query=${query}&client_id=${API_KEY}` 18 | ); 19 | const data = await response.json(); 20 | return data.results; 21 | } else { 22 | const response = await fetch( 23 | `https://api.unsplash.com/photos?page=${page}&per_page=12&client_id=${API_KEY}` 24 | ); 25 | return response.json(); 26 | } 27 | }; 28 | 29 | const RQPhotoList = () => { 30 | const { ref, inView } = useInView(); 31 | const query = useAtomValue(searchAtom); 32 | const { 33 | fetchNextPage, 34 | hasNextPage, 35 | isLoading, 36 | isError, 37 | data: photos, 38 | } = useInfiniteQuery( 39 | ["photos", query], 40 | ({ pageParam = 1 }) => fetcher(pageParam, query), 41 | { 42 | getNextPageParam: (lastPage, allPages) => { 43 | const nextPage = allPages.length + 1; 44 | return nextPage; 45 | }, 46 | getPreviousPageParam: (firstPage, allPages) => firstPage, 47 | } 48 | ); 49 | 50 | useEffect(() => { 51 | if (inView && hasNextPage) { 52 | fetchNextPage(); 53 | } 54 | }, [inView, fetchNextPage, hasNextPage]); 55 | 56 | const renderPhotos = () => { 57 | if (isLoading) return ; 58 | if (isError) return

Error loading photos

; 59 | if (photos.pages[0].length === 0) 60 | return

No photos found

; 61 | 62 | return photos.pages.map((page) => 63 | page.map((photo: any) => ) 64 | ); 65 | }; 66 | 67 | return ( 68 | <> 69 |
70 | {renderPhotos()} 71 | 72 |
73 |
74 |
75 | {hasNextPage && photos?.pages[0].length ? : ""} 76 |
77 | 78 | 79 | ); 80 | }; 81 | 82 | export default RQPhotoList; 83 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = ({ 14 | className, 15 | children, 16 | ...props 17 | }: DialogPrimitive.DialogPortalProps) => ( 18 | 19 |
20 | {children} 21 |
22 |
23 | ) 24 | DialogPortal.displayName = DialogPrimitive.Portal.displayName 25 | 26 | const DialogOverlay = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, ...props }, ref) => ( 30 | 38 | )) 39 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 40 | 41 | const DialogContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 46 | 47 | 55 | {children} 56 | 57 | 58 | Close 59 | 60 | 61 | 62 | )) 63 | DialogContent.displayName = DialogPrimitive.Content.displayName 64 | 65 | const DialogHeader = ({ 66 | className, 67 | ...props 68 | }: React.HTMLAttributes) => ( 69 |
76 | ) 77 | DialogHeader.displayName = "DialogHeader" 78 | 79 | const DialogFooter = ({ 80 | className, 81 | ...props 82 | }: React.HTMLAttributes) => ( 83 |
90 | ) 91 | DialogFooter.displayName = "DialogFooter" 92 | 93 | const DialogTitle = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, ...props }, ref) => ( 97 | 105 | )) 106 | DialogTitle.displayName = DialogPrimitive.Title.displayName 107 | 108 | const DialogDescription = React.forwardRef< 109 | React.ElementRef, 110 | React.ComponentPropsWithoutRef 111 | >(({ className, ...props }, ref) => ( 112 | 117 | )) 118 | DialogDescription.displayName = DialogPrimitive.Description.displayName 119 | 120 | export { 121 | Dialog, 122 | DialogTrigger, 123 | DialogContent, 124 | DialogHeader, 125 | DialogFooter, 126 | DialogTitle, 127 | DialogDescription, 128 | } 129 | --------------------------------------------------------------------------------