├── .npmrc ├── .vscode └── settings.json ├── styles └── globals.css ├── public ├── favicon.ico ├── vercel.svg └── next.svg ├── postcss.config.js ├── types └── index.ts ├── drizzle ├── meta │ ├── _journal.json │ └── 0000_snapshot.json └── 0000_known_obadiah_stane.sql ├── lib ├── utils.ts └── db │ ├── migrate.ts │ ├── index.ts │ └── schema.ts ├── .eslintignore ├── config ├── fonts.ts └── site.ts ├── components ├── FileLoadingState.tsx ├── FileEmptyState.tsx ├── FileIcon.tsx ├── FolderNavigation.tsx ├── FileActionButtons.tsx ├── ui │ ├── Badge.tsx │ └── ConfirmationModal.tsx ├── FileTabs.tsx ├── FileActions.tsx ├── DashboardContent.tsx ├── SignInForm.tsx ├── UserProfile.tsx ├── SignUpForm.tsx ├── FileUploadForm.tsx ├── Navbar.tsx └── FileList.tsx ├── schemas ├── signInSchema.ts └── signUpSchema.ts ├── .gitignore ├── next.config.js ├── tailwind.config.js ├── .env.example ├── app ├── error.tsx ├── layout.tsx ├── sign-in │ └── [[...sign-in]] │ │ └── page.tsx ├── sign-up │ └── [[...sign-up]] │ │ └── page.tsx ├── api │ ├── imagekit-auth │ │ └── route.ts │ ├── files │ │ ├── [fileId] │ │ │ ├── star │ │ │ │ └── route.ts │ │ │ ├── trash │ │ │ │ └── route.ts │ │ │ └── delete │ │ │ │ └── route.ts │ │ ├── route.ts │ │ ├── empty-trash │ │ │ └── route.ts │ │ └── upload │ │ │ └── route.ts │ ├── upload │ │ └── route.ts │ └── folders │ │ └── create │ │ └── route.ts ├── dashboard │ └── page.tsx ├── providers.tsx └── page.tsx ├── tsconfig.json ├── drizzle.config.ts ├── middleware.ts ├── LICENSE ├── package.json ├── README.md ├── drizzle.md ├── .eslintrc.json └── steps.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiteshchoudhary/droply/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export type IconSvgProps = SVGProps & { 4 | size?: number; 5 | }; 6 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1741681852961, 9 | "tag": "0000_known_obadiah_stane", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | /** 5 | * Combines multiple class names and merges Tailwind CSS classes efficiently 6 | */ 7 | export function cn(...inputs: ClassValue[]) { 8 | return twMerge(clsx(inputs)); 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .now/* 2 | *.css 3 | .changeset 4 | dist 5 | esm/* 6 | public/* 7 | tests/* 8 | scripts/* 9 | *.config.js 10 | .DS_Store 11 | node_modules 12 | coverage 13 | .next 14 | build 15 | !.commitlintrc.cjs 16 | !.lintstagedrc.cjs 17 | !jest.config.js 18 | !plopfile.js 19 | !react-shim.js 20 | !tsup.config.ts -------------------------------------------------------------------------------- /config/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Fira_Code as FontMono, Inter as FontSans } from "next/font/google"; 2 | 3 | export const fontSans = FontSans({ 4 | subsets: ["latin"], 5 | variable: "--font-sans", 6 | }); 7 | 8 | export const fontMono = FontMono({ 9 | subsets: ["latin"], 10 | variable: "--font-mono", 11 | }); 12 | -------------------------------------------------------------------------------- /components/FileLoadingState.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Spinner } from "@heroui/spinner"; 4 | 5 | export default function FileLoadingState() { 6 | return ( 7 |
8 | 9 |

Loading your files...

10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /schemas/signInSchema.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | 3 | export const signInSchema = z.object({ 4 | identifier: z 5 | .string() 6 | .min(1, { message: "Email or username is required" }) 7 | .email({ message: "Please enter a valid email address" }), 8 | password: z 9 | .string() 10 | .min(1, { message: "Password is required" }) 11 | .min(8, { message: "Password must be at least 8 characters" }), 12 | }); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | .env.local 37 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | /* config options here */ 4 | reactStrictMode: true, 5 | images: { 6 | remotePatterns: [ 7 | { 8 | protocol: "https", 9 | hostname: "ik.imagekit.io", 10 | port: "", 11 | pathname: "/**", 12 | }, 13 | { 14 | protocol: "https", 15 | hostname: "images.unsplash.com", 16 | port: "", 17 | pathname: "/**", 18 | }, 19 | ], 20 | }, 21 | }; 22 | 23 | module.exports = nextConfig; 24 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import {heroui} from "@heroui/theme" 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | const config = { 5 | content: [ 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | "./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}" 9 | ], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ["var(--font-sans)"], 14 | mono: ["var(--font-mono)"], 15 | }, 16 | }, 17 | }, 18 | darkMode: "class", 19 | plugins: [heroui()], 20 | } 21 | 22 | module.exports = config; -------------------------------------------------------------------------------- /drizzle/0000_known_obadiah_stane.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "files" ( 2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 3 | "name" text NOT NULL, 4 | "path" text NOT NULL, 5 | "size" integer NOT NULL, 6 | "type" text NOT NULL, 7 | "file_url" text NOT NULL, 8 | "thumbnail_url" text, 9 | "user_id" text NOT NULL, 10 | "parent_id" uuid, 11 | "is_folder" boolean DEFAULT false NOT NULL, 12 | "is_starred" boolean DEFAULT false NOT NULL, 13 | "is_trash" boolean DEFAULT false NOT NULL, 14 | "created_at" timestamp DEFAULT now() NOT NULL, 15 | "updated_at" timestamp DEFAULT now() NOT NULL 16 | ); 17 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Clerk Authentication 2 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 3 | CLERK_SECRET_KEY= 4 | 5 | # ImageKit 6 | NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY= 7 | IMAGEKIT_PRIVATE_KEY= 8 | NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT= 9 | 10 | 11 | # Clerk URLs 12 | NEXT_PUBLIC_CLERK_SIGN_IN_URL= 13 | NEXT_PUBLIC_CLERK_SIGN_UP_URL= 14 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL= 15 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL= 16 | 17 | # Fallback URLs 18 | NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL= 19 | NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL= 20 | 21 | # App URLs 22 | NEXT_PUBLIC_APP_URL= 23 | 24 | # Database - Neon PostgreSQL 25 | DATABASE_URL= -------------------------------------------------------------------------------- /app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | 5 | export default function Error({ 6 | error, 7 | reset, 8 | }: { 9 | error: Error; 10 | reset: () => void; 11 | }) { 12 | useEffect(() => { 13 | // Log the error to an error reporting service 14 | /* eslint-disable no-console */ 15 | console.error(error); 16 | }, [error]); 17 | 18 | return ( 19 |
20 |

Something went wrong!

