commit e2b2419f54addf6ec8c5e157943e2f4f462d06e4 Author: WeeXnes Date: Wed Jun 11 22:05:20 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fad0cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.genkit/* +.env* + +# firebase +firebase-debug.log +firestore-debug.log \ No newline at end of file diff --git a/.modified b/.modified new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc730c7 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Firebase Studio + +This is a NextJS starter in Firebase Studio. + +To get started, take a look at src/app/page.tsx. diff --git a/apphosting.yaml b/apphosting.yaml new file mode 100644 index 0000000..a55af7b --- /dev/null +++ b/apphosting.yaml @@ -0,0 +1,7 @@ +# Settings to manage and configure a Firebase App Hosting backend. +# https://firebase.google.com/docs/app-hosting/configure + +runConfig: + # Increase this value if you'd like to automatically spin up + # more instances in response to increased traffic. + maxInstances: 1 diff --git a/components.json b/components.json new file mode 100644 index 0000000..d710b49 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..3f21ce3 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,26 @@ +import type {NextConfig} from 'next'; + +const nextConfig: NextConfig = { + /* config options here */ + typescript: { + ignoreBuildErrors: true, + }, + eslint: { + ignoreDuringBuilds: true, + }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'placehold.co', + port: '', + pathname: '/**', + }, + ], + }, + serverActions: { + bodySizeLimit: '500mb', + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..5d88153 --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "vivid_cdn", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack -p 9002", + "genkit:dev": "genkit start -- tsx src/ai/dev.ts", + "genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@genkit-ai/googleai": "^1.8.0", + "@genkit-ai/next": "^1.8.0", + "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-menubar": "^1.1.6", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-tooltip": "^1.1.8", + "bcryptjs": "^2.4.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "dotenv": "^16.5.0", + "firebase": "^11.8.1", + "genkit": "^1.8.0", + "lucide-react": "^0.475.0", + "next": "15.3.3", + "patch-package": "^8.0.0", + "react": "^18.3.1", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.54.2", + "recharts": "^2.15.1", + "tailwind-merge": "^3.0.1", + "tailwindcss-animate": "^1.0.7", + "uuid": "^9.0.1", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/uuid": "^9.0.8", + "genkit-cli": "^1.8.0", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..1a69fd2 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/src/ai/dev.ts b/src/ai/dev.ts new file mode 100644 index 0000000..51e556a --- /dev/null +++ b/src/ai/dev.ts @@ -0,0 +1 @@ +// Flows will be imported for their side effects in this file. diff --git a/src/ai/genkit.ts b/src/ai/genkit.ts new file mode 100644 index 0000000..cbf2594 --- /dev/null +++ b/src/ai/genkit.ts @@ -0,0 +1,7 @@ +import {genkit} from 'genkit'; +import {googleAI} from '@genkit-ai/googleai'; + +export const ai = genkit({ + plugins: [googleAI()], + model: 'googleai/gemini-2.0-flash', +}); diff --git a/src/app/browse/components/file-list-item.tsx b/src/app/browse/components/file-list-item.tsx new file mode 100644 index 0000000..1a2d823 --- /dev/null +++ b/src/app/browse/components/file-list-item.tsx @@ -0,0 +1,50 @@ +import Link from 'next/link'; +import type { FileItem } from '@/types'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { format } from 'date-fns'; +import { Eye, DownloadCloud } from 'lucide-react'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; + +interface FileListItemProps { + file: FileItem; +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export default function FileListItem({ file }: FileListItemProps) { + return ( + + +
+ + + {file.name} + +
+
+ +

Size: {formatFileSize(file.size)}

+

Uploaded: {format(new Date(file.uploadDate), "MMM d, yyyy 'at' h:mm a")}

+
+ + + {/* Actual download would require a different setup. This is a placeholder. */} + {/* */} + +
+ ); +} diff --git a/src/app/browse/components/file-list.tsx b/src/app/browse/components/file-list.tsx new file mode 100644 index 0000000..12521c6 --- /dev/null +++ b/src/app/browse/components/file-list.tsx @@ -0,0 +1,16 @@ +import type { FileItem } from '@/types'; +import FileListItem from './file-list-item'; + +interface FileListProps { + files: FileItem[]; +} + +export default function FileList({ files }: FileListProps) { + return ( +
+ {files.map((file) => ( + + ))} +
+ ); +} diff --git a/src/app/browse/page.tsx b/src/app/browse/page.tsx new file mode 100644 index 0000000..fb8cf69 --- /dev/null +++ b/src/app/browse/page.tsx @@ -0,0 +1,37 @@ + +import { getFiles } from '@/lib/file-service'; +import FileList from './components/file-list'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { PlusCircle } from 'lucide-react'; + +export default async function BrowsePage() { + const files = await getFiles(); + + return ( +
+
+

+ VividCDN - Your Files +

+ +
+ + {files.length === 0 ? ( +
+

No files uploaded yet.

+ +
+ ) : ( + + )} +
+ ); +} diff --git a/src/app/cdn/[fileId]/route.ts b/src/app/cdn/[fileId]/route.ts new file mode 100644 index 0000000..467af95 --- /dev/null +++ b/src/app/cdn/[fileId]/route.ts @@ -0,0 +1,52 @@ + +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getFileById } from '@/lib/file-service'; +import fs from 'node:fs'; +import path from 'node:path'; + +export async function GET( + request: NextRequest, + { params }: { params: { fileId: string } } +) { + const fileId = params.fileId; + + if (!fileId) { + return NextResponse.json({ error: 'File ID is required' }, { status: 400 }); + } + + const fileMetadata = await getFileById(fileId); + + if (!fileMetadata) { + return NextResponse.json({ error: 'File metadata not found or ID is incorrect' }, { status: 404 }); + } + + // Construct the full path to the file in the CDN directory + // The filename on disk is [id].[extension] + const cdnDirectory = path.join(process.cwd(), 'CDN'); + const filePath = path.join(cdnDirectory, `${fileMetadata.id}.${fileMetadata.extension}`); + + if (!fs.existsSync(filePath)) { + console.error(`File not found at path: ${filePath} for ID ${fileId}`); + return NextResponse.json({ error: 'File not found in CDN storage' }, { status: 404 }); + } + + try { + const fileBuffer = fs.readFileSync(filePath); + + const headers = new Headers(); + headers.set('Content-Type', fileMetadata.type || 'application/octet-stream'); + // Use 'inline' for display in browser, 'attachment' to force download + headers.set('Content-Disposition', `inline; filename="${encodeURIComponent(fileMetadata.name)}"`); + headers.set('Content-Length', fileMetadata.size.toString()); + + return new NextResponse(fileBuffer, { + status: 200, + headers: headers, + }); + + } catch (error) { + console.error(`Error reading file ${fileMetadata.id}.${fileMetadata.extension}:`, error); + return NextResponse.json({ error: 'Error serving file' }, { status: 500 }); + } +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..a91b8fc Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/files/[fileId]/page.tsx b/src/app/files/[fileId]/page.tsx new file mode 100644 index 0000000..04aa899 --- /dev/null +++ b/src/app/files/[fileId]/page.tsx @@ -0,0 +1,106 @@ + +import { getFileById } from '@/lib/file-service'; +import { notFound } from 'next/navigation'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { ArrowLeft, DownloadCloud, ExternalLink } from 'lucide-react'; // Added ExternalLink +import { format } from 'date-fns'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { Badge } from '@/components/ui/badge'; // Added Badge + +interface FileDetailsPageProps { + params: { + fileId: string; + }; +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export default async function FileDetailsPage({ params }: FileDetailsPageProps) { + const file = await getFileById(params.fileId); + + if (!file) { + notFound(); + } + + const cdnUrl = `/cdn/${file.id}`; + // In a real app with a proper domain, you might construct the full URL: + // const fullCdnUrl = typeof window !== 'undefined' ? `${window.location.origin}${cdnUrl}` : cdnUrl; + // For server components, we can't rely on window.location.origin easily without passing it or client component tricks. + // So we'll display the relative path which is still useful. + + return ( +
+ + + +
+ +
+ {file.name} + + Unique ID: {file.id} + +
+
+
+ +
+
+ File Size: +

{formatFileSize(file.size)}

+
+
+ MIME Type: +

{file.type || 'N/A'}

+
+
+ Uploaded: +

{format(new Date(file.uploadDate), "PPPp")}

+
+
+ Extension: +

.{file.extension}

+
+
+ +
+ Direct CDN Link: +
+
+ {cdnUrl} +
+ +
+

This is the direct link to access the file content.

+

(For full URL, prefix with your application's domain, e.g., https://yourdomain.com{cdnUrl})

+
+ + +
+
+
+ ); +} + diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..4d08141 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,88 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + .dark { + --background: 0 0% 7%; /* #121212 */ + --foreground: 0 0% 98%; /* #FAFAFA */ + --card: 0 0% 10%; /* Slightly lighter than background */ + --card-foreground: 0 0% 98%; + --popover: 0 0% 7%; + --popover-foreground: 0 0% 98%; + --primary: 174 42% 59%; /* Teal #4DB6AC */ + --primary-foreground: 0 0% 10%; /* Dark text on Teal */ + --secondary: 0 0% 15%; /* A bit lighter than background */ + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 15%; + --muted-foreground: 0 0% 60%; + --accent: 283 82% 65%; /* Electric Purple #BF5AF2 */ + --accent-foreground: 0 0% 98%; /* White text on Purple */ + --destructive: 0 70% 50%; /* A more vibrant red */ + --destructive-foreground: 0 0% 98%; + --border: 0 0% 18%; /* Slightly lighter than card/secondary */ + --input: 0 0% 18%; + --ring: 174 42% 59%; /* Teal for focus rings */ + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 174 42% 59%; /* Teal for sidebar primary */ + --sidebar-primary-foreground: 0 0% 10%; + --sidebar-accent: 283 82% 65%; /* Purple for sidebar accent */ + --sidebar-accent-foreground: 0 0% 98%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 174 42% 59%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..4f8654a --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,32 @@ +import type {Metadata} from 'next'; +import './globals.css'; +import { Toaster } from "@/components/ui/toaster"; +import Header from '@/components/core/Header'; // Adjusted path + +export const metadata: Metadata = { + title: 'VividCDN - Your Personal CDN', + description: 'Securely store and access your files with VividCDN.', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + +
+
+ {children} +
+ + + + ); +} diff --git a/src/app/login/components/login-form.tsx b/src/app/login/components/login-form.tsx new file mode 100644 index 0000000..cb9769b --- /dev/null +++ b/src/app/login/components/login-form.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useFormStatus } from 'react-dom'; +import { loginAction } from '@/lib/actions/auth.actions'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Terminal, LogIn, Loader2 } from "lucide-react"; +import { useEffect, useActionState } from 'react'; // Updated import +import { useToast } from '@/hooks/use-toast'; + + +function SubmitButton() { + const { pending } = useFormStatus(); + return ( + + ); +} + +export default function LoginForm() { + const initialState = { message: null }; + // Updated to useActionState + const [state, dispatch, isPending] = useActionState(loginAction, initialState); + const { toast } = useToast(); + + useEffect(() => { + if (state?.message && !state.message.includes("Invalid fields")) { // Don't toast form validation errors handled inline + toast({ + variant: "destructive", + title: "Login Failed", + description: state.message, + }); + } + }, [state, toast]); + + + return ( +
+
+ + +
+
+ + +
+ {state?.message && state.message.includes("Invalid fields") && ( + + + Error + {state.message} + + )} + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..58ec071 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,22 @@ +import LoginForm from './components/login-form'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { AppLogo } from '@/components/core/AppLogo'; + +export default function LoginPage() { + return ( +
+ + +
+ +
+ Welcome Back + Sign in to access your VividCDN account. +
+ + + +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..ebd97d7 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from 'next/navigation'; +import { isAuthenticated } from '@/lib/auth'; + +export default async function HomePage() { + const authenticated = await isAuthenticated(); + + if (authenticated) { + redirect('/browse'); + } else { + redirect('/login'); + } + + // This part will not be reached due to redirects, but Next.js requires a return. + return null; +} diff --git a/src/app/upload/components/file-upload-form.tsx b/src/app/upload/components/file-upload-form.tsx new file mode 100644 index 0000000..2436a4e --- /dev/null +++ b/src/app/upload/components/file-upload-form.tsx @@ -0,0 +1,147 @@ + +"use client"; + +import { useRef, useState, useEffect, useActionState } from 'react'; // Updated import +import { useFormStatus } from 'react-dom'; +import { uploadFileAction } from '@/lib/actions/file.actions'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Progress } from '@/components/ui/progress'; +import { useToast } from '@/hooks/use-toast'; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { UploadCloud, CheckCircle, XCircle, Loader2, File as FileIconLucide } from "lucide-react"; + +function SubmitButton() { + const { pending } = useFormStatus(); + return ( + + ); +} + +export default function FileUploadForm() { + const initialState = { message: null, error: false, file: null }; + // Updated to useActionState + const [state, dispatch, isPending] = useActionState(uploadFileAction, initialState); + const { toast } = useToast(); + const [selectedFile, setSelectedFile] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); // Mock progress + const formRef = useRef(null); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + setSelectedFile(file); + setUploadProgress(0); // Reset progress + } else { + setSelectedFile(null); + } + }; + + // Simulate upload progress for UX + useEffect(() => { + let interval: NodeJS.Timeout; + // Check if isPending (from useActionState) is true and selectedFile exists + if (isPending && selectedFile && !state?.message) { + let progress = 0; + // Simulate progress quickly at first, then slower + interval = setInterval(() => { + progress += Math.random() * 15 + 5; // Random progress increment + if (progress >= 95 && !state?.message) { // Stall at 95% if no response yet + setUploadProgress(95); + } else if (progress <= 100) { + setUploadProgress(Math.min(progress, 100)); + } + if (progress >= 100 || state?.message) { + clearInterval(interval); + } + }, 150); + } else if (state?.message && !state.error) { + // If successful, jump to 100% + setUploadProgress(100); + } else if (state?.message && state.error) { + // If error, reset progress or show error state (e.g., 0 or specific error indicator) + setUploadProgress(0); + } + return () => clearInterval(interval); + }, [isPending, selectedFile, state]); + + + useEffect(() => { + if (state?.message) { + if (state.error) { + toast({ + variant: "destructive", + title: "Upload Failed", + description: state.message, + icon: , + }); + } else { + toast({ + title: "Upload Successful!", + description: state.message, + icon: , + }); + formRef.current?.reset(); + setSelectedFile(null); + setUploadProgress(100); + // Optionally reset progress to 0 after a short delay if user stays on page + setTimeout(() => setUploadProgress(0), 2000); + } + } + }, [state, toast]); + + return ( +
+
+ +
+
+ {selectedFile ? ( +
+
+
+ + {selectedFile && (isPending || uploadProgress > 0) && ( +
+ +

+ {isPending && uploadProgress < 100 ? `${uploadProgress}% uploading...` : state?.error ? 'Error' : uploadProgress === 100 && !isPending ? 'Complete' : `${uploadProgress}%`} +

+
+ )} + + {state?.error && state.message && !isPending && ( + + + Error + {state.message} + + )} + + + + ); +} diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx new file mode 100644 index 0000000..a550711 --- /dev/null +++ b/src/app/upload/page.tsx @@ -0,0 +1,20 @@ +import FileUploadForm from './components/file-upload-form'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { UploadCloud } from 'lucide-react'; + +export default function UploadPage() { + return ( +
+ + + + Upload File + Choose a file from your device to store in VividCDN. + + + + + +
+ ); +} diff --git a/src/components/core/AppLogo.tsx b/src/components/core/AppLogo.tsx new file mode 100644 index 0000000..b83426a --- /dev/null +++ b/src/components/core/AppLogo.tsx @@ -0,0 +1,35 @@ +import type { SVGProps } from 'react'; + +export function AppLogo(props: SVGProps) { + return ( + + + + + + + + + + + + VividCDN + + + ); +} diff --git a/src/components/core/Header.tsx b/src/components/core/Header.tsx new file mode 100644 index 0000000..1bf7089 --- /dev/null +++ b/src/components/core/Header.tsx @@ -0,0 +1,53 @@ + +import Link from 'next/link'; +import { AppLogo } from '@/components/core/AppLogo'; +import { Button } from '@/components/ui/button'; +import { isAuthenticated } from '@/lib/auth'; +import { logoutAction } from '@/lib/actions/auth.actions'; +import { UploadCloud, LogOut, LogIn, LayoutGrid } from 'lucide-react'; + +export default async function Header() { + const authenticated = await isAuthenticated(); + + return ( +
+
+ + + + +
+
+ ); +} diff --git a/src/components/icons/FileTypeIcon.tsx b/src/components/icons/FileTypeIcon.tsx new file mode 100644 index 0000000..6aa1f3e --- /dev/null +++ b/src/components/icons/FileTypeIcon.tsx @@ -0,0 +1,74 @@ +import { + File, + FileImage, + FileText, + FileVideo, + Music, // Lucide doesn't have FileAudio, Music is a good alternative + Archive, + FileCode2, // Lucide doesn't have FileCode, FileCode2 is available + FileSpreadsheet, + FileJson2, // Lucide doesn't have FileJson, FileJson2 is available + FileQuestion, + LucideProps, +} from 'lucide-react'; +import type { FileItem } from '@/types'; + +interface FileTypeIconProps extends LucideProps { + fileType: FileItem['type']; + extension: FileItem['extension']; +} + +const iconMapping: Record> = { + // MIME type based + 'image': FileImage, + 'application/pdf': FileText, + 'text': FileText, + 'video': FileVideo, + 'audio': Music, + 'application/zip': Archive, + 'application/x-rar-compressed': Archive, + 'application/x-tar': Archive, + 'application/gzip': Archive, + 'application/json': FileJson2, + 'application/vnd.ms-excel': FileSpreadsheet, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': FileSpreadsheet, + // Extension based + 'doc': FileText, + 'docx': FileText, + 'txt': FileText, + 'md': FileText, + 'rtf': FileText, + 'csv': FileSpreadsheet, + 'xls': FileSpreadsheet, + 'xlsx': FileSpreadsheet, + 'ppt': FileText, // Placeholder, could be PresentationIcon if available + 'pptx': FileText, + 'html': FileCode2, + 'css': FileCode2, + 'js': FileCode2, + 'ts': FileCode2, + 'py': FileCode2, + 'java': FileCode2, + 'c': FileCode2, + 'cpp': FileCode2, + 'cs': FileCode2, + 'php': FileCode2, + 'rb': FileCode2, + 'go': FileCode2, + 'swift': FileCode2, + 'kt': FileCode2, + 'sql': FileCode2, +}; + +export function FileTypeIcon({ fileType, extension, className, ...props }: FileTypeIconProps) { + let IconComponent = iconMapping[extension] || iconMapping[fileType.split('/')[0]] || File; + + if (fileType.startsWith('image/')) IconComponent = FileImage; + else if (fileType.startsWith('video/')) IconComponent = FileVideo; + else if (fileType.startsWith('audio/')) IconComponent = Music; + else if (fileType === 'application/pdf') IconComponent = FileText; + else if (extension === 'unknown' && fileType === 'application/octet-stream') IconComponent = FileQuestion; + + + return ; +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..24c788c --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..25e7b47 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..36496a2 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..3cd65cc --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,70 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..f62edea --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 0000000..32dc873 --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,365 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +