├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── README.md ├── next-env.d.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public └── favicon.ico ├── src ├── app │ ├── head.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── privacy-policy │ │ ├── head.tsx │ │ └── page.tsx │ └── terms │ │ ├── head.tsx │ │ └── page.tsx ├── components │ ├── icons.tsx │ ├── main-nav.tsx │ ├── site-header.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── file-input.tsx │ │ ├── hover-card.tsx │ │ ├── image-upload.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── slider.tsx │ │ ├── spinner.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── tooltip.tsx ├── config │ ├── image.ts │ ├── s3.ts │ └── site.ts ├── hooks │ ├── use-s3-upload.ts │ ├── use-toast.ts │ └── use-upload-file.ts ├── lib │ ├── api-middlewares │ │ └── with-methods.ts │ ├── exceptions.ts │ ├── s3.ts │ ├── utils.ts │ └── validations │ │ └── s3.ts ├── pages │ └── api │ │ └── image │ │ ├── presign.ts │ │ └── process.ts ├── styles │ └── globals.css └── types │ ├── api │ └── image.ts │ └── nav.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | .cache 3 | public 4 | node_modules 5 | *.esm.js 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "tailwindcss/classnames-order": "off", 12 | "@next/next/no-html-link-for-pages": "off", 13 | "react/jsx-key": "off", 14 | "tailwindcss/no-custom-classname": "off", 15 | "react-hooks/exhaustive-deps": "off" 16 | }, 17 | "settings": { 18 | "tailwindcss": { 19 | "callees": ["cn"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.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 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | .contentlayer 36 | .env -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | cache 2 | .cache 3 | package.json 4 | package-lock.json 5 | public 6 | CHANGELOG.md 7 | .yarn 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next alt generator 2 | 3 | A Next.js 13 project for generating image alt tags automatically and in bulk. 4 | 5 | ## Features 6 | 7 | - Radix UI Primitives 8 | - Tailwind CSS 9 | - Fonts with `@next/font` 10 | - Icons from [Lucide](https://lucide.dev) 11 | - Dark mode with `next-themes` 12 | - Automatic import sorting with `@ianvs/prettier-plugin-sort-imports` 13 | 14 | ## Tailwind CSS Features 15 | 16 | - Class merging with `taiwind-merge` 17 | - Animation with `tailwindcss-animate` 18 | - Conditional classes with `clsx` 19 | - Variants with `class-variance-authority` 20 | - Automatic class sorting with `eslint-plugin-tailwindcss` 21 | 22 | ## Import Sort 23 | 24 | The starter comes with `@ianvs/prettier-plugin-sort-imports` for automatically sort your imports. 25 | 26 | ### Input 27 | 28 | ```tsx 29 | import * as React from "react" 30 | import Link from "next/link" 31 | 32 | import { siteConfig } from "@/config/site" 33 | import { buttonVariants } from "@/components/ui/button" 34 | import "@/styles/globals.css" 35 | import { twMerge } from "tailwind-merge" 36 | 37 | import { NavItem } from "@/types/nav" 38 | import { cn } from "@/lib/utils" 39 | ``` 40 | 41 | ### Output 42 | 43 | ```tsx 44 | import * as React from "react" 45 | // React is always first. 46 | import Link from "next/link" 47 | // Followed by next modules. 48 | import { twMerge } from "tailwind-merge" 49 | 50 | // Followed by third-party modules 51 | // Space 52 | import "@/styles/globals.css" 53 | // styles 54 | import { NavItem } from "@/types/nav" 55 | // types 56 | import { siteConfig } from "@/config/site" 57 | // config 58 | import { cn } from "@/lib/utils" 59 | // lib 60 | import { buttonVariants } from "@/components/ui/button" 61 | 62 | // components 63 | ``` 64 | 65 | ### Class Merging 66 | 67 | The `cn` util handles conditional classes and class merging. 68 | 69 | ### Input 70 | 71 | ```ts 72 | cn("px-2 bg-slate-100 py-2 bg-slate-200") 73 | // Outputs `p-2 bg-slate-200` 74 | ``` 75 | 76 | ## License & Credits 77 | 78 | Licensed under the [MIT license](https://opensource.org/license/mit/). 79 | Boilerplate project template made by [shadcn](https://github.com/shadcn/next-template) 80 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | appDir: true, 6 | fontLoaders: [ 7 | { 8 | loader: "@next/font/google", 9 | options: { subsets: ["latin"] }, 10 | }, 11 | ], 12 | }, 13 | images: { 14 | domains: ["image-to-alt.s3.eu-central-1.amazonaws.com"], 15 | }, 16 | } 17 | 18 | export default nextConfig 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-template", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "preview": "next build && next start" 11 | }, 12 | "dependencies": { 13 | "@next/font": "^13.1.6", 14 | "@radix-ui/react-accessible-icon": "^1.0.1", 15 | "@radix-ui/react-accordion": "^1.1.0", 16 | "@radix-ui/react-alert-dialog": "^1.0.2", 17 | "@radix-ui/react-aspect-ratio": "^1.0.1", 18 | "@radix-ui/react-avatar": "^1.0.1", 19 | "@radix-ui/react-checkbox": "^1.0.1", 20 | "@radix-ui/react-collapsible": "^1.0.1", 21 | "@radix-ui/react-context-menu": "^2.1.1", 22 | "@radix-ui/react-dialog": "^1.0.2", 23 | "@radix-ui/react-dropdown-menu": "^2.0.1", 24 | "@radix-ui/react-hover-card": "^1.0.3", 25 | "@radix-ui/react-label": "^2.0.0", 26 | "@radix-ui/react-menubar": "^1.0.0", 27 | "@radix-ui/react-navigation-menu": "^1.1.1", 28 | "@radix-ui/react-popover": "^1.0.2", 29 | "@radix-ui/react-progress": "^1.0.1", 30 | "@radix-ui/react-radio-group": "^1.1.0", 31 | "@radix-ui/react-scroll-area": "^1.0.2", 32 | "@radix-ui/react-select": "^1.2.0", 33 | "@radix-ui/react-separator": "^1.0.1", 34 | "@radix-ui/react-slider": "^1.1.0", 35 | "@radix-ui/react-slot": "^1.0.1", 36 | "@radix-ui/react-switch": "^1.0.1", 37 | "@radix-ui/react-tabs": "^1.0.2", 38 | "@radix-ui/react-toast": "^1.1.2", 39 | "@radix-ui/react-toggle-group": "^1.0.1", 40 | "@radix-ui/react-tooltip": "^1.0.3", 41 | "aws-sdk": "^2.1318.0", 42 | "axios": "^1.3.3", 43 | "class-variance-authority": "^0.4.0", 44 | "clsx": "^1.2.1", 45 | "lucide-react": "0.105.0-alpha.4", 46 | "nanoid": "^4.0.1", 47 | "next": "^13.1.6", 48 | "next-themes": "^0.2.1", 49 | "react": "^18.2.0", 50 | "react-dom": "^18.2.0", 51 | "sharp": "^0.31.3", 52 | "tailwind-merge": "^1.8.0", 53 | "tailwindcss-animate": "^1.0.5", 54 | "zod": "^3.20.6" 55 | }, 56 | "devDependencies": { 57 | "@ianvs/prettier-plugin-sort-imports": "^3.7.1", 58 | "@types/node": "^17.0.12", 59 | "@types/react": "^18.0.22", 60 | "@types/react-dom": "^18.0.7", 61 | "autoprefixer": "^10.4.13", 62 | "eslint": "^8.31.0", 63 | "eslint-config-next": "13.0.0", 64 | "eslint-config-prettier": "^8.3.0", 65 | "eslint-plugin-react": "^7.31.11", 66 | "eslint-plugin-tailwindcss": "^3.8.0", 67 | "postcss": "^8.4.14", 68 | "prettier": "^2.7.1", 69 | "tailwindcss": "^3.1.7", 70 | "typescript": "^4.5.3" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | endOfLine: "lf", 4 | semi: false, 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: "es5", 8 | importOrder: [ 9 | "^(react/(.*)$)|^(react$)", 10 | "^(next/(.*)$)|^(next$)", 11 | "", 12 | "", 13 | "^types$", 14 | "^@/types/(.*)$", 15 | "^@/config/(.*)$", 16 | "^@/lib/(.*)$", 17 | "^@/components/(.*)$", 18 | "^@/styles/(.*)$", 19 | "^[./]", 20 | ], 21 | importOrderSeparation: false, 22 | importOrderSortSpecifiers: true, 23 | importOrderBuiltinModulesToTop: true, 24 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 25 | importOrderMergeDuplicateImports: true, 26 | importOrderCombineTypeAndValueImports: true, 27 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 28 | } 29 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joschan21/image-alt-generator/12dcac4220233c3fa1d053f86e5c5142c1b0153a/public/favicon.ico -------------------------------------------------------------------------------- /src/app/head.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | const head: FC = () => { 4 | return ImageToAlt - Generate alt tags from images 5 | } 6 | 7 | export default head 8 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter as FontSans } from '@next/font/google' 2 | 3 | import '@/styles/globals.css' 4 | import { Toaster } from '@/ui/toaster' 5 | 6 | import { cn } from '@/lib/utils' 7 | import { SiteHeader } from '../components/site-header' 8 | import { TooltipProvider } from '../components/ui/tooltip' 9 | 10 | const fontSans = FontSans({ 11 | subsets: ['latin'], 12 | variable: '--font-inter', 13 | }) 14 | 15 | interface RootLayoutProps { 16 | children: React.ReactNode 17 | } 18 | 19 | export default function RootLayout({ children }: RootLayoutProps) { 20 | return ( 21 | <> 22 | 29 | 30 | 31 | 32 | 33 |
{children}
34 |
35 | 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FC } from 'react' 4 | import { Button, buttonVariants } from '@/ui/button' 5 | import { FileInput } from '@/ui/file-input' 6 | 7 | import { 8 | Tooltip, 9 | TooltipContent, 10 | TooltipTrigger, 11 | } from '@/components/ui/tooltip' 12 | 13 | export const metadata = { 14 | title: 'ImageToAlt - Home', 15 | } 16 | 17 | const page: FC = () => { 18 | return ( 19 |
20 |
21 |

22 | Easily create alt-descriptions
23 | for your images. 24 |

25 |

26 | Bulk-generate SEO-optimized alt-descriptions that you can copy and 27 | paste into your app. Free & open-source. 28 |

29 |
30 |
31 | 32 |
33 | 34 | 35 | 36 |
37 | 43 |
44 |
45 | 46 |

Available soon

47 |
48 |
49 | 50 | {/* Legal disclaimers */} 51 |
52 |

53 | All images are used solely for alt-generation and are automatically 54 | deleted after 24h. 55 |

56 |
57 | 63 | 69 |
70 |
71 |
72 | ) 73 | } 74 | 75 | export default page 76 | -------------------------------------------------------------------------------- /src/app/privacy-policy/head.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | const head: FC = () => { 4 | return Privacy Policy - ImageToAlt 5 | } 6 | 7 | export default head 8 | -------------------------------------------------------------------------------- /src/app/privacy-policy/page.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import Link from 'next/link' 3 | 4 | const page: FC = () => { 5 | return ( 6 |
7 |
8 |

9 | ImageToAlt Privacy Policy 10 |

11 |

12 | ImageToAlt is a free online service that provides a simple way to 13 | generate an alt tag (a description of what is visible on an image, 14 | determined by a machine learning algorithm) from an image. In order to 15 | provide this service, we need to collect and store images on our 16 | servers. 17 |

18 |

Information We Collect

19 |

20 | When you use the App, we automatically collect certain information 21 | about your device, including information about your web browser, IP 22 | address, time zone, and some of the cookies that are installed on your 23 | device. We refer to this automatically-collected information as 24 | "Device Information". 25 |

26 |

27 | We collect Device Information using the following technologies: 28 |

29 |
    30 |
  • 31 | Cookies: Cookies are data files that are placed on your device or 32 | computer and often include an anonymous unique identifier. 33 |
  • 34 |
  • 35 | Log files: Log files track actions occurring on the App, and collect 36 | data including your IP address, browser type, Internet service 37 | provider, referring/exit pages, and date/time stamps. 38 |
  • 39 |
40 |

41 | When you upload an image to the App, we collect the image itself. We 42 | use the image to generate an alt tag and store the alt tag. The image 43 | is then automatically deleted after 24 hours. 44 |

45 |

How We Use Your Information

46 |

47 | We use the images you upload to our servers solely for the purpose of 48 | generating an alt tag and serving it back to you. We do not use your 49 | images for any other purpose. To provide this service, we use a 50 | machine learning algorithm provided by Replicate, Inc. You can read 51 | more about how Replicate, Inc. uses your data{' '} 52 | 56 | here 57 | 58 | . We require all third-party providers to have adequate technical and 59 | organizational measures in place to ensure the security of user data. 60 | We do not share user data with any other third parties. 61 |

62 |

63 | How We Protect Your Information 64 |

65 |

66 | We take the security of your information very seriously. All images 67 | uploaded to our servers are stored in a secure location in Germany. We 68 | do not share your images with any third parties. We also automatically 69 | delete all images from our servers after 24 hours. 70 |

71 |

Use of Cookies

72 |

73 | We do not use any cookies to track user behavior. We only use session 74 | cookies to manage your session on our website. 75 |

76 |

77 | Changes to Our Privacy Policy 78 |

79 |

80 | We reserve the right to make changes to this Privacy Policy at any 81 | time. Any changes will be posted on this page, so please check back 82 | periodically for updates. 83 |

84 |

Contact Us

85 |

86 | If you have any questions about this Privacy Policy, please contact us 87 | at admin@wordful.ai. 88 |

89 |
90 |
91 | ) 92 | } 93 | 94 | export default page 95 | -------------------------------------------------------------------------------- /src/app/terms/head.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | const head: FC = () => { 4 | return Terms and Conditions - ImageToAlt 5 | } 6 | 7 | export default head 8 | -------------------------------------------------------------------------------- /src/app/terms/page.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import Head from 'next/head' 3 | 4 | const page: FC = () => { 5 | return ( 6 |
7 | 8 | ImageToAlt - Terms and Conditions 9 | 10 | 11 |
12 |

13 | ImageToAlt Terms and Conditions 14 |

15 |

16 | These terms and conditions ("Terms") apply to your use of 17 | the ImageToAlt app ("App") and the alt tag generation 18 | service ("Service") provided by ImageToAlt ("we" 19 | or "us"). By using the App or the Service, you agree to be 20 | bound by these Terms. 21 |

22 |

23 | Use of the App and the Service 24 |

25 |

26 | The App and the Service are provided for informational purposes only. 27 | You may use the App and the Service at your own risk, and we shall not 28 | be liable for any damages or harm that may arise from your use of the 29 | App or the Service. 30 |

31 |

Intellectual Property

32 |

33 | The App and the Service, including any content or materials made 34 | available through the App or the Service, are protected by copyright 35 | and other intellectual property laws. You may not copy, modify, 36 | distribute, sell, or lease any part of the App or the Service without 37 | our prior written consent. 38 |

39 |

Disclaimer of Liability

40 |

41 | The App and the Service are provided "as is" and without warranty of 42 | any kind. We make no representations or warranties of any kind, 43 | express or implied, about the completeness, accuracy, reliability, 44 | suitability or availability with respect to the App or the Service or 45 | the information, products, services, or related graphics contained in 46 | the App or the Service for any purpose. To the fullest extent 47 | permitted by law, we disclaim any and all warranties, express or 48 | implied, including, but not limited to, implied warranties of 49 | merchantability and fitness for a particular purpose. 50 |

51 |

52 | In no event shall ImageToAlt be liable for any direct, indirect, 53 | incidental, consequential, special or exemplary damages, including, 54 | but not limited to, damages for loss of profits, goodwill, use, data 55 | or other intangible losses resulting from the use of or inability to 56 | use the App or the Service. 57 |

58 |

Indemnification

59 |

60 | You agree to indemnify and hold ImageToAlt, its affiliates, officers, 61 | agents, and other partners and employees, harmless from any loss, 62 | liability, claim or demand, including reasonable attorneys' fees, made 63 | by any third party due to or arising out of your use of the App or the 64 | Service. 65 |

66 |

Termination

67 |

68 | We may terminate your access to the App and the Service at any time, 69 | without cause or notice. 70 |

71 |

Governing Law

72 |

73 | These Terms and your use of the App and the Service shall be governed 74 | by and construed in accordance with the laws of Germany, without 75 | giving effect to any principles of conflicts of law. 76 |

77 |

Changes to these Terms

78 |

79 | We reserve the right to modify these Terms at any time. If we make 80 | changes to these Terms, we will post the revised Terms on the App and 81 | update the "Last Updated" date at the top of these Terms. By 82 | continuing to use the App and the Service after the revised Terms 83 | become effective, you agree to be bound by the revised Terms. 84 |

85 |

Contact Us

86 |

87 | If you have any questions about these Terms or the App or the Service, 88 | please contact us at admin@wordful.ai. 89 |

90 |

Last Updated: Feb 20th, 2023

91 |
92 |
93 | ) 94 | } 95 | 96 | export default page 97 | -------------------------------------------------------------------------------- /src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Laptop, 3 | LucideProps, 4 | Moon, 5 | SunMedium, 6 | type Icon as LucideIcon, 7 | } from 'lucide-react' 8 | 9 | export type Icon = LucideIcon 10 | 11 | export const Icons = { 12 | sun: SunMedium, 13 | moon: Moon, 14 | laptop: Laptop, 15 | youtube: (props: LucideProps) => ( 16 | 21 | 25 | 29 | 30 | ), 31 | logo: (props: LucideProps) => ( 32 | 33 | 38 | 43 | 50 | 56 | 61 | 62 | ), 63 | gitHub: (props: LucideProps) => ( 64 | 65 | 69 | 70 | ), 71 | plus: (props: LucideProps) => ( 72 | 73 | 74 | 75 | 76 | 77 | ), 78 | redx: (props: LucideProps) => ( 79 | 80 | 86 | 92 | 100 | 101 | ), 102 | } 103 | -------------------------------------------------------------------------------- /src/components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { NavItem } from '@/src/types/nav' 3 | 4 | import { siteConfig } from '@/config/site' 5 | import { cn } from '@/lib/utils' 6 | import { Icons } from '@/components/icons' 7 | import { Button } from '@/components/ui/button' 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuLabel, 13 | DropdownMenuSeparator, 14 | DropdownMenuTrigger, 15 | } from '@/components/ui/dropdown-menu' 16 | 17 | interface MainNavProps { 18 | items?: NavItem[] 19 | } 20 | 21 | export function MainNav({ items }: MainNavProps) { 22 | return ( 23 |
24 | 25 | 26 | 27 | {siteConfig.name} 28 | 29 | 30 | {items?.length ? ( 31 | 48 | ) : null} 49 | 50 | 51 | 58 | 59 | 64 | 65 | 66 | {siteConfig.name} 67 | 68 | 69 | 70 | {items?.map( 71 | (item, index) => 72 | item.href && ( 73 | 74 | {item.title} 75 | 76 | ) 77 | )} 78 | 79 | 80 |
81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /src/components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | import { siteConfig } from '@/config/site' 4 | import { Icons } from '@/components/icons' 5 | import { MainNav } from '@/components/main-nav' 6 | import { buttonVariants } from '@/components/ui/button' 7 | 8 | export function SiteHeader() { 9 | return ( 10 |
11 |
12 | 13 |
14 | 48 |
49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as AccordionPrimitive from '@radix-ui/react-accordion' 5 | import { ChevronDown } from 'lucide-react' 6 | 7 | import { cn } from '@/lib/utils' 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | AccordionItem.displayName = 'AccordionItem' 25 | 26 | const AccordionTrigger = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, children, ...props }, ref) => ( 30 | 31 | svg]:rotate-180', 35 | className 36 | )} 37 | {...props} 38 | > 39 | {children} 40 | 41 | 42 | 43 | )) 44 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 45 | 46 | const AccordionContent = React.forwardRef< 47 | React.ElementRef, 48 | React.ComponentPropsWithoutRef 49 | >(({ className, children, ...props }, ref) => ( 50 | 58 |
{children}
59 |
60 | )) 61 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 62 | 63 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 64 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' 5 | 6 | import { cn } from '@/lib/utils' 7 | 8 | const AlertDialog = AlertDialogPrimitive.Root 9 | 10 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 11 | 12 | const AlertDialogPortal = ({ 13 | className, 14 | children, 15 | ...props 16 | }: AlertDialogPrimitive.AlertDialogPortalProps) => ( 17 | 18 |
19 | {children} 20 |
21 |
22 | ) 23 | AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName 24 | 25 | const AlertDialogOverlay = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, children, ...props }, ref) => ( 29 | 37 | )) 38 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 39 | 40 | const AlertDialogContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 45 | 46 | 55 | 56 | )) 57 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 58 | 59 | const AlertDialogHeader = ({ 60 | className, 61 | ...props 62 | }: React.HTMLAttributes) => ( 63 |
70 | ) 71 | AlertDialogHeader.displayName = 'AlertDialogHeader' 72 | 73 | const AlertDialogFooter = ({ 74 | className, 75 | ...props 76 | }: React.HTMLAttributes) => ( 77 |
84 | ) 85 | AlertDialogFooter.displayName = 'AlertDialogFooter' 86 | 87 | const AlertDialogTitle = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => ( 91 | 100 | )) 101 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 102 | 103 | const AlertDialogDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | AlertDialogDescription.displayName = 114 | AlertDialogPrimitive.Description.displayName 115 | 116 | const AlertDialogAction = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, ...props }, ref) => ( 120 | 128 | )) 129 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 130 | 131 | const AlertDialogCancel = React.forwardRef< 132 | React.ElementRef, 133 | React.ComponentPropsWithoutRef 134 | >(({ className, ...props }, ref) => ( 135 | 143 | )) 144 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 145 | 146 | export { 147 | AlertDialog, 148 | AlertDialogTrigger, 149 | AlertDialogContent, 150 | AlertDialogHeader, 151 | AlertDialogFooter, 152 | AlertDialogTitle, 153 | AlertDialogDescription, 154 | AlertDialogAction, 155 | AlertDialogCancel, 156 | } 157 | -------------------------------------------------------------------------------- /src/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio' 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as AvatarPrimitive from '@radix-ui/react-avatar' 5 | 6 | import { cn } from '@/lib/utils' 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Link from 'next/link' 3 | import { VariantProps, cva } 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:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:hover:bg-slate-800 dark:hover:text-slate-100 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800', 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900', 14 | destructive: 15 | 'bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600', 16 | outline: 17 | 'bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100', 18 | subtle: 19 | 'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100', 20 | ghost: 21 | 'bg-transparent dark:bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent', 22 | link: 'bg-transparent dark:bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-300 hover:bg-transparent dark:hover:bg-transparent', 23 | }, 24 | size: { 25 | default: 'h-10 py-2 px-4', 26 | sm: 'h-9 px-2 rounded-md', 27 | lg: 'h-11 px-8 rounded-md', 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default', 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | href?: string 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, children, href, variant, size, ...props }, ref) => { 45 | if (href) { 46 | return ( 47 | 51 | {children} 52 | 53 | ) 54 | } 55 | return ( 56 | 63 | ) 64 | } 65 | ) 66 | Button.displayName = 'Button' 67 | 68 | export { Button, buttonVariants } 69 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox' 5 | import { Check } from 'lucide-react' 6 | 7 | import { cn } from '@/lib/utils' 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /src/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' 4 | 5 | const Collapsible = CollapsiblePrimitive.Root 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 12 | -------------------------------------------------------------------------------- /src/components/ui/context-menu.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as ContextMenuPrimitive from '@radix-ui/react-context-menu' 5 | import { Check, ChevronRight, Circle } from 'lucide-react' 6 | 7 | import { cn } from '@/lib/utils' 8 | 9 | const ContextMenu = ContextMenuPrimitive.Root 10 | 11 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger 12 | 13 | const ContextMenuGroup = ContextMenuPrimitive.Group 14 | 15 | const ContextMenuPortal = ContextMenuPrimitive.Portal 16 | 17 | const ContextMenuSub = ContextMenuPrimitive.Sub 18 | 19 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup 20 | 21 | const ContextMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName 41 | 42 | const ContextMenuSubContent = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef 45 | >(({ className, ...props }, ref) => ( 46 | 54 | )) 55 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName 56 | 57 | const ContextMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 62 | 70 | 71 | )) 72 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName 73 | 74 | const ContextMenuItem = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef & { 77 | inset?: boolean 78 | } 79 | >(({ className, inset, ...props }, ref) => ( 80 | 89 | )) 90 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName 91 | 92 | const ContextMenuCheckboxItem = React.forwardRef< 93 | React.ElementRef, 94 | React.ComponentPropsWithoutRef 95 | >(({ className, children, checked, ...props }, ref) => ( 96 | 105 | 106 | 107 | 108 | 109 | 110 | {children} 111 | 112 | )) 113 | ContextMenuCheckboxItem.displayName = 114 | ContextMenuPrimitive.CheckboxItem.displayName 115 | 116 | const ContextMenuRadioItem = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, children, ...props }, ref) => ( 120 | 128 | 129 | 130 | 131 | 132 | 133 | {children} 134 | 135 | )) 136 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName 137 | 138 | const ContextMenuLabel = React.forwardRef< 139 | React.ElementRef, 140 | React.ComponentPropsWithoutRef & { 141 | inset?: boolean 142 | } 143 | >(({ className, inset, ...props }, ref) => ( 144 | 153 | )) 154 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName 155 | 156 | const ContextMenuSeparator = React.forwardRef< 157 | React.ElementRef, 158 | React.ComponentPropsWithoutRef 159 | >(({ className, ...props }, ref) => ( 160 | 165 | )) 166 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName 167 | 168 | const ContextMenuShortcut = ({ 169 | className, 170 | ...props 171 | }: React.HTMLAttributes) => { 172 | return ( 173 | 180 | ) 181 | } 182 | ContextMenuShortcut.displayName = 'ContextMenuShortcut' 183 | 184 | export { 185 | ContextMenu, 186 | ContextMenuTrigger, 187 | ContextMenuContent, 188 | ContextMenuItem, 189 | ContextMenuCheckboxItem, 190 | ContextMenuRadioItem, 191 | ContextMenuLabel, 192 | ContextMenuSeparator, 193 | ContextMenuShortcut, 194 | ContextMenuGroup, 195 | ContextMenuPortal, 196 | ContextMenuSub, 197 | ContextMenuSubContent, 198 | ContextMenuSubTrigger, 199 | ContextMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /src/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, children, ...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 | 56 | {children} 57 | 58 | 59 | Close 60 | 61 | 62 | 63 | )) 64 | DialogContent.displayName = DialogPrimitive.Content.displayName 65 | 66 | const DialogHeader = ({ 67 | className, 68 | ...props 69 | }: React.HTMLAttributes) => ( 70 |
77 | ) 78 | DialogHeader.displayName = 'DialogHeader' 79 | 80 | const DialogFooter = ({ 81 | className, 82 | ...props 83 | }: React.HTMLAttributes) => ( 84 |
91 | ) 92 | DialogFooter.displayName = 'DialogFooter' 93 | 94 | const DialogTitle = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, ...props }, ref) => ( 98 | 107 | )) 108 | DialogTitle.displayName = DialogPrimitive.Title.displayName 109 | 110 | const DialogDescription = React.forwardRef< 111 | React.ElementRef, 112 | React.ComponentPropsWithoutRef 113 | >(({ className, ...props }, ref) => ( 114 | 119 | )) 120 | DialogDescription.displayName = DialogPrimitive.Description.displayName 121 | 122 | export { 123 | Dialog, 124 | DialogTrigger, 125 | DialogContent, 126 | DialogHeader, 127 | DialogFooter, 128 | DialogTitle, 129 | DialogDescription, 130 | } 131 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' 5 | import { Check, ChevronRight, Circle } from 'lucide-react' 6 | 7 | import { cn } from '@/lib/utils' 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 183 | ) 184 | } 185 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' 186 | 187 | export { 188 | DropdownMenu, 189 | DropdownMenuTrigger, 190 | DropdownMenuContent, 191 | DropdownMenuItem, 192 | DropdownMenuCheckboxItem, 193 | DropdownMenuRadioItem, 194 | DropdownMenuLabel, 195 | DropdownMenuSeparator, 196 | DropdownMenuShortcut, 197 | DropdownMenuGroup, 198 | DropdownMenuPortal, 199 | DropdownMenuSub, 200 | DropdownMenuSubContent, 201 | DropdownMenuSubTrigger, 202 | DropdownMenuRadioGroup, 203 | } 204 | -------------------------------------------------------------------------------- /src/components/ui/file-input.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | forwardRef, 5 | useReducer, 6 | useState, 7 | type ChangeEvent, 8 | type DragEvent, 9 | } from 'react' 10 | import { useS3Upload } from '@/src/hooks/use-s3-upload' 11 | import { useToast } from '@/src/hooks/use-toast' 12 | import ImageUpload from '@/ui/image-upload' 13 | 14 | import { MAX_FILE_SIZE } from '@/config/image' 15 | import { cn, validateFileType } from '@/lib/utils' 16 | import { Icons } from '../icons' 17 | 18 | interface FileWithUrl { 19 | name: string 20 | getUrl: string 21 | size: number 22 | error?: boolean | undefined 23 | } 24 | 25 | // Reducer action(s) 26 | const addFilesToInput = () => ({ 27 | type: 'ADD_FILES_TO_INPUT' as const, 28 | payload: [] as FileWithUrl[], 29 | }) 30 | 31 | type Action = ReturnType 32 | type State = FileWithUrl[] 33 | 34 | export interface InputProps 35 | extends Omit, 'type'> {} 36 | 37 | const FileInput = forwardRef( 38 | ({ className, ...props }, ref) => { 39 | const { toast } = useToast() 40 | const { s3Upload } = useS3Upload() 41 | const [dragActive, setDragActive] = useState(false) 42 | const [input, dispatch] = useReducer((state: State, action: Action) => { 43 | switch (action.type) { 44 | case 'ADD_FILES_TO_INPUT': { 45 | // do not allow more than 5 files to be uploaded at once 46 | if (state.length + action.payload.length > 10) { 47 | toast({ 48 | title: 'Too many files', 49 | description: 50 | 'You can only upload a maximum of 5 files at a time.', 51 | }) 52 | return state 53 | } 54 | 55 | return [...state, ...action.payload] 56 | } 57 | 58 | // You could extend this, for example to allow removing files 59 | } 60 | }, []) 61 | 62 | const noInput = input.length === 0 63 | 64 | // handle drag events 65 | const handleDrag = (e: DragEvent) => { 66 | e.preventDefault() 67 | e.stopPropagation() 68 | if (e.type === 'dragenter' || e.type === 'dragover') { 69 | setDragActive(true) 70 | } else if (e.type === 'dragleave') { 71 | setDragActive(false) 72 | } 73 | } 74 | 75 | // triggers when file is selected with click 76 | const handleChange = async (e: ChangeEvent) => { 77 | e.preventDefault() 78 | try { 79 | if (e.target.files && e.target.files[0]) { 80 | // at least one file has been selected 81 | 82 | // validate file type 83 | const valid = validateFileType(e.target.files[0]) 84 | if (!valid) { 85 | toast({ 86 | title: 'Invalid file type', 87 | description: 'Please upload a valid file type.', 88 | }) 89 | return 90 | } 91 | 92 | const { getUrl, error } = await s3Upload(e.target.files[0]) 93 | if (!getUrl || error) throw new Error('Error uploading file') 94 | 95 | const { name, size } = e.target.files[0] 96 | 97 | addFilesToState([{ name, getUrl, size }]) 98 | } 99 | } catch (error) { 100 | // already handled 101 | } 102 | } 103 | 104 | const addFilesToState = (files: FileWithUrl[]) => { 105 | dispatch({ type: 'ADD_FILES_TO_INPUT', payload: files }) 106 | } 107 | 108 | // triggers when file is dropped 109 | const handleDrop = async (e: DragEvent) => { 110 | e.preventDefault() 111 | e.stopPropagation() 112 | 113 | // validate file type 114 | if (e.dataTransfer.files && e.dataTransfer.files[0]) { 115 | const files = Array.from(e.dataTransfer.files) 116 | const validFiles = files.filter((file) => validateFileType(file)) 117 | 118 | if (files.length !== validFiles.length) { 119 | toast({ 120 | title: 'Invalid file type', 121 | description: 'Only image files are allowed.', 122 | }) 123 | } 124 | 125 | try { 126 | const filesWithUrl = await Promise.all( 127 | validFiles.map(async (file) => { 128 | const { name, size } = file 129 | const { getUrl, error } = await s3Upload(file) 130 | 131 | if (!getUrl || error) return { name, size, getUrl: '', error } 132 | return { name, size, getUrl } 133 | }) 134 | ) 135 | 136 | setDragActive(false) 137 | 138 | // at least one file has been selected 139 | addFilesToState(filesWithUrl) 140 | 141 | e.dataTransfer.clearData() 142 | } catch (error) { 143 | // already handled 144 | } 145 | } 146 | } 147 | 148 | return ( 149 |
e.preventDefault()} 151 | onDragEnter={handleDrag} 152 | className="flex h-full items-center w-full lg:w-2/3 justify-start" 153 | > 154 |