21 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /schemas/signUpSchema.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | 3 | export const signUpSchema = z 4 | .object({ 5 | email: z 6 | .string() 7 | .min(1, { message: "Email is required" }) 8 | .email({ message: "Please enter a valid email address" }), 9 | password: z 10 | .string() 11 | .min(1, { message: "Password is required" }) 12 | .min(8, { message: "Password must be at least 8 characters" }), 13 | passwordConfirmation: z 14 | .string() 15 | .min(1, { message: "Please confirm your password" }), 16 | }) 17 | .refine((data) => data.password === data.passwordConfirmation, { 18 | message: "Passwords do not match", 19 | path: ["passwordConfirmation"], 20 | }); 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import { ClerkProvider } from "@clerk/nextjs"; 4 | import { Providers } from "./providers"; 5 | import "../styles/globals.css"; 6 | 7 | const inter = Inter({ 8 | subsets: ["latin"], 9 | variable: "--font-inter", 10 | }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Droply", 14 | description: "Secure cloud storage for your images, powered by ImageKit", 15 | }; 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | return ( 23 | 24 | 25 | 28 | {children} 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Drizzle Configuration 3 | * 4 | * This file configures Drizzle ORM to work with our Neon PostgreSQL database. 5 | * It's used by the Drizzle CLI for schema migrations and generating SQL. 6 | */ 7 | 8 | import { defineConfig } from "drizzle-kit"; 9 | import * as dotenv from "dotenv"; 10 | 11 | // Load environment variables from .env.local 12 | dotenv.config({ path: ".env.local" }); 13 | 14 | if (!process.env.DATABASE_URL) { 15 | throw new Error("DATABASE_URL is not set in .env.local"); 16 | } 17 | 18 | export default defineConfig({ 19 | schema: "./lib/db/schema.ts", 20 | out: "./drizzle", 21 | dialect: "postgresql", 22 | dbCredentials: { 23 | url: process.env.DATABASE_URL, 24 | }, 25 | // Configure migrations table 26 | migrations: { 27 | table: "__drizzle_migrations", 28 | schema: "public", 29 | }, 30 | // Additional options 31 | verbose: true, 32 | strict: true, 33 | 34 | }); 35 | -------------------------------------------------------------------------------- /app/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import SignInForm from "@/components/SignInForm"; 2 | import { CloudUpload } from "lucide-react"; 3 | import Link from "next/link"; 4 | import Navbar from "@/components/Navbar"; 5 | 6 | export default function SignInPage() { 7 | return ( 8 |
9 | {/* Use the unified Navbar component */} 10 | 11 | 12 |
13 | 14 |
15 | 16 | {/* Dark mode footer */} 17 |
18 |
19 |

20 | © {new Date().getFullYear()} Droply. All rights reserved. 21 |

22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import SignUpForm from "@/components/SignUpForm"; 2 | import { CloudUpload } from "lucide-react"; 3 | import Link from "next/link"; 4 | import Navbar from "@/components/Navbar"; 5 | 6 | export default function SignUpPage() { 7 | return ( 8 |
9 | {/* Use the unified Navbar component */} 10 | 11 | 12 |
13 | 14 |
15 | 16 | {/* Dark mode footer */} 17 |
18 |
19 |

20 | © {new Date().getFullYear()} Droply. All rights reserved. 21 |

22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | clerkMiddleware, 3 | createRouteMatcher, 4 | auth, 5 | } from "@clerk/nextjs/server"; 6 | import { NextResponse } from "next/server"; 7 | 8 | const isPublicRoute = createRouteMatcher(["/", "/sign-in(.*)", "/sign-up(.*)"]); 9 | 10 | export default clerkMiddleware(async (auth, request) => { 11 | const user = auth(); 12 | const userId = (await user).userId; 13 | const url = new URL(request.url); 14 | 15 | if (userId && isPublicRoute(request) && url.pathname !== "/") { 16 | return NextResponse.redirect(new URL("/dashboard", request.url)); 17 | } 18 | 19 | // Protect non-public routes 20 | if (!isPublicRoute(request)) { 21 | await auth.protect(); 22 | } 23 | }); 24 | 25 | export const config = { 26 | matcher: [ 27 | // Skip Next.js internals and all static files, unless found in search params 28 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", 29 | // Always run for API routes 30 | "/(api|trpc)(.*)", 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /app/api/imagekit-auth/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import ImageKit from "imagekit"; 4 | 5 | // Initialize ImageKit with your credentials 6 | const imagekit = new ImageKit({ 7 | publicKey: process.env.NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY || "", 8 | privateKey: process.env.IMAGEKIT_PRIVATE_KEY || "", 9 | urlEndpoint: process.env.NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT || "", 10 | }); 11 | 12 | export async function GET() { 13 | try { 14 | // Check authentication 15 | const { userId } = await auth(); 16 | if (!userId) { 17 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 18 | } 19 | 20 | // Get authentication parameters from ImageKit 21 | const authParams = imagekit.getAuthenticationParameters(); 22 | 23 | return NextResponse.json(authParams); 24 | } catch (error) { 25 | console.error("Error generating ImageKit auth params:", error); 26 | return NextResponse.json( 27 | { error: "Failed to generate authentication parameters" }, 28 | { status: 500 } 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Next UI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/FileEmptyState.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { File } from "lucide-react"; 4 | import { Card, CardBody } from "@heroui/card"; 5 | 6 | interface FileEmptyStateProps { 7 | activeTab: string; 8 | } 9 | 10 | export default function FileEmptyState({ activeTab }: FileEmptyStateProps) { 11 | return ( 12 | 13 | 14 | 15 |

16 | {activeTab === "all" && "No files available"} 17 | {activeTab === "starred" && "No starred files"} 18 | {activeTab === "trash" && "Trash is empty"} 19 |

20 |

21 | {activeTab === "all" && 22 | "Upload your first file to get started with your personal cloud storage"} 23 | {activeTab === "starred" && 24 | "Mark important files with a star to find them quickly when you need them"} 25 | {activeTab === "trash" && 26 | "Files you delete will appear here for 30 days before being permanently removed"} 27 |

28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/db/migrate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Database Migration Script 3 | * 4 | * This script applies Drizzle migrations to your Neon PostgreSQL database. 5 | * Run it with: npm run db:migrate 6 | */ 7 | 8 | import { drizzle } from "drizzle-orm/neon-http"; 9 | import { migrate } from "drizzle-orm/neon-http/migrator"; 10 | import { neon } from "@neondatabase/serverless"; 11 | import * as dotenv from "dotenv"; 12 | 13 | // Load environment variables from .env.local 14 | dotenv.config({ path: ".env.local" }); 15 | 16 | // Validate environment variables 17 | if (!process.env.DATABASE_URL) { 18 | throw new Error("DATABASE_URL is not set in .env.local"); 19 | } 20 | 21 | // Main migration function 22 | async function runMigration() { 23 | console.log("🔄 Starting database migration..."); 24 | 25 | try { 26 | // Create a Neon SQL connection 27 | const sql = neon(process.env.DATABASE_URL!); 28 | 29 | // Initialize Drizzle with the connection 30 | const db = drizzle(sql); 31 | 32 | // Run migrations from the drizzle folder 33 | console.log("📂 Running migrations from ./drizzle folder"); 34 | await migrate(db, { migrationsFolder: "./drizzle" }); 35 | 36 | console.log("✅ Database migration completed successfully!"); 37 | } catch (error) { 38 | console.error("❌ Migration failed:", error); 39 | process.exit(1); 40 | } 41 | } 42 | 43 | // Run the migration 44 | runMigration(); 45 | -------------------------------------------------------------------------------- /lib/db/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Database Connection for Droply 3 | * 4 | * This file sets up the connection to our Neon PostgreSQL database using Drizzle ORM. 5 | * We're using the HTTP-based driver which is optimized for serverless environments. 6 | */ 7 | 8 | import { drizzle } from "drizzle-orm/neon-http"; 9 | import { neon } from "@neondatabase/serverless"; 10 | import * as schema from "./schema"; 11 | 12 | /** 13 | * Step 1: Create a SQL client using Neon's serverless driver 14 | * 15 | * The neon function creates a connection to our Neon PostgreSQL database. 16 | * It uses the DATABASE_URL environment variable which should contain your connection string. 17 | * 18 | * Example connection string format: 19 | * postgres://username:password@endpoint:port/database 20 | */ 21 | const sql = neon(process.env.DATABASE_URL!); 22 | 23 | /** 24 | * Step 2: Initialize Drizzle ORM with our schema 25 | * 26 | * This creates a database client that we can use to interact with our database. 27 | * We pass in our schema so Drizzle knows about our table structure. 28 | */ 29 | export const db = drizzle(sql, { schema }); 30 | 31 | /** 32 | * Step 3: Export the SQL client for direct queries 33 | * 34 | * Sometimes you might want to run raw SQL queries instead of using the ORM. 35 | * This exports the SQL client so you can use it directly with: 36 | * 37 | * import { sql } from './db'; 38 | * const result = await sql`SELECT * FROM files WHERE user_id = ${userId}`; 39 | */ 40 | export { sql }; 41 | -------------------------------------------------------------------------------- /components/FileIcon.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Folder, FileText } from "lucide-react"; 4 | import { IKImage } from "imagekitio-next"; 5 | import type { File as FileType } from "@/lib/db/schema"; 6 | 7 | interface FileIconProps { 8 | file: FileType; 9 | } 10 | 11 | export default function FileIcon({ file }: FileIconProps) { 12 | if (file.isFolder) return ; 13 | 14 | const fileType = file.type.split("/")[0]; 15 | switch (fileType) { 16 | case "image": 17 | return ( 18 |
19 | 35 |
36 | ); 37 | case "application": 38 | if (file.type.includes("pdf")) { 39 | return ; 40 | } 41 | return ; 42 | case "video": 43 | return ; 44 | default: 45 | return ; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/site.ts: -------------------------------------------------------------------------------- 1 | export type SiteConfig = typeof siteConfig; 2 | 3 | export const siteConfig = { 4 | name: "Next.js + HeroUI", 5 | description: "Make beautiful websites regardless of your design experience.", 6 | navItems: [ 7 | { 8 | label: "Home", 9 | href: "/", 10 | }, 11 | { 12 | label: "Docs", 13 | href: "/docs", 14 | }, 15 | { 16 | label: "Pricing", 17 | href: "/pricing", 18 | }, 19 | { 20 | label: "Blog", 21 | href: "/blog", 22 | }, 23 | { 24 | label: "About", 25 | href: "/about", 26 | }, 27 | ], 28 | navMenuItems: [ 29 | { 30 | label: "Profile", 31 | href: "/profile", 32 | }, 33 | { 34 | label: "Dashboard", 35 | href: "/dashboard", 36 | }, 37 | { 38 | label: "Projects", 39 | href: "/projects", 40 | }, 41 | { 42 | label: "Team", 43 | href: "/team", 44 | }, 45 | { 46 | label: "Calendar", 47 | href: "/calendar", 48 | }, 49 | { 50 | label: "Settings", 51 | href: "/settings", 52 | }, 53 | { 54 | label: "Help & Feedback", 55 | href: "/help-feedback", 56 | }, 57 | { 58 | label: "Logout", 59 | href: "/logout", 60 | }, 61 | ], 62 | links: { 63 | github: "https://github.com/heroui-inc/heroui", 64 | twitter: "https://twitter.com/hero_ui", 65 | docs: "https://heroui.com", 66 | discord: "https://discord.gg/9b6yyZKmH4", 67 | sponsor: "https://patreon.com/jrgarciadev", 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /app/api/files/[fileId]/star/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import { db } from "@/lib/db"; 4 | import { files } from "@/lib/db/schema"; 5 | import { eq, and } from "drizzle-orm"; 6 | 7 | export async function PATCH( 8 | request: NextRequest, 9 | props: { params: Promise<{ fileId: string }> } 10 | ) { 11 | try { 12 | // Check authentication 13 | const { userId } = await auth(); 14 | if (!userId) { 15 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 16 | } 17 | 18 | const { fileId } = await props.params; 19 | 20 | if (!fileId) { 21 | return NextResponse.json( 22 | { error: "File ID is required" }, 23 | { status: 400 } 24 | ); 25 | } 26 | 27 | // Get the current file 28 | const [file] = await db 29 | .select() 30 | .from(files) 31 | .where(and(eq(files.id, fileId), eq(files.userId, userId))); 32 | 33 | if (!file) { 34 | return NextResponse.json({ error: "File not found" }, { status: 404 }); 35 | } 36 | 37 | // Toggle the isStarred status 38 | const updatedFiles = await db 39 | .update(files) 40 | .set({ isStarred: !file.isStarred }) 41 | .where(and(eq(files.id, fileId), eq(files.userId, userId))) 42 | .returning(); 43 | 44 | const updatedFile = updatedFiles[0]; 45 | 46 | return NextResponse.json(updatedFile); 47 | } catch (error) { 48 | console.error("Error starring file:", error); 49 | return NextResponse.json( 50 | { error: "Failed to update file" }, 51 | { status: 500 } 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /components/FolderNavigation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ArrowUpFromLine } from "lucide-react"; 4 | import { Button } from "@heroui/button"; 5 | 6 | interface FolderNavigationProps { 7 | folderPath: Array<{ id: string; name: string }>; 8 | navigateUp: () => void; 9 | navigateToPathFolder: (index: number) => void; 10 | } 11 | 12 | export default function FolderNavigation({ 13 | folderPath, 14 | navigateUp, 15 | navigateToPathFolder, 16 | }: FolderNavigationProps) { 17 | return ( 18 |
19 | 28 | 36 | {folderPath.map((folder, index) => ( 37 |
38 | / 39 | 48 |
49 | ))} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /components/FileActionButtons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { RefreshCw, Trash } from "lucide-react"; 4 | import { Button } from "@heroui/button"; 5 | 6 | interface FileActionButtonsProps { 7 | activeTab: string; 8 | trashCount: number; 9 | folderPath: Array<{ id: string; name: string }>; 10 | onRefresh: () => void; 11 | onEmptyTrash: () => void; 12 | } 13 | 14 | export default function FileActionButtons({ 15 | activeTab, 16 | trashCount, 17 | folderPath, 18 | onRefresh, 19 | onEmptyTrash, 20 | }: FileActionButtonsProps) { 21 | return ( 22 |
23 |

24 | {activeTab === "all" && 25 | (folderPath.length > 0 26 | ? folderPath[folderPath.length - 1].name 27 | : "All Files")} 28 | {activeTab === "starred" && "Starred Files"} 29 | {activeTab === "trash" && "Trash"} 30 |

31 |
32 | 40 | {activeTab === "trash" && trashCount > 0 && ( 41 | 50 | )} 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/api/files/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import { db } from "@/lib/db"; 4 | import { files } from "@/lib/db/schema"; 5 | import { eq, and, isNull } from "drizzle-orm"; 6 | 7 | export async function GET(request: NextRequest) { 8 | try { 9 | // Check authentication 10 | const { userId } = await auth(); 11 | if (!userId) { 12 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 13 | } 14 | 15 | // Get query parameters 16 | const searchParams = request.nextUrl.searchParams; 17 | const queryUserId = searchParams.get("userId"); 18 | const parentId = searchParams.get("parentId"); 19 | 20 | // Verify the user is requesting their own files 21 | if (!queryUserId || queryUserId !== userId) { 22 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 23 | } 24 | 25 | // Fetch files from database based on parentId 26 | let userFiles; 27 | if (parentId) { 28 | // Fetch files within a specific folder 29 | userFiles = await db 30 | .select() 31 | .from(files) 32 | .where(and(eq(files.userId, userId), eq(files.parentId, parentId))); 33 | } else { 34 | // Fetch root-level files (where parentId is null) 35 | userFiles = await db 36 | .select() 37 | .from(files) 38 | .where(and(eq(files.userId, userId), isNull(files.parentId))); 39 | } 40 | 41 | return NextResponse.json(userFiles); 42 | } catch (error) { 43 | console.error("Error fetching files:", error); 44 | return NextResponse.json( 45 | { error: "Failed to fetch files" }, 46 | { status: 500 } 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/api/files/[fileId]/trash/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import { db } from "@/lib/db"; 4 | import { files } from "@/lib/db/schema"; 5 | import { eq, and } from "drizzle-orm"; 6 | 7 | export async function PATCH( 8 | request: NextRequest, 9 | props: { params: Promise<{ fileId: string }> } 10 | ) { 11 | try { 12 | const { userId } = await auth(); 13 | if (!userId) { 14 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 15 | } 16 | 17 | const { fileId } = await props.params; 18 | if (!fileId) { 19 | return NextResponse.json( 20 | { error: "File ID is required" }, 21 | { status: 400 } 22 | ); 23 | } 24 | 25 | // Get the current file 26 | const [file] = await db 27 | .select() 28 | .from(files) 29 | .where(and(eq(files.id, fileId), eq(files.userId, userId))); 30 | 31 | if (!file) { 32 | return NextResponse.json({ error: "File not found" }, { status: 404 }); 33 | } 34 | 35 | // Toggle the isTrash status (move to trash or restore) 36 | const [updatedFile] = await db 37 | .update(files) 38 | .set({ isTrash: !file.isTrash }) 39 | .where(and(eq(files.id, fileId), eq(files.userId, userId))) 40 | .returning(); 41 | 42 | const action = updatedFile.isTrash ? "moved to trash" : "restored"; 43 | return NextResponse.json({ 44 | ...updatedFile, 45 | message: `File ${action} successfully`, 46 | }); 47 | } catch (error) { 48 | console.error("Error updating trash status:", error); 49 | return NextResponse.json( 50 | { error: "Failed to update file trash status" }, 51 | { status: 500 } 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/api/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import { db } from "@/lib/db"; 4 | import { files } from "@/lib/db/schema"; 5 | 6 | export async function POST(request: NextRequest) { 7 | try { 8 | const { userId } = await auth(); 9 | if (!userId) { 10 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 11 | } 12 | 13 | // Parse request body 14 | const body = await request.json(); 15 | const { imagekit, userId: bodyUserId } = body; 16 | 17 | // Verify the user is uploading to their own account 18 | if (bodyUserId !== userId) { 19 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 20 | } 21 | 22 | // Validate ImageKit response 23 | if (!imagekit || !imagekit.url) { 24 | return NextResponse.json( 25 | { error: "Invalid file upload data" }, 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | // Extract file information from ImageKit response 31 | const fileData = { 32 | name: imagekit.name || "Untitled", 33 | path: imagekit.filePath || `/droply/${userId}/${imagekit.name}`, 34 | size: imagekit.size || 0, 35 | type: imagekit.fileType || "image", 36 | fileUrl: imagekit.url, 37 | thumbnailUrl: imagekit.thumbnailUrl || null, 38 | userId: userId, 39 | parentId: null, // Root level by default 40 | isFolder: false, 41 | isStarred: false, 42 | isTrash: false, 43 | }; 44 | 45 | // Insert file record into database 46 | const [newFile] = await db.insert(files).values(fileData).returning(); 47 | 48 | return NextResponse.json(newFile); 49 | } catch (error) { 50 | console.error("Error saving file:", error); 51 | return NextResponse.json( 52 | { error: "Failed to save file information" }, 53 | { status: 500 } 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth, currentUser } from "@clerk/nextjs/server"; 2 | import { redirect } from "next/navigation"; 3 | import DashboardContent from "@/components/DashboardContent"; 4 | import { CloudUpload } from "lucide-react"; 5 | import Navbar from "@/components/Navbar"; 6 | 7 | export default async function Dashboard() { 8 | const { userId } = await auth(); 9 | const user = await currentUser(); 10 | 11 | if (!userId) { 12 | redirect("/sign-in"); 13 | } 14 | 15 | // Serialize the user data to avoid passing the Clerk User object directly 16 | const serializedUser = user 17 | ? { 18 | id: user.id, 19 | firstName: user.firstName, 20 | lastName: user.lastName, 21 | imageUrl: user.imageUrl, 22 | username: user.username, 23 | emailAddress: user.emailAddresses?.[0]?.emailAddress, 24 | } 25 | : null; 26 | 27 | return ( 28 |
29 | 30 | 31 |
32 | 41 |
42 | 43 |
44 |
45 |
46 |
47 | 48 |

Droply

49 |
50 |

51 | © {new Date().getFullYear()} Droply 52 |

53 |
54 |
55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ThemeProviderProps } from "next-themes"; 4 | import * as React from "react"; 5 | import { HeroUIProvider } from "@heroui/system"; 6 | import { useRouter } from "next/navigation"; 7 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 8 | import { ImageKitProvider } from "imagekitio-next"; 9 | import { ToastProvider } from "@heroui/toast"; 10 | import { createContext, useContext } from "react"; 11 | 12 | export interface ProvidersProps { 13 | children: React.ReactNode; 14 | themeProps?: ThemeProviderProps; 15 | } 16 | 17 | declare module "@react-types/shared" { 18 | interface RouterConfig { 19 | routerOptions: NonNullable< 20 | Parameters["push"]>[1] 21 | >; 22 | } 23 | } 24 | 25 | // Create a context for ImageKit authentication 26 | export const ImageKitAuthContext = createContext<{ 27 | authenticate: () => Promise<{ 28 | signature: string; 29 | token: string; 30 | expire: number; 31 | }>; 32 | }>({ 33 | authenticate: async () => ({ signature: "", token: "", expire: 0 }), 34 | }); 35 | 36 | export const useImageKitAuth = () => useContext(ImageKitAuthContext); 37 | 38 | // ImageKit authentication function 39 | const authenticator = async () => { 40 | try { 41 | const response = await fetch("/api/imagekit-auth"); 42 | const data = await response.json(); 43 | return data; 44 | } catch (error) { 45 | console.error("Authentication error:", error); 46 | throw error; 47 | } 48 | }; 49 | 50 | export function Providers({ children, themeProps }: ProvidersProps) { 51 | const router = useRouter(); 52 | 53 | return ( 54 | 55 | 60 | 61 | 62 | {children} 63 | 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /components/ui/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import React from "react"; 3 | 4 | export type BadgeProps = { 5 | children: React.ReactNode; 6 | color?: 7 | | "default" 8 | | "primary" 9 | | "secondary" 10 | | "success" 11 | | "warning" 12 | | "danger"; 13 | variant?: "solid" | "flat" | "outline"; 14 | size?: "sm" | "md" | "lg"; 15 | className?: string; 16 | }; 17 | 18 | export const Badge = ({ 19 | children, 20 | color = "default", 21 | variant = "solid", 22 | size = "md", 23 | className, 24 | ...props 25 | }: BadgeProps & React.HTMLAttributes) => { 26 | const colorStyles = { 27 | default: { 28 | solid: "bg-default-500 text-white", 29 | flat: "bg-default-100 text-default-800", 30 | outline: "border border-default-300 text-default-800", 31 | }, 32 | primary: { 33 | solid: "bg-primary text-white", 34 | flat: "bg-primary-100 text-primary-800", 35 | outline: "border border-primary-300 text-primary-800", 36 | }, 37 | secondary: { 38 | solid: "bg-secondary text-white", 39 | flat: "bg-secondary-100 text-secondary-800", 40 | outline: "border border-secondary-300 text-secondary-800", 41 | }, 42 | success: { 43 | solid: "bg-success text-white", 44 | flat: "bg-success-100 text-success-800", 45 | outline: "border border-success-300 text-success-800", 46 | }, 47 | warning: { 48 | solid: "bg-warning text-white", 49 | flat: "bg-warning-100 text-warning-800", 50 | outline: "border border-warning-300 text-warning-800", 51 | }, 52 | danger: { 53 | solid: "bg-danger text-white", 54 | flat: "bg-danger-100 text-danger-800", 55 | outline: "border border-danger-300 text-danger-800", 56 | }, 57 | }; 58 | 59 | const sizeStyles = { 60 | sm: "text-xs px-1.5 py-0.5 rounded", 61 | md: "text-xs px-2 py-1 rounded-md", 62 | lg: "text-sm px-2.5 py-1 rounded-md", 63 | }; 64 | 65 | return ( 66 | 75 | {children} 76 | 77 | ); 78 | }; 79 | 80 | export default Badge; 81 | -------------------------------------------------------------------------------- /app/api/folders/create/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import { db } from "@/lib/db"; 4 | import { files } from "@/lib/db/schema"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | import { eq, and } from "drizzle-orm"; 7 | 8 | export async function POST(request: NextRequest) { 9 | try { 10 | const { userId } = await auth(); 11 | if (!userId) { 12 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 13 | } 14 | 15 | const body = await request.json(); 16 | const { name, userId: bodyUserId, parentId = null } = body; 17 | 18 | // Verify the user is creating a folder in their own account 19 | if (bodyUserId !== userId) { 20 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 21 | } 22 | 23 | if (!name || typeof name !== "string" || name.trim() === "") { 24 | return NextResponse.json( 25 | { error: "Folder name is required" }, 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | // Check if parent folder exists if parentId is provided 31 | if (parentId) { 32 | const [parentFolder] = await db 33 | .select() 34 | .from(files) 35 | .where( 36 | and( 37 | eq(files.id, parentId), 38 | eq(files.userId, userId), 39 | eq(files.isFolder, true) 40 | ) 41 | ); 42 | 43 | if (!parentFolder) { 44 | return NextResponse.json( 45 | { error: "Parent folder not found" }, 46 | { status: 404 } 47 | ); 48 | } 49 | } 50 | 51 | // Create folder record in database 52 | const folderData = { 53 | id: uuidv4(), 54 | name: name.trim(), 55 | path: `/folders/${userId}/${uuidv4()}`, 56 | size: 0, 57 | type: "folder", 58 | fileUrl: "", 59 | thumbnailUrl: null, 60 | userId, 61 | parentId, 62 | isFolder: true, 63 | isStarred: false, 64 | isTrash: false, 65 | }; 66 | 67 | const [newFolder] = await db.insert(files).values(folderData).returning(); 68 | 69 | return NextResponse.json({ 70 | success: true, 71 | message: "Folder created successfully", 72 | folder: newFolder, 73 | }); 74 | } catch (error) { 75 | console.error("Error creating folder:", error); 76 | return NextResponse.json( 77 | { error: "Failed to create folder" }, 78 | { status: 500 } 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /components/FileTabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { File, Star, Trash } from "lucide-react"; 4 | import { Tabs, Tab } from "@heroui/tabs"; 5 | import Badge from "@/components/ui/Badge"; 6 | import type { File as FileType } from "@/lib/db/schema"; 7 | 8 | interface FileTabsProps { 9 | activeTab: string; 10 | onTabChange: (key: string) => void; 11 | files: FileType[]; 12 | starredCount: number; 13 | trashCount: number; 14 | } 15 | 16 | export default function FileTabs({ 17 | activeTab, 18 | onTabChange, 19 | files, 20 | starredCount, 21 | trashCount, 22 | }: FileTabsProps) { 23 | return ( 24 | onTabChange(key as string)} 27 | color="primary" 28 | variant="underlined" 29 | classNames={{ 30 | base: "w-full overflow-x-auto", 31 | tabList: "gap-2 sm:gap-4 md:gap-6 flex-nowrap min-w-full", 32 | tab: "py-3 whitespace-nowrap", 33 | cursor: "bg-primary", 34 | }} 35 | > 36 | 40 | 41 | All Files 42 | !file.isTrash).length} files`} 47 | > 48 | {files.filter((file) => !file.isTrash).length} 49 | 50 | 51 | } 52 | /> 53 | 57 | 58 | Starred 59 | 65 | {starredCount} 66 | 67 | 68 | } 69 | /> 70 | 74 | 75 | Trash 76 | 82 | {trashCount} 83 | 84 | 85 | } 86 | /> 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /components/FileActions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Star, Trash, X, ArrowUpFromLine, Download } from "lucide-react"; 4 | import { Button } from "@heroui/button"; 5 | import type { File as FileType } from "@/lib/db/schema"; 6 | 7 | interface FileActionsProps { 8 | file: FileType; 9 | onStar: (id: string) => void; 10 | onTrash: (id: string) => void; 11 | onDelete: (file: FileType) => void; 12 | onDownload: (file: FileType) => void; 13 | } 14 | 15 | export default function FileActions({ 16 | file, 17 | onStar, 18 | onTrash, 19 | onDelete, 20 | onDownload, 21 | }: FileActionsProps) { 22 | return ( 23 |
24 | {/* Download button */} 25 | {!file.isTrash && !file.isFolder && ( 26 | 35 | )} 36 | 37 | {/* Star button */} 38 | {!file.isTrash && ( 39 | 58 | )} 59 | 60 | {/* Trash/Restore button */} 61 | 79 | 80 | {/* Delete permanently button */} 81 | {file.isTrash && ( 82 | 92 | )} 93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /lib/db/schema.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Database Schema for Droply 3 | * 4 | * This file defines the database structure for our Droply application. 5 | * We're using Drizzle ORM with PostgreSQL (via Neon) for our database. 6 | */ 7 | 8 | import { 9 | pgTable, 10 | text, 11 | timestamp, 12 | uuid, 13 | integer, 14 | boolean, 15 | } from "drizzle-orm/pg-core"; 16 | import { relations } from "drizzle-orm"; 17 | 18 | /** 19 | * Files Table 20 | * 21 | * This table stores all files and folders in our Droply. 22 | * - Both files and folders are stored in the same table 23 | * - Folders are identified by the isFolder flag 24 | * - Files/folders can be nested using the parentId (creating a tree structure) 25 | */ 26 | export const files = pgTable("files", { 27 | // Unique identifier for each file/folder 28 | id: uuid("id").defaultRandom().primaryKey(), 29 | 30 | // Basic file/folder information 31 | name: text("name").notNull(), 32 | path: text("path").notNull(), // Full path to the file/folder 33 | size: integer("size").notNull(), // Size in bytes (0 for folders) 34 | type: text("type").notNull(), // MIME type for files, "folder" for folders 35 | 36 | // Storage information 37 | fileUrl: text("file_url").notNull(), // URL to access the file 38 | thumbnailUrl: text("thumbnail_url"), // Optional thumbnail for images/documents 39 | 40 | // Ownership and hierarchy 41 | userId: text("user_id").notNull(), // Owner of the file/folder 42 | parentId: uuid("parent_id"), // Parent folder ID (null for root items) 43 | 44 | // File/folder flags 45 | isFolder: boolean("is_folder").default(false).notNull(), // Whether this is a folder 46 | isStarred: boolean("is_starred").default(false).notNull(), // Starred/favorite items 47 | isTrash: boolean("is_trash").default(false).notNull(), // Items in trash 48 | 49 | // Timestamps 50 | createdAt: timestamp("created_at").defaultNow().notNull(), 51 | updatedAt: timestamp("updated_at").defaultNow().notNull(), 52 | }); 53 | 54 | /** 55 | * File Relations 56 | * 57 | * This defines the relationships between records in our files table: 58 | * 1. parent - Each file/folder can have one parent folder 59 | * 2. children - Each folder can have many child files/folders 60 | * 61 | * This creates a hierarchical file structure similar to a real filesystem. 62 | */ 63 | export const filesRelations = relations(files, ({ one, many }) => ({ 64 | // Relationship to parent folder 65 | parent: one(files, { 66 | fields: [files.parentId], // The foreign key in this table 67 | references: [files.id], // The primary key in the parent table 68 | }), 69 | 70 | // Relationship to child files/folders 71 | children: many(files), 72 | })); 73 | 74 | /** 75 | * Type Definitions 76 | * 77 | * These types help with TypeScript integration: 78 | * - File: Type for retrieving file data from the database 79 | * - NewFile: Type for inserting new file data into the database 80 | */ 81 | export type File = typeof files.$inferSelect; 82 | export type NewFile = typeof files.$inferInsert; 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "droply", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "db:push": "drizzle-kit push", 11 | "db:studio": "drizzle-kit studio", 12 | "db:generate": "drizzle-kit generate", 13 | "db:migrate": "tsx src/lib/db/migrate.ts" 14 | }, 15 | "dependencies": { 16 | "@clerk/nextjs": "^6.12.4", 17 | "@heroui/avatar": "^2.2.12", 18 | "@heroui/badge": "^2.2.10", 19 | "@heroui/button": "2.2.16", 20 | "@heroui/card": "^2.2.15", 21 | "@heroui/code": "2.2.12", 22 | "@heroui/divider": "^2.2.11", 23 | "@heroui/dropdown": "^2.3.16", 24 | "@heroui/input": "2.4.16", 25 | "@heroui/kbd": "2.2.12", 26 | "@heroui/link": "2.2.13", 27 | "@heroui/listbox": "2.3.15", 28 | "@heroui/modal": "^2.2.13", 29 | "@heroui/navbar": "2.2.14", 30 | "@heroui/progress": "^2.2.12", 31 | "@heroui/snippet": "2.2.17", 32 | "@heroui/spinner": "^2.2.13", 33 | "@heroui/switch": "2.2.14", 34 | "@heroui/system": "2.4.12", 35 | "@heroui/table": "^2.2.15", 36 | "@heroui/tabs": "^2.2.13", 37 | "@heroui/theme": "2.4.12", 38 | "@heroui/toast": "^2.0.6", 39 | "@heroui/tooltip": "^2.2.13", 40 | "@hookform/resolvers": "^4.1.3", 41 | "@neondatabase/serverless": "^0.9.0", 42 | "@react-aria/ssr": "3.9.7", 43 | "@react-aria/visually-hidden": "3.8.20", 44 | "axios": "^1.6.7", 45 | "clsx": "2.1.1", 46 | "date-fns": "^3.6.0", 47 | "dotenv": "^16.4.5", 48 | "drizzle-orm": "^0.30.4", 49 | "framer-motion": "11.13.1", 50 | "imagekit": "^5.0.0", 51 | "imagekitio-next": "^1.0.1", 52 | "intl-messageformat": "^10.5.0", 53 | "lucide-react": "^0.363.0", 54 | "next": "^15.2.3", 55 | "next-themes": "^0.4.4", 56 | "react": "18.3.1", 57 | "react-dom": "18.3.1", 58 | "react-hook-form": "^7.54.2", 59 | "uuid": "^9.0.1" 60 | }, 61 | "devDependencies": { 62 | "@next/eslint-plugin-next": "15.0.4", 63 | "@react-types/shared": "3.25.0", 64 | "@tailwindcss/postcss": "^4.0.13", 65 | "@types/node": "20.5.7", 66 | "@types/react": "18.3.3", 67 | "@types/react-dom": "18.3.0", 68 | "@types/uuid": "^9.0.8", 69 | "@typescript-eslint/eslint-plugin": "8.11.0", 70 | "@typescript-eslint/parser": "8.11.0", 71 | "autoprefixer": "10.4.19", 72 | "drizzle-kit": "^0.21.3", 73 | "eslint": "^8.57.0", 74 | "eslint-config-next": "15.0.4", 75 | "eslint-config-prettier": "9.1.0", 76 | "eslint-plugin-import": "^2.26.0", 77 | "eslint-plugin-jsx-a11y": "^6.4.1", 78 | "eslint-plugin-node": "^11.1.0", 79 | "eslint-plugin-prettier": "5.2.1", 80 | "eslint-plugin-react": "^7.23.2", 81 | "eslint-plugin-react-hooks": "^4.6.0", 82 | "eslint-plugin-unused-imports": "4.1.4", 83 | "postcss": "8.4.49", 84 | "prettier": "3.3.3", 85 | "tailwind-variants": "0.3.0", 86 | "tailwindcss": "3.4.16", 87 | "tsx": "^4.7.1", 88 | "typescript": "5.6.3" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Droply 2 | 3 | A simple file storage application built with Next.js, Clerk, Neon, Drizzle, and HeroUI. 4 | 5 | ## Features 6 | 7 | - User authentication with Clerk 8 | - File uploads with ImageKit 9 | - File management (star, trash) 10 | - Responsive UI with HeroUI 11 | 12 | ## Tech Stack 13 | 14 | - **Frontend**: Next.js, HeroUI 15 | - **Authentication**: [Clerk](https://hitesh.ai/Clerk) 16 | - **Database**: Neon (PostgreSQL) 17 | - **ORM**: Drizzle 18 | - **File Storage**: [ImageKit](https://hitesh.ai/imagekit) 19 | 20 | ## Getting Started 21 | 22 | ### Prerequisites 23 | 24 | - Node.js 18+ and npm 25 | - Clerk account 26 | - Neon PostgreSQL database 27 | - ImageKit account 28 | 29 | ### Installation 30 | 31 | 1. Clone the repository: 32 | 33 | ```bash 34 | git clone https://github.com/yourusername/droply.git 35 | cd droply 36 | ``` 37 | 38 | 2. Install dependencies: 39 | 40 | ```bash 41 | npm install 42 | # or 43 | yarn install 44 | # or 45 | pnpm install 46 | ``` 47 | 48 | 3. Create a `.env.local` file in the root directory with the following environment variables: 49 | 50 | ``` 51 | # Clerk Authentication 52 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key 53 | CLERK_SECRET_KEY=your_clerk_secret_key 54 | 55 | # ImageKit 56 | NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY=your_imagekit_public_key 57 | IMAGEKIT_PRIVATE_KEY=your_imagekit_private_key 58 | NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT=your_imagekit_url_endpoint 59 | 60 | # Clerk URLs 61 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 62 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 63 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard 64 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard 65 | 66 | # Fallback URLs 67 | NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/ 68 | NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/ 69 | 70 | # App URLs 71 | NEXT_PUBLIC_APP_URL=http://localhost:3000 72 | 73 | # Database - Neon PostgreSQL 74 | DATABASE_URL=your_neon_database_url 75 | ``` 76 | 77 | 4. Set up your accounts and get the required API keys: 78 | - Create a [Clerk](https://clerk.dev/) account and get your API keys 79 | - Create a [Neon](https://neon.tech/) PostgreSQL database and get your connection string 80 | - Create an [ImageKit](https://imagekit.io/) account and get your API keys 81 | 82 | ### Running the Application 83 | 84 | 1. Run the development server: 85 | 86 | ```bash 87 | npm run dev 88 | # or 89 | yarn dev 90 | # or 91 | pnpm dev 92 | ``` 93 | 94 | 2. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application. 95 | 96 | ### Building for Production 97 | 98 | 1. Build the application: 99 | 100 | ```bash 101 | npm run build 102 | # or 103 | yarn build 104 | # or 105 | pnpm build 106 | ``` 107 | 108 | 2. Start the production server: 109 | ```bash 110 | npm start 111 | # or 112 | yarn start 113 | # or 114 | pnpm start 115 | ``` 116 | -------------------------------------------------------------------------------- /components/ui/ConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Modal, 4 | ModalContent, 5 | ModalHeader, 6 | ModalBody, 7 | ModalFooter, 8 | } from "@heroui/modal"; 9 | import { Button } from "@heroui/button"; 10 | import { LucideIcon } from "lucide-react"; 11 | 12 | interface ConfirmationModalProps { 13 | isOpen: boolean; 14 | onOpenChange: (isOpen: boolean) => void; 15 | title: string; 16 | description: string; 17 | icon?: LucideIcon; 18 | iconColor?: string; 19 | confirmText?: string; 20 | cancelText?: string; 21 | confirmColor?: "primary" | "danger" | "warning" | "success" | "default"; 22 | onConfirm: () => void; 23 | isDangerous?: boolean; 24 | warningMessage?: string; 25 | } 26 | 27 | const ConfirmationModal: React.FC = ({ 28 | isOpen, 29 | onOpenChange, 30 | title, 31 | description, 32 | icon: Icon, 33 | iconColor = "text-danger", 34 | confirmText = "Confirm", 35 | cancelText = "Cancel", 36 | confirmColor = "danger", 37 | onConfirm, 38 | isDangerous = false, 39 | warningMessage, 40 | }) => { 41 | return ( 42 | 52 | 53 | 54 | {Icon && } 55 | {title} 56 | 57 | 58 | {isDangerous && warningMessage && ( 59 |
60 |
61 | {Icon && ( 62 | 65 | )} 66 |
67 |

This action cannot be undone

68 |

{warningMessage}

69 |
70 |
71 |
72 | )} 73 |

{description}

74 |
75 | 76 | 83 | 93 | 94 |
95 |
96 | ); 97 | }; 98 | 99 | export default ConfirmationModal; 100 | -------------------------------------------------------------------------------- /drizzle.md: -------------------------------------------------------------------------------- 1 | # Drizzle Setup Guide 2 | 3 | ## Prerequisites 4 | - Node.js and npm installed 5 | - PostgreSQL database (Neon) 6 | - ImageKit account 7 | 8 | ## 1. Install Dependencies 9 | ```bash 10 | npm install drizzle-orm @neondatabase/serverless 11 | npm install -D drizzle-kit 12 | ``` 13 | 14 | ## 2. Database Schema 15 | Create `lib/db/schema.ts`: 16 | ```typescript 17 | import { pgTable, text, timestamp, boolean, integer } from "drizzle-orm/pg-core"; 18 | import { createId } from "@paralleldrive/cuid2"; 19 | 20 | export const files = pgTable("files", { 21 | id: text("id").primaryKey().$defaultFn(() => createId()), 22 | name: text("name").notNull(), 23 | path: text("path").notNull(), 24 | size: integer("size").notNull(), 25 | type: text("type").notNull(), 26 | fileUrl: text("file_url"), 27 | thumbnailUrl: text("thumbnail_url"), 28 | userId: text("user_id").notNull(), 29 | parentId: text("parent_id"), 30 | isFolder: boolean("is_folder").default(false), 31 | isStarred: boolean("is_starred").default(false), 32 | isTrash: boolean("is_trash").default(false), 33 | createdAt: timestamp("created_at").defaultNow(), 34 | updatedAt: timestamp("updated_at").defaultNow(), 35 | }); 36 | ``` 37 | 38 | ## 3. Database Connection 39 | Create `lib/db/index.ts`: 40 | ```typescript 41 | import { drizzle } from "drizzle-orm/neon-http"; 42 | import { neon } from "@neondatabase/serverless"; 43 | import * as schema from "./schema"; 44 | 45 | const sql = neon(process.env.DATABASE_URL!); 46 | export const db = drizzle(sql, { schema }); 47 | ``` 48 | 49 | ## 4. Drizzle Configuration 50 | Create `drizzle.config.ts` in the root directory: 51 | ```typescript 52 | import type { Config } from "drizzle-kit"; 53 | 54 | export default { 55 | schema: "./lib/db/schema.ts", 56 | out: "./drizzle", 57 | driver: "pg", 58 | dbCredentials: { 59 | connectionString: process.env.DATABASE_URL!, 60 | }, 61 | } satisfies Config; 62 | ``` 63 | 64 | ## 5. Environment Setup 65 | Add to your `.env` file: 66 | ```env 67 | DATABASE_URL="your-postgres-connection-string" 68 | ``` 69 | 70 | ## 6. NPM Scripts 71 | Add to your `package.json`: 72 | ```json 73 | { 74 | "scripts": { 75 | "db:generate": "drizzle-kit generate:pg", 76 | "db:push": "drizzle-kit push:pg", 77 | "db:studio": "drizzle-kit studio" 78 | } 79 | } 80 | ``` 81 | 82 | ## 7. Initialize Database 83 | Run these commands: 84 | ```bash 85 | npm run db:generate # Generate migrations 86 | npm run db:push # Push schema to database 87 | ``` 88 | 89 | ## Usage 90 | You can now use the Drizzle ORM in your application to interact with the database: 91 | ```typescript 92 | import { db } from "@/lib/db"; 93 | import { files } from "@/lib/db/schema"; 94 | 95 | // Example: Insert a new file 96 | const newFile = await db.insert(files).values({ 97 | name: "example.txt", 98 | path: "/files/example.txt", 99 | size: 1024, 100 | type: "text/plain", 101 | userId: "user123" 102 | }).returning(); 103 | ``` 104 | 105 | That's all you need for a basic Drizzle setup! The schema includes a `files` table with all necessary fields for file management. -------------------------------------------------------------------------------- /app/api/files/[fileId]/delete/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import { db } from "@/lib/db"; 4 | import { files } from "@/lib/db/schema"; 5 | import { eq, and } from "drizzle-orm"; 6 | import ImageKit from "imagekit"; 7 | 8 | // Initialize ImageKit with your credentials 9 | const imagekit = new ImageKit({ 10 | publicKey: process.env.NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY || "", 11 | privateKey: process.env.IMAGEKIT_PRIVATE_KEY || "", 12 | urlEndpoint: process.env.NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT || "", 13 | }); 14 | 15 | export async function DELETE( 16 | request: NextRequest, 17 | props: { params: Promise<{ fileId: string }> } 18 | ) { 19 | try { 20 | const { userId } = await auth(); 21 | if (!userId) { 22 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 23 | } 24 | 25 | const { fileId } = await props.params; 26 | 27 | if (!fileId) { 28 | return NextResponse.json( 29 | { error: "File ID is required" }, 30 | { status: 400 } 31 | ); 32 | } 33 | 34 | // Get the file to be deleted 35 | const [file] = await db 36 | .select() 37 | .from(files) 38 | .where(and(eq(files.id, fileId), eq(files.userId, userId))); 39 | 40 | if (!file) { 41 | return NextResponse.json({ error: "File not found" }, { status: 404 }); 42 | } 43 | 44 | // Delete file from ImageKit if it's not a folder 45 | if (!file.isFolder) { 46 | try { 47 | let imagekitFileId = null; 48 | 49 | if (file.fileUrl) { 50 | const urlWithoutQuery = file.fileUrl.split("?")[0]; 51 | imagekitFileId = urlWithoutQuery.split("/").pop(); 52 | } 53 | 54 | if (!imagekitFileId && file.path) { 55 | imagekitFileId = file.path.split("/").pop(); 56 | } 57 | 58 | if (imagekitFileId) { 59 | try { 60 | const searchResults = await imagekit.listFiles({ 61 | name: imagekitFileId, 62 | limit: 1, 63 | }); 64 | 65 | if (searchResults && searchResults.length > 0) { 66 | await imagekit.deleteFile(searchResults[0].fileId); 67 | } else { 68 | await imagekit.deleteFile(imagekitFileId); 69 | } 70 | } catch (searchError) { 71 | console.error(`Error searching for file in ImageKit:`, searchError); 72 | await imagekit.deleteFile(imagekitFileId); 73 | } 74 | } 75 | } catch (error) { 76 | console.error(`Error deleting file ${fileId} from ImageKit:`, error); 77 | } 78 | } 79 | 80 | // Delete file from database 81 | const [deletedFile] = await db 82 | .delete(files) 83 | .where(and(eq(files.id, fileId), eq(files.userId, userId))) 84 | .returning(); 85 | 86 | return NextResponse.json({ 87 | success: true, 88 | message: "File deleted successfully", 89 | deletedFile, 90 | }); 91 | } catch (error) { 92 | console.error("Error deleting file:", error); 93 | return NextResponse.json( 94 | { error: "Failed to delete file" }, 95 | { status: 500 } 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "35be4bc1-0c32-4899-a9e0-c0aa2ae884d8", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "6", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.files": { 8 | "name": "files", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "uuid", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "default": "gen_random_uuid()" 17 | }, 18 | "name": { 19 | "name": "name", 20 | "type": "text", 21 | "primaryKey": false, 22 | "notNull": true 23 | }, 24 | "path": { 25 | "name": "path", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true 29 | }, 30 | "size": { 31 | "name": "size", 32 | "type": "integer", 33 | "primaryKey": false, 34 | "notNull": true 35 | }, 36 | "type": { 37 | "name": "type", 38 | "type": "text", 39 | "primaryKey": false, 40 | "notNull": true 41 | }, 42 | "file_url": { 43 | "name": "file_url", 44 | "type": "text", 45 | "primaryKey": false, 46 | "notNull": true 47 | }, 48 | "thumbnail_url": { 49 | "name": "thumbnail_url", 50 | "type": "text", 51 | "primaryKey": false, 52 | "notNull": false 53 | }, 54 | "user_id": { 55 | "name": "user_id", 56 | "type": "text", 57 | "primaryKey": false, 58 | "notNull": true 59 | }, 60 | "parent_id": { 61 | "name": "parent_id", 62 | "type": "uuid", 63 | "primaryKey": false, 64 | "notNull": false 65 | }, 66 | "is_folder": { 67 | "name": "is_folder", 68 | "type": "boolean", 69 | "primaryKey": false, 70 | "notNull": true, 71 | "default": false 72 | }, 73 | "is_starred": { 74 | "name": "is_starred", 75 | "type": "boolean", 76 | "primaryKey": false, 77 | "notNull": true, 78 | "default": false 79 | }, 80 | "is_trash": { 81 | "name": "is_trash", 82 | "type": "boolean", 83 | "primaryKey": false, 84 | "notNull": true, 85 | "default": false 86 | }, 87 | "created_at": { 88 | "name": "created_at", 89 | "type": "timestamp", 90 | "primaryKey": false, 91 | "notNull": true, 92 | "default": "now()" 93 | }, 94 | "updated_at": { 95 | "name": "updated_at", 96 | "type": "timestamp", 97 | "primaryKey": false, 98 | "notNull": true, 99 | "default": "now()" 100 | } 101 | }, 102 | "indexes": {}, 103 | "foreignKeys": {}, 104 | "compositePrimaryKeys": {}, 105 | "uniqueConstraints": {} 106 | } 107 | }, 108 | "enums": {}, 109 | "schemas": {}, 110 | "_meta": { 111 | "columns": {}, 112 | "schemas": {}, 113 | "tables": {} 114 | } 115 | } -------------------------------------------------------------------------------- /app/api/files/empty-trash/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import { db } from "@/lib/db"; 4 | import { files } from "@/lib/db/schema"; 5 | import { eq, and } from "drizzle-orm"; 6 | import ImageKit from "imagekit"; 7 | 8 | // Initialize ImageKit with your credentials 9 | const imagekit = new ImageKit({ 10 | publicKey: process.env.NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY || "", 11 | privateKey: process.env.IMAGEKIT_PRIVATE_KEY || "", 12 | urlEndpoint: process.env.NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT || "", 13 | }); 14 | 15 | export async function DELETE() { 16 | try { 17 | const { userId } = await auth(); 18 | if (!userId) { 19 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 20 | } 21 | 22 | // Get all files in trash for this user 23 | const trashedFiles = await db 24 | .select() 25 | .from(files) 26 | .where(and(eq(files.userId, userId), eq(files.isTrash, true))); 27 | 28 | if (trashedFiles.length === 0) { 29 | return NextResponse.json( 30 | { message: "No files in trash" }, 31 | { status: 200 } 32 | ); 33 | } 34 | 35 | // Delete files from ImageKit 36 | const deletePromises = trashedFiles 37 | .filter((file) => !file.isFolder) // Skip folders 38 | .map(async (file) => { 39 | try { 40 | let imagekitFileId = null; 41 | 42 | if (file.fileUrl) { 43 | const urlWithoutQuery = file.fileUrl.split("?")[0]; 44 | imagekitFileId = urlWithoutQuery.split("/").pop(); 45 | } 46 | 47 | if (!imagekitFileId && file.path) { 48 | imagekitFileId = file.path.split("/").pop(); 49 | } 50 | 51 | if (imagekitFileId) { 52 | try { 53 | const searchResults = await imagekit.listFiles({ 54 | name: imagekitFileId, 55 | limit: 1, 56 | }); 57 | 58 | if (searchResults && searchResults.length > 0) { 59 | await imagekit.deleteFile(searchResults[0].fileId); 60 | } else { 61 | await imagekit.deleteFile(imagekitFileId); 62 | } 63 | } catch (searchError) { 64 | console.error( 65 | `Error searching for file in ImageKit:`, 66 | searchError 67 | ); 68 | await imagekit.deleteFile(imagekitFileId); 69 | } 70 | } 71 | } catch (error) { 72 | console.error(`Error deleting file ${file.id} from ImageKit:`, error); 73 | } 74 | }); 75 | 76 | // Wait for all ImageKit deletions to complete (or fail) 77 | await Promise.allSettled(deletePromises); 78 | 79 | // Delete all trashed files from the database 80 | const deletedFiles = await db 81 | .delete(files) 82 | .where(and(eq(files.userId, userId), eq(files.isTrash, true))) 83 | .returning(); 84 | 85 | return NextResponse.json({ 86 | success: true, 87 | message: `Successfully deleted ${deletedFiles.length} files from trash`, 88 | }); 89 | } catch (error) { 90 | console.error("Error emptying trash:", error); 91 | return NextResponse.json( 92 | { error: "Failed to empty trash" }, 93 | { status: 500 } 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc.json", 3 | "env": { 4 | "browser": false, 5 | "es2021": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "plugin:react/recommended", 10 | "plugin:prettier/recommended", 11 | "plugin:react-hooks/recommended", 12 | "plugin:jsx-a11y/recommended", 13 | "plugin:@next/next/recommended" 14 | ], 15 | "plugins": [ 16 | "react", 17 | "unused-imports", 18 | "import", 19 | "@typescript-eslint", 20 | "jsx-a11y", 21 | "prettier" 22 | ], 23 | "parser": "@typescript-eslint/parser", 24 | "parserOptions": { 25 | "ecmaFeatures": { 26 | "jsx": true 27 | }, 28 | "ecmaVersion": 12, 29 | "sourceType": "module" 30 | }, 31 | "settings": { 32 | "react": { 33 | "version": "detect" 34 | } 35 | }, 36 | "rules": { 37 | "no-console": "warn", 38 | "react/prop-types": "off", 39 | "react/jsx-uses-react": "off", 40 | "react/react-in-jsx-scope": "off", 41 | "react-hooks/exhaustive-deps": "off", 42 | "jsx-a11y/click-events-have-key-events": "warn", 43 | "jsx-a11y/interactive-supports-focus": "warn", 44 | "prettier/prettier": "warn", 45 | "no-unused-vars": "off", 46 | "unused-imports/no-unused-vars": "off", 47 | "unused-imports/no-unused-imports": "warn", 48 | "@typescript-eslint/no-unused-vars": [ 49 | "warn", 50 | { 51 | "args": "after-used", 52 | "ignoreRestSiblings": false, 53 | "argsIgnorePattern": "^_.*?$" 54 | } 55 | ], 56 | "import/order": [ 57 | "warn", 58 | { 59 | "groups": [ 60 | "type", 61 | "builtin", 62 | "object", 63 | "external", 64 | "internal", 65 | "parent", 66 | "sibling", 67 | "index" 68 | ], 69 | "pathGroups": [ 70 | { 71 | "pattern": "~/**", 72 | "group": "external", 73 | "position": "after" 74 | } 75 | ], 76 | "newlines-between": "always" 77 | } 78 | ], 79 | "react/self-closing-comp": "warn", 80 | "react/jsx-sort-props": [ 81 | "warn", 82 | { 83 | "callbacksLast": true, 84 | "shorthandFirst": true, 85 | "noSortAlphabetically": false, 86 | "reservedFirst": true 87 | } 88 | ], 89 | "padding-line-between-statements": [ 90 | "warn", 91 | { "blankLine": "always", "prev": "*", "next": "return" }, 92 | { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, 93 | { 94 | "blankLine": "any", 95 | "prev": ["const", "let", "var"], 96 | "next": ["const", "let", "var"] 97 | }, 98 | { "blankLine": "always", "prev": "multiline-block-like", "next": "*" }, 99 | { "blankLine": "always", "prev": "multiline-expression", "next": "*" }, 100 | { "blankLine": "always", "prev": "directive", "next": "*" }, 101 | { "blankLine": "always", "prev": "block-like", "next": "*" }, 102 | { "blankLine": "always", "prev": "*", "next": "function" }, 103 | { "blankLine": "always", "prev": "*", "next": "function-expression" }, 104 | { "blankLine": "always", "prev": "*", "next": "block-like" }, 105 | { "blankLine": "always", "prev": "import", "next": "*" }, 106 | { "blankLine": "any", "prev": "import", "next": "import" }, 107 | { "blankLine": "always", "prev": "export", "next": "*" }, 108 | { "blankLine": "any", "prev": "export", "next": "export" } 109 | ] 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/api/files/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import { db } from "@/lib/db"; 4 | import { files } from "@/lib/db/schema"; 5 | import { eq, and } from "drizzle-orm"; 6 | import ImageKit from "imagekit"; 7 | import { v4 as uuidv4 } from "uuid"; 8 | 9 | // Initialize ImageKit with your credentials 10 | const imagekit = new ImageKit({ 11 | publicKey: process.env.NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY || "", 12 | privateKey: process.env.IMAGEKIT_PRIVATE_KEY || "", 13 | urlEndpoint: process.env.NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT || "", 14 | }); 15 | 16 | export async function POST(request: NextRequest) { 17 | try { 18 | const { userId } = await auth(); 19 | if (!userId) { 20 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 21 | } 22 | 23 | const formData = await request.formData(); 24 | const file = formData.get("file") as File; 25 | const formUserId = formData.get("userId") as string; 26 | const parentId = (formData.get("parentId") as string) || null; 27 | 28 | // Verify the user is uploading to their own account 29 | if (formUserId !== userId) { 30 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 31 | } 32 | 33 | if (!file) { 34 | return NextResponse.json({ error: "No file provided" }, { status: 400 }); 35 | } 36 | 37 | // Check if parent folder exists if parentId is provided 38 | if (parentId) { 39 | const [parentFolder] = await db 40 | .select() 41 | .from(files) 42 | .where( 43 | and( 44 | eq(files.id, parentId), 45 | eq(files.userId, userId), 46 | eq(files.isFolder, true) 47 | ) 48 | ); 49 | 50 | if (!parentFolder) { 51 | return NextResponse.json( 52 | { error: "Parent folder not found" }, 53 | { status: 404 } 54 | ); 55 | } 56 | } 57 | 58 | // Only allow image uploads 59 | if (!file.type.startsWith("image/") && file.type !== "application/pdf") { 60 | return NextResponse.json( 61 | { error: "Only image files are supported" }, 62 | { status: 400 } 63 | ); 64 | } 65 | 66 | const buffer = await file.arrayBuffer(); 67 | const fileBuffer = Buffer.from(buffer); 68 | 69 | const originalFilename = file.name; 70 | const fileExtension = originalFilename.split(".").pop() || ""; 71 | const uniqueFilename = `${uuidv4()}.${fileExtension}`; 72 | 73 | // Create folder path based on parent folder if exists 74 | const folderPath = parentId 75 | ? `/droply/${userId}/folders/${parentId}` 76 | : `/droply/${userId}`; 77 | 78 | const uploadResponse = await imagekit.upload({ 79 | file: fileBuffer, 80 | fileName: uniqueFilename, 81 | folder: folderPath, 82 | useUniqueFileName: false, 83 | }); 84 | 85 | const fileData = { 86 | name: originalFilename, 87 | path: uploadResponse.filePath, 88 | size: file.size, 89 | type: file.type, 90 | fileUrl: uploadResponse.url, 91 | thumbnailUrl: uploadResponse.thumbnailUrl || null, 92 | userId: userId, 93 | parentId: parentId, 94 | isFolder: false, 95 | isStarred: false, 96 | isTrash: false, 97 | }; 98 | 99 | const [newFile] = await db.insert(files).values(fileData).returning(); 100 | 101 | return NextResponse.json(newFile); 102 | } catch (error) { 103 | console.error("Error uploading file:", error); 104 | return NextResponse.json( 105 | { error: "Failed to upload file" }, 106 | { status: 500 } 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /components/DashboardContent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useCallback, useEffect } from "react"; 4 | import { Card, CardBody, CardHeader } from "@heroui/card"; 5 | import { Tabs, Tab } from "@heroui/tabs"; 6 | import { FileUp, FileText, User } from "lucide-react"; 7 | import FileUploadForm from "@/components/FileUploadForm"; 8 | import FileList from "@/components/FileList"; 9 | import UserProfile from "@/components/UserProfile"; 10 | import { useSearchParams } from "next/navigation"; 11 | 12 | interface DashboardContentProps { 13 | userId: string; 14 | userName: string; 15 | } 16 | 17 | export default function DashboardContent({ 18 | userId, 19 | userName, 20 | }: DashboardContentProps) { 21 | const searchParams = useSearchParams(); 22 | const tabParam = searchParams.get("tab"); 23 | 24 | const [activeTab, setActiveTab] = useState("files"); 25 | const [refreshTrigger, setRefreshTrigger] = useState(0); 26 | const [currentFolder, setCurrentFolder] = useState(null); 27 | 28 | // Set the active tab based on URL parameter 29 | useEffect(() => { 30 | if (tabParam === "profile") { 31 | setActiveTab("profile"); 32 | } else { 33 | setActiveTab("files"); 34 | } 35 | }, [tabParam]); 36 | 37 | const handleFileUploadSuccess = useCallback(() => { 38 | setRefreshTrigger((prev) => prev + 1); 39 | }, []); 40 | 41 | const handleFolderChange = useCallback((folderId: string | null) => { 42 | setCurrentFolder(folderId); 43 | }, []); 44 | 45 | return ( 46 | <> 47 |
48 |

49 | Hi,{" "} 50 | 51 | {userName?.length > 10 52 | ? `${userName?.substring(0, 10)}...` 53 | : userName?.split(" ")[0] || "there"} 54 | 55 | ! 56 |

57 |

58 | Your images are waiting for you. 59 |

60 |
61 | 62 | setActiveTab(key as string)} 68 | classNames={{ 69 | tabList: "gap-6", 70 | tab: "py-3", 71 | cursor: "bg-primary", 72 | }} 73 | > 74 | 78 | 79 | My Files 80 | 81 | } 82 | > 83 |
84 |
85 | 86 | 87 | 88 |

Upload

89 |
90 | 91 | 96 | 97 |
98 |
99 | 100 |
101 | 102 | 103 | 104 |

Your Files

105 |
106 | 107 | 112 | 113 |
114 |
115 |
116 |
117 | 118 | 122 | 123 | Profile 124 | 125 | } 126 | > 127 |
128 | 129 |
130 |
131 |
132 | 133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /components/SignInForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useForm } from "react-hook-form"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import { useSignIn } from "@clerk/nextjs"; 7 | import { useRouter } from "next/navigation"; 8 | import Link from "next/link"; 9 | import { z } from "zod"; 10 | import { Button } from "@heroui/button"; 11 | import { Input } from "@heroui/input"; 12 | import { Card, CardBody, CardHeader, CardFooter } from "@heroui/card"; 13 | import { Divider } from "@heroui/divider"; 14 | import { Mail, Lock, AlertCircle, Eye, EyeOff } from "lucide-react"; 15 | import { signInSchema } from "@/schemas/signInSchema"; 16 | 17 | export default function SignInForm() { 18 | const router = useRouter(); 19 | const { signIn, isLoaded, setActive } = useSignIn(); 20 | const [isSubmitting, setIsSubmitting] = useState(false); 21 | const [authError, setAuthError] = useState(null); 22 | const [showPassword, setShowPassword] = useState(false); 23 | 24 | const { 25 | register, 26 | handleSubmit, 27 | formState: { errors }, 28 | } = useForm>({ 29 | resolver: zodResolver(signInSchema), 30 | defaultValues: { 31 | identifier: "", 32 | password: "", 33 | }, 34 | }); 35 | 36 | const onSubmit = async (data: z.infer) => { 37 | if (!isLoaded) return; 38 | 39 | setIsSubmitting(true); 40 | setAuthError(null); 41 | 42 | try { 43 | const result = await signIn.create({ 44 | identifier: data.identifier, 45 | password: data.password, 46 | }); 47 | 48 | if (result.status === "complete") { 49 | await setActive({ session: result.createdSessionId }); 50 | router.push("/dashboard"); 51 | } else { 52 | console.error("Sign-in incomplete:", result); 53 | setAuthError("Sign-in could not be completed. Please try again."); 54 | } 55 | } catch (error: any) { 56 | console.error("Sign-in error:", error); 57 | setAuthError( 58 | error.errors?.[0]?.message || 59 | "An error occurred during sign-in. Please try again." 60 | ); 61 | } finally { 62 | setIsSubmitting(false); 63 | } 64 | }; 65 | 66 | return ( 67 | 68 | 69 |

Welcome Back

70 |

71 | Sign in to access your secure cloud storage 72 |

73 |
74 | 75 | 76 | 77 | 78 | {authError && ( 79 |
80 | 81 |

{authError}

82 |
83 | )} 84 | 85 |
86 |
87 | 93 | } 98 | isInvalid={!!errors.identifier} 99 | errorMessage={errors.identifier?.message} 100 | {...register("identifier")} 101 | className="w-full" 102 | /> 103 |
104 | 105 |
106 |
107 | 113 |
114 | } 119 | endContent={ 120 | 133 | } 134 | isInvalid={!!errors.password} 135 | errorMessage={errors.password?.message} 136 | {...register("password")} 137 | className="w-full" 138 | /> 139 |
140 | 141 | 149 |
150 |
151 | 152 | 153 | 154 | 155 |

156 | Don't have an account?{" "} 157 | 161 | Sign up 162 | 163 |

164 |
165 |
166 | ); 167 | } 168 | -------------------------------------------------------------------------------- /components/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useUser, useClerk } from "@clerk/nextjs"; 4 | import { Button } from "@heroui/button"; 5 | import { Card, CardBody, CardHeader, CardFooter } from "@heroui/card"; 6 | import { Spinner } from "@heroui/spinner"; 7 | import { Avatar } from "@heroui/avatar"; 8 | import { Divider } from "@heroui/divider"; 9 | import Badge from "@/components/ui/Badge"; 10 | import { useRouter } from "next/navigation"; 11 | import { Mail, User, LogOut, Shield, ArrowRight } from "lucide-react"; 12 | 13 | export default function UserProfile() { 14 | const { isLoaded, isSignedIn, user } = useUser(); 15 | const { signOut } = useClerk(); 16 | const router = useRouter(); 17 | 18 | if (!isLoaded) { 19 | return ( 20 |
21 | 22 |

Loading your profile...

23 |
24 | ); 25 | } 26 | 27 | if (!isSignedIn) { 28 | return ( 29 | 30 | 31 | 32 |

User Profile

33 |
34 | 35 | 36 |
37 | 38 |

Not Signed In

39 |

40 | Please sign in to access your profile 41 |

42 |
43 | 53 |
54 |
55 | ); 56 | } 57 | 58 | const fullName = `${user.firstName || ""} ${user.lastName || ""}`.trim(); 59 | const email = user.primaryEmailAddress?.emailAddress || ""; 60 | const initials = fullName 61 | .split(" ") 62 | .map((name) => name[0]) 63 | .join("") 64 | .toUpperCase(); 65 | 66 | const userRole = user.publicMetadata.role as string | undefined; 67 | 68 | const handleSignOut = () => { 69 | signOut(() => { 70 | router.push("/"); 71 | }); 72 | }; 73 | 74 | return ( 75 | 76 | 77 | 78 |

User Profile

79 |
80 | 81 | 82 |
83 | {user.imageUrl ? ( 84 | 90 | ) : ( 91 | 96 | )} 97 |

{fullName}

98 | {user.emailAddresses && user.emailAddresses.length > 0 && ( 99 |
100 | 101 | {email} 102 |
103 | )} 104 | {userRole && ( 105 | 111 | {userRole} 112 | 113 | )} 114 |
115 | 116 | 117 | 118 |
119 |
120 |
121 | 122 | Account Status 123 |
124 | 129 | Active 130 | 131 |
132 | 133 |
134 |
135 | 136 | Email Verification 137 |
138 | 151 | {user.emailAddresses?.[0]?.verification?.status === "verified" 152 | ? "Verified" 153 | : "Pending"} 154 | 155 |
156 |
157 |
158 | 159 | 160 | 168 | 169 |
170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@heroui/button"; 2 | import { SignedIn, SignedOut } from "@clerk/nextjs"; 3 | import Link from "next/link"; 4 | import { Card, CardBody } from "@heroui/card"; 5 | import { 6 | CloudUpload, 7 | Shield, 8 | Folder, 9 | Image as ImageIcon, 10 | ArrowRight, 11 | } from "lucide-react"; 12 | import Navbar from "@/components/Navbar"; 13 | 14 | export default function Home() { 15 | return ( 16 |
17 | {/* Use the unified Navbar component */} 18 | 19 | 20 | {/* Main content */} 21 |
22 | {/* Hero section */} 23 |
24 |
25 |
26 |
27 |
28 |

29 | Store your images with 30 | ease 31 |

32 |

33 | Simple. Secure. Fast. 34 |

35 |
36 | 37 |
38 | 39 | 40 | 43 | 44 | 45 | 48 | 49 | 50 | 51 | 52 | 60 | 61 | 62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 | 70 |
71 |
72 |
73 |
74 |
75 |
76 | 77 | {/* Features section */} 78 |
79 |
80 |
81 |

82 | What You Get 83 |

84 |
85 | 86 |
87 | 88 | 89 | 90 |

91 | Quick Uploads 92 |

93 |

Drag, drop, done.

94 |
95 |
96 | 97 | 98 | 99 | 100 |

101 | Smart Organization 102 |

103 |

104 | Keep it tidy, find it fast. 105 |

106 |
107 |
108 | 109 | 110 | 111 | 112 |

113 | Locked Down 114 |

115 |

116 | Your images, your eyes only. 117 |

118 |
119 |
120 |
121 |
122 |
123 | 124 | {/* CTA section */} 125 |
126 |
127 |

128 | Ready? 129 |

130 | 131 |
132 | 133 | 141 | 142 |
143 |
144 | 145 | 146 | 154 | 155 | 156 |
157 |
158 |
159 | 160 | {/* Simple footer */} 161 |
162 |
163 |
164 |
165 | 166 |

Droply

167 |
168 |

169 | © {new Date().getFullYear()} Droply 170 |

171 |
172 |
173 |
174 |
175 | ); 176 | } 177 | -------------------------------------------------------------------------------- /components/SignUpForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useForm } from "react-hook-form"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import { useSignUp } from "@clerk/nextjs"; 7 | import { useRouter } from "next/navigation"; 8 | import Link from "next/link"; 9 | import { z } from "zod"; 10 | import { Button } from "@heroui/button"; 11 | import { Input } from "@heroui/input"; 12 | import { Card, CardBody, CardHeader, CardFooter } from "@heroui/card"; 13 | import { Divider } from "@heroui/divider"; 14 | import { 15 | Mail, 16 | Lock, 17 | AlertCircle, 18 | CheckCircle, 19 | Eye, 20 | EyeOff, 21 | } from "lucide-react"; 22 | import { signUpSchema } from "@/schemas/signUpSchema"; 23 | 24 | export default function SignUpForm() { 25 | const router = useRouter(); 26 | const { signUp, isLoaded, setActive } = useSignUp(); 27 | const [isSubmitting, setIsSubmitting] = useState(false); 28 | const [authError, setAuthError] = useState(null); 29 | const [verifying, setVerifying] = useState(false); 30 | const [verificationCode, setVerificationCode] = useState(""); 31 | const [verificationError, setVerificationError] = useState( 32 | null 33 | ); 34 | const [showPassword, setShowPassword] = useState(false); 35 | const [showConfirmPassword, setShowConfirmPassword] = useState(false); 36 | 37 | const { 38 | register, 39 | handleSubmit, 40 | formState: { errors }, 41 | } = useForm>({ 42 | resolver: zodResolver(signUpSchema), 43 | defaultValues: { 44 | email: "", 45 | password: "", 46 | passwordConfirmation: "", 47 | }, 48 | }); 49 | 50 | const onSubmit = async (data: z.infer) => { 51 | if (!isLoaded) return; 52 | 53 | setIsSubmitting(true); 54 | setAuthError(null); 55 | 56 | try { 57 | await signUp.create({ 58 | emailAddress: data.email, 59 | password: data.password, 60 | }); 61 | 62 | await signUp.prepareEmailAddressVerification({ strategy: "email_code" }); 63 | setVerifying(true); 64 | } catch (error: any) { 65 | console.error("Sign-up error:", error); 66 | setAuthError( 67 | error.errors?.[0]?.message || 68 | "An error occurred during sign-up. Please try again." 69 | ); 70 | } finally { 71 | setIsSubmitting(false); 72 | } 73 | }; 74 | 75 | const handleVerificationSubmit = async ( 76 | e: React.FormEvent 77 | ) => { 78 | e.preventDefault(); 79 | if (!isLoaded || !signUp) return; 80 | 81 | setIsSubmitting(true); 82 | setVerificationError(null); 83 | 84 | try { 85 | const result = await signUp.attemptEmailAddressVerification({ 86 | code: verificationCode, 87 | }); 88 | 89 | if (result.status === "complete") { 90 | await setActive({ session: result.createdSessionId }); 91 | router.push("/dashboard"); 92 | } else { 93 | console.error("Verification incomplete:", result); 94 | setVerificationError( 95 | "Verification could not be completed. Please try again." 96 | ); 97 | } 98 | } catch (error: any) { 99 | console.error("Verification error:", error); 100 | setVerificationError( 101 | error.errors?.[0]?.message || 102 | "An error occurred during verification. Please try again." 103 | ); 104 | } finally { 105 | setIsSubmitting(false); 106 | } 107 | }; 108 | 109 | if (verifying) { 110 | return ( 111 | 112 | 113 |

114 | Verify Your Email 115 |

116 |

117 | We've sent a verification code to your email 118 |

119 |
120 | 121 | 122 | 123 | 124 | {verificationError && ( 125 |
126 | 127 |

{verificationError}

128 |
129 | )} 130 | 131 |
132 |
133 | 139 | setVerificationCode(e.target.value)} 145 | className="w-full" 146 | autoFocus 147 | /> 148 |
149 | 150 | 158 |
159 | 160 |
161 |

162 | Didn't receive a code?{" "} 163 | 175 |

176 |
177 |
178 |
179 | ); 180 | } 181 | 182 | return ( 183 | 184 | 185 |

186 | Create Your Account 187 |

188 |

189 | Sign up to start managing your images securely 190 |

191 |
192 | 193 | 194 | 195 | 196 | {authError && ( 197 |
198 | 199 |

{authError}

200 |
201 | )} 202 | 203 |
204 |
205 | 211 | } 216 | isInvalid={!!errors.email} 217 | errorMessage={errors.email?.message} 218 | {...register("email")} 219 | className="w-full" 220 | /> 221 |
222 | 223 |
224 | 230 | } 235 | endContent={ 236 | 249 | } 250 | isInvalid={!!errors.password} 251 | errorMessage={errors.password?.message} 252 | {...register("password")} 253 | className="w-full" 254 | /> 255 |
256 | 257 |
258 | 264 | } 269 | endContent={ 270 | 283 | } 284 | isInvalid={!!errors.passwordConfirmation} 285 | errorMessage={errors.passwordConfirmation?.message} 286 | {...register("passwordConfirmation")} 287 | className="w-full" 288 | /> 289 |
290 | 291 |
292 |
293 | 294 |

