├── public ├── images │ └── screenshot.png ├── site.webmanifest └── favicon.ico ├── postcss.config.cjs ├── src ├── types │ └── index.ts ├── lib │ ├── fonts.ts │ ├── uploadthing.ts │ ├── rate-limit.ts │ ├── handle-error.ts │ └── utils.ts ├── app │ ├── page.tsx │ ├── api │ │ └── uploadthing │ │ │ ├── route.ts │ │ │ └── core.ts │ ├── icon.tsx │ └── layout.tsx ├── config │ ├── site.ts │ └── data.ts ├── components │ ├── providers.tsx │ ├── tailwind-indicator.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── progress.tsx │ │ ├── sonner.tsx │ │ ├── checkbox.tsx │ │ ├── tooltip.tsx │ │ ├── popover.tsx │ │ ├── scroll-area.tsx │ │ ├── button.tsx │ │ ├── table.tsx │ │ ├── dialog.tsx │ │ ├── command.tsx │ │ └── dropdown-menu.tsx │ ├── shell.tsx │ ├── layouts │ │ ├── mode-toggle.tsx │ │ └── site-header.tsx │ ├── tricks-table.tsx │ ├── file-uploader.tsx │ └── csv-importer.tsx ├── hooks │ ├── use-callback-ref.ts │ ├── use-upload-file.ts │ ├── use-controllable-state.ts │ └── use-parse-csv.ts ├── env.js └── styles │ └── globals.css ├── next.config.js ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ └── code-check.yml ├── components.json ├── .gitignore ├── .env.example ├── tsconfig.json ├── prettier.config.js ├── .eslintrc.cjs ├── README.md ├── tailwind.config.ts └── package.json /public/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sadmann7/csv-importer/HEAD/public/images/screenshot.png -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | }; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { type ClientUploadedFileData } from "uploadthing/types" 2 | 3 | export interface UploadedFile extends ClientUploadedFileData {} 4 | -------------------------------------------------------------------------------- /src/lib/fonts.ts: -------------------------------------------------------------------------------- 1 | import { GeistMono } from "geist/font/mono" 2 | import { GeistSans } from "geist/font/sans" 3 | 4 | export const fontSans = GeistSans 5 | export const fontMono = GeistMono 6 | -------------------------------------------------------------------------------- /src/lib/uploadthing.ts: -------------------------------------------------------------------------------- 1 | import { generateReactHelpers } from "@uploadthing/react" 2 | 3 | import type { OurFileRouter } from "@/app/api/uploadthing/core" 4 | 5 | export const { useUploadThing, uploadFiles } = 6 | generateReactHelpers() 7 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Shell } from "@/components/shell" 2 | import { TricksTable } from "@/components/tricks-table" 3 | 4 | export default function IndexPage() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | await import("./src/env.js"); 6 | 7 | /** @type {import("next").NextConfig} */ 8 | const config = {}; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CSV Importer", 3 | "short_name": "CSV Importer", 4 | "icons": [ 5 | { 6 | "src": "/icon.png", 7 | "sizes": "32x32", 8 | "type": "image/png" 9 | } 10 | ], 11 | "theme_color": "#ffffff", 12 | "background_color": "#ffffff", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandler } from "uploadthing/next" 2 | 3 | import { ourFileRouter } from "./core" 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createRouteHandler({ 7 | router: ourFileRouter, 8 | 9 | // Apply an (optional) custom config: 10 | // config: { ... }, 11 | }) 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # This template is heavily inspired by the shadcn-ui/ui repository. 2 | # See: https://github.com/shadcn-ui/ui/blob/main/.github/ISSUE_TEMPLATE/config.yml 3 | 4 | blank_issues_enabled: false 5 | contact_links: 6 | - name: General questions 7 | url: https://github.com/sadmann7/csv-importer/discussions?category=general 8 | about: Please ask and answer questions here 9 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /src/config/site.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env" 2 | 3 | export type SiteConfig = typeof siteConfig 4 | 5 | export const siteConfig = { 6 | name: "CSV Importer", 7 | description: 8 | "CSV importer built with shadcn-ui, react-dropzone, and papaparse.", 9 | url: 10 | env.NODE_ENV === "development" 11 | ? "http://localhost:3000" 12 | : "https://importer.sadmn.com", 13 | links: { github: "https://github.com/sadmann7/csv-importer" }, 14 | } 15 | -------------------------------------------------------------------------------- /src/components/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ThemeProvider as NextThemesProvider } from "next-themes" 4 | import type { ThemeProviderProps } from "next-themes/dist/types" 5 | 6 | import { TooltipProvider } from "@/components/ui/tooltip" 7 | 8 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 9 | return ( 10 | 11 | {children} 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/rate-limit.ts: -------------------------------------------------------------------------------- 1 | import { Ratelimit } from "@upstash/ratelimit" // for deno: see above 2 | import { Redis } from "@upstash/redis" // see below for cloudflare and fastly adapters 3 | 4 | export const ratelimit = new Ratelimit({ 5 | redis: Redis.fromEnv(), 6 | // Rate limit to 10 requests per 10 seconds 7 | limiter: Ratelimit.slidingWindow(10, "10 s"), 8 | analytics: true, 9 | /** 10 | * Optional prefix for the keys used in redis. This is useful if you want to share a redis 11 | * instance with other applications and want to avoid key collisions. The default prefix is 12 | * "@upstash/ratelimit" 13 | */ 14 | prefix: "importer/ratelimit", 15 | }) 16 | -------------------------------------------------------------------------------- /src/lib/handle-error.ts: -------------------------------------------------------------------------------- 1 | import { isRedirectError } from "next/dist/client/components/redirect" 2 | import { toast } from "sonner" 3 | import { z } from "zod" 4 | 5 | export function getErrorMessage(err: unknown) { 6 | const unknownError = "Something went wrong, please try again later." 7 | 8 | if (err instanceof z.ZodError) { 9 | const errors = err.issues.map((issue) => { 10 | return issue.message 11 | }) 12 | return errors.join("\n") 13 | } else if (err instanceof Error) { 14 | return err.message 15 | } else if (isRedirectError(err)) { 16 | throw err 17 | } else { 18 | return unknownError 19 | } 20 | } 21 | 22 | export function showErrorToast(err: unknown) { 23 | const errorMessage = getErrorMessage(err) 24 | return toast.error(errorMessage) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "@/env.js" 2 | 3 | export function TailwindIndicator() { 4 | if (env.NODE_ENV === "production") return null 5 | 6 | return ( 7 |
8 |
xs
9 |
10 | sm 11 |
12 |
md
13 |
lg
14 |
xl
15 |
2xl
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /.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 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | db.sqlite 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | next-env.d.ts 20 | 21 | # production 22 | /build 23 | 24 | # misc 25 | .DS_Store 26 | *.pem 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .pnpm-debug.log* 33 | 34 | # local env files 35 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 36 | .env 37 | .env*.local 38 | 39 | # vercel 40 | .vercel 41 | 42 | # typescript 43 | *.tsbuildinfo 44 | 45 | # idea files 46 | .idea -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/hooks/use-callback-ref.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | /** 4 | * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx 5 | */ 6 | 7 | /** 8 | * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a 9 | * prop or avoid re-executing effects when passed as a dependency 10 | */ 11 | function useCallbackRef unknown>( 12 | callback: T | undefined 13 | ): T { 14 | const callbackRef = React.useRef(callback) 15 | 16 | React.useEffect(() => { 17 | callbackRef.current = callback 18 | }) 19 | 20 | // https://github.com/facebook/react/issues/19240 21 | return React.useMemo( 22 | () => ((...args) => callbackRef.current?.(...args)) as T, 23 | [] 24 | ) 25 | } 26 | 27 | export { useCallbackRef } 28 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/src/env.js" 10 | # should be updated accordingly. 11 | 12 | # App 13 | # Use the production URL when deploying to production 14 | NEXT_PUBLIC_APP_URL="http://localhost:3000" 15 | 16 | # uploadthing 17 | UPLOADTHING_SECRET="sk_live_" 18 | UPLOADTHING_APP_ID="********" 19 | 20 | # Optional 21 | # upstash 22 | UPSTASH_REDIS_REST_URL="https://********upstash.io" 23 | UPSTASH_REDIS_REST_TOKEN="********" 24 | -------------------------------------------------------------------------------- /src/app/icon.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/og" 2 | 3 | // Route segment config 4 | export const runtime = "edge" 5 | 6 | // Image metadata 7 | export const size = { 8 | width: 32, 9 | height: 32, 10 | } 11 | export const contentType = "image/png" 12 | 13 | // Image generation 14 | export default function Icon() { 15 | return new ImageResponse( 16 | ( 17 | // ImageResponse JSX element 18 |
25 | I 26 |
27 | ), 28 | // ImageResponse options 29 | { 30 | // For convenience, we can re-use the exported icons size metadata 31 | // config to also set the ImageResponse's width and height. 32 | ...size, 33 | } 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )) 26 | Progress.displayName = ProgressPrimitive.Root.displayName 27 | 28 | export { Progress } 29 | -------------------------------------------------------------------------------- /src/components/shell.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 shellVariants = cva("grid items-center gap-8 pb-8 pt-6 md:py-8", { 7 | variants: { 8 | variant: { 9 | default: "container", 10 | sidebar: "", 11 | centered: "mx-auto mb-16 mt-20 max-w-md justify-center", 12 | markdown: "container max-w-3xl gap-0 py-8 md:py-10 lg:py-10", 13 | }, 14 | }, 15 | defaultVariants: { 16 | variant: "default", 17 | }, 18 | }) 19 | 20 | interface ShellProps 21 | extends React.HTMLAttributes, 22 | VariantProps { 23 | as?: React.ElementType 24 | } 25 | 26 | function Shell({ 27 | className, 28 | as: Comp = "section", 29 | variant, 30 | ...props 31 | }: ShellProps) { 32 | return ( 33 | 34 | ) 35 | } 36 | 37 | export { Shell, shellVariants } 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "allowJs": true, 8 | "resolveJsonModule": true, 9 | "moduleDetection": "force", 10 | "isolatedModules": true, 11 | 12 | /* Strictness */ 13 | "strict": true, 14 | "noUncheckedIndexedAccess": true, 15 | "checkJs": true, 16 | 17 | /* Bundled projects */ 18 | "lib": ["dom", "dom.iterable", "ES2022"], 19 | "noEmit": true, 20 | "module": "ESNext", 21 | "moduleResolution": "Bundler", 22 | "jsx": "preserve", 23 | "plugins": [{ "name": "next" }], 24 | "incremental": true, 25 | 26 | /* Path Aliases */ 27 | "baseUrl": ".", 28 | "paths": { 29 | "@/*": ["./src/*"] 30 | } 31 | }, 32 | "include": [ 33 | ".eslintrc.cjs", 34 | "next-env.d.ts", 35 | "**/*.ts", 36 | "**/*.tsx", 37 | "**/*.cjs", 38 | "**/*.js", 39 | ".next/types/**/*.ts" 40 | ], 41 | "exclude": ["node_modules"] 42 | } 43 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | function Toaster({ ...props }: ToasterProps) { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 29 | ) 30 | } 31 | 32 | export { Toaster } 33 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ 2 | const config = { 3 | endOfLine: "lf", 4 | semi: false, 5 | singleQuote: false, 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 | "^@/hooks/(.*)$", 18 | "^@/components/ui/(.*)$", 19 | "^@/components/(.*)$", 20 | "^@/styles/(.*)$", 21 | "^@/app/(.*)$", 22 | "", 23 | "^[./]", 24 | ], 25 | importOrderSeparation: false, 26 | importOrderSortSpecifiers: true, 27 | importOrderBuiltinModulesToTop: true, 28 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 29 | importOrderMergeDuplicateImports: true, 30 | importOrderCombineTypeAndValueImports: true, 31 | tailwindAttributes: ["tw"], 32 | tailwindFunctions: ["cva"], 33 | plugins: [ 34 | "@ianvs/prettier-plugin-sort-imports", 35 | "prettier-plugin-tailwindcss", 36 | ], 37 | }; 38 | 39 | export default config; 40 | -------------------------------------------------------------------------------- /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 { CheckIcon } from "@radix-ui/react-icons" 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/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | project: true, 6 | }, 7 | plugins: ["@typescript-eslint", "tailwindcss"], 8 | extends: [ 9 | "next/core-web-vitals", 10 | "plugin:@typescript-eslint/recommended-type-checked", 11 | "prettier", 12 | "plugin:tailwindcss/recommended", 13 | ], 14 | rules: { 15 | "@typescript-eslint/array-type": "off", 16 | "@typescript-eslint/consistent-type-definitions": "off", 17 | "@typescript-eslint/consistent-type-imports": [ 18 | "warn", 19 | { 20 | prefer: "type-imports", 21 | fixStyle: "inline-type-imports", 22 | }, 23 | ], 24 | "@typescript-eslint/no-unused-vars": [ 25 | "warn", 26 | { 27 | argsIgnorePattern: "^_", 28 | }, 29 | ], 30 | "@typescript-eslint/require-await": "off", 31 | "@typescript-eslint/no-misused-promises": [ 32 | "error", 33 | { 34 | checksVoidReturn: { 35 | attributes: false, 36 | }, 37 | }, 38 | ], 39 | }, 40 | settings: { 41 | tailwindcss: { 42 | callees: ["cn", "cva"], 43 | config: "./tailwind.config.ts", 44 | classRegex: "^(class(Name)?|tw)$", 45 | }, 46 | next: { 47 | rootDir: ["./"], 48 | }, 49 | }, 50 | } 51 | module.exports = config 52 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )) 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [CSV Importer](https://importer.sadmn.com) 2 | 3 | This is a csv-importer built with `shadnc/ui`, `react-dropzone`, and `papaparse`. It is bootstrapped with `create-t3-app`. 4 | 5 | [![CSV Importer](./public/images/screenshot.png)](https://importer.sadmn.com) 6 | 7 | ## Tech Stack 8 | 9 | - **Framework:** [Next.js](https://nextjs.org) 10 | - **Styling:** [Tailwind CSS](https://tailwindcss.com) 11 | - **UI Components:** [shadcn/ui](https://ui.shadcn.com) 12 | - **DND Uploader:** [react-dropzone](https://react-dropzone.js.org/) 13 | - **Storage:** [uploadthing](https://uploadthing.com) 14 | - **CSV Parsing:** [Papaparse](https://www.papaparse.com) 15 | 16 | ## Features 17 | 18 | - [x] Upload CSV file using `use-upload-file.ts` 19 | - [x] Parse CSV file using `use-parse-csv.ts` 20 | - [x] Preview the parsed CSV data 21 | - [x] Map the CSV fields to the corresponding table fields 22 | - [x] Import the mapped data into the table 23 | 24 | ## Running Locally 25 | 26 | 1. Clone the repository 27 | 28 | ```bash 29 | git clone https://github.com/sadmann7/csv-importer 30 | ``` 31 | 32 | 2. Install dependencies using pnpm 33 | 34 | ```bash 35 | pnpm install 36 | ``` 37 | 38 | 3. Start the development server 39 | 40 | ```bash 41 | pnpm run dev 42 | ``` 43 | 44 | ## How do I deploy this? 45 | 46 | Follow the deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. 47 | -------------------------------------------------------------------------------- /src/components/layouts/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { LaptopIcon, MoonIcon, SunIcon } from "@radix-ui/react-icons" 4 | import { useTheme } from "next-themes" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu" 13 | 14 | export function ModeToggle() { 15 | const { setTheme } = useTheme() 16 | 17 | return ( 18 | 19 | 20 | 25 | 26 | 27 | setTheme("light")}> 28 | 29 | Light 30 | 31 | setTheme("dark")}> 32 | 33 | Dark 34 | 35 | setTheme("system")}> 36 | 37 | System 38 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | # This template is heavily inspired by the acme-corp and shadcn-ui/ui repositories. 2 | # See: https://github.com/juliusmarminge/acme-corp/blob/main/.github/ISSUE_TEMPLATE/bug_report.yml 3 | # See: https://github.com/shadcn-ui/ui/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml 4 | 5 | name: Bug report 6 | description: Create a bug report to help us improve 7 | title: "[bug]: " 8 | labels: ["🐞❔ unconfirmed bug"] 9 | body: 10 | - type: textarea 11 | attributes: 12 | label: Describe the bug 13 | description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: How to reproduce 19 | description: A step-by-step description of how to reproduce the bug. 20 | placeholder: | 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. See error 24 | validations: 25 | required: true 26 | - type: input 27 | attributes: 28 | label: Link to reproduction 29 | description: A link to a CodeSandbox or StackBlitz that includes a minimal reproduction of the problem. In rare cases when not applicable, you can link to a GitHub repository that we can easily run to recreate the issue. If a report is vague and does not have a reproduction, it will be closed without warning. 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Additional information 35 | description: Add any other information related to the bug here, screenshots if applicable. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | # This template is heavily inspired by the shadcn-ui/ui repository. 2 | # See: https://github.com/shadcn-ui/ui/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml 3 | 4 | name: "Feature request" 5 | description: Create a feature request for csv-importer 6 | title: "[feat]: " 7 | labels: ["✨ enhancement"] 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: | 12 | ### Thanks for suggesting a feature request! Make sure to see if your feature request has already been suggested by searching through the existing issues. If you find a similar request, give it a thumbs up and add any additional context you have in the comments. 13 | 14 | - type: textarea 15 | id: feature-description 16 | attributes: 17 | label: Feature description 18 | description: Tell us about your feature request. 19 | placeholder: "I think this feature would be great because..." 20 | value: "Describe your feature request..." 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | id: context 26 | attributes: 27 | label: Additional Context 28 | description: Add any other context about the feature here. 29 | placeholder: ex. screenshots, Stack Overflow links, forum links, etc. 30 | value: "Additional details here..." 31 | validations: 32 | required: false 33 | 34 | - type: checkboxes 35 | id: terms 36 | attributes: 37 | label: Before submitting 38 | description: Please ensure the following 39 | options: 40 | - label: I've made research efforts and searched the documentation 41 | required: true 42 | - label: I've searched for existing issues and PRs 43 | required: true 44 | -------------------------------------------------------------------------------- /src/components/layouts/site-header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { FileIcon, GitHubLogoIcon } from "@radix-ui/react-icons" 3 | 4 | import { siteConfig } from "@/config/site" 5 | import { Button } from "@/components/ui/button" 6 | import { ModeToggle } from "@/components/layouts/mode-toggle" 7 | 8 | export function SiteHeader() { 9 | return ( 10 |
11 |
12 | 13 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } 49 | -------------------------------------------------------------------------------- /src/hooks/use-upload-file.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import type { UploadedFile } from "@/types" 3 | import { toast } from "sonner" 4 | import type { UploadFilesOptions } from "uploadthing/types" 5 | 6 | import { getErrorMessage } from "@/lib/handle-error" 7 | import { uploadFiles } from "@/lib/uploadthing" 8 | import { type OurFileRouter } from "@/app/api/uploadthing/core" 9 | 10 | interface UseUploadFileProps 11 | extends Pick< 12 | UploadFilesOptions, 13 | "headers" | "onUploadBegin" | "onUploadProgress" | "skipPolling" 14 | > { 15 | defaultUploadedFiles?: UploadedFile[] 16 | } 17 | 18 | export function useUploadFile( 19 | endpoint: keyof OurFileRouter, 20 | { defaultUploadedFiles = [], ...props }: UseUploadFileProps = {} 21 | ) { 22 | const [uploadedFiles, setUploadedFiles] = 23 | React.useState(defaultUploadedFiles) 24 | const [progresses, setProgresses] = React.useState>({}) 25 | const [isUploading, setIsUploading] = React.useState(false) 26 | 27 | async function onUpload(files: File[]) { 28 | setIsUploading(true) 29 | try { 30 | const res = await uploadFiles(endpoint, { 31 | ...props, 32 | files, 33 | onUploadProgress: ({ file, progress }) => { 34 | setProgresses((prev) => { 35 | return { 36 | ...prev, 37 | [file]: progress ?? 0, 38 | } 39 | }) 40 | }, 41 | }) 42 | 43 | setUploadedFiles((prev) => (prev ? [...prev, ...res] : res)) 44 | } catch (err) { 45 | toast.error(getErrorMessage(err)) 46 | } finally { 47 | setProgresses({}) 48 | setIsUploading(false) 49 | } 50 | } 51 | 52 | return { 53 | onUpload, 54 | uploadedFiles, 55 | progresses, 56 | isUploading, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { createUploadthing, type FileRouter } from "uploadthing/next" 2 | import { UploadThingError } from "uploadthing/server" 3 | 4 | import { ratelimit } from "@/lib/rate-limit" 5 | 6 | const f = createUploadthing() 7 | 8 | // Fake auth function 9 | async function auth(req: Request) { 10 | await new Promise((resolve) => setTimeout(resolve, 100)) 11 | return { id: "fakeId" } 12 | } 13 | 14 | // FileRouter for your app, can contain multiple FileRoutes 15 | export const ourFileRouter = { 16 | // Define as many FileRoutes as you like, each with a unique routeSlug 17 | csvUploader: f({ "text/csv": { maxFileSize: "4MB", maxFileCount: 1 } }) 18 | // Set permissions and file types for this FileRoute 19 | .middleware(async ({ req }) => { 20 | // Rate limit the upload 21 | const ip = req.ip ?? "127.0.0.1" 22 | 23 | const { success } = await ratelimit.limit(ip) 24 | 25 | if (!success) { 26 | throw new UploadThingError("Rate limit exceeded") 27 | } 28 | 29 | // This code runs on your server before upload 30 | const user = await auth(req) 31 | 32 | // If you throw, the user will not be able to upload 33 | if (!user) throw new UploadThingError("Unauthorized") 34 | 35 | // Whatever is returned here is accessible in onUploadComplete as `metadata` 36 | return { userId: user.id } 37 | }) 38 | .onUploadComplete(async ({ metadata, file }) => { 39 | // This code RUNS ON YOUR SERVER after upload 40 | console.log("Upload complete for userId:", metadata.userId) 41 | 42 | console.log("file url", file.url) 43 | 44 | // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback 45 | return { uploadedBy: metadata.userId } 46 | }), 47 | } satisfies FileRouter 48 | 49 | export type OurFileRouter = typeof ourFileRouter 50 | -------------------------------------------------------------------------------- /src/env.js: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs" 2 | import { z } from "zod" 3 | 4 | export const env = createEnv({ 5 | /** 6 | * Specify your server-side environment variables schema here. This way you can ensure the app 7 | * isn't built with invalid env vars. 8 | */ 9 | server: { 10 | NODE_ENV: z.enum(["development", "test", "production"]), 11 | UPLOADTHING_SECRET: z.string().min(1), 12 | UPLOADTHING_APP_ID: z.string().min(1), 13 | UPSTASH_REDIS_REST_URL: z.string().url(), 14 | UPSTASH_REDIS_REST_TOKEN: z.string().min(1), 15 | }, 16 | 17 | /** 18 | * Specify your client-side environment variables schema here. This way you can ensure the app 19 | * isn't built with invalid env vars. To expose them to the client, prefix them with 20 | * `NEXT_PUBLIC_`. 21 | */ 22 | client: { 23 | NEXT_PUBLIC_APP_URL: z.string().url(), 24 | }, 25 | 26 | /** 27 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. 28 | * middlewares) or client-side so we need to destruct manually. 29 | */ 30 | runtimeEnv: { 31 | NODE_ENV: process.env.NODE_ENV, 32 | UPLOADTHING_SECRET: process.env.UPLOADTHING_SECRET, 33 | UPLOADTHING_APP_ID: process.env.UPLOADTHING_APP_ID, 34 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, 35 | UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, 36 | UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, 37 | }, 38 | /** 39 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially 40 | * useful for Docker builds. 41 | */ 42 | skipValidation: !!process.env.SKIP_ENV_VALIDATION, 43 | /** 44 | * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and 45 | * `SOME_VAR=''` will throw an error. 46 | */ 47 | emptyStringAsUndefined: true, 48 | }) 49 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env" 2 | import { clsx, type ClassValue } from "clsx" 3 | import { twMerge } from "tailwind-merge" 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)) 7 | } 8 | 9 | export function formatBytes( 10 | bytes: number, 11 | opts: { 12 | decimals?: number 13 | sizeType?: "accurate" | "normal" 14 | } = {} 15 | ) { 16 | const { decimals = 0, sizeType = "normal" } = opts 17 | 18 | const sizes = ["Bytes", "KB", "MB", "GB", "TB"] 19 | const accurateSizes = ["Bytes", "KiB", "MiB", "GiB", "TiB"] 20 | if (bytes === 0) return "0 Byte" 21 | const i = Math.floor(Math.log(bytes) / Math.log(1024)) 22 | return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${ 23 | sizeType === "accurate" 24 | ? (accurateSizes[i] ?? "Bytest") 25 | : (sizes[i] ?? "Bytes") 26 | }` 27 | } 28 | 29 | export function slugify(text: string) { 30 | return text 31 | .toString() 32 | .toLowerCase() 33 | .replace(/\s+/g, "-") // Replace spaces with - 34 | .replace(/[^\w-]+/g, "") // Remove all non-word chars 35 | .replace(/--+/g, "-") // Replace multiple - with single - 36 | .replace(/^-+/, "") // Trim - from start of text 37 | .replace(/-+$/, "") // Trim - from end of text 38 | } 39 | 40 | export function absoluteUrl(path: string) { 41 | return `${env.NEXT_PUBLIC_APP_URL}${path}` 42 | } 43 | 44 | /** 45 | * Stole this from the @radix-ui/primitive 46 | * @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx 47 | */ 48 | export function composeEventHandlers( 49 | originalEventHandler?: (event: E) => void, 50 | ourEventHandler?: (event: E) => void, 51 | { checkForDefaultPrevented = true } = {} 52 | ) { 53 | return function handleEvent(event: E) { 54 | originalEventHandler?.(event) 55 | 56 | if ( 57 | checkForDefaultPrevented === false || 58 | !(event as unknown as Event).defaultPrevented 59 | ) { 60 | return ourEventHandler?.(event) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/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: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 240 5.9% 10%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 10% 3.9%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | 34 | .dark { 35 | --background: 240 10% 3.9%; 36 | --foreground: 0 0% 98%; 37 | --card: 240 10% 3.9%; 38 | --card-foreground: 0 0% 98%; 39 | --popover: 240 10% 3.9%; 40 | --popover-foreground: 0 0% 98%; 41 | --primary: 0 0% 98%; 42 | --primary-foreground: 240 5.9% 10%; 43 | --secondary: 240 3.7% 15.9%; 44 | --secondary-foreground: 0 0% 98%; 45 | --muted: 240 3.7% 15.9%; 46 | --muted-foreground: 240 5% 64.9%; 47 | --accent: 240 3.7% 15.9%; 48 | --accent-foreground: 0 0% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 0 0% 98%; 51 | --border: 240 3.7% 15.9%; 52 | --input: 240 3.7% 15.9%; 53 | --ring: 240 4.9% 83.9%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | @layer base { 63 | * { 64 | @apply border-border; 65 | } 66 | body { 67 | @apply bg-background text-foreground; 68 | } 69 | } -------------------------------------------------------------------------------- /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 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", 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: "size-9", 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 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/hooks/use-controllable-state.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { useCallbackRef } from "@/hooks/use-callback-ref" 4 | 5 | /** 6 | * @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx 7 | */ 8 | 9 | type UseControllableStateParams = { 10 | prop?: T | undefined 11 | defaultProp?: T | undefined 12 | onChange?: (state: T) => void 13 | } 14 | 15 | type SetStateFn = (prevState?: T) => T 16 | 17 | function useControllableState({ 18 | prop, 19 | defaultProp, 20 | onChange = () => {}, 21 | }: UseControllableStateParams) { 22 | const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ 23 | defaultProp, 24 | onChange, 25 | }) 26 | const isControlled = prop !== undefined 27 | const value = isControlled ? prop : uncontrolledProp 28 | const handleChange = useCallbackRef(onChange) 29 | 30 | const setValue: React.Dispatch> = 31 | React.useCallback( 32 | (nextValue) => { 33 | if (isControlled) { 34 | const setter = nextValue as SetStateFn 35 | const value = 36 | typeof nextValue === "function" ? setter(prop) : nextValue 37 | if (value !== prop) handleChange(value as T) 38 | } else { 39 | setUncontrolledProp(nextValue) 40 | } 41 | }, 42 | [isControlled, prop, setUncontrolledProp, handleChange] 43 | ) 44 | 45 | return [value, setValue] as const 46 | } 47 | 48 | function useUncontrolledState({ 49 | defaultProp, 50 | onChange, 51 | }: Omit, "prop">) { 52 | const uncontrolledState = React.useState(defaultProp) 53 | const [value] = uncontrolledState 54 | const prevValueRef = React.useRef(value) 55 | const handleChange = useCallbackRef(onChange) 56 | 57 | React.useEffect(() => { 58 | if (prevValueRef.current !== value) { 59 | handleChange(value as T) 60 | prevValueRef.current = value 61 | } 62 | }, [value, prevValueRef, handleChange]) 63 | 64 | return uncontrolledState 65 | } 66 | 67 | export { useControllableState } 68 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { siteConfig } from "@/config/site" 2 | import { cn } from "@/lib/utils" 3 | import { SiteHeader } from "@/components/layouts/site-header" 4 | import { ThemeProvider } from "@/components/providers" 5 | import { TailwindIndicator } from "@/components/tailwind-indicator" 6 | 7 | import "@/styles/globals.css" 8 | 9 | import type { Metadata, Viewport } from "next" 10 | import { Toaster } from "sonner" 11 | 12 | import { fontMono, fontSans } from "@/lib/fonts" 13 | 14 | export const metadata: Metadata = { 15 | metadataBase: new URL(siteConfig.url), 16 | title: { 17 | default: siteConfig.name, 18 | template: `%s - ${siteConfig.name}`, 19 | }, 20 | description: siteConfig.description, 21 | keywords: ["nextjs", "react", "importer", "csv-importer"], 22 | authors: [ 23 | { 24 | name: "sadmann7", 25 | url: "https://www.sadmn.com", 26 | }, 27 | ], 28 | creator: "sadmann7", 29 | openGraph: { 30 | type: "website", 31 | locale: "en_US", 32 | url: siteConfig.url, 33 | title: siteConfig.name, 34 | description: siteConfig.description, 35 | siteName: siteConfig.name, 36 | }, 37 | twitter: { 38 | card: "summary_large_image", 39 | title: siteConfig.name, 40 | description: siteConfig.description, 41 | images: [`${siteConfig.url}/og.jpg`], 42 | creator: "@sadmann17", 43 | }, 44 | icons: { 45 | icon: "/icon.png", 46 | }, 47 | manifest: `${siteConfig.url}/site.webmanifest`, 48 | } 49 | 50 | export const viewport: Viewport = { 51 | colorScheme: "dark light", 52 | themeColor: [ 53 | { media: "(prefers-color-scheme: light)", color: "white" }, 54 | { media: "(prefers-color-scheme: dark)", color: "black" }, 55 | ], 56 | } 57 | 58 | export default function RootLayout({ children }: React.PropsWithChildren) { 59 | return ( 60 | 61 | 62 | 69 | 75 |
76 | 77 |
{children}
78 |
79 | 80 |
81 | 82 | 83 | 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | import { fontFamily } from "tailwindcss/defaultTheme" 3 | 4 | const config = { 5 | darkMode: ["class"], 6 | content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], 7 | prefix: "", 8 | theme: { 9 | container: { 10 | center: true, 11 | padding: "2rem", 12 | screens: { 13 | "2xl": "1400px", 14 | }, 15 | }, 16 | extend: { 17 | colors: { 18 | border: "hsl(var(--border))", 19 | input: "hsl(var(--input))", 20 | ring: "hsl(var(--ring))", 21 | background: "hsl(var(--background))", 22 | foreground: "hsl(var(--foreground))", 23 | primary: { 24 | DEFAULT: "hsl(var(--primary))", 25 | foreground: "hsl(var(--primary-foreground))", 26 | }, 27 | secondary: { 28 | DEFAULT: "hsl(var(--secondary))", 29 | foreground: "hsl(var(--secondary-foreground))", 30 | }, 31 | destructive: { 32 | DEFAULT: "hsl(var(--destructive))", 33 | foreground: "hsl(var(--destructive-foreground))", 34 | }, 35 | muted: { 36 | DEFAULT: "hsl(var(--muted))", 37 | foreground: "hsl(var(--muted-foreground))", 38 | }, 39 | accent: { 40 | DEFAULT: "hsl(var(--accent))", 41 | foreground: "hsl(var(--accent-foreground))", 42 | }, 43 | popover: { 44 | DEFAULT: "hsl(var(--popover))", 45 | foreground: "hsl(var(--popover-foreground))", 46 | }, 47 | card: { 48 | DEFAULT: "hsl(var(--card))", 49 | foreground: "hsl(var(--card-foreground))", 50 | }, 51 | }, 52 | borderRadius: { 53 | lg: "var(--radius)", 54 | md: "calc(var(--radius) - 2px)", 55 | sm: "calc(var(--radius) - 4px)", 56 | }, 57 | fontFamily: { 58 | sans: ["var(--font-geist-sans)", ...fontFamily.sans], 59 | mono: ["var(--font-geist-mono)", ...fontFamily.mono], 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config 79 | 80 | export default config 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "csv-importer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "clean": "rimraf --glob **/node_modules **/dist **/.next pnpm-lock.yaml **/.tsbuildinfo", 8 | "build": "next build", 9 | "dev": "next dev", 10 | "start": "next start", 11 | "lint": "next lint", 12 | "lint:fix": "next lint --fix", 13 | "typecheck": "tsc --noEmit", 14 | "format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache", 15 | "format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache", 16 | "check": "pnpm lint && pnpm typecheck && pnpm format:check", 17 | "shadcn:add": "pnpm dlx shadcn-ui@latest add" 18 | }, 19 | "dependencies": { 20 | "@radix-ui/react-checkbox": "^1.1.1", 21 | "@radix-ui/react-dialog": "^1.1.1", 22 | "@radix-ui/react-dropdown-menu": "^2.1.1", 23 | "@radix-ui/react-icons": "^1.3.0", 24 | "@radix-ui/react-label": "^2.1.0", 25 | "@radix-ui/react-popover": "^1.1.1", 26 | "@radix-ui/react-progress": "^1.1.0", 27 | "@radix-ui/react-scroll-area": "^1.1.0", 28 | "@radix-ui/react-slot": "^1.1.0", 29 | "@radix-ui/react-tooltip": "^1.1.2", 30 | "@t3-oss/env-nextjs": "^0.11.0", 31 | "@uploadthing/react": "^6.7.2", 32 | "@upstash/ratelimit": "^2.0.1", 33 | "@upstash/redis": "^1.33.0", 34 | "class-variance-authority": "^0.7.0", 35 | "clsx": "^2.1.1", 36 | "cmdk": "^1.0.0", 37 | "geist": "^1.3.1", 38 | "next": "14.2.35", 39 | "next-themes": "^0.3.0", 40 | "papaparse": "^5.4.1", 41 | "react": "^18.3.1", 42 | "react-dom": "^18.3.1", 43 | "react-dropzone": "^14.2.3", 44 | "sonner": "^1.5.0", 45 | "tailwind-merge": "^2.4.0", 46 | "tailwindcss-animate": "^1.0.7", 47 | "uploadthing": "^6.13.2", 48 | "zod": "^3.23.8" 49 | }, 50 | "devDependencies": { 51 | "@ianvs/prettier-plugin-sort-imports": "^4.3.1", 52 | "@types/eslint": "^8.56.10", 53 | "@types/node": "^20.14.12", 54 | "@types/papaparse": "^5.3.14", 55 | "@types/react": "^18.3.3", 56 | "@types/react-dom": "^18.3.0", 57 | "@typescript-eslint/eslint-plugin": "^7.17.0", 58 | "@typescript-eslint/parser": "^7.17.0", 59 | "eslint": "^8.57.0", 60 | "eslint-config-next": "^14.2.5", 61 | "eslint-config-prettier": "^9.1.0", 62 | "eslint-plugin-tailwindcss": "^3.17.4", 63 | "postcss": "^8.4.40", 64 | "prettier": "^3.3.3", 65 | "prettier-plugin-tailwindcss": "^0.6.5", 66 | "rimraf": "^6.0.1", 67 | "tailwindcss": "^3.4.7", 68 | "typescript": "^5.5.4" 69 | }, 70 | "ct3aMetadata": { 71 | "initVersion": "7.36.1" 72 | }, 73 | "packageManager": "pnpm@9.5.0" 74 | } 75 | -------------------------------------------------------------------------------- /src/components/tricks-table.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { dataConfig, type DataConfig } from "@/config/data" 6 | import { 7 | Table, 8 | TableBody, 9 | TableCell, 10 | TableHead, 11 | TableHeader, 12 | TableRow, 13 | } from "@/components/ui/table" 14 | import { CsvImporter } from "@/components/csv-importer" 15 | 16 | export function TricksTable() { 17 | const [data, setData] = React.useState(dataConfig.speicalTricks) 18 | 19 | return ( 20 |
21 | { 30 | const formattedData: DataConfig["speicalTricks"] = parsedData.map( 31 | (item) => ({ 32 | id: crypto.randomUUID(), 33 | name: String(item.name ?? ""), 34 | description: String(item.description ?? ""), 35 | points: Number.isNaN(Number(item.points)) 36 | ? 0 37 | : Number(item.points), 38 | difficulty: String(item.difficulty ?? ""), 39 | style: String(item.style ?? ""), 40 | }) 41 | ) 42 | 43 | setData((prev) => [...prev, ...formattedData]) 44 | }} 45 | className="self-end" 46 | /> 47 |
48 | 49 | 50 | 51 | Name 52 | Description 53 | Points 54 | Difficulty 55 | Style 56 | 57 | 58 | 59 | {data.map((item) => ( 60 | 61 | 62 | {item.name} 63 | 64 | 65 | {item.description} 66 | 67 | 68 | {item.points} 69 | 70 | 71 | {item.difficulty} 72 | 73 | 74 | {item.style} 75 | 76 | 77 | ))} 78 | 79 |
80 |
81 |
82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px]", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /.github/workflows/code-check.yml: -------------------------------------------------------------------------------- 1 | name: Code check 2 | 3 | on: 4 | pull_request: 5 | branches: ["*"] 6 | push: 7 | branches: ["main"] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | name: Lint 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Install Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | 23 | - uses: pnpm/action-setup@v3.0.0 24 | name: Install pnpm 25 | id: pnpm-install 26 | with: 27 | version: 8.6.1 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | run: | 33 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 34 | - uses: actions/cache@v4 35 | name: Setup pnpm cache 36 | with: 37 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 38 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 39 | restore-keys: | 40 | ${{ runner.os }}-pnpm-store- 41 | - name: Install dependencies 42 | run: pnpm install 43 | 44 | - run: cp .env.example .env.local 45 | 46 | - run: pnpm lint 47 | 48 | format: 49 | runs-on: ubuntu-latest 50 | name: Format 51 | steps: 52 | - uses: actions/checkout@v4 53 | with: 54 | fetch-depth: 0 55 | 56 | - name: Install Node.js 57 | uses: actions/setup-node@v4 58 | with: 59 | node-version: 20 60 | 61 | - uses: pnpm/action-setup@v3.0.0 62 | name: Install pnpm 63 | id: pnpm-install 64 | with: 65 | version: 8.6.1 66 | run_install: false 67 | 68 | - name: Get pnpm store directory 69 | id: pnpm-cache 70 | run: | 71 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 72 | 73 | - uses: actions/cache@v4 74 | name: Setup pnpm cache 75 | with: 76 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 77 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 78 | restore-keys: | 79 | ${{ runner.os }}-pnpm-store- 80 | 81 | - name: Install dependencies 82 | run: pnpm install 83 | 84 | - run: cp .env.example .env.local 85 | 86 | - run: pnpm format:check 87 | 88 | tsc: 89 | runs-on: ubuntu-latest 90 | name: Typecheck 91 | steps: 92 | - uses: actions/checkout@v4 93 | with: 94 | fetch-depth: 0 95 | 96 | - name: Install Node.js 97 | uses: actions/setup-node@v4 98 | with: 99 | node-version: 20 100 | 101 | - uses: pnpm/action-setup@v3.0.0 102 | name: Install pnpm 103 | id: pnpm-install 104 | with: 105 | version: 8.6.1 106 | run_install: false 107 | 108 | - name: Get pnpm store directory 109 | id: pnpm-cache 110 | run: | 111 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 112 | - uses: actions/cache@v4 113 | name: Setup pnpm cache 114 | with: 115 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 116 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 117 | restore-keys: | 118 | ${{ runner.os }}-pnpm-store- 119 | - name: Install dependencies 120 | run: pnpm install 121 | 122 | - run: cp .env.example .env.local 123 | 124 | - run: pnpm typecheck 125 | -------------------------------------------------------------------------------- /src/config/data.ts: -------------------------------------------------------------------------------- 1 | export type DataConfig = typeof dataConfig 2 | 3 | export const dataConfig = { 4 | speicalTricks: [ 5 | { 6 | id: crypto.randomUUID(), 7 | name: "The 900", 8 | points: 9000, 9 | description: "A two-and-a-half-revolution spin in the air", 10 | difficulty: "Expert", 11 | style: "Vert", 12 | }, 13 | { 14 | id: crypto.randomUUID(), 15 | name: "Indy Backflip", 16 | points: 4000, 17 | description: "An Indy grab combined with a backflip", 18 | difficulty: "Advanced", 19 | style: "Vert", 20 | }, 21 | { 22 | id: crypto.randomUUID(), 23 | name: "Pizza Guy", 24 | points: 1500, 25 | description: "A quirky, stylish grab trick", 26 | difficulty: "Intermediate", 27 | style: "Grab", 28 | }, 29 | { 30 | id: crypto.randomUUID(), 31 | name: "360 Varial McTwist", 32 | points: 5000, 33 | description: "A 360-degree spin with a Varial and McTwist", 34 | difficulty: "Expert", 35 | style: "Vert", 36 | }, 37 | { 38 | id: crypto.randomUUID(), 39 | name: "Kickflip Backflip", 40 | points: 3000, 41 | description: "A Kickflip combined with a backflip", 42 | difficulty: "Advanced", 43 | style: "Flip", 44 | }, 45 | { 46 | id: crypto.randomUUID(), 47 | name: "FS 540", 48 | points: 4500, 49 | description: "A frontside 540-degree spin", 50 | difficulty: "Advanced", 51 | style: "Vert", 52 | }, 53 | { 54 | id: crypto.randomUUID(), 55 | name: "Ghetto Bird", 56 | points: 3500, 57 | description: "A Hardflip late 180", 58 | difficulty: "Intermediate", 59 | style: "Flip", 60 | }, 61 | { 62 | id: crypto.randomUUID(), 63 | name: "Casper Flip 360 Flip", 64 | points: 2500, 65 | description: "A Casper Flip combined with a 360 Flip", 66 | difficulty: "Advanced", 67 | style: "Flip", 68 | }, 69 | { 70 | id: crypto.randomUUID(), 71 | name: "Christ Air", 72 | points: 6000, 73 | description: 74 | "A grab trick where the skater spreads their arms like a cross", 75 | difficulty: "Expert", 76 | style: "Grab", 77 | }, 78 | { 79 | id: crypto.randomUUID(), 80 | name: "Hardflip", 81 | points: 2000, 82 | description: "A combination of a Kickflip and a frontside shove-it", 83 | difficulty: "Intermediate", 84 | style: "Flip", 85 | }, 86 | { 87 | id: crypto.randomUUID(), 88 | name: "Heelflip", 89 | points: 1500, 90 | description: "A flip trick using the heel of the front foot", 91 | difficulty: "Intermediate", 92 | style: "Flip", 93 | }, 94 | { 95 | id: crypto.randomUUID(), 96 | name: "Benihana", 97 | points: 3000, 98 | description: "A stylish one-footed grab trick", 99 | difficulty: "Advanced", 100 | style: "Grab", 101 | }, 102 | { 103 | id: crypto.randomUUID(), 104 | name: "Judo Air", 105 | points: 3500, 106 | description: 107 | "A grab trick with a leg kick similar to a martial arts move", 108 | difficulty: "Advanced", 109 | style: "Grab", 110 | }, 111 | { 112 | id: crypto.randomUUID(), 113 | name: "Laser Flip", 114 | points: 4000, 115 | description: "A combination of a 360 Heelflip and a 360 shove-it", 116 | difficulty: "Expert", 117 | style: "Flip", 118 | }, 119 | { 120 | id: crypto.randomUUID(), 121 | name: "McTwist", 122 | points: 5000, 123 | description: "A 540-degree flip with a grab", 124 | difficulty: "Expert", 125 | style: "Vert", 126 | }, 127 | { 128 | id: crypto.randomUUID(), 129 | name: "Impossible", 130 | points: 2500, 131 | description: "A flip trick where the board wraps around the back foot", 132 | difficulty: "Advanced", 133 | style: "Flip", 134 | }, 135 | ], 136 | } 137 | -------------------------------------------------------------------------------- /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 { Cross2Icon } from "@radix-ui/react-icons" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { type DialogProps } from "@radix-ui/react-dialog" 5 | import { MagnifyingGlassIcon } from "@radix-ui/react-icons" 6 | import { Command as CommandPrimitive } from "cmdk" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Dialog, DialogContent } from "@/components/ui/dialog" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | interface CommandDialogProps extends DialogProps {} 27 | 28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 29 | return ( 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | const CommandInput = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 |
45 | 46 | 54 |
55 | )) 56 | 57 | CommandInput.displayName = CommandPrimitive.Input.displayName 58 | 59 | const CommandList = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 68 | )) 69 | 70 | CommandList.displayName = CommandPrimitive.List.displayName 71 | 72 | const CommandEmpty = React.forwardRef< 73 | React.ElementRef, 74 | React.ComponentPropsWithoutRef 75 | >((props, ref) => ( 76 | 81 | )) 82 | 83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 84 | 85 | const CommandGroup = React.forwardRef< 86 | React.ElementRef, 87 | React.ComponentPropsWithoutRef 88 | >(({ className, ...props }, ref) => ( 89 | 97 | )) 98 | 99 | CommandGroup.displayName = CommandPrimitive.Group.displayName 100 | 101 | const CommandSeparator = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 112 | 113 | const CommandItem = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 125 | )) 126 | 127 | CommandItem.displayName = CommandPrimitive.Item.displayName 128 | 129 | const CommandShortcut = ({ 130 | className, 131 | ...props 132 | }: React.HTMLAttributes) => { 133 | return ( 134 | 141 | ) 142 | } 143 | CommandShortcut.displayName = "CommandShortcut" 144 | 145 | export { 146 | Command, 147 | CommandDialog, 148 | CommandInput, 149 | CommandList, 150 | CommandEmpty, 151 | CommandGroup, 152 | CommandItem, 153 | CommandShortcut, 154 | CommandSeparator, 155 | } 156 | -------------------------------------------------------------------------------- /src/hooks/use-parse-csv.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as Papa from "papaparse" 3 | 4 | import { getErrorMessage } from "@/lib/handle-error" 5 | 6 | interface CsvState { 7 | fileName: string 8 | data: { 9 | parsed: Record[] 10 | mapped: Record[] 11 | } 12 | fieldMappings: { 13 | original: Record 14 | current: Record 15 | } 16 | error: string | null 17 | } 18 | 19 | interface UseParseCsvProps extends Papa.ParseConfig { 20 | /** 21 | * Array of field mappings defining the structure of the imported data. 22 | * Each field includes a label, value, and optional required flag. 23 | * @example fields={[{ label: 'Name', value: 'name', required: true }, { label: 'Email', value: 'email' }]} 24 | */ 25 | fields: { label: string; value: string; required?: boolean }[] 26 | 27 | /** 28 | * Callback function invoked when data is successfully parsed. 29 | * Receives an array of records representing the imported data. 30 | * @example onSuccess={(data) => console.log(data)} 31 | */ 32 | onSuccess?: (data: Record[]) => void 33 | 34 | /** 35 | * Callback function invoked when an error occurs during parsing. 36 | * Receives an error message. 37 | * @example onError={(message) => console.error(message)} 38 | */ 39 | onError?: (message: string) => void 40 | 41 | /** 42 | * Flag to indicate if empty fields should be shown. 43 | * @default false 44 | * @example showEmptyFields={true} 45 | */ 46 | showEmptyFields?: boolean 47 | } 48 | 49 | export function useParseCsv({ 50 | fields, 51 | onSuccess, 52 | onError, 53 | showEmptyFields, 54 | ...props 55 | }: UseParseCsvProps) { 56 | const [csvState, setCsvState] = React.useState({ 57 | fileName: "", 58 | data: { 59 | parsed: [], 60 | mapped: [], 61 | }, 62 | fieldMappings: { 63 | current: {}, 64 | original: {}, 65 | }, 66 | error: null, 67 | }) 68 | 69 | function onParse({ file, limit = Infinity }: { file: File; limit?: number }) { 70 | let count = 0 71 | const allResults: Record[] = [] 72 | 73 | Papa.parse>(file, { 74 | ...props, 75 | header: true, 76 | dynamicTyping: true, 77 | skipEmptyLines: true, 78 | beforeFirstChunk: (chunk) => { 79 | const parsedChunk = Papa.parse(chunk, { 80 | header: false, 81 | skipEmptyLines: true, 82 | }) 83 | 84 | const rows = parsedChunk.data 85 | const columns = rows[0] ?? [] 86 | 87 | const newColumns = columns 88 | .map((column, index) => { 89 | if (column.trim() === "" && !showEmptyFields) { 90 | const hasNonEmptyValue = rows 91 | .slice(1) 92 | .some( 93 | (row) => 94 | row[index] !== "" && 95 | row[index] !== null && 96 | row[index] !== undefined 97 | ) 98 | if (!hasNonEmptyValue) { 99 | return null 100 | } 101 | } 102 | return column.trim() === "" ? `Field ${index + 1}` : column 103 | }) 104 | .filter((column) => column !== null) 105 | 106 | rows[0] = newColumns 107 | return Papa.unparse(rows) 108 | }, 109 | step: (results, parser) => { 110 | try { 111 | if (count === 0) { 112 | const mappings = (results.meta.fields ?? [])?.reduce( 113 | (acc, field) => ({ 114 | ...acc, 115 | [field]: field, 116 | }), 117 | {} 118 | ) 119 | 120 | setCsvState((prevState) => ({ 121 | ...prevState, 122 | fieldMappings: { 123 | original: mappings, 124 | current: mappings, 125 | }, 126 | })) 127 | } 128 | 129 | if (count < limit) { 130 | allResults.push(results.data) 131 | count++ 132 | } else { 133 | parser.abort() 134 | throw new Error(`Only ${limit} rows are allowed`) 135 | } 136 | } catch (err) { 137 | const message = getErrorMessage(err) 138 | setCsvState((prevState) => ({ ...prevState, error: message })) 139 | onError?.(message) 140 | } 141 | }, 142 | complete: (_, localFile: File) => { 143 | setCsvState((prevState) => ({ 144 | ...prevState, 145 | fileName: localFile?.name 146 | ? localFile.name.replace(/\.[^/.]+$/, "") 147 | : "Untitled", 148 | data: { 149 | parsed: allResults, 150 | mapped: allResults, 151 | }, 152 | })) 153 | onSuccess?.(allResults) 154 | }, 155 | }) 156 | } 157 | 158 | function onFieldChange({ 159 | oldValue, 160 | newValue, 161 | }: { 162 | oldValue: string 163 | newValue: string 164 | }) { 165 | setCsvState((prevState) => ({ 166 | ...prevState, 167 | fieldMappings: { 168 | ...prevState.fieldMappings, 169 | current: { ...prevState.fieldMappings.current, [newValue]: oldValue }, 170 | }, 171 | data: { 172 | ...prevState.data, 173 | mapped: prevState.data.mapped.map((row, index) => ({ 174 | ...row, 175 | [newValue]: prevState.data.parsed[index]?.[oldValue], 176 | })), 177 | }, 178 | })) 179 | } 180 | 181 | function onFieldToggle({ 182 | value, 183 | checked, 184 | }: { 185 | value: string 186 | checked: boolean 187 | }) { 188 | setCsvState((prevState) => ({ 189 | ...prevState, 190 | fieldMappings: { 191 | ...prevState.fieldMappings, 192 | current: { 193 | ...prevState.fieldMappings.current, 194 | [value]: checked ? "" : undefined, 195 | }, 196 | }, 197 | data: { 198 | ...prevState.data, 199 | mapped: prevState.data.mapped.map((row) => { 200 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 201 | const { [value]: _, ...rest } = row 202 | return rest 203 | }), 204 | }, 205 | })) 206 | } 207 | 208 | function onFieldsReset() { 209 | setCsvState((prevState) => ({ 210 | ...prevState, 211 | fieldMappings: { 212 | ...prevState.fieldMappings, 213 | current: prevState.fieldMappings.original, 214 | }, 215 | data: { 216 | ...prevState.data, 217 | mapped: prevState.data.parsed, 218 | }, 219 | })) 220 | } 221 | 222 | function getSanitizedData({ data }: { data: Record[] }) { 223 | return data.map((row) => 224 | Object.keys(row).reduce( 225 | (acc, key) => ({ 226 | ...acc, 227 | [key]: row[key] === null ? "" : row[key], 228 | }), 229 | {} 230 | ) 231 | ) 232 | } 233 | 234 | return { 235 | fileName: csvState.fileName, 236 | data: csvState.data.mapped, 237 | fieldMappings: csvState.fieldMappings, 238 | error: csvState.error, 239 | getSanitizedData, 240 | onParse, 241 | onFieldChange, 242 | onFieldToggle, 243 | onFieldsReset, 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /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 { 6 | CheckIcon, 7 | ChevronRightIcon, 8 | DotFilledIcon, 9 | } from "@radix-ui/react-icons" 10 | 11 | import { cn } from "@/lib/utils" 12 | 13 | const DropdownMenu = DropdownMenuPrimitive.Root 14 | 15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 16 | 17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 18 | 19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 20 | 21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 22 | 23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 24 | 25 | const DropdownMenuSubTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef & { 28 | inset?: boolean 29 | } 30 | >(({ className, inset, children, ...props }, ref) => ( 31 | 40 | {children} 41 | 42 | 43 | )) 44 | DropdownMenuSubTrigger.displayName = 45 | DropdownMenuPrimitive.SubTrigger.displayName 46 | 47 | const DropdownMenuSubContent = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, ...props }, ref) => ( 51 | 59 | )) 60 | DropdownMenuSubContent.displayName = 61 | DropdownMenuPrimitive.SubContent.displayName 62 | 63 | const DropdownMenuContent = React.forwardRef< 64 | React.ElementRef, 65 | React.ComponentPropsWithoutRef 66 | >(({ className, sideOffset = 4, ...props }, ref) => ( 67 | 68 | 78 | 79 | )) 80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 81 | 82 | const DropdownMenuItem = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef & { 85 | inset?: boolean 86 | } 87 | >(({ className, inset, ...props }, ref) => ( 88 | 97 | )) 98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 99 | 100 | const DropdownMenuCheckboxItem = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, children, checked, ...props }, ref) => ( 104 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | )) 121 | DropdownMenuCheckboxItem.displayName = 122 | DropdownMenuPrimitive.CheckboxItem.displayName 123 | 124 | const DropdownMenuRadioItem = React.forwardRef< 125 | React.ElementRef, 126 | React.ComponentPropsWithoutRef 127 | >(({ className, children, ...props }, ref) => ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | )) 144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 145 | 146 | const DropdownMenuLabel = React.forwardRef< 147 | React.ElementRef, 148 | React.ComponentPropsWithoutRef & { 149 | inset?: boolean 150 | } 151 | >(({ className, inset, ...props }, ref) => ( 152 | 161 | )) 162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 163 | 164 | const DropdownMenuSeparator = React.forwardRef< 165 | React.ElementRef, 166 | React.ComponentPropsWithoutRef 167 | >(({ className, ...props }, ref) => ( 168 | 173 | )) 174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 175 | 176 | const DropdownMenuShortcut = ({ 177 | className, 178 | ...props 179 | }: React.HTMLAttributes) => { 180 | return ( 181 | 185 | ) 186 | } 187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 188 | 189 | export { 190 | DropdownMenu, 191 | DropdownMenuTrigger, 192 | DropdownMenuContent, 193 | DropdownMenuItem, 194 | DropdownMenuCheckboxItem, 195 | DropdownMenuRadioItem, 196 | DropdownMenuLabel, 197 | DropdownMenuSeparator, 198 | DropdownMenuShortcut, 199 | DropdownMenuGroup, 200 | DropdownMenuPortal, 201 | DropdownMenuSub, 202 | DropdownMenuSubContent, 203 | DropdownMenuSubTrigger, 204 | DropdownMenuRadioGroup, 205 | } 206 | -------------------------------------------------------------------------------- /src/components/file-uploader.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import Image from "next/image" 5 | import { Cross2Icon, FileTextIcon, UploadIcon } from "@radix-ui/react-icons" 6 | import Dropzone, { 7 | type DropzoneProps, 8 | type FileRejection, 9 | } from "react-dropzone" 10 | import { toast } from "sonner" 11 | 12 | import { cn, formatBytes } from "@/lib/utils" 13 | import { useControllableState } from "@/hooks/use-controllable-state" 14 | import { Button } from "@/components/ui/button" 15 | import { Progress } from "@/components/ui/progress" 16 | import { ScrollArea } from "@/components/ui/scroll-area" 17 | 18 | interface FileUploaderProps extends React.HTMLAttributes { 19 | /** 20 | * Value of the uploader. 21 | * @type File[] 22 | * @default undefined 23 | * @example value={files} 24 | */ 25 | value?: File[] 26 | 27 | /** 28 | * Function to be called when the value changes. 29 | * @type (files: File[]) => void 30 | * @default undefined 31 | * @example onValueChange={(files) => setFiles(files)} 32 | */ 33 | onValueChange?: (files: File[]) => void 34 | 35 | /** 36 | * Function to be called when files are uploaded. 37 | * @type (files: File[]) => Promise 38 | * @default undefined 39 | * @example onUpload={(files) => uploadFiles(files)} 40 | */ 41 | onUpload?: (files: File[]) => Promise 42 | 43 | /** 44 | * Progress of the uploaded files. 45 | * @type Record | undefined 46 | * @default undefined 47 | * @example progresses={{ "file1.png": 50 }} 48 | */ 49 | progresses?: Record 50 | 51 | /** 52 | * Accepted file types for the uploader. 53 | * @type { [key: string]: string[]} 54 | * @default 55 | * ```ts 56 | * { "image/*": [] } 57 | * ``` 58 | * @example accept={["image/png", "image/jpeg"]} 59 | */ 60 | accept?: DropzoneProps["accept"] 61 | 62 | /** 63 | * Maximum file size for the uploader. 64 | * @type number | undefined 65 | * @default 1024 * 1024 * 2 // 2MB 66 | * @example maxSize={1024 * 1024 * 2} // 2MB 67 | */ 68 | maxSize?: DropzoneProps["maxSize"] 69 | 70 | /** 71 | * Maximum number of files for the uploader. 72 | * @type number | undefined 73 | * @default 1 74 | * @example maxFileCount={4} 75 | */ 76 | maxFileCount?: DropzoneProps["maxFiles"] 77 | 78 | /** 79 | * Whether the uploader should accept multiple files. 80 | * @type boolean 81 | * @default false 82 | * @example multiple 83 | */ 84 | multiple?: boolean 85 | 86 | /** 87 | * Whether the uploader is disabled. 88 | * @type boolean 89 | * @default false 90 | * @example disabled 91 | */ 92 | disabled?: boolean 93 | } 94 | 95 | export function FileUploader(props: FileUploaderProps) { 96 | const { 97 | value: valueProp, 98 | onValueChange, 99 | onUpload, 100 | progresses, 101 | accept = { 102 | "image/*": [], 103 | }, 104 | maxSize = 1024 * 1024 * 2, 105 | maxFileCount = 1, 106 | multiple = false, 107 | disabled = false, 108 | className, 109 | ...dropzoneProps 110 | } = props 111 | 112 | const [files, setFiles] = useControllableState({ 113 | prop: valueProp, 114 | onChange: onValueChange, 115 | }) 116 | 117 | const onDrop = React.useCallback( 118 | (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { 119 | if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) { 120 | toast.error("Cannot upload more than 1 file at a time") 121 | return 122 | } 123 | 124 | if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) { 125 | toast.error(`Cannot upload more than ${maxFileCount} files`) 126 | return 127 | } 128 | 129 | const newFiles = acceptedFiles.map((file) => 130 | Object.assign(file, { 131 | preview: URL.createObjectURL(file), 132 | }) 133 | ) 134 | 135 | const updatedFiles = files ? [...files, ...newFiles] : newFiles 136 | 137 | setFiles(updatedFiles) 138 | 139 | if (rejectedFiles.length > 0) { 140 | rejectedFiles.forEach(({ file }) => { 141 | toast.error(`File ${file.name} was rejected`) 142 | }) 143 | } 144 | 145 | if ( 146 | onUpload && 147 | updatedFiles.length > 0 && 148 | updatedFiles.length <= maxFileCount 149 | ) { 150 | const target = 151 | updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file` 152 | 153 | toast.promise(onUpload(updatedFiles), { 154 | loading: `Uploading ${target}...`, 155 | success: () => { 156 | setFiles([]) 157 | return `${target} uploaded` 158 | }, 159 | error: `Failed to upload ${target}`, 160 | }) 161 | } 162 | }, 163 | 164 | [files, maxFileCount, multiple, onUpload, setFiles] 165 | ) 166 | 167 | function onRemove(index: number) { 168 | if (!files) return 169 | const newFiles = files.filter((_, i) => i !== index) 170 | setFiles(newFiles) 171 | onValueChange?.(newFiles) 172 | } 173 | 174 | // Revoke preview url when component unmounts 175 | React.useEffect(() => { 176 | return () => { 177 | if (!files) return 178 | files.forEach((file) => { 179 | if (isFileWithPreview(file)) { 180 | URL.revokeObjectURL(file.preview) 181 | } 182 | }) 183 | } 184 | // eslint-disable-next-line react-hooks/exhaustive-deps 185 | }, []) 186 | 187 | const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount 188 | 189 | return ( 190 |
191 | 1 || multiple} 197 | disabled={isDisabled} 198 | > 199 | {({ getRootProps, getInputProps, isDragActive }) => ( 200 |
211 | 212 | {isDragActive ? ( 213 |
214 |
215 |
220 |

221 | Drop the files here 222 |

223 |
224 | ) : ( 225 |
226 |
227 |
232 |
233 |

234 | Drag {`'n'`} drop files here, or click to select files 235 |

236 |

237 | You can upload 238 | {maxFileCount > 1 239 | ? ` ${maxFileCount === Infinity ? "multiple" : maxFileCount} 240 | files (up to ${formatBytes(maxSize)} each)` 241 | : ` a file with ${formatBytes(maxSize)}`} 242 |

243 |
244 |
245 | )} 246 |
247 | )} 248 |
249 | {files?.length ? ( 250 | 251 |
252 | {files?.map((file, index) => ( 253 | onRemove(index)} 257 | progress={progresses?.[file.name]} 258 | /> 259 | ))} 260 |
261 |
262 | ) : null} 263 |
264 | ) 265 | } 266 | 267 | interface FileCardProps { 268 | file: File 269 | onRemove: () => void 270 | progress?: number 271 | } 272 | 273 | function FileCard({ file, progress, onRemove }: FileCardProps) { 274 | return ( 275 |
276 |
277 | {isFileWithPreview(file) ? : null} 278 |
279 |
280 |

281 | {file.name} 282 |

283 |

284 | {formatBytes(file.size)} 285 |

286 |
287 | {progress ? : null} 288 |
289 |
290 |
291 | 301 |
302 |
303 | ) 304 | } 305 | 306 | function isFileWithPreview(file: File): file is File & { preview: string } { 307 | return "preview" in file && typeof file.preview === "string" 308 | } 309 | 310 | interface FilePreviewProps { 311 | file: File & { preview: string } 312 | } 313 | 314 | function FilePreview({ file }: FilePreviewProps) { 315 | if (file.type.startsWith("image/")) { 316 | return ( 317 | {file.name} 325 | ) 326 | } 327 | 328 | return ( 329 |