295 | By signing up, you agree to our Terms of Service and Privacy 296 | Policy 297 |

298 |
299 |
300 | 301 | 309 |
310 |
311 | 312 | 313 | 314 | 315 |

316 | Already have an account?{" "} 317 | 321 | Sign in 322 | 323 |

324 |
325 |
326 | ); 327 | } 328 | -------------------------------------------------------------------------------- /components/FileUploadForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useRef } from "react"; 4 | import { Button } from "@heroui/button"; 5 | import { Progress } from "@heroui/progress"; 6 | import { Input } from "@heroui/input"; 7 | import { 8 | Upload, 9 | X, 10 | FileUp, 11 | AlertTriangle, 12 | FolderPlus, 13 | ArrowRight, 14 | } from "lucide-react"; 15 | import { addToast } from "@heroui/toast"; 16 | import { 17 | Modal, 18 | ModalContent, 19 | ModalHeader, 20 | ModalBody, 21 | ModalFooter, 22 | } from "@heroui/modal"; 23 | import axios from "axios"; 24 | 25 | interface FileUploadFormProps { 26 | userId: string; 27 | onUploadSuccess?: () => void; 28 | currentFolder?: string | null; 29 | } 30 | 31 | export default function FileUploadForm({ 32 | userId, 33 | onUploadSuccess, 34 | currentFolder = null, 35 | }: FileUploadFormProps) { 36 | const [file, setFile] = useState(null); 37 | const [uploading, setUploading] = useState(false); 38 | const [progress, setProgress] = useState(0); 39 | const [error, setError] = useState(null); 40 | const fileInputRef = useRef(null); 41 | 42 | // Folder creation state 43 | const [folderModalOpen, setFolderModalOpen] = useState(false); 44 | const [folderName, setFolderName] = useState(""); 45 | const [creatingFolder, setCreatingFolder] = useState(false); 46 | 47 | const handleFileChange = (e: React.ChangeEvent) => { 48 | if (e.target.files && e.target.files[0]) { 49 | const selectedFile = e.target.files[0]; 50 | 51 | // Validate file size (5MB limit) 52 | if (selectedFile.size > 5 * 1024 * 1024) { 53 | setError("File size exceeds 5MB limit"); 54 | return; 55 | } 56 | 57 | setFile(selectedFile); 58 | setError(null); 59 | } 60 | }; 61 | 62 | const handleDrop = (e: React.DragEvent) => { 63 | e.preventDefault(); 64 | if (e.dataTransfer.files && e.dataTransfer.files[0]) { 65 | const droppedFile = e.dataTransfer.files[0]; 66 | 67 | // Validate file size (5MB limit) 68 | if (droppedFile.size > 5 * 1024 * 1024) { 69 | setError("File size exceeds 5MB limit"); 70 | return; 71 | } 72 | 73 | setFile(droppedFile); 74 | setError(null); 75 | } 76 | }; 77 | 78 | const handleDragOver = (e: React.DragEvent) => { 79 | e.preventDefault(); 80 | }; 81 | 82 | const clearFile = () => { 83 | setFile(null); 84 | setError(null); 85 | if (fileInputRef.current) { 86 | fileInputRef.current.value = ""; 87 | } 88 | }; 89 | 90 | const handleUpload = async () => { 91 | if (!file) return; 92 | 93 | const formData = new FormData(); 94 | formData.append("file", file); 95 | formData.append("userId", userId); 96 | if (currentFolder) { 97 | formData.append("parentId", currentFolder); 98 | } 99 | 100 | setUploading(true); 101 | setProgress(0); 102 | setError(null); 103 | 104 | try { 105 | await axios.post("/api/files/upload", formData, { 106 | headers: { 107 | "Content-Type": "multipart/form-data", 108 | }, 109 | onUploadProgress: (progressEvent) => { 110 | if (progressEvent.total) { 111 | const percentCompleted = Math.round( 112 | (progressEvent.loaded * 100) / progressEvent.total 113 | ); 114 | setProgress(percentCompleted); 115 | } 116 | }, 117 | }); 118 | 119 | addToast({ 120 | title: "Upload Successful", 121 | description: `${file.name} has been uploaded successfully.`, 122 | color: "success", 123 | }); 124 | 125 | // Clear the file after successful upload 126 | clearFile(); 127 | 128 | // Call the onUploadSuccess callback if provided 129 | if (onUploadSuccess) { 130 | onUploadSuccess(); 131 | } 132 | } catch (error) { 133 | console.error("Error uploading file:", error); 134 | setError("Failed to upload file. Please try again."); 135 | addToast({ 136 | title: "Upload Failed", 137 | description: "We couldn't upload your file. Please try again.", 138 | color: "danger", 139 | }); 140 | } finally { 141 | setUploading(false); 142 | } 143 | }; 144 | 145 | const handleCreateFolder = async () => { 146 | if (!folderName.trim()) { 147 | addToast({ 148 | title: "Invalid Folder Name", 149 | description: "Please enter a valid folder name.", 150 | color: "danger", 151 | }); 152 | return; 153 | } 154 | 155 | setCreatingFolder(true); 156 | 157 | try { 158 | await axios.post("/api/folders/create", { 159 | name: folderName.trim(), 160 | userId: userId, 161 | parentId: currentFolder, 162 | }); 163 | 164 | addToast({ 165 | title: "Folder Created", 166 | description: `Folder "${folderName}" has been created successfully.`, 167 | color: "success", 168 | }); 169 | 170 | // Reset folder name and close modal 171 | setFolderName(""); 172 | setFolderModalOpen(false); 173 | 174 | // Call the onUploadSuccess callback to refresh the file list 175 | if (onUploadSuccess) { 176 | onUploadSuccess(); 177 | } 178 | } catch (error) { 179 | console.error("Error creating folder:", error); 180 | addToast({ 181 | title: "Folder Creation Failed", 182 | description: "We couldn't create the folder. Please try again.", 183 | color: "danger", 184 | }); 185 | } finally { 186 | setCreatingFolder(false); 187 | } 188 | }; 189 | 190 | return ( 191 |
192 | {/* Action buttons */} 193 |
194 | 203 | 212 |
213 | 214 | {/* File drop area */} 215 |
226 | {!file ? ( 227 |
228 | 229 |
230 |

231 | Drag and drop your image here, or{" "} 232 | 239 |

240 |

Images up to 5MB

241 |
242 | 249 |
250 | ) : ( 251 |
252 |
253 |
254 |
255 | 256 |
257 |
258 |

259 | {file.name} 260 |

261 |

262 | {file.size < 1024 263 | ? `${file.size} B` 264 | : file.size < 1024 * 1024 265 | ? `${(file.size / 1024).toFixed(1)} KB` 266 | : `${(file.size / (1024 * 1024)).toFixed(1)} MB`} 267 |

268 |
269 |
270 | 279 |
280 | 281 | {error && ( 282 |
283 | 284 | {error} 285 |
286 | )} 287 | 288 | {uploading && ( 289 | 296 | )} 297 | 298 | 309 |
310 | )} 311 |
312 | 313 | {/* Upload tips */} 314 |
315 |

Tips

316 |
    317 |
  • • Images are private and only visible to you
  • 318 |
  • • Supported formats: JPG, PNG, GIF, WebP
  • 319 |
  • • Maximum file size: 5MB
  • 320 |
321 |
322 | 323 | {/* Create Folder Modal */} 324 | 334 | 335 | 336 | 337 | New Folder 338 | 339 | 340 |
341 |

342 | Enter a name for your folder: 343 |

344 | setFolderName(e.target.value)} 350 | autoFocus 351 | /> 352 |
353 |
354 | 355 | 362 | 371 | 372 |
373 |
374 |
375 | ); 376 | } 377 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useClerk, SignedIn, SignedOut } from "@clerk/nextjs"; 4 | import { useRouter, usePathname } from "next/navigation"; 5 | import Link from "next/link"; 6 | import { CloudUpload, ChevronDown, User, Menu, X } from "lucide-react"; 7 | import { 8 | Dropdown, 9 | DropdownTrigger, 10 | DropdownMenu, 11 | DropdownItem, 12 | } from "@heroui/dropdown"; 13 | import { Avatar } from "@heroui/avatar"; 14 | import { Button } from "@heroui/button"; 15 | import { useState, useEffect, useRef } from "react"; 16 | 17 | interface SerializedUser { 18 | id: string; 19 | firstName?: string | null; 20 | lastName?: string | null; 21 | imageUrl?: string | null; 22 | username?: string | null; 23 | emailAddress?: string | null; 24 | } 25 | 26 | interface NavbarProps { 27 | user?: SerializedUser | null; 28 | } 29 | 30 | export default function Navbar({ user }: NavbarProps) { 31 | const { signOut } = useClerk(); 32 | const router = useRouter(); 33 | const pathname = usePathname(); 34 | const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); 35 | const [isScrolled, setIsScrolled] = useState(false); 36 | const mobileMenuRef = useRef(null); 37 | 38 | // Check if we're on the dashboard page 39 | const isOnDashboard = 40 | pathname === "/dashboard" || pathname?.startsWith("/dashboard/"); 41 | 42 | // Handle scroll effect 43 | useEffect(() => { 44 | const handleScroll = () => { 45 | setIsScrolled(window.scrollY > 10); 46 | }; 47 | 48 | window.addEventListener("scroll", handleScroll); 49 | return () => window.removeEventListener("scroll", handleScroll); 50 | }, []); 51 | 52 | // Close mobile menu when window is resized to desktop size 53 | useEffect(() => { 54 | const handleResize = () => { 55 | if (window.innerWidth >= 768) { 56 | setIsMobileMenuOpen(false); 57 | } 58 | }; 59 | 60 | window.addEventListener("resize", handleResize); 61 | return () => window.removeEventListener("resize", handleResize); 62 | }, []); 63 | 64 | // Handle body scroll lock when mobile menu is open 65 | useEffect(() => { 66 | if (isMobileMenuOpen) { 67 | document.body.style.overflow = "hidden"; 68 | } else { 69 | document.body.style.overflow = ""; 70 | } 71 | 72 | return () => { 73 | document.body.style.overflow = ""; 74 | }; 75 | }, [isMobileMenuOpen]); 76 | 77 | // Handle clicks outside the mobile menu 78 | useEffect(() => { 79 | const handleClickOutside = (event: MouseEvent) => { 80 | if ( 81 | isMobileMenuOpen && 82 | mobileMenuRef.current && 83 | !mobileMenuRef.current.contains(event.target as Node) 84 | ) { 85 | // Check if the click is not on the menu button (which has its own handler) 86 | const target = event.target as HTMLElement; 87 | if (!target.closest('[data-menu-button="true"]')) { 88 | setIsMobileMenuOpen(false); 89 | } 90 | } 91 | }; 92 | 93 | document.addEventListener("mousedown", handleClickOutside); 94 | return () => { 95 | document.removeEventListener("mousedown", handleClickOutside); 96 | }; 97 | }, [isMobileMenuOpen]); 98 | 99 | const handleSignOut = () => { 100 | signOut(() => { 101 | router.push("/"); 102 | }); 103 | }; 104 | 105 | // Process user data with defaults if not provided 106 | const userDetails = { 107 | fullName: user 108 | ? `${user.firstName || ""} ${user.lastName || ""}`.trim() 109 | : "", 110 | initials: user 111 | ? `${user.firstName || ""} ${user.lastName || ""}` 112 | .trim() 113 | .split(" ") 114 | .map((name) => name?.[0] || "") 115 | .join("") 116 | .toUpperCase() || "U" 117 | : "U", 118 | displayName: user 119 | ? user.firstName && user.lastName 120 | ? `${user.firstName} ${user.lastName}` 121 | : user.firstName || user.username || user.emailAddress || "User" 122 | : "User", 123 | email: user?.emailAddress || "", 124 | }; 125 | 126 | const toggleMobileMenu = () => { 127 | setIsMobileMenuOpen(!isMobileMenuOpen); 128 | }; 129 | 130 | return ( 131 |
134 |
135 |
136 | {/* Logo */} 137 | 138 | 139 |

Droply

140 | 141 | 142 | {/* Desktop Navigation */} 143 |
144 | {/* Show these buttons when user is signed out */} 145 | 146 | 147 | 150 | 151 | 152 | 155 | 156 | 157 | 158 | {/* Show these when user is signed in */} 159 | 160 |
161 | {!isOnDashboard && ( 162 | 163 | 166 | 167 | )} 168 | 169 | 170 | 188 | 189 | 190 | router.push("/dashboard?tab=profile")} 194 | > 195 | Profile 196 | 197 | router.push("/dashboard")} 201 | > 202 | My Files 203 | 204 | 211 | Sign Out 212 | 213 | 214 | 215 |
216 |
217 |
218 | 219 | {/* Mobile Menu Button */} 220 |
221 | 222 | } 228 | /> 229 | 230 | 242 |
243 | 244 | {/* Mobile Menu Overlay */} 245 | {isMobileMenuOpen && ( 246 |
setIsMobileMenuOpen(false)} 249 | aria-hidden="true" 250 | /> 251 | )} 252 | 253 | {/* Mobile Menu */} 254 |
260 | 261 |
262 | setIsMobileMenuOpen(false)} 266 | > 267 | 270 | 271 | setIsMobileMenuOpen(false)} 275 | > 276 | 279 | 280 |
281 |
282 | 283 | 284 |
285 | {/* User info */} 286 |
287 | } 293 | /> 294 |
295 |

{userDetails.displayName}

296 |

297 | {userDetails.email} 298 |

299 |
300 |
301 | 302 | {/* Navigation links */} 303 |
304 | {!isOnDashboard && ( 305 | setIsMobileMenuOpen(false)} 309 | > 310 | Dashboard 311 | 312 | )} 313 | setIsMobileMenuOpen(false)} 317 | > 318 | Profile 319 | 320 | 329 |
330 |
331 |
332 |
333 |
334 |
335 |
336 | ); 337 | } 338 | -------------------------------------------------------------------------------- /steps.md: -------------------------------------------------------------------------------- 1 | # Droply - Step by Step Guide 2 | 3 | This guide will walk you through recreating the Droply project, a file storage application built with Next.js, Clerk, Neon PostgreSQL, Drizzle ORM, and HeroUI. 4 | 5 | ## Prerequisites 6 | 7 | Before starting, make sure you have the following: 8 | 9 | - Node.js 18+ and npm 10 | - A Clerk account for authentication 11 | - A Neon PostgreSQL database 12 | - An ImageKit account for file storage 13 | 14 | ## Step 1: Project Setup 15 | 16 | 1. Create a new Next.js project: 17 | 18 | ```bash 19 | npx create-next-app@latest droply 20 | cd droply 21 | ``` 22 | 23 | 2. When prompted, choose the following options: 24 | - TypeScript: Yes 25 | - ESLint: Yes 26 | - Tailwind CSS: Yes 27 | - App Router: Yes 28 | - Import alias: Yes (default: @/*) 29 | 30 | ## Step 2: Install Dependencies 31 | 32 | Install the required dependencies: 33 | 34 | ```bash 35 | npm install @clerk/nextjs @heroui/avatar @heroui/badge @heroui/button @heroui/card @heroui/code @heroui/divider @heroui/dropdown @heroui/input @heroui/kbd @heroui/link @heroui/listbox @heroui/modal @heroui/navbar @heroui/progress @heroui/snippet @heroui/spinner @heroui/switch @heroui/system @heroui/table @heroui/tabs @heroui/theme @heroui/toast @heroui/tooltip @hookform/resolvers @neondatabase/serverless @react-aria/ssr @react-aria/visually-hidden axios clsx date-fns dotenv drizzle-orm framer-motion imagekit imagekitio-next intl-messageformat lucide-react next-themes react-hook-form uuid 36 | ``` 37 | 38 | Install dev dependencies: 39 | 40 | ```bash 41 | npm install -D @next/eslint-plugin-next @react-types/shared @tailwindcss/postcss @types/node @types/react @types/react-dom @types/uuid @typescript-eslint/eslint-plugin @typescript-eslint/parser autoprefixer drizzle-kit eslint eslint-config-next eslint-config-prettier eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-node eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-unused-imports postcss prettier tailwind-variants tailwindcss tsx typescript 42 | ``` 43 | 44 | ## Step 3: Configure Environment Variables 45 | 46 | 1. Create a `.env.example` file in the root directory: 47 | 48 | ``` 49 | # Clerk Authentication 50 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key 51 | CLERK_SECRET_KEY=your_clerk_secret_key 52 | 53 | # ImageKit 54 | NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY=your_imagekit_public_key 55 | IMAGEKIT_PRIVATE_KEY=your_imagekit_private_key 56 | NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT=your_imagekit_url_endpoint 57 | 58 | # Clerk URLs 59 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 60 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 61 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard 62 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard 63 | 64 | # Fallback URLs 65 | NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/ 66 | NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/ 67 | 68 | # App URLs 69 | NEXT_PUBLIC_APP_URL=http://localhost:3000 70 | 71 | # Database - Neon PostgreSQL 72 | DATABASE_URL=your_neon_database_url 73 | ``` 74 | 75 | 2. Create a `.env.local` file with your actual credentials. 76 | 77 | ## Step 4: Configure Tailwind CSS 78 | 79 | Update `tailwind.config.js`: 80 | 81 | ```js 82 | /** @type {import('tailwindcss').Config} */ 83 | module.exports = { 84 | content: [ 85 | "./app/**/*.{js,ts,jsx,tsx}", 86 | "./components/**/*.{js,ts,jsx,tsx}", 87 | "./node_modules/@heroui/**/*.{js,ts,jsx,tsx}", 88 | ], 89 | theme: { 90 | extend: {}, 91 | }, 92 | plugins: [], 93 | }; 94 | ``` 95 | 96 | ## Step 5: Set Up Database Schema with Drizzle 97 | 98 | 1. Create `drizzle.config.ts` in the root directory: 99 | 100 | ```typescript 101 | import type { Config } from "drizzle-kit"; 102 | import * as dotenv from "dotenv"; 103 | 104 | dotenv.config(); 105 | 106 | export default { 107 | schema: "./lib/db/schema.ts", 108 | out: "./drizzle", 109 | driver: "pg", 110 | dbCredentials: { 111 | connectionString: process.env.DATABASE_URL || "", 112 | }, 113 | verbose: true, 114 | strict: true, 115 | } satisfies Config; 116 | ``` 117 | 118 | 2. Create database schema in `lib/db/schema.ts`: 119 | 120 | ```typescript 121 | /** 122 | * Database Schema for Droply 123 | * 124 | * This file defines the database structure for our Droply application. 125 | * We're using Drizzle ORM with PostgreSQL (via Neon) for our database. 126 | */ 127 | 128 | import { 129 | pgTable, 130 | text, 131 | timestamp, 132 | uuid, 133 | integer, 134 | boolean, 135 | } from "drizzle-orm/pg-core"; 136 | import { relations } from "drizzle-orm"; 137 | 138 | /** 139 | * Files Table 140 | * 141 | * This table stores all files and folders in our Droply. 142 | * - Both files and folders are stored in the same table 143 | * - Folders are identified by the isFolder flag 144 | * - Files/folders can be nested using the parentId (creating a tree structure) 145 | */ 146 | export const files = pgTable("files", { 147 | // Unique identifier for each file/folder 148 | id: uuid("id").defaultRandom().primaryKey(), 149 | 150 | // Basic file/folder information 151 | name: text("name").notNull(), 152 | path: text("path").notNull(), // Full path to the file/folder 153 | size: integer("size").notNull(), // Size in bytes (0 for folders) 154 | type: text("type").notNull(), // MIME type for files, "folder" for folders 155 | 156 | // Storage information 157 | fileUrl: text("file_url").notNull(), // URL to access the file 158 | thumbnailUrl: text("thumbnail_url"), // Optional thumbnail for images/documents 159 | 160 | // Ownership and hierarchy 161 | userId: text("user_id").notNull(), // Owner of the file/folder 162 | parentId: uuid("parent_id"), // Parent folder ID (null for root items) 163 | 164 | // File/folder flags 165 | isFolder: boolean("is_folder").default(false).notNull(), // Whether this is a folder 166 | isStarred: boolean("is_starred").default(false).notNull(), // Starred/favorite items 167 | isTrash: boolean("is_trash").default(false).notNull(), // Items in trash 168 | 169 | // Timestamps 170 | createdAt: timestamp("created_at").defaultNow().notNull(), 171 | updatedAt: timestamp("updated_at").defaultNow().notNull(), 172 | }); 173 | 174 | /** 175 | * File Relations 176 | * 177 | * This defines the relationships between records in our files table: 178 | * 1. parent - Each file/folder can have one parent folder 179 | * 2. children - Each folder can have many child files/folders 180 | * 181 | * This creates a hierarchical file structure similar to a real filesystem. 182 | */ 183 | export const filesRelations = relations(files, ({ one, many }) => ({ 184 | // Relationship to parent folder 185 | parent: one(files, { 186 | fields: [files.parentId], // The foreign key in this table 187 | references: [files.id], // The primary key in the parent table 188 | }), 189 | 190 | // Relationship to child files/folders 191 | children: many(files), 192 | })); 193 | 194 | /** 195 | * Type Definitions 196 | * 197 | * These types help with TypeScript integration: 198 | * - File: Type for retrieving file data from the database 199 | * - NewFile: Type for inserting new file data into the database 200 | */ 201 | export type File = typeof files.$inferSelect; 202 | export type NewFile = typeof files.$inferInsert; 203 | ``` 204 | 205 | 3. Create database connection in `lib/db/index.ts`: 206 | 207 | ```typescript 208 | import { neon, neonConfig } from "@neondatabase/serverless"; 209 | import { drizzle } from "drizzle-orm/neon-http"; 210 | import * as schema from "./schema"; 211 | 212 | // Configure Neon to use WebSockets 213 | neonConfig.fetchConnectionCache = true; 214 | 215 | // Create a SQL client with the connection string 216 | const sql = neon(process.env.DATABASE_URL!); 217 | 218 | // Create a Drizzle client with the SQL client and schema 219 | export const db = drizzle(sql, { schema }); 220 | ``` 221 | 222 | 4. Create migration script in `lib/db/migrate.ts`: 223 | 224 | ```typescript 225 | import { migrate } from "drizzle-orm/neon-http/migrator"; 226 | import { db } from "./index"; 227 | 228 | // This script will run all migrations in the drizzle directory 229 | async function main() { 230 | console.log("Running migrations..."); 231 | 232 | try { 233 | await migrate(db, { migrationsFolder: "drizzle" }); 234 | console.log("Migrations completed successfully"); 235 | } catch (error) { 236 | console.error("Error running migrations:", error); 237 | process.exit(1); 238 | } 239 | 240 | process.exit(0); 241 | } 242 | 243 | main(); 244 | ``` 245 | 246 | 5. Add utility functions in `lib/utils.ts`: 247 | 248 | ```typescript 249 | export function formatFileSize(bytes: number): string { 250 | if (bytes === 0) return "0 Bytes"; 251 | const k = 1024; 252 | const sizes = ["Bytes", "KB", "MB", "GB"]; 253 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 254 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; 255 | } 256 | ``` 257 | 258 | ## Step 6: Configure Clerk Authentication 259 | 260 | 1. Create a middleware.ts file in the root directory: 261 | 262 | ```typescript 263 | import { authMiddleware } from "@clerk/nextjs"; 264 | 265 | export default authMiddleware({ 266 | // Public routes that don't require authentication 267 | publicRoutes: [ 268 | "/", 269 | "/sign-in(.*)", 270 | "/sign-up(.*)", 271 | "/api/imagekit-auth", 272 | ], 273 | 274 | // Routes that can be accessed by authenticated users or via an API key 275 | ignoredRoutes: [ 276 | "/api/webhooks(.*)", 277 | ], 278 | }); 279 | 280 | export const config = { 281 | // Protects all routes, including api/trpc. 282 | // See https://clerk.com/docs/references/nextjs/auth-middleware 283 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 284 | }; 285 | ``` 286 | 287 | ## Step 7: Create App Layout and Providers 288 | 289 | 1. Create `app/providers.tsx`: 290 | 291 | ```tsx 292 | "use client"; 293 | 294 | import { ClerkProvider } from "@clerk/nextjs"; 295 | import { ThemeProvider } from "next-themes"; 296 | import { ToastProvider } from "@heroui/toast"; 297 | import { SSRProvider } from "@react-aria/ssr"; 298 | import { VisuallyHidden } from "@react-aria/visually-hidden"; 299 | import { useRouter } from "next/navigation"; 300 | 301 | interface ProvidersProps { 302 | children: React.ReactNode; 303 | } 304 | 305 | export default function Providers({ children }: ProvidersProps) { 306 | const router = useRouter(); 307 | 308 | return ( 309 | 310 | router.push(to)} 317 | > 318 | 324 | 325 | 326 |

Droply - Simple File Storage

327 |
328 | {children} 329 |
330 |
331 |
332 |
333 | ); 334 | } 335 | ``` 336 | 337 | 2. Create `app/layout.tsx`: 338 | 339 | ```tsx 340 | import "./globals.css"; 341 | import type { Metadata } from "next"; 342 | import Providers from "./providers"; 343 | 344 | export const metadata: Metadata = { 345 | title: "Droply - Simple File Storage", 346 | description: "A simple file storage application", 347 | }; 348 | 349 | export default function RootLayout({ 350 | children, 351 | }: { 352 | children: React.ReactNode; 353 | }) { 354 | return ( 355 | 356 | 357 | {children} 358 | 359 | 360 | ); 361 | } 362 | ``` 363 | 364 | ## Step 8: Create Components 365 | 366 | Create the following components in the `components` directory: 367 | 368 | 1. Navbar.tsx 369 | 2. DashboardContent.tsx 370 | 3. FileUploadForm.tsx 371 | 4. FileList.tsx 372 | 5. FileIcon.tsx 373 | 6. FileActions.tsx 374 | 7. FileActionButtons.tsx 375 | 8. FileEmptyState.tsx 376 | 9. FileLoadingState.tsx 377 | 10. FileTabs.tsx 378 | 11. FolderNavigation.tsx 379 | 12. UserProfile.tsx 380 | 13. ConfirmationModal.tsx (in components/ui directory) 381 | 382 | ## Step 9: Create API Routes 383 | 384 | Create the following API routes: 385 | 386 | 1. `app/api/files/upload/route.ts` - For file uploads 387 | 2. `app/api/files/route.ts` - For fetching files 388 | 3. `app/api/files/[id]/star/route.ts` - For starring/unstarring files 389 | 4. `app/api/files/[id]/trash/route.ts` - For moving files to trash 390 | 5. `app/api/files/[id]/delete/route.ts` - For permanently deleting files 391 | 6. `app/api/folders/create/route.ts` - For creating folders 392 | 7. `app/api/imagekit-auth/route.ts` - For ImageKit authentication 393 | 394 | ## Step 10: Create Pages 395 | 396 | 1. Create `app/page.tsx` - Landing page 397 | 2. Create `app/dashboard/page.tsx` - Dashboard page 398 | 3. Create `app/sign-in/[[...sign-in]]/page.tsx` - Sign in page 399 | 4. Create `app/sign-up/[[...sign-up]]/page.tsx` - Sign up page 400 | 5. Create `app/error.tsx` - Error page 401 | 402 | ## Step 11: Initialize the Database 403 | 404 | Run the database migrations: 405 | 406 | ```bash 407 | npm run db:generate 408 | npm run db:push 409 | ``` 410 | 411 | ## Step 12: Run the Application 412 | 413 | Start the development server: 414 | 415 | ```bash 416 | npm run dev 417 | ``` 418 | 419 | Visit http://localhost:3000 to see your application. 420 | 421 | ## Step 13: Build for Production 422 | 423 | When you're ready to deploy: 424 | 425 | ```bash 426 | npm run build 427 | npm start 428 | ``` 429 | 430 | ## Additional Notes 431 | 432 | - Make sure to set up your Clerk, Neon, and ImageKit accounts properly 433 | - Update the environment variables with your actual credentials 434 | - The application uses HeroUI components for the UI 435 | - File uploads are handled by ImageKit 436 | - Authentication is handled by Clerk 437 | - Database operations are handled by Drizzle ORM with Neon PostgreSQL 438 | -------------------------------------------------------------------------------- /components/FileList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState, useMemo } from "react"; 4 | import { Folder, Star, Trash, X, ExternalLink } from "lucide-react"; 5 | import { 6 | Table, 7 | TableHeader, 8 | TableColumn, 9 | TableBody, 10 | TableRow, 11 | TableCell, 12 | } from "@heroui/table"; 13 | import { Divider } from "@heroui/divider"; 14 | import { Tooltip } from "@heroui/tooltip"; 15 | import { Card } from "@heroui/card"; 16 | import { addToast } from "@heroui/toast"; 17 | import { formatDistanceToNow, format } from "date-fns"; 18 | import type { File as FileType } from "@/lib/db/schema"; 19 | import axios from "axios"; 20 | import ConfirmationModal from "@/components/ui/ConfirmationModal"; 21 | import FileEmptyState from "@/components/FileEmptyState"; 22 | import FileIcon from "@/components/FileIcon"; 23 | import FileActions from "@/components/FileActions"; 24 | import FileLoadingState from "@/components/FileLoadingState"; 25 | import FileTabs from "@/components/FileTabs"; 26 | import FolderNavigation from "@/components/FolderNavigation"; 27 | import FileActionButtons from "@/components/FileActionButtons"; 28 | 29 | interface FileListProps { 30 | userId: string; 31 | refreshTrigger?: number; 32 | onFolderChange?: (folderId: string | null) => void; 33 | } 34 | 35 | export default function FileList({ 36 | userId, 37 | refreshTrigger = 0, 38 | onFolderChange, 39 | }: FileListProps) { 40 | const [files, setFiles] = useState([]); 41 | const [loading, setLoading] = useState(true); 42 | const [activeTab, setActiveTab] = useState("all"); 43 | const [currentFolder, setCurrentFolder] = useState(null); 44 | const [folderPath, setFolderPath] = useState< 45 | Array<{ id: string; name: string }> 46 | >([]); 47 | 48 | // Modal states 49 | const [deleteModalOpen, setDeleteModalOpen] = useState(false); 50 | const [emptyTrashModalOpen, setEmptyTrashModalOpen] = useState(false); 51 | const [selectedFile, setSelectedFile] = useState(null); 52 | 53 | // Fetch files 54 | const fetchFiles = async () => { 55 | setLoading(true); 56 | try { 57 | let url = `/api/files?userId=${userId}`; 58 | if (currentFolder) { 59 | url += `&parentId=${currentFolder}`; 60 | } 61 | 62 | const response = await axios.get(url); 63 | setFiles(response.data); 64 | } catch (error) { 65 | console.error("Error fetching files:", error); 66 | addToast({ 67 | title: "Error Loading Files", 68 | description: "We couldn't load your files. Please try again later.", 69 | color: "danger", 70 | }); 71 | } finally { 72 | setLoading(false); 73 | } 74 | }; 75 | 76 | // Fetch files when userId, refreshTrigger, or currentFolder changes 77 | useEffect(() => { 78 | fetchFiles(); 79 | }, [userId, refreshTrigger, currentFolder]); 80 | 81 | // Filter files based on active tab 82 | const filteredFiles = useMemo(() => { 83 | switch (activeTab) { 84 | case "starred": 85 | return files.filter((file) => file.isStarred && !file.isTrash); 86 | case "trash": 87 | return files.filter((file) => file.isTrash); 88 | case "all": 89 | default: 90 | return files.filter((file) => !file.isTrash); 91 | } 92 | }, [files, activeTab]); 93 | 94 | // Count files in trash 95 | const trashCount = useMemo(() => { 96 | return files.filter((file) => file.isTrash).length; 97 | }, [files]); 98 | 99 | // Count starred files 100 | const starredCount = useMemo(() => { 101 | return files.filter((file) => file.isStarred && !file.isTrash).length; 102 | }, [files]); 103 | 104 | const handleStarFile = async (fileId: string) => { 105 | try { 106 | await axios.patch(`/api/files/${fileId}/star`); 107 | 108 | // Update local state 109 | setFiles( 110 | files.map((file) => 111 | file.id === fileId ? { ...file, isStarred: !file.isStarred } : file 112 | ) 113 | ); 114 | 115 | // Show toast 116 | const file = files.find((f) => f.id === fileId); 117 | addToast({ 118 | title: file?.isStarred ? "Removed from Starred" : "Added to Starred", 119 | description: `"${file?.name}" has been ${ 120 | file?.isStarred ? "removed from" : "added to" 121 | } your starred files`, 122 | color: "success", 123 | }); 124 | } catch (error) { 125 | console.error("Error starring file:", error); 126 | addToast({ 127 | title: "Action Failed", 128 | description: "We couldn't update the star status. Please try again.", 129 | color: "danger", 130 | }); 131 | } 132 | }; 133 | 134 | const handleTrashFile = async (fileId: string) => { 135 | try { 136 | const response = await axios.patch(`/api/files/${fileId}/trash`); 137 | const responseData = response.data; 138 | 139 | // Update local state 140 | setFiles( 141 | files.map((file) => 142 | file.id === fileId ? { ...file, isTrash: !file.isTrash } : file 143 | ) 144 | ); 145 | 146 | // Show toast 147 | const file = files.find((f) => f.id === fileId); 148 | addToast({ 149 | title: responseData.isTrash ? "Moved to Trash" : "Restored from Trash", 150 | description: `"${file?.name}" has been ${ 151 | responseData.isTrash ? "moved to trash" : "restored" 152 | }`, 153 | color: "success", 154 | }); 155 | } catch (error) { 156 | console.error("Error trashing file:", error); 157 | addToast({ 158 | title: "Action Failed", 159 | description: "We couldn't update the file status. Please try again.", 160 | color: "danger", 161 | }); 162 | } 163 | }; 164 | 165 | const handleDeleteFile = async (fileId: string) => { 166 | try { 167 | // Store file info before deletion for the toast message 168 | const fileToDelete = files.find((f) => f.id === fileId); 169 | const fileName = fileToDelete?.name || "File"; 170 | 171 | // Send delete request 172 | const response = await axios.delete(`/api/files/${fileId}/delete`); 173 | 174 | if (response.data.success) { 175 | // Remove file from local state 176 | setFiles(files.filter((file) => file.id !== fileId)); 177 | 178 | // Show success toast 179 | addToast({ 180 | title: "File Permanently Deleted", 181 | description: `"${fileName}" has been permanently removed`, 182 | color: "success", 183 | }); 184 | 185 | // Close modal if it was open 186 | setDeleteModalOpen(false); 187 | } else { 188 | throw new Error(response.data.error || "Failed to delete file"); 189 | } 190 | } catch (error) { 191 | console.error("Error deleting file:", error); 192 | addToast({ 193 | title: "Deletion Failed", 194 | description: "We couldn't delete the file. Please try again later.", 195 | color: "danger", 196 | }); 197 | } 198 | }; 199 | 200 | const handleEmptyTrash = async () => { 201 | try { 202 | await axios.delete(`/api/files/empty-trash`); 203 | 204 | // Remove all trashed files from local state 205 | setFiles(files.filter((file) => !file.isTrash)); 206 | 207 | // Show toast 208 | addToast({ 209 | title: "Trash Emptied", 210 | description: `All ${trashCount} items have been permanently deleted`, 211 | color: "success", 212 | }); 213 | 214 | // Close modal 215 | setEmptyTrashModalOpen(false); 216 | } catch (error) { 217 | console.error("Error emptying trash:", error); 218 | addToast({ 219 | title: "Action Failed", 220 | description: "We couldn't empty the trash. Please try again later.", 221 | color: "danger", 222 | }); 223 | } 224 | }; 225 | 226 | // Add this function to handle file downloads 227 | const handleDownloadFile = async (file: FileType) => { 228 | try { 229 | // Show loading toast 230 | const loadingToastId = addToast({ 231 | title: "Preparing Download", 232 | description: `Getting "${file.name}" ready for download...`, 233 | color: "primary", 234 | }); 235 | 236 | // For images, we can use the ImageKit URL directly with optimized settings 237 | if (file.type.startsWith("image/")) { 238 | // Create a download-optimized URL with ImageKit 239 | // Using high quality and original dimensions for downloads 240 | const downloadUrl = `${process.env.NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT}/tr:q-100,orig-true/${file.path}`; 241 | 242 | // Fetch the image first to ensure it's available 243 | const response = await fetch(downloadUrl); 244 | if (!response.ok) { 245 | throw new Error(`Failed to download image: ${response.statusText}`); 246 | } 247 | 248 | // Get the blob data 249 | const blob = await response.blob(); 250 | 251 | // Create a download link 252 | const blobUrl = URL.createObjectURL(blob); 253 | const link = document.createElement("a"); 254 | link.href = blobUrl; 255 | link.download = file.name; 256 | document.body.appendChild(link); 257 | 258 | // Remove loading toast and show success toast 259 | addToast({ 260 | title: "Download Ready", 261 | description: `"${file.name}" is ready to download.`, 262 | color: "success", 263 | }); 264 | 265 | // Trigger download 266 | link.click(); 267 | 268 | // Clean up 269 | document.body.removeChild(link); 270 | URL.revokeObjectURL(blobUrl); 271 | } else { 272 | // For other file types, use the fileUrl directly 273 | const response = await fetch(file.fileUrl); 274 | if (!response.ok) { 275 | throw new Error(`Failed to download file: ${response.statusText}`); 276 | } 277 | 278 | // Get the blob data 279 | const blob = await response.blob(); 280 | 281 | // Create a download link 282 | const blobUrl = URL.createObjectURL(blob); 283 | const link = document.createElement("a"); 284 | link.href = blobUrl; 285 | link.download = file.name; 286 | document.body.appendChild(link); 287 | 288 | // Remove loading toast and show success toast 289 | addToast({ 290 | title: "Download Ready", 291 | description: `"${file.name}" is ready to download.`, 292 | color: "success", 293 | }); 294 | 295 | // Trigger download 296 | link.click(); 297 | 298 | // Clean up 299 | document.body.removeChild(link); 300 | URL.revokeObjectURL(blobUrl); 301 | } 302 | } catch (error) { 303 | console.error("Error downloading file:", error); 304 | addToast({ 305 | title: "Download Failed", 306 | description: "We couldn't download the file. Please try again later.", 307 | color: "danger", 308 | }); 309 | } 310 | }; 311 | 312 | // Function to open image in a new tab with optimized view 313 | const openImageViewer = (file: FileType) => { 314 | if (file.type.startsWith("image/")) { 315 | // Create an optimized URL with ImageKit transformations for viewing 316 | // Using higher quality and responsive sizing for better viewing experience 317 | const optimizedUrl = `${process.env.NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT}/tr:q-90,w-1600,h-1200,fo-auto/${file.path}`; 318 | window.open(optimizedUrl, "_blank"); 319 | } 320 | }; 321 | 322 | // Navigate to a folder 323 | const navigateToFolder = (folderId: string, folderName: string) => { 324 | setCurrentFolder(folderId); 325 | setFolderPath([...folderPath, { id: folderId, name: folderName }]); 326 | 327 | // Notify parent component about folder change 328 | if (onFolderChange) { 329 | onFolderChange(folderId); 330 | } 331 | }; 332 | 333 | // Navigate back to parent folder 334 | const navigateUp = () => { 335 | if (folderPath.length > 0) { 336 | const newPath = [...folderPath]; 337 | newPath.pop(); 338 | setFolderPath(newPath); 339 | const newFolderId = 340 | newPath.length > 0 ? newPath[newPath.length - 1].id : null; 341 | setCurrentFolder(newFolderId); 342 | 343 | // Notify parent component about folder change 344 | if (onFolderChange) { 345 | onFolderChange(newFolderId); 346 | } 347 | } 348 | }; 349 | 350 | // Navigate to specific folder in path 351 | const navigateToPathFolder = (index: number) => { 352 | if (index < 0) { 353 | setCurrentFolder(null); 354 | setFolderPath([]); 355 | 356 | // Notify parent component about folder change 357 | if (onFolderChange) { 358 | onFolderChange(null); 359 | } 360 | } else { 361 | const newPath = folderPath.slice(0, index + 1); 362 | setFolderPath(newPath); 363 | const newFolderId = newPath[newPath.length - 1].id; 364 | setCurrentFolder(newFolderId); 365 | 366 | // Notify parent component about folder change 367 | if (onFolderChange) { 368 | onFolderChange(newFolderId); 369 | } 370 | } 371 | }; 372 | 373 | // Handle file or folder click 374 | const handleItemClick = (file: FileType) => { 375 | if (file.isFolder) { 376 | navigateToFolder(file.id, file.name); 377 | } else if (file.type.startsWith("image/")) { 378 | openImageViewer(file); 379 | } 380 | }; 381 | 382 | if (loading) { 383 | return ; 384 | } 385 | 386 | return ( 387 |
388 | {/* Tabs for filtering files */} 389 | 396 | 397 | {/* Folder navigation */} 398 | {activeTab === "all" && ( 399 | 404 | )} 405 | 406 | {/* Action buttons */} 407 | setEmptyTrashModalOpen(true)} 413 | /> 414 | 415 | 416 | 417 | {/* Files table */} 418 | {filteredFiles.length === 0 ? ( 419 | 420 | ) : ( 421 | 425 |
426 | 437 | 438 | Name 439 | Type 440 | Size 441 | 442 | Added 443 | 444 | Actions 445 | 446 | 447 | {filteredFiles.map((file) => ( 448 | handleItemClick(file)} 456 | > 457 | 458 |
459 | 460 |
461 |
462 | 463 | {file.name} 464 | 465 | {file.isStarred && ( 466 | 467 | 471 | 472 | )} 473 | {file.isFolder && ( 474 | 475 | 476 | 477 | )} 478 | {file.type.startsWith("image/") && ( 479 | 480 | 481 | 482 | )} 483 |
484 |
485 | {formatDistanceToNow(new Date(file.createdAt), { 486 | addSuffix: true, 487 | })} 488 |
489 |
490 |
491 |
492 | 493 |
494 | {file.isFolder ? "Folder" : file.type} 495 |
496 |
497 | 498 |
499 | {file.isFolder 500 | ? "-" 501 | : file.size < 1024 502 | ? `${file.size} B` 503 | : file.size < 1024 * 1024 504 | ? `${(file.size / 1024).toFixed(1)} KB` 505 | : `${(file.size / (1024 * 1024)).toFixed(1)} MB`} 506 |
507 |
508 | 509 |
510 |
511 | {formatDistanceToNow(new Date(file.createdAt), { 512 | addSuffix: true, 513 | })} 514 |
515 |
516 | {format(new Date(file.createdAt), "MMMM d, yyyy")} 517 |
518 |
519 |
520 | e.stopPropagation()}> 521 | { 526 | setSelectedFile(file); 527 | setDeleteModalOpen(true); 528 | }} 529 | onDownload={handleDownloadFile} 530 | /> 531 | 532 |
533 | ))} 534 |
535 |
536 |
537 |
538 | )} 539 | 540 | {/* Delete confirmation modal */} 541 | { 551 | if (selectedFile) { 552 | handleDeleteFile(selectedFile.id); 553 | } 554 | }} 555 | isDangerous={true} 556 | warningMessage={`You are about to permanently delete "${selectedFile?.name}". This file will be permanently removed from your account and cannot be recovered.`} 557 | /> 558 | 559 | {/* Empty trash confirmation modal */} 560 | 573 |
574 | ); 575 | } 576 | --------------------------------------------------------------------------------