Zerotracex-Stuff commited on
Commit
a5871f0
·
1 Parent(s): 0f090fe

First model version

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +45 -0
  2. .modified +0 -0
  3. Dockerfile +24 -0
  4. apphosting.yaml +7 -0
  5. components.json +21 -0
  6. ncp +0 -0
  7. next.config.ts +36 -0
  8. nextn@0.1.0 +0 -0
  9. npm +0 -0
  10. npx +0 -0
  11. package-lock.json +0 -0
  12. package.json +76 -0
  13. postcss.config.mjs +8 -0
  14. public/1.pdf +3 -0
  15. public/pdf.worker.min.js +0 -0
  16. public/pdf.worker.min.mjs +0 -0
  17. public/testing/emerging diseases hesh.pdf +3 -0
  18. scripts/postinstall.js +14 -0
  19. src/ai/dev.ts +1 -0
  20. src/ai/flows/generate-pdf-description.ts +1 -0
  21. src/ai/genkit.ts +1 -0
  22. src/app/admin/dashboard/page.tsx +41 -0
  23. src/app/api/files/route.ts +100 -0
  24. src/app/favicon.ico +0 -0
  25. src/app/globals.css +122 -0
  26. src/app/layout.tsx +36 -0
  27. src/app/page.tsx +72 -0
  28. src/app/share/[id]/page.tsx +84 -0
  29. src/components/file-browser-skeleton.tsx +66 -0
  30. src/components/file-browser.tsx +314 -0
  31. src/components/file-grid-item.tsx +179 -0
  32. src/components/file-item.tsx +303 -0
  33. src/components/file-list.tsx +116 -0
  34. src/components/folder-grid-item.tsx +95 -0
  35. src/components/folder-item.tsx +102 -0
  36. src/components/folder-tree.tsx +97 -0
  37. src/components/grid-view.tsx +83 -0
  38. src/components/logo.tsx +16 -0
  39. src/components/mobile-sheet.tsx +56 -0
  40. src/components/pdf-thumbnail.tsx +72 -0
  41. src/components/pdf-viewer.tsx +200 -0
  42. src/components/share-dialog.tsx +96 -0
  43. src/components/share-page-client.tsx +26 -0
  44. src/components/splash-screen.tsx +24 -0
  45. src/components/theme-provider.tsx +9 -0
  46. src/components/theme-toggle.tsx +40 -0
  47. src/components/ui/accordion.tsx +58 -0
  48. src/components/ui/alert-dialog.tsx +141 -0
  49. src/components/ui/alert.tsx +59 -0
  50. src/components/ui/avatar.tsx +50 -0
.gitignore ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # vercel
34
+ .vercel
35
+
36
+ # typescript
37
+ *.tsbuildinfo
38
+ next-env.d.ts
39
+
40
+ .genkit/*
41
+ .env*
42
+
43
+ # firebase
44
+ firebase-debug.log
45
+ firestore-debug.log
.modified ADDED
File without changes
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # Use the official Node.js image as the base image
3
+ FROM node:20-slim
4
+
5
+ # Set the working directory
6
+ WORKDIR /app
7
+
8
+ # Copy package.json and package-lock.json to the working directory
9
+ COPY package*.json ./
10
+
11
+ # Install dependencies
12
+ RUN npm install
13
+
14
+ # Copy the rest of the application code to the working directory
15
+ COPY . .
16
+
17
+ # Build the Next.js application
18
+ RUN npm run build
19
+
20
+ # Expose the port your app runs on. Use 7860 as per the user's requirement.
21
+ EXPOSE 7860
22
+
23
+ # Command to run the application
24
+ CMD ["npm", "start", "--", "-p", "7860"]
apphosting.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Settings to manage and configure a Firebase App Hosting backend.
2
+ # https://firebase.google.com/docs/app-hosting/configure
3
+
4
+ runConfig:
5
+ # Increase this value if you'd like to automatically spin up
6
+ # more instances in response to increased traffic.
7
+ maxInstances: 1
components.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "default",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.ts",
8
+ "css": "src/app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ },
20
+ "iconLibrary": "lucide"
21
+ }
ncp ADDED
File without changes
next.config.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {NextConfig} from 'next';
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ typescript: {
6
+ ignoreBuildErrors: true,
7
+ },
8
+ eslint: {
9
+ ignoreDuringBuilds: true,
10
+ },
11
+ images: {
12
+ remotePatterns: [
13
+ {
14
+ protocol: 'https',
15
+ hostname: 'placehold.co',
16
+ port: '',
17
+ pathname: '/**',
18
+ },
19
+ ],
20
+ },
21
+ async headers() {
22
+ return [
23
+ {
24
+ source: '/:path*',
25
+ headers: [
26
+ {
27
+ key: 'x-next-pathname',
28
+ value: ':path*',
29
+ },
30
+ ],
31
+ },
32
+ ];
33
+ },
34
+ };
35
+
36
+ export default nextConfig;
nextn@0.1.0 ADDED
File without changes
npm ADDED
File without changes
npx ADDED
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ {
3
+ "name": "nextn",
4
+ "version": "0.1.0",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "next dev --turbopack -p 9002",
8
+ "genkit:dev": "genkit start -- tsx src/ai/dev.ts",
9
+ "genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts",
10
+ "build": "next build",
11
+ "start": "next start",
12
+ "lint": "next lint",
13
+ "typecheck": "tsc --noEmit",
14
+ "postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.js public/"
15
+ },
16
+ "dependencies": {
17
+ "@genkit-ai/googleai": "^1.14.1",
18
+ "@genkit-ai/next": "^1.14.1",
19
+ "@hookform/resolvers": "^4.1.3",
20
+ "@radix-ui/react-accordion": "^1.2.3",
21
+ "@radix-ui/react-alert-dialog": "^1.1.6",
22
+ "@radix-ui/react-avatar": "^1.1.3",
23
+ "@radix-ui/react-checkbox": "^1.1.4",
24
+ "@radix-ui/react-collapsible": "^1.1.11",
25
+ "@radix-ui/react-dialog": "^1.1.6",
26
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
27
+ "@radix-ui/react-label": "^2.1.2",
28
+ "@radix-ui/react-menubar": "^1.1.6",
29
+ "@radix-ui/react-popover": "^1.1.6",
30
+ "@radix-ui/react-progress": "^1.1.2",
31
+ "@radix-ui/react-radio-group": "^1.2.3",
32
+ "@radix-ui/react-scroll-area": "^1.2.3",
33
+ "@radix-ui/react-select": "^2.1.6",
34
+ "@radix-ui/react-separator": "^1.1.2",
35
+ "@radix-ui/react-slider": "^1.2.3",
36
+ "@radix-ui/react-slot": "^1.2.3",
37
+ "@radix-ui/react-switch": "^1.1.3",
38
+ "@radix-ui/react-tabs": "^1.1.3",
39
+ "@radix-ui/react-toast": "^1.2.6",
40
+ "@radix-ui/react-toggle-group": "^1.1.0",
41
+ "@radix-ui/react-tooltip": "^1.1.8",
42
+ "class-variance-authority": "^0.7.1",
43
+ "clsx": "^2.1.1",
44
+ "date-fns": "^3.6.0",
45
+ "dotenv": "^16.5.0",
46
+ "embla-carousel-react": "^8.6.0",
47
+ "firebase": "^11.9.1",
48
+ "framer-motion": "^11.3.19",
49
+ "genkit": "^1.14.1",
50
+ "jszip": "^3.10.1",
51
+ "lucide-react": "^0.475.0",
52
+ "next": "15.3.3",
53
+ "next-themes": "^0.3.0",
54
+ "pdfjs-dist": "3.11.174",
55
+ "react": "^18.3.1",
56
+ "react-day-picker": "^8.10.1",
57
+ "react-dom": "^18.3.1",
58
+ "react-hook-form": "^7.54.2",
59
+ "react-pdf": "^8.0.2",
60
+ "recharts": "^2.15.1",
61
+ "tailwind-merge": "^3.0.1",
62
+ "tailwindcss-animate": "^1.0.7",
63
+ "zod": "^3.24.2"
64
+ },
65
+ "devDependencies": {
66
+ "@types/file-saver": "^2.0.7",
67
+ "@types/node": "^20",
68
+ "@types/react": "^18",
69
+ "@types/react-dom": "^18",
70
+ "file-saver": "^2.0.5",
71
+ "genkit-cli": "^1.14.1",
72
+ "postcss": "^8",
73
+ "tailwindcss": "^3.4.1",
74
+ "typescript": "^5"
75
+ }
76
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ },
6
+ };
7
+
8
+ export default config;
public/1.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:db5e784e15d8bf5101ad59b95c0469ad6aad0598f63a82b03960630a5269b05b
3
+ size 2453906
public/pdf.worker.min.js ADDED
The diff for this file is too large to render. See raw diff
 
public/pdf.worker.min.mjs ADDED
The diff for this file is too large to render. See raw diff
 
public/testing/emerging diseases hesh.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:51e1ea99b69441bbfe0132affa9ec192d95511f06e6796e91db16389c9ff9aa1
3
+ size 477614
scripts/postinstall.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const source = path.resolve(__dirname, '../node_modules/pdfjs-dist/build/pdf.worker.min.js');
5
+ const destination = path.resolve(__dirname, '../public/pdf.worker.min.js');
6
+
7
+ fs.copyFile(source, destination, (err) => {
8
+ if (err) {
9
+ console.error('❌ Error copying pdf.worker.min.js:', err.message);
10
+ process.exit(1);
11
+ } else {
12
+ console.log('✅ pdf.worker.min.js copied to /public');
13
+ }
14
+ });
src/ai/dev.ts ADDED
@@ -0,0 +1 @@
 
 
1
+
src/ai/flows/generate-pdf-description.ts ADDED
@@ -0,0 +1 @@
 
 
1
+
src/ai/genkit.ts ADDED
@@ -0,0 +1 @@
 
 
1
+
src/app/admin/dashboard/page.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import {
3
+ Card,
4
+ CardContent,
5
+ CardDescription,
6
+ CardHeader,
7
+ CardTitle,
8
+ } from "@/components/ui/card"
9
+
10
+ export default function Dashboard() {
11
+ return (
12
+ <div className="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
13
+ <Card>
14
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
15
+ <CardTitle className="text-sm font-medium">
16
+ Total Downloads
17
+ </CardTitle>
18
+ </CardHeader>
19
+ <CardContent>
20
+ <div className="text-2xl font-bold">0</div>
21
+ <p className="text-xs text-muted-foreground">
22
+ Analytics data not yet available.
23
+ </p>
24
+ </CardContent>
25
+ </Card>
26
+ <Card>
27
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
28
+ <CardTitle className="text-sm font-medium">
29
+ Popular Searches
30
+ </CardTitle>
31
+ </CardHeader>
32
+ <CardContent>
33
+ <div className="text-2xl font-bold">0</div>
34
+ <p className="text-xs text-muted-foreground">
35
+ Analytics data not yet available.
36
+ </p>
37
+ </CardContent>
38
+ </Card>
39
+ </div>
40
+ )
41
+ }
src/app/api/files/route.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ // src/app/api/files/route.ts
3
+ import { NextResponse } from 'next/server';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+
7
+ // Define types directly in this file as they are specific to this API route.
8
+ export interface File {
9
+ id: string;
10
+ type: 'file';
11
+ name: string;
12
+ path: string;
13
+ fileType: 'pdf' | 'docx' | 'pptx' | 'other';
14
+ contentSnippet: string;
15
+ }
16
+
17
+ export interface Folder {
18
+ id:string;
19
+ type: 'folder';
20
+ name: string;
21
+ path: string;
22
+ children: (Folder | File)[];
23
+ }
24
+
25
+ export type FileSystemNode = Folder | File;
26
+
27
+
28
+ // Helper function to recursively scan directories
29
+ function getFileStructure(dirPath: string, relativeTo: string): FileSystemNode[] {
30
+ // Ensure the base directory exists
31
+ if (!fs.existsSync(dirPath)) {
32
+ fs.mkdirSync(dirPath, { recursive: true });
33
+ }
34
+
35
+ const children: FileSystemNode[] = fs.readdirSync(dirPath, { withFileTypes: true }).map((dirent) => {
36
+ const childAbsolutePath = path.join(dirPath, dirent.name);
37
+ // This relative path will be like 'folder/file.pdf'
38
+ const fileRelativePath = path.relative(relativeTo, childAbsolutePath).replace(/\\/g, '/');
39
+ const id = fileRelativePath.replace(/[\/\.]/g, '-') || 'root';
40
+
41
+ if (dirent.isDirectory()) {
42
+ const subChildren = getFileStructure(childAbsolutePath, relativeTo);
43
+ return {
44
+ id,
45
+ type: 'folder',
46
+ name: dirent.name,
47
+ // The browser path should start with a leading slash
48
+ path: `/${fileRelativePath}`,
49
+ children: subChildren
50
+ } as Folder;
51
+ } else {
52
+ const extension = path.extname(dirent.name).toLowerCase();
53
+ let fileType: File['fileType'] = 'other';
54
+ let contentSnippet = 'A document file.';
55
+
56
+ if (extension === '.pdf') {
57
+ fileType = 'pdf';
58
+ contentSnippet = 'A PDF document.';
59
+ } else if (extension === '.docx') {
60
+ fileType = 'docx';
61
+ contentSnippet = 'A Word document.';
62
+ } else if (extension === '.pptx') {
63
+ fileType = 'pptx';
64
+ contentSnippet = 'A PowerPoint presentation.';
65
+ }
66
+
67
+ // We only want to return specific file types for this app.
68
+ // You could remove this if you want to show all files.
69
+ if (fileType !== 'other') {
70
+ return {
71
+ id,
72
+ type: 'file',
73
+ name: dirent.name,
74
+ // The path for the browser needs to be a root-relative URL
75
+ path: `/${fileRelativePath}`,
76
+ fileType: fileType,
77
+ contentSnippet: contentSnippet,
78
+ } as File;
79
+ }
80
+
81
+ return null;
82
+ }
83
+ }).filter((node): node is FileSystemNode => node !== null);
84
+
85
+ return children;
86
+ }
87
+
88
+ export async function GET() {
89
+ try {
90
+ const publicDir = path.join(process.cwd(), 'public');
91
+ const children = getFileStructure(publicDir, publicDir);
92
+
93
+ // The root is now the list of files/folders in the public directory
94
+ return NextResponse.json(children);
95
+ } catch (error) {
96
+ console.error('Failed to read file system:', error);
97
+ // If 'public' directory doesn't exist or another error occurs, return an empty array.
98
+ return NextResponse.json([]);
99
+ }
100
+ }
src/app/favicon.ico ADDED
src/app/globals.css ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ body {
6
+ font-family: 'Inter', sans-serif;
7
+ }
8
+
9
+ @layer base {
10
+ :root {
11
+ --background: 220 13% 96%; /* Light Gray */
12
+ --foreground: 224 71% 4%;
13
+
14
+ --card: 220 13% 98%;
15
+ --card-foreground: 224 71% 4%;
16
+
17
+ --popover: 0 0% 100%;
18
+ --popover-foreground: 224 71% 4%;
19
+
20
+ --primary: 221 83% 53%; /* Deep Blue */
21
+ --primary-foreground: 210 40% 98%;
22
+
23
+ --secondary: 220 14% 91%;
24
+ --secondary-foreground: 224 71% 4%;
25
+
26
+ --muted: 220 14% 91%;
27
+ --muted-foreground: 220 9% 46%;
28
+
29
+ --accent: 196 84% 78%; /* Soft Sky Blue */
30
+ --accent-foreground: 224 71% 4%;
31
+
32
+ --destructive: 0 84.2% 60.2%;
33
+ --destructive-foreground: 0 0% 98%;
34
+
35
+ --border: 220 13% 89%;
36
+ --input: 220 13% 94%;
37
+ --ring: 221 83% 53%;
38
+ --radius: 0.8rem;
39
+ }
40
+ .dark {
41
+ --background: 222 47% 11%;
42
+ --foreground: 210 40% 98%;
43
+ --card: 222 47% 11%;
44
+ --card-foreground: 210 40% 98%;
45
+ --popover: 222 47% 11%;
46
+ --popover-foreground: 210 40% 98%;
47
+ --primary: 221 83% 53%;
48
+ --primary-foreground: 210 40% 98%;
49
+ --secondary: 217 33% 17%;
50
+ --secondary-foreground: 210 40% 98%;
51
+ --muted: 217 33% 17%;
52
+ --muted-foreground: 215 20% 65%;
53
+ --accent: 196 84% 78%;
54
+ --accent-foreground: 224 71% 4%;
55
+ --destructive: 0 63% 31%;
56
+ --destructive-foreground: 210 40% 98%;
57
+ --border: 217 33% 17%;
58
+ --input: 217 33% 17%;
59
+ --ring: 221 83% 53%;
60
+ }
61
+ }
62
+
63
+ @layer base {
64
+ * {
65
+ @apply border-border;
66
+ }
67
+ body {
68
+ @apply bg-background text-foreground;
69
+ }
70
+ }
71
+
72
+ @layer utilities {
73
+ .animate-fade-in {
74
+ animation: fade-in 0.5s ease-out forwards;
75
+ opacity: 0;
76
+ }
77
+ .animate-fade-out {
78
+ animation: fade-out 0.5s ease-in forwards;
79
+ }
80
+ @keyframes fade-in {
81
+ from {
82
+ opacity: 0;
83
+ transform: translateY(10px);
84
+ }
85
+ to {
86
+ opacity: 1;
87
+ transform: translateY(0);
88
+ }
89
+ }
90
+ @keyframes fade-out {
91
+ from {
92
+ opacity: 1;
93
+ }
94
+ to {
95
+ opacity: 0;
96
+ }
97
+ }
98
+
99
+ .animate-pulse-slow-1 {
100
+ animation: pulse-float 8s ease-in-out infinite;
101
+ }
102
+ .animate-pulse-slow-2 {
103
+ animation: pulse-float 10s ease-in-out infinite;
104
+ }
105
+ .animate-pulse-slow-3 {
106
+ animation: pulse-float 12s ease-in-out infinite;
107
+ }
108
+ .animate-pulse-fast {
109
+ animation: pulse-float 6s ease-in-out infinite;
110
+ }
111
+
112
+ @keyframes pulse-float {
113
+ 0%, 100% {
114
+ transform: scale(1) translate(0, 0);
115
+ opacity: 0.5;
116
+ }
117
+ 50% {
118
+ transform: scale(1.1) translate(5px, -5px);
119
+ opacity: 1;
120
+ }
121
+ }
122
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {Metadata} from 'next';
2
+ import './globals.css';
3
+ import { Toaster } from "@/components/ui/toaster"
4
+ import { ThemeProvider } from '@/components/theme-provider';
5
+
6
+ export const metadata: Metadata = {
7
+ title: 'Medico Docs by ztx',
8
+ description: 'A dynamic PDF download portal.',
9
+ };
10
+
11
+ export default function RootLayout({
12
+ children,
13
+ }: Readonly<{
14
+ children: React.ReactNode;
15
+ }>) {
16
+ return (
17
+ <html lang="en" suppressHydrationWarning>
18
+ <head>
19
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
20
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
21
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"></link>
22
+ </head>
23
+ <body className="font-body antialiased">
24
+ <ThemeProvider
25
+ attribute="class"
26
+ defaultTheme="system"
27
+ enableSystem
28
+ disableTransitionOnChange
29
+ >
30
+ {children}
31
+ <Toaster />
32
+ </ThemeProvider>
33
+ </body>
34
+ </html>
35
+ );
36
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { Suspense } from 'react';
6
+ import { FileBrowser } from '@/components/file-browser';
7
+ import type { FileSystemNode } from '@/app/api/files/route';
8
+ import { FileBrowserSkeleton } from '@/components/file-browser-skeleton';
9
+ import { SplashScreen } from '@/components/splash-screen';
10
+
11
+ async function getFileSystemData(): Promise<FileSystemNode[]> {
12
+ const res = await fetch(`/api/files`, { cache: 'no-store' });
13
+ if (!res.ok) {
14
+ console.error('Failed to fetch file system data', res.status, res.statusText);
15
+ throw new Error('Failed to fetch file system data');
16
+ }
17
+ const data = await res.json();
18
+ return data as FileSystemNode[];
19
+ }
20
+
21
+ function FileBrowserComponent({ onDataLoaded }: { onDataLoaded: () => void }) {
22
+ const [data, setData] = React.useState<FileSystemNode[] | null>(null);
23
+
24
+ React.useEffect(() => {
25
+ getFileSystemData().then(fetchedData => {
26
+ setData(fetchedData);
27
+ onDataLoaded();
28
+ });
29
+ }, [onDataLoaded]);
30
+
31
+ if (!data) {
32
+ return <FileBrowserSkeleton />;
33
+ }
34
+
35
+ const rootFolder = {
36
+ id: 'root',
37
+ type: 'folder' as const,
38
+ name: 'Files',
39
+ path: '/',
40
+ children: data,
41
+ };
42
+
43
+ return <FileBrowser initialData={rootFolder} />;
44
+ }
45
+
46
+
47
+ export default function Home() {
48
+ const [isAppLoading, setIsAppLoading] = React.useState(true);
49
+
50
+ React.useEffect(() => {
51
+ const timer = setTimeout(() => {
52
+ handleDataLoaded();
53
+ }, 2000);
54
+
55
+ return () => clearTimeout(timer);
56
+ }, []);
57
+
58
+ const handleDataLoaded = () => {
59
+ setIsAppLoading(false);
60
+ };
61
+
62
+ return (
63
+ <main>
64
+ <SplashScreen isVisible={isAppLoading} />
65
+ <div className={`transition-opacity duration-500 ${isAppLoading ? 'opacity-0' : 'opacity-100'}`}>
66
+ <Suspense fallback={<FileBrowserSkeleton />}>
67
+ <FileBrowserComponent onDataLoaded={handleDataLoaded} />
68
+ </Suspense>
69
+ </div>
70
+ </main>
71
+ );
72
+ }
src/app/share/[id]/page.tsx ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import * as React from 'react';
3
+ import type { File, FileSystemNode, Folder } from '@/app/api/files/route';
4
+ import { notFound } from 'next/navigation';
5
+ import { SharePageClient } from '@/components/share-page-client';
6
+
7
+ function urlSafeAtob(str: string): string {
8
+ str = str.replace(/-/g, '+').replace(/_/g, '/');
9
+ while (str.length % 4) {
10
+ str += '=';
11
+ }
12
+ return atob(str);
13
+ }
14
+
15
+
16
+ async function getSharedItem(shareId: string): Promise<FileSystemNode | null> {
17
+ // 1. Decode the share ID to get the path
18
+ let sharedPath: string;
19
+ try {
20
+ sharedPath = urlSafeAtob(shareId);
21
+ } catch (e) {
22
+ console.error('Failed to decode shareId:', e);
23
+ return null;
24
+ }
25
+
26
+ // 2. Fetch the entire file system. We create a full URL to be robust on the server.
27
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
28
+ const filesRes = await fetch(`${baseUrl}/api/files`, { cache: 'no-store' });
29
+ if (!filesRes.ok) {
30
+ console.error('Failed to fetch file system data');
31
+ return null;
32
+ }
33
+ const allFiles: FileSystemNode[] = await filesRes.json();
34
+ const root = { id: 'root', type: 'folder' as const, name: 'Files', path: '/', children: allFiles };
35
+
36
+ // 3. Find the shared node in the file system
37
+ const findNode = (nodes: FileSystemNode[], path: string): FileSystemNode | null => {
38
+ for (const node of nodes) {
39
+ // Normalize paths for comparison
40
+ const nodePath = node.path.startsWith('/') ? node.path : `/${node.path}`;
41
+ const targetPath = path.startsWith('/') ? path : `/${path}`;
42
+ if (nodePath === targetPath) return node;
43
+ if (node.type === 'folder') {
44
+ const found = findNode(node.children, path);
45
+ if (found) return found;
46
+ }
47
+ }
48
+ return null;
49
+ };
50
+
51
+ const sharedNode = findNode(root.children, sharedPath);
52
+ return sharedNode;
53
+ }
54
+
55
+
56
+ export default async function SharePage({ params }: { params: { id: string } }) {
57
+ const sharedItem = await getSharedItem(params.id);
58
+
59
+ if (!sharedItem) {
60
+ notFound();
61
+ }
62
+
63
+ let rootNode: Folder;
64
+
65
+ if (sharedItem.type === 'folder') {
66
+ // If a folder is shared, it becomes the root of the file browser on the share page.
67
+ rootNode = sharedItem;
68
+ } else {
69
+ // If a file is shared, wrap it in a virtual folder to display it.
70
+ rootNode = {
71
+ id: 'shared-root',
72
+ type: 'folder' as const,
73
+ name: `Shared: ${sharedItem.name}`,
74
+ path: '/',
75
+ children: [sharedItem],
76
+ };
77
+ }
78
+
79
+ return (
80
+ <main>
81
+ <SharePageClient initialData={rootNode} />
82
+ </main>
83
+ );
84
+ }
src/components/file-browser-skeleton.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { Search } from 'lucide-react';
3
+ import { Skeleton } from '@/components/ui/skeleton';
4
+ import { Logo } from '@/components/logo';
5
+ import { Card, CardContent } from './ui/card';
6
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
7
+
8
+ export function FileBrowserSkeleton() {
9
+ return (
10
+ <div className="grid md:grid-cols-[280px_1fr] h-screen w-full bg-background font-body text-foreground animate-pulse">
11
+ <aside className="hidden md:flex flex-col border-r bg-card">
12
+ <div className="p-4 border-b">
13
+ <Logo />
14
+ </div>
15
+ <div className="flex-1 overflow-auto py-2 p-4 space-y-2">
16
+ <Skeleton className="h-8 w-full" />
17
+ <Skeleton className="h-8 w-full" />
18
+ <div className="pl-6 space-y-2">
19
+ <Skeleton className="h-8 w-full" />
20
+ </div>
21
+ <Skeleton className="h-8 w-full" />
22
+ </div>
23
+ </aside>
24
+
25
+ <div className="flex flex-col">
26
+ <header className="flex h-16 items-center gap-4 border-b bg-card px-6">
27
+ <Skeleton className="h-10 w-10 md:hidden" />
28
+ <div className="relative flex-1">
29
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
30
+ <Skeleton className="pl-10 h-10 w-full" />
31
+ </div>
32
+ <Skeleton className="h-10 w-32" />
33
+ </header>
34
+
35
+ <main className="flex-1 overflow-auto p-6 space-y-4">
36
+ <div className="flex items-center gap-2">
37
+ <Skeleton className="h-5 w-24" />
38
+ </div>
39
+ <Skeleton className="h-8 w-48" />
40
+ <Card className="shadow-sm">
41
+ <CardContent className="p-0">
42
+ <Table>
43
+ <TableHeader>
44
+ <TableRow>
45
+ <TableHead className="w-2/5"><Skeleton className="h-5 w-20" /></TableHead>
46
+ <TableHead><Skeleton className="h-5 w-24" /></TableHead>
47
+ <TableHead className="text-right w-[100px]"><Skeleton className="h-5 w-16" /></TableHead>
48
+ </TableRow>
49
+ </TableHeader>
50
+ <TableBody>
51
+ {[...Array(5)].map((_, i) => (
52
+ <TableRow key={i}>
53
+ <TableCell><Skeleton className="h-6 w-full" /></TableCell>
54
+ <TableCell><Skeleton className="h-6 w-full" /></TableCell>
55
+ <TableCell><Skeleton className="h-10 w-10" /></TableCell>
56
+ </TableRow>
57
+ ))}
58
+ </TableBody>
59
+ </Table>
60
+ </CardContent>
61
+ </Card>
62
+ </main>
63
+ </div>
64
+ </div>
65
+ );
66
+ }
src/components/file-browser.tsx ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { Search, Send, Download, X, List, LayoutGrid, CheckSquare, XSquare } from 'lucide-react';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Logo } from '@/components/logo';
8
+ import { FolderTree } from '@/components/folder-tree';
9
+ import { FileList } from '@/components/file-list';
10
+ import { findNodeByPath, getAllFiles, getNodesByIds } from '@/lib/utils';
11
+ import type { File as FileType, Folder, FileSystemNode } from '@/app/api/files/route';
12
+ import { Card, CardContent } from './ui/card';
13
+ import { MobileSheet } from './mobile-sheet';
14
+ import { Button } from './ui/button';
15
+ import Link from 'next/link';
16
+ import { ThemeToggle } from './theme-toggle';
17
+ import { AnimatePresence, motion } from 'framer-motion';
18
+ import { useToast } from '@/hooks/use-toast';
19
+ import JSZip from 'jszip';
20
+ import { GridView } from './grid-view';
21
+ import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group';
22
+ import { useLocalStorage } from '@/hooks/use-local-storage';
23
+ import { cn } from '@/lib/utils';
24
+
25
+
26
+ interface FileBrowserProps {
27
+ initialData: Folder;
28
+ isPublicShare?: boolean;
29
+ }
30
+
31
+ export function FileBrowser({ initialData, isPublicShare = false }: FileBrowserProps) {
32
+ const [fileSystemData] = React.useState(initialData);
33
+ const [currentPath, setCurrentPath] = React.useState('/');
34
+ const [searchTerm, setSearchTerm] = React.useState('');
35
+ const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
36
+ const [view, setView] = useLocalStorage<'list' | 'grid'>('file-browser-view', 'grid');
37
+ const [isSelectionMode, setIsSelectionMode] = React.useState(false);
38
+
39
+ const { toast, dismiss } = useToast();
40
+
41
+ React.useEffect(() => {
42
+ if (!isSelectionMode) {
43
+ setSelectedIds([]);
44
+ }
45
+ }, [isSelectionMode]);
46
+
47
+ const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
48
+ setSearchTerm(event.target.value);
49
+ setSelectedIds([]); // Clear selection on new search
50
+ };
51
+
52
+ const currentFolder = React.useMemo(() => {
53
+ if (isPublicShare) return fileSystemData;
54
+ return findNodeByPath(fileSystemData, currentPath);
55
+ }, [fileSystemData, currentPath, isPublicShare]);
56
+
57
+
58
+ const { filesToShow, foldersToShow, allVisibleItems } = React.useMemo(() => {
59
+ if (searchTerm) {
60
+ const allFiles = getAllFiles(fileSystemData);
61
+ const filteredFiles = allFiles.filter((file) =>
62
+ file.name.toLowerCase().includes(searchTerm.toLowerCase())
63
+ );
64
+ return {
65
+ filesToShow: filteredFiles,
66
+ foldersToShow: [],
67
+ allVisibleItems: filteredFiles,
68
+ };
69
+ }
70
+
71
+ if (currentFolder && currentFolder.type === 'folder') {
72
+ const files = currentFolder.children.filter(
73
+ (child): child is FileType => child.type === 'file'
74
+ );
75
+ const folders = currentFolder.children.filter(
76
+ (child): child is Folder => child.type === 'folder'
77
+ );
78
+ return {
79
+ filesToShow: files,
80
+ foldersToShow: folders,
81
+ allVisibleItems: [...folders, ...files]
82
+ };
83
+ }
84
+ return { filesToShow: [], foldersToShow: [], allVisibleItems: [] };
85
+ }, [fileSystemData, currentPath, searchTerm, currentFolder]);
86
+
87
+ const breadcrumbs = currentPath.split('/').filter(Boolean);
88
+
89
+ const handleSelectAll = (isChecked: boolean) => {
90
+ if (isChecked) {
91
+ setSelectedIds(allVisibleItems.map(item => item.id));
92
+ } else {
93
+ setSelectedIds([]);
94
+ }
95
+ };
96
+
97
+ const handleSelectItem = (id: string, isChecked: boolean) => {
98
+ setSelectedIds(prev =>
99
+ isChecked ? [...prev, id] : prev.filter(selectedId => selectedId !== id)
100
+ );
101
+ };
102
+
103
+ const handleBatchDownload = async () => {
104
+ const { saveAs } = await import('file-saver');
105
+
106
+ const { id: toastId } = toast({
107
+ title: 'Preparing Download',
108
+ description: 'Zipping your files... Please wait.',
109
+ });
110
+
111
+ try {
112
+ const selectedNodes = getNodesByIds(fileSystemData, selectedIds);
113
+
114
+ const filesToZip: FileType[] = [];
115
+ selectedNodes.forEach(node => {
116
+ if (node.type === 'file') {
117
+ filesToZip.push(node);
118
+ } else if (node.type === 'folder') {
119
+ filesToZip.push(...getAllFiles(node));
120
+ }
121
+ });
122
+
123
+ if (filesToZip.length === 0) {
124
+ toast({
125
+ variant: 'destructive',
126
+ title: 'No Files Selected',
127
+ description: 'Please select files to download.',
128
+ });
129
+ dismiss(toastId);
130
+ return;
131
+ }
132
+
133
+ const zip = new JSZip();
134
+
135
+ await Promise.all(
136
+ filesToZip.map(async (file) => {
137
+ const response = await fetch(file.path);
138
+ const blob = await response.blob();
139
+ // Use the relative path within the zip file
140
+ const zipPath = file.path.startsWith('/') ? file.path.substring(1) : file.path;
141
+ zip.file(zipPath, blob);
142
+ })
143
+ );
144
+
145
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
146
+ saveAs(zipBlob, 'medico-docs.zip');
147
+
148
+ toast({
149
+ title: 'Download Ready',
150
+ description: 'Your ZIP file has been downloaded.',
151
+ });
152
+
153
+ } catch (error) {
154
+ console.error('Batch download failed:', error);
155
+ toast({
156
+ variant: 'destructive',
157
+ title: 'Download Failed',
158
+ description: 'There was an error creating the ZIP file.',
159
+ });
160
+ } finally {
161
+ dismiss(toastId);
162
+ setSelectedIds([]);
163
+ setIsSelectionMode(false);
164
+ }
165
+ };
166
+
167
+
168
+ const numSelected = selectedIds.length;
169
+
170
+ return (
171
+ <div className="grid md:grid-cols-[280px_1fr] h-screen w-full bg-background font-body text-foreground">
172
+ <aside className={cn("hidden md:flex flex-col border-r bg-card", isPublicShare && "hidden")}>
173
+ <div className="p-4 border-b">
174
+ <Logo />
175
+ </div>
176
+ <div className="flex-1 overflow-auto py-2">
177
+ <FolderTree
178
+ rootFolder={fileSystemData}
179
+ currentPath={currentPath}
180
+ onSelectFolder={setCurrentPath}
181
+ />
182
+ </div>
183
+ </aside>
184
+
185
+ <div className="flex flex-col">
186
+ <header className="flex h-16 items-center gap-4 border-b bg-card px-6 shrink-0">
187
+ <div className="md:hidden">
188
+ {isPublicShare ? <Logo /> : <MobileSheet rootFolder={fileSystemData} currentPath={currentPath} onSelectFolder={setCurrentPath} />}
189
+ </div>
190
+ {!isPublicShare && <div className="hidden md:block w-[215px]"/>}
191
+ <div className="relative flex-1">
192
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
193
+ <Input
194
+ placeholder="Search documents by name..."
195
+ className="pl-10 h-10 bg-card"
196
+ value={searchTerm}
197
+ onChange={handleSearchChange}
198
+ />
199
+ </div>
200
+ <ThemeToggle />
201
+ {isSelectionMode ? (
202
+ <Button variant="outline" onClick={() => setIsSelectionMode(false)}>
203
+ <XSquare className="mr-2 h-4 w-4" />
204
+ Cancel
205
+ </Button>
206
+ ) : (
207
+ <Button variant="outline" onClick={() => setIsSelectionMode(true)}>
208
+ <CheckSquare className="mr-2 h-4 w-4" />
209
+ Select
210
+ </Button>
211
+ )}
212
+ {!isPublicShare && (
213
+ <Link href="https://t.me/ztx" target="_blank">
214
+ <Button>
215
+ <Send className="mr-2 h-4 w-4" />
216
+ Contact Me
217
+ </Button>
218
+ </Link>
219
+ )}
220
+ </header>
221
+
222
+ <main className="flex-1 overflow-auto p-6 space-y-4">
223
+ <div className="flex items-center justify-between gap-4">
224
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
225
+ {isPublicShare || breadcrumbs.length === 0 ? (
226
+ <span>{currentFolder?.name || 'Files'}</span>
227
+ ) : (
228
+ <React.Fragment>
229
+ <span className="cursor-pointer hover:underline" onClick={() => setCurrentPath('/')}>Files</span>
230
+ {breadcrumbs.map((crumb, index) => {
231
+ const path = '/' + breadcrumbs.slice(0, index + 1).join('/');
232
+ return (
233
+ <React.Fragment key={index}>
234
+ <span>/</span>
235
+ <span className="cursor-pointer hover:underline" onClick={() => setCurrentPath(path)}>{crumb}</span>
236
+ </React.Fragment>
237
+ )
238
+ })}
239
+ </React.Fragment>
240
+ )}
241
+ </div>
242
+ <ToggleGroup type="single" value={view} onValueChange={(value) => { if (value) setView(value as 'list' | 'grid')}} size="sm">
243
+ <ToggleGroupItem value="list" aria-label="List view">
244
+ <List className="h-4 w-4" />
245
+ </ToggleGroupItem>
246
+ <ToggleGroupItem value="grid" aria-label="Grid view">
247
+ <LayoutGrid className="h-4 w-4" />
248
+ </ToggleGroupItem>
249
+ </ToggleGroup>
250
+ </div>
251
+ <h1 className="text-2xl font-bold">
252
+ {searchTerm ? `Search Results` : currentFolder?.name}
253
+ </h1>
254
+ <Card className="shadow-sm">
255
+ <CardContent className="p-0">
256
+ {view === 'list' ? (
257
+ <FileList
258
+ files={filesToShow}
259
+ folders={foldersToShow}
260
+ searchTerm={searchTerm}
261
+ onSelectFolder={setCurrentPath}
262
+ selectedIds={selectedIds}
263
+ onSelectAll={handleSelectAll}
264
+ onSelectItem={handleSelectItem}
265
+ allItemCount={allVisibleItems.length}
266
+ isSelectionActive={isSelectionMode}
267
+ isPublicShare={isPublicShare}
268
+ />
269
+ ) : (
270
+ <GridView
271
+ files={filesToShow}
272
+ folders={foldersToShow}
273
+ searchTerm={searchTerm}
274
+ onSelectFolder={setCurrentPath}
275
+ selectedIds={selectedIds}
276
+ onSelectItem={handleSelectItem}
277
+ isSelectionActive={isSelectionMode}
278
+ isPublicShare={isPublicShare}
279
+ />
280
+ )}
281
+ </CardContent>
282
+ </Card>
283
+ </main>
284
+ <AnimatePresence>
285
+ {numSelected > 0 && (
286
+ <motion.div
287
+ initial={{ y: 100, opacity: 0 }}
288
+ animate={{ y: 0, opacity: 1 }}
289
+ exit={{ y: 100, opacity: 0 }}
290
+ transition={{ type: 'spring', stiffness: 300, damping: 30 }}
291
+ className="fixed bottom-6 left-1/2 -translate-x-1/2 w-auto bg-primary/90 text-primary-foreground backdrop-blur-md rounded-lg shadow-2xl z-50 overflow-hidden"
292
+ >
293
+ <div className="flex items-center gap-4 px-4 py-2">
294
+ <div className="flex items-center gap-2">
295
+ <Button variant="ghost" size="icon" className="hover:bg-primary-foreground/10" onClick={() => setSelectedIds([])}>
296
+ <X className="h-5 w-5" />
297
+ </Button>
298
+ <span className="font-medium text-sm whitespace-nowrap">{numSelected} item{numSelected > 1 ? 's' : ''} selected</span>
299
+ </div>
300
+ <div className="h-6 w-px bg-primary-foreground/20" />
301
+ <div className="flex items-center gap-2">
302
+ <Button variant="ghost" className="hover:bg-primary-foreground/10" onClick={handleBatchDownload}>
303
+ <Download className="mr-2 h-4 w-4"/>
304
+ Download
305
+ </Button>
306
+ </div>
307
+ </div>
308
+ </motion.div>
309
+ )}
310
+ </AnimatePresence>
311
+ </div>
312
+ </div>
313
+ );
314
+ }
src/components/file-grid-item.tsx ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { FileText, Download, Eye, Loader2, File as FileIcon, Presentation, Share2 } from 'lucide-react';
6
+ import type { File as FileType } from '@/app/api/files/route';
7
+ import { useToast } from "@/hooks/use-toast"
8
+ import { cn } from '@/lib/utils';
9
+ import dynamic from 'next/dynamic';
10
+ import { Checkbox } from './ui/checkbox';
11
+ import { Card, CardFooter } from './ui/card';
12
+ import { Button } from './ui/button';
13
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
14
+ import { ShareDialog } from './share-dialog';
15
+
16
+ const PdfThumbnail = dynamic(() => import('./pdf-thumbnail').then(mod => mod.PdfThumbnail), {
17
+ ssr: false,
18
+ loading: () => <div className="aspect-[3/4] w-full flex items-center justify-center bg-muted rounded-md"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
19
+ });
20
+
21
+ const PdfViewer = dynamic(() => import('./pdf-viewer').then(mod => mod.PdfViewer), {
22
+ ssr: false,
23
+ loading: () => null,
24
+ });
25
+
26
+ interface FileGridItemProps extends React.HTMLAttributes<HTMLDivElement> {
27
+ file: FileType;
28
+ isSelected: boolean;
29
+ onSelectItem: (id: string, isSelected: boolean) => void;
30
+ isSelectionActive: boolean;
31
+ isPublicShare?: boolean;
32
+ }
33
+
34
+ const fileTypeConfig = {
35
+ pdf: { icon: FileText, color: 'destructive' as const },
36
+ docx: { icon: FileText, color: 'default' as const },
37
+ pptx: { icon: Presentation, color: 'secondary' as const },
38
+ other: { icon: FileIcon, color: 'outline' as const }
39
+ }
40
+
41
+ export function FileGridItem({ file, className, isSelected, onSelectItem, isSelectionActive, isPublicShare, ...props }: FileGridItemProps) {
42
+ const [isDownloading, setIsDownloading] = React.useState(false);
43
+ const [isPreviewOpen, setIsPreviewOpen] = React.useState(false);
44
+ const [isShareOpen, setIsShareOpen] = React.useState(false);
45
+
46
+ const { toast } = useToast();
47
+
48
+ const handleDownload = async (e: React.MouseEvent) => {
49
+ e.stopPropagation();
50
+ setIsDownloading(true);
51
+ try {
52
+ const response = await fetch(new URL(file.path, window.location.origin).href);
53
+ const blob = await response.blob();
54
+ const { saveAs } = await import('file-saver');
55
+ saveAs(blob, file.name);
56
+ } catch (error) {
57
+ toast({ variant: "destructive", title: "Download Error" });
58
+ } finally {
59
+ setIsDownloading(false);
60
+ }
61
+ };
62
+
63
+ const config = fileTypeConfig[file.fileType] ?? fileTypeConfig.other;
64
+ const Icon = config.icon;
65
+
66
+ const handleCheckboxChange = (checked: boolean) => {
67
+ onSelectItem(file.id, checked);
68
+ };
69
+
70
+ const handlePreviewClick = (e: React.MouseEvent) => {
71
+ e.stopPropagation();
72
+ if (file.fileType === 'pdf') {
73
+ setIsPreviewOpen(true);
74
+ }
75
+ }
76
+
77
+ const handleCardClick = (e: React.MouseEvent<HTMLDivElement>) => {
78
+ if (isSelectionActive) {
79
+ onSelectItem(file.id, !isSelected);
80
+ return;
81
+ }
82
+
83
+ // Default action: preview for PDFs
84
+ if (file.fileType === 'pdf') {
85
+ setIsPreviewOpen(true);
86
+ }
87
+ };
88
+
89
+ const handleShareClick = (e: React.MouseEvent) => {
90
+ e.stopPropagation();
91
+ setIsShareOpen(true);
92
+ }
93
+
94
+ return (
95
+ <>
96
+ <Card
97
+ className={cn(
98
+ "group relative w-full aspect-square flex flex-col justify-between transition-all duration-200 hover:shadow-md",
99
+ isSelected && "border-primary shadow-lg scale-[1.02]",
100
+ isSelectionActive && "cursor-pointer",
101
+ className
102
+ )}
103
+ onClick={handleCardClick}
104
+ {...props}
105
+ >
106
+ <div className={cn("absolute top-2 right-2 z-10", !isSelectionActive && "hidden")} onClick={(e) => e.stopPropagation()} >
107
+ <Checkbox
108
+ checked={isSelected}
109
+ onCheckedChange={handleCheckboxChange}
110
+ aria-label={`Select file ${file.name}`}
111
+ />
112
+ </div>
113
+
114
+ <div className="flex-1 w-full overflow-hidden rounded-t-lg cursor-pointer" onClick={(e) => { e.stopPropagation(); handlePreviewClick(e)}}>
115
+ {file.fileType === 'pdf' ? (
116
+ <PdfThumbnail fileUrl={file.path} />
117
+ ) : (
118
+ <div className="w-full aspect-[3/4] bg-muted flex items-center justify-center rounded-md">
119
+ <Icon className="h-16 w-16 text-muted-foreground" />
120
+ </div>
121
+ )}
122
+ </div>
123
+
124
+ <CardFooter className="flex-col items-start p-2 !pt-2">
125
+ <p className="w-full font-semibold text-sm truncate">{file.name}</p>
126
+ <p className="text-xs text-muted-foreground">{file.contentSnippet}</p>
127
+ <div className="w-full flex justify-end gap-1 mt-2">
128
+ <TooltipProvider>
129
+ {!isPublicShare && (
130
+ <Tooltip>
131
+ <TooltipTrigger asChild>
132
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleShareClick}>
133
+ <Share2 className="h-4 w-4" />
134
+ </Button>
135
+ </TooltipTrigger>
136
+ <TooltipContent><p>Share</p></TooltipContent>
137
+ </Tooltip>
138
+ )}
139
+ <Tooltip>
140
+ <TooltipTrigger asChild>
141
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePreviewClick} disabled={file.fileType !== 'pdf'}>
142
+ <Eye className="h-4 w-4" />
143
+ </Button>
144
+ </TooltipTrigger>
145
+ <TooltipContent><p>Preview</p></TooltipContent>
146
+ </Tooltip>
147
+ <Tooltip>
148
+ <TooltipTrigger asChild>
149
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleDownload} disabled={isDownloading}>
150
+ {isDownloading ? <Loader2 className="h-4 w-4 animate-spin"/> : <Download className="h-4 w-4" />}
151
+ </Button>
152
+ </TooltipTrigger>
153
+ <TooltipContent><p>Download</p></TooltipContent>
154
+ </Tooltip>
155
+ </TooltipProvider>
156
+ </div>
157
+ </CardFooter>
158
+ </Card>
159
+
160
+ {isPreviewOpen && file.fileType === 'pdf' && (
161
+ <PdfViewer
162
+ isOpen={isPreviewOpen}
163
+ onOpenChange={setIsPreviewOpen}
164
+ fileUrl={file.path}
165
+ fileName={file.name}
166
+ />
167
+ )}
168
+ {!isPublicShare && (
169
+ <ShareDialog
170
+ isOpen={isShareOpen}
171
+ onOpenChange={setIsShareOpen}
172
+ itemName={file.name}
173
+ itemPath={file.path}
174
+ itemType="file"
175
+ />
176
+ )}
177
+ </>
178
+ );
179
+ }
src/components/file-item.tsx ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import ReactDOM from 'react-dom';
6
+ import { FileText, Download, X, Eye, Loader2, File as FileIcon, Presentation, Share2 } from 'lucide-react';
7
+ import { TableRow, TableCell } from '@/components/ui/table';
8
+ import { Button } from '@/components/ui/button';
9
+ import {
10
+ Tooltip,
11
+ TooltipContent,
12
+ TooltipProvider,
13
+ TooltipTrigger,
14
+ } from '@/components/ui/tooltip';
15
+ import type { File as FileType } from '@/app/api/files/route';
16
+ import { useToast } from "@/hooks/use-toast"
17
+ import { Progress } from './ui/progress';
18
+ import { cn } from '@/lib/utils';
19
+ import dynamic from 'next/dynamic';
20
+ import { Badge } from './ui/badge';
21
+ import { Checkbox } from './ui/checkbox';
22
+ import { ShareDialog } from './share-dialog';
23
+
24
+ const PdfThumbnail = dynamic(() => import('./pdf-thumbnail').then(mod => mod.PdfThumbnail), {
25
+ ssr: false,
26
+ loading: () => <div className="h-[280px] w-[200px] flex items-center justify-center bg-muted"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
27
+ });
28
+
29
+
30
+ function PdfLoader() {
31
+ const [isMounted, setIsMounted] = React.useState(false);
32
+
33
+ React.useEffect(() => {
34
+ setIsMounted(true);
35
+ return () => setIsMounted(false);
36
+ }, []);
37
+
38
+ if (!isMounted) {
39
+ return null;
40
+ }
41
+
42
+ return ReactDOM.createPortal(
43
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80">
44
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
45
+ </div>,
46
+ document.body
47
+ );
48
+ }
49
+
50
+ const PdfViewer = dynamic(() => import('./pdf-viewer').then(mod => mod.PdfViewer), {
51
+ ssr: false,
52
+ loading: () => <PdfLoader />,
53
+ });
54
+
55
+ interface FileItemProps extends React.HTMLAttributes<HTMLTableRowElement> {
56
+ file: FileType;
57
+ isSelected: boolean;
58
+ onSelectItem: (id: string, isSelected: boolean) => void;
59
+ isSelectionActive: boolean;
60
+ isPublicShare?: boolean;
61
+ }
62
+
63
+ const fileTypeConfig = {
64
+ pdf: {
65
+ icon: FileText,
66
+ color: 'destructive' as const,
67
+ label: 'PDF',
68
+ },
69
+ docx: {
70
+ icon: FileText,
71
+ color: 'default' as const,
72
+ label: 'DOCX',
73
+ },
74
+ pptx: {
75
+ icon: Presentation,
76
+ color: 'secondary' as const,
77
+ label: 'PPTX',
78
+ },
79
+ other: {
80
+ icon: FileIcon,
81
+ color: 'outline' as const,
82
+ label: 'File'
83
+ }
84
+ }
85
+
86
+
87
+ export function FileItem({ file, className, isSelected, onSelectItem, isSelectionActive, isPublicShare, ...props }: FileItemProps) {
88
+ const [downloadProgress, setDownloadProgress] = React.useState<number | null>(null);
89
+ const [isDownloading, setIsDownloading] = React.useState(false);
90
+ const downloadAbortController = React.useRef<AbortController | null>(null);
91
+ const [isPreviewOpen, setIsPreviewOpen] = React.useState(false);
92
+ const [isShareOpen, setIsShareOpen] = React.useState(false);
93
+
94
+
95
+ const { toast } = useToast();
96
+
97
+ const handleRowClick = (e: React.MouseEvent<HTMLTableRowElement>) => {
98
+ if ((e.target as HTMLElement).closest('[role="checkbox"]') || (e.target as HTMLElement).closest('button')) {
99
+ return;
100
+ }
101
+
102
+ if (isSelectionActive) {
103
+ onSelectItem(file.id, !isSelected);
104
+ } else if (file.fileType === 'pdf') {
105
+ setIsPreviewOpen(true);
106
+ }
107
+ }
108
+
109
+ const handleDownload = async () => {
110
+ if (isDownloading) {
111
+ if (downloadAbortController.current) {
112
+ downloadAbortController.current.abort();
113
+ }
114
+ return;
115
+ }
116
+
117
+ setIsDownloading(true);
118
+ setDownloadProgress(0);
119
+
120
+ const controller = new AbortController();
121
+ downloadAbortController.current = controller;
122
+
123
+ try {
124
+ const response = await fetch(file.path, {
125
+ signal: controller.signal,
126
+ });
127
+
128
+ if (!response.ok) {
129
+ throw new Error('Network response was not ok');
130
+ }
131
+
132
+ if (!response.body) {
133
+ throw new Error('Response body is null');
134
+ }
135
+
136
+ const contentLength = response.headers.get('content-length');
137
+ const totalSize = contentLength ? parseInt(contentLength, 10) : 0;
138
+ let loaded = 0;
139
+
140
+ const reader = response.body.getReader();
141
+ const chunks: Uint8Array[] = [];
142
+
143
+ while (true) {
144
+ const { done, value } = await reader.read();
145
+ if (done) {
146
+ break;
147
+ }
148
+ chunks.push(value);
149
+ loaded += value.length;
150
+ if (totalSize > 0) {
151
+ const progress = Math.round((loaded / totalSize) * 100);
152
+ setDownloadProgress(progress);
153
+ }
154
+ }
155
+
156
+ const blob = new Blob(chunks);
157
+ const url = window.URL.createObjectURL(blob);
158
+ const link = document.createElement('a');
159
+ link.href = url;
160
+ link.download = file.name;
161
+ document.body.appendChild(link);
162
+ link.click();
163
+ document.body.removeChild(link);
164
+ window.URL.revokeObjectURL(url);
165
+ setDownloadProgress(100);
166
+
167
+ } catch (error: any) {
168
+ if (error.name === 'AbortError') {
169
+ console.log('Download was aborted.');
170
+ } else {
171
+ toast({
172
+ variant: "destructive",
173
+ title: "Download Error",
174
+ description: "There was a problem downloading the file.",
175
+ });
176
+ console.error('Download error:', error);
177
+ }
178
+ } finally {
179
+ setIsDownloading(false);
180
+ setDownloadProgress(null);
181
+ downloadAbortController.current = null;
182
+ }
183
+ };
184
+
185
+ const config = fileTypeConfig[file.fileType] ?? fileTypeConfig.other;
186
+ const Icon = config.icon;
187
+
188
+ const FileNameDisplay = (
189
+ <span className="cursor-pointer hover:underline" onClick={(e) => {e.stopPropagation(); if (file.fileType === 'pdf' && !isSelectionActive) setIsPreviewOpen(true);}}>{file.name}</span>
190
+ )
191
+
192
+ const handleShareClick = (e: React.MouseEvent) => {
193
+ e.stopPropagation();
194
+ setIsShareOpen(true);
195
+ }
196
+
197
+ return (
198
+ <>
199
+ <TableRow
200
+ className={cn("group cursor-pointer", className, isSelected && "bg-accent/50")}
201
+ data-selected={isSelected}
202
+ onClick={handleRowClick}
203
+ {...props}
204
+ >
205
+ <TableCell className={cn("w-[40px]", !isSelectionActive && "hidden")} onClick={(e) => e.stopPropagation()}>
206
+ <Checkbox
207
+ checked={isSelected}
208
+ onCheckedChange={(checked) => onSelectItem(file.id, Boolean(checked))}
209
+ aria-label={`Select file ${file.name}`}
210
+ />
211
+ </TableCell>
212
+ <TableCell className="font-medium">
213
+ <div className="flex items-center gap-3">
214
+ <Icon className="h-5 w-5 text-muted-foreground" />
215
+ <div className="flex-1 flex flex-col gap-1">
216
+ <div className="flex items-center gap-2">
217
+ {file.fileType === 'pdf' ? (
218
+ <TooltipProvider delayDuration={200}>
219
+ <Tooltip>
220
+ <TooltipTrigger asChild>
221
+ {FileNameDisplay}
222
+ </TooltipTrigger>
223
+ <TooltipContent className="p-0 border-2 border-primary/20 shadow-2xl bg-muted" side="bottom" align="start">
224
+ <PdfThumbnail fileUrl={file.path} />
225
+ </TooltipContent>
226
+ </Tooltip>
227
+ </TooltipProvider>
228
+ ) : FileNameDisplay }
229
+
230
+ <Badge variant={config.color}>{config.label}</Badge>
231
+ </div>
232
+ {isDownloading && downloadProgress !== null && (
233
+ <div className="flex items-center gap-2 mt-1">
234
+ <Progress value={downloadProgress} className="w-full h-1.5" />
235
+ <span className="text-xs text-muted-foreground w-10 text-right">{downloadProgress}%</span>
236
+ </div>
237
+ )}
238
+ </div>
239
+ </div>
240
+ </TableCell>
241
+ <TableCell className="text-muted-foreground max-w-sm truncate">
242
+ <p>{file.contentSnippet}</p>
243
+ </TableCell>
244
+ <TableCell className="text-right">
245
+ <div className="flex items-center justify-end gap-2">
246
+ <TooltipProvider>
247
+ {!isPublicShare && (
248
+ <Tooltip>
249
+ <TooltipTrigger asChild>
250
+ <Button variant="ghost" size="icon" onClick={handleShareClick}>
251
+ <Share2 className="h-4 w-4" />
252
+ <span className="sr-only">Share</span>
253
+ </Button>
254
+ </TooltipTrigger>
255
+ <TooltipContent><p>Share</p></TooltipContent>
256
+ </Tooltip>
257
+ )}
258
+ <Tooltip>
259
+ <TooltipTrigger asChild>
260
+ <Button variant="ghost" size="icon" onClick={(e) => {e.stopPropagation(); if (file.fileType === 'pdf') setIsPreviewOpen(true);}} disabled={file.fileType !== 'pdf'}>
261
+ <Eye className="h-4 w-4" />
262
+ <span className="sr-only">Preview</span>
263
+ </Button>
264
+ </TooltipTrigger>
265
+ <TooltipContent>
266
+ <p>Preview (PDFs only)</p>
267
+ </TooltipContent>
268
+ </Tooltip>
269
+ <Tooltip>
270
+ <TooltipTrigger asChild>
271
+ <Button variant="ghost" size="icon" onClick={(e) => {e.stopPropagation(); handleDownload();}}>
272
+ {isDownloading ? <X className="h-4 w-4" /> : <Download className="h-4 w-4" />}
273
+ <span className="sr-only">{isDownloading ? 'Cancel' : 'Download'}</span>
274
+ </Button>
275
+ </TooltipTrigger>
276
+ <TooltipContent>
277
+ <p>{isDownloading ? 'Cancel' : 'Download'}</p>
278
+ </TooltipContent>
279
+ </Tooltip>
280
+ </TooltipProvider>
281
+ </div>
282
+ </TableCell>
283
+ </TableRow>
284
+ {isPreviewOpen && file.fileType === 'pdf' && (
285
+ <PdfViewer
286
+ isOpen={isPreviewOpen}
287
+ onOpenChange={setIsPreviewOpen}
288
+ fileUrl={file.path}
289
+ fileName={file.name}
290
+ />
291
+ )}
292
+ {!isPublicShare && (
293
+ <ShareDialog
294
+ isOpen={isShareOpen}
295
+ onOpenChange={setIsShareOpen}
296
+ itemName={file.name}
297
+ itemPath={file.path}
298
+ itemType="file"
299
+ />
300
+ )}
301
+ </>
302
+ );
303
+ }
src/components/file-list.tsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { FolderSearch } from 'lucide-react';
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ } from '@/components/ui/table';
13
+ import { FileItem } from '@/components/file-item';
14
+ import { FolderItem } from '@/components/folder-item';
15
+ import type { File as FileType, Folder as FolderType } from '@/app/api/files/route';
16
+ import { Checkbox } from './ui/checkbox';
17
+ import { cn } from '@/lib/utils';
18
+
19
+ interface FileListProps {
20
+ files: FileType[];
21
+ folders: FolderType[];
22
+ searchTerm: string;
23
+ onSelectFolder: (path: string) => void;
24
+ selectedIds: string[];
25
+ onSelectAll: (isChecked: boolean) => void;
26
+ onSelectItem: (id: string, isChecked: boolean) => void;
27
+ allItemCount: number;
28
+ isSelectionActive: boolean;
29
+ isPublicShare?: boolean;
30
+ }
31
+
32
+ export function FileList({
33
+ files,
34
+ folders,
35
+ searchTerm,
36
+ onSelectFolder,
37
+ selectedIds,
38
+ onSelectAll,
39
+ onSelectItem,
40
+ allItemCount,
41
+ isSelectionActive,
42
+ isPublicShare,
43
+ }: FileListProps) {
44
+ const hasItems = files.length > 0 || folders.length > 0;
45
+ const [isAnimating, setIsAnimating] = React.useState(false);
46
+
47
+ React.useEffect(() => {
48
+ setIsAnimating(true);
49
+ const timer = setTimeout(() => setIsAnimating(false), 500);
50
+ return () => clearTimeout(timer);
51
+ }, [files, folders]);
52
+
53
+ const numSelected = selectedIds.length;
54
+ const isAllSelected = allItemCount > 0 && numSelected === allItemCount;
55
+
56
+ if (!hasItems) {
57
+ return (
58
+ <div className="flex flex-col items-center justify-center h-full text-center text-muted-foreground p-8">
59
+ <FolderSearch className="h-16 w-16 mb-4" />
60
+ <h3 className="text-xl font-semibold">
61
+ {searchTerm ? `No results for "${searchTerm}"` : 'This folder is empty'}
62
+ </h3>
63
+ <p className="mt-2">
64
+ {searchTerm ? 'Try a different search term.' : 'There are no files or folders here.'}
65
+ </p>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ return (
71
+ <Table>
72
+ <TableHeader>
73
+ <TableRow>
74
+ <TableHead className={cn("w-[40px]", !isSelectionActive && "hidden")}>
75
+ <Checkbox
76
+ checked={isAllSelected}
77
+ onCheckedChange={(checked) => onSelectAll(Boolean(checked))}
78
+ aria-label="Select all"
79
+ disabled={allItemCount === 0}
80
+ />
81
+ </TableHead>
82
+ <TableHead className="w-2/5">Name</TableHead>
83
+ <TableHead className="w-1/3">Description</TableHead>
84
+ <TableHead className="text-right">Actions</TableHead>
85
+ </TableRow>
86
+ </TableHeader>
87
+ <TableBody>
88
+ {folders.map((folder, index) => (
89
+ <FolderItem
90
+ key={folder.id}
91
+ folder={folder}
92
+ onSelectFolder={onSelectFolder}
93
+ style={{ animationDelay: `${index * 50}ms` }}
94
+ className={isAnimating ? 'animate-fade-in' : ''}
95
+ isSelected={selectedIds.includes(folder.id)}
96
+ onSelectItem={onSelectItem}
97
+ isSelectionActive={isSelectionActive}
98
+ isPublicShare={isPublicShare}
99
+ />
100
+ ))}
101
+ {files.map((file, index) => (
102
+ <FileItem
103
+ key={file.id}
104
+ file={file as FileType}
105
+ style={{ animationDelay: `${(folders.length + index) * 50}ms` }}
106
+ className={isAnimating ? 'animate-fade-in' : ''}
107
+ isSelected={selectedIds.includes(file.id)}
108
+ onSelectItem={onSelectItem}
109
+ isSelectionActive={isSelectionActive}
110
+ isPublicShare={isPublicShare}
111
+ />
112
+ ))}
113
+ </TableBody>
114
+ </Table>
115
+ );
116
+ }
src/components/folder-grid-item.tsx ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { Folder as FolderIcon, Share2 } from 'lucide-react';
6
+ import { Card, CardFooter } from '@/components/ui/card';
7
+ import type { Folder } from '@/app/api/files/route';
8
+ import { cn } from '@/lib/utils';
9
+ import { Checkbox } from './ui/checkbox';
10
+ import { Button } from './ui/button';
11
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
12
+ import { ShareDialog } from './share-dialog';
13
+
14
+ interface FolderGridItemProps extends React.HTMLAttributes<HTMLDivElement> {
15
+ folder: Folder;
16
+ onSelectFolder: (path: string) => void;
17
+ isSelected: boolean;
18
+ onSelectItem: (id: string, isSelected: boolean) => void;
19
+ isSelectionActive: boolean;
20
+ isPublicShare?: boolean;
21
+ }
22
+
23
+ export function FolderGridItem({ folder, onSelectFolder, isSelected, onSelectItem, isSelectionActive, isPublicShare, className, ...props }: FolderGridItemProps) {
24
+ const [isShareOpen, setIsShareOpen] = React.useState(false);
25
+
26
+ const handleCheckboxChange = (checked: boolean) => {
27
+ onSelectItem(folder.id, checked);
28
+ };
29
+
30
+ const handleCardClick = () => {
31
+ if (isSelectionActive) {
32
+ onSelectItem(folder.id, !isSelected);
33
+ } else {
34
+ onSelectFolder(folder.path);
35
+ }
36
+ };
37
+
38
+ const handleShareClick = (e: React.MouseEvent) => {
39
+ e.stopPropagation();
40
+ setIsShareOpen(true);
41
+ };
42
+
43
+ return (
44
+ <>
45
+ <Card
46
+ className={cn(
47
+ "group relative w-full aspect-square flex flex-col justify-center items-center cursor-pointer transition-all duration-200 hover:shadow-md",
48
+ isSelected && "border-primary shadow-lg scale-[1.02]",
49
+ className
50
+ )}
51
+ onClick={handleCardClick}
52
+ {...props}
53
+ >
54
+ <div className={cn("absolute top-2 right-2 z-10", !isSelectionActive && "hidden")} onClick={(e) => e.stopPropagation()}>
55
+ <Checkbox
56
+ checked={isSelected}
57
+ onCheckedChange={handleCheckboxChange}
58
+ aria-label={`Select folder ${folder.name}`}
59
+ />
60
+ </div>
61
+
62
+ <div className="flex flex-col items-center justify-center flex-1">
63
+ <FolderIcon className="h-24 w-24 text-primary/70 group-hover:text-primary transition-colors" />
64
+ </div>
65
+
66
+ <CardFooter className="p-2 !pt-2 w-full flex-col items-start">
67
+ <p className="font-semibold text-sm truncate text-center w-full">{folder.name}</p>
68
+ <div className="w-full flex justify-end gap-1 mt-1">
69
+ {!isPublicShare && (
70
+ <TooltipProvider>
71
+ <Tooltip>
72
+ <TooltipTrigger asChild>
73
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleShareClick}>
74
+ <Share2 className="h-4 w-4" />
75
+ </Button>
76
+ </TooltipTrigger>
77
+ <TooltipContent><p>Share</p></TooltipContent>
78
+ </Tooltip>
79
+ </TooltipProvider>
80
+ )}
81
+ </div>
82
+ </CardFooter>
83
+ </Card>
84
+ {!isPublicShare && (
85
+ <ShareDialog
86
+ isOpen={isShareOpen}
87
+ onOpenChange={setIsShareOpen}
88
+ itemName={folder.name}
89
+ itemPath={folder.path}
90
+ itemType="folder"
91
+ />
92
+ )}
93
+ </>
94
+ );
95
+ }
src/components/folder-item.tsx ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { Folder as FolderIcon, Share2 } from 'lucide-react';
6
+ import { TableRow, TableCell } from '@/components/ui/table';
7
+ import type { Folder } from '@/app/api/files/route';
8
+ import { cn } from '@/lib/utils';
9
+ import { Checkbox } from './ui/checkbox';
10
+ import { Button } from './ui/button';
11
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
12
+ import { ShareDialog } from './share-dialog';
13
+
14
+ interface FolderItemProps extends React.HTMLAttributes<HTMLTableRowElement> {
15
+ folder: Folder;
16
+ onSelectFolder: (path: string) => void;
17
+ isSelected: boolean;
18
+ onSelectItem: (id: string, isSelected: boolean) => void;
19
+ isSelectionActive: boolean;
20
+ isPublicShare?: boolean;
21
+ }
22
+
23
+ export function FolderItem({ folder, onSelectFolder, isSelected, onSelectItem, isSelectionActive, isPublicShare, className, ...props }: FolderItemProps) {
24
+ const [isShareOpen, setIsShareOpen] = React.useState(false);
25
+
26
+ const handleRowClick = (e: React.MouseEvent<HTMLTableRowElement>) => {
27
+ // Prevent row click from propagating when clicking checkbox or button
28
+ if ((e.target as HTMLElement).closest('[role="checkbox"]') || (e.target as HTMLElement).closest('button')) {
29
+ return;
30
+ }
31
+
32
+ if (isSelectionActive) {
33
+ onSelectItem(folder.id, !isSelected);
34
+ } else {
35
+ onSelectFolder(folder.path);
36
+ }
37
+ }
38
+
39
+ const handleShareClick = (e: React.MouseEvent) => {
40
+ e.stopPropagation();
41
+ setIsShareOpen(true);
42
+ }
43
+
44
+ return (
45
+ <>
46
+ <TableRow
47
+ className={cn("group cursor-pointer", className, isSelected && "bg-accent/50")}
48
+ onClick={handleRowClick}
49
+ data-selected={isSelected}
50
+ {...props}
51
+ >
52
+ <TableCell className={cn("w-[40px]", !isSelectionActive && "hidden")} onClick={(e) => e.stopPropagation()}>
53
+ <Checkbox
54
+ checked={isSelected}
55
+ onCheckedChange={(checked) => onSelectItem(folder.id, Boolean(checked))}
56
+ aria-label={`Select folder ${folder.name}`}
57
+ />
58
+ </TableCell>
59
+ <TableCell className="font-medium">
60
+ <div className="flex items-center gap-3">
61
+ <FolderIcon className="h-5 w-5 text-muted-foreground" />
62
+ <div className="flex-1 flex flex-col gap-1">
63
+ <div className="flex items-center gap-2">
64
+ <span>{folder.name}</span>
65
+ </div>
66
+ <div />
67
+ </div>
68
+ </div>
69
+ </TableCell>
70
+ <TableCell className="text-muted-foreground max-w-sm truncate">
71
+ <p>Folder</p>
72
+ </TableCell>
73
+ <TableCell className="text-right">
74
+ <div className="flex items-center justify-end gap-2 h-10">
75
+ {!isPublicShare && (
76
+ <TooltipProvider>
77
+ <Tooltip>
78
+ <TooltipTrigger asChild>
79
+ <Button variant="ghost" size="icon" onClick={handleShareClick}>
80
+ <Share2 className="h-4 w-4" />
81
+ <span className="sr-only">Share</span>
82
+ </Button>
83
+ </TooltipTrigger>
84
+ <TooltipContent><p>Share</p></TooltipContent>
85
+ </Tooltip>
86
+ </TooltipProvider>
87
+ )}
88
+ </div>
89
+ </TableCell>
90
+ </TableRow>
91
+ {!isPublicShare && (
92
+ <ShareDialog
93
+ isOpen={isShareOpen}
94
+ onOpenChange={setIsShareOpen}
95
+ itemName={folder.name}
96
+ itemPath={folder.path}
97
+ itemType="folder"
98
+ />
99
+ )}
100
+ </>
101
+ );
102
+ }
src/components/folder-tree.tsx ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { Folder, FolderOpen, ChevronRight } from 'lucide-react';
6
+ import {
7
+ Collapsible,
8
+ CollapsibleContent,
9
+ CollapsibleTrigger,
10
+ } from '@/components/ui/collapsible';
11
+ import { cn } from '@/lib/utils';
12
+ import type { Folder as FolderType } from '@/app/api/files/route';
13
+
14
+ interface FolderTreeProps {
15
+ rootFolder: FolderType;
16
+ currentPath: string;
17
+ onSelectFolder: (path: string) => void;
18
+ }
19
+
20
+ export function FolderTree({ rootFolder, currentPath, onSelectFolder }: FolderTreeProps) {
21
+ const subFolders = rootFolder.children.filter(
22
+ (child): child is FolderType => child.type === 'folder'
23
+ );
24
+ return (
25
+ <nav className="p-2">
26
+ {subFolders.map((folder) => (
27
+ <RecursiveFolder
28
+ key={folder.id}
29
+ folder={folder}
30
+ currentPath={currentPath}
31
+ onSelectFolder={onSelectFolder}
32
+ level={0}
33
+ />
34
+ ))}
35
+ </nav>
36
+ );
37
+ }
38
+
39
+ interface RecursiveFolderProps {
40
+ folder: FolderType;
41
+ currentPath: string;
42
+ onSelectFolder: (path: string) => void;
43
+ level: number;
44
+ }
45
+
46
+ function RecursiveFolder({ folder, currentPath, onSelectFolder, level }: RecursiveFolderProps) {
47
+ const [isOpen, setIsOpen] = React.useState(
48
+ currentPath.startsWith(`${folder.path}`)
49
+ );
50
+
51
+ React.useEffect(() => {
52
+ setIsOpen(currentPath.startsWith(`${folder.path}`));
53
+ }, [currentPath, folder.path]);
54
+
55
+ const subFolders = folder.children.filter(
56
+ (child): child is FolderType => child.type === 'folder'
57
+ );
58
+
59
+ const isActive = currentPath === folder.path;
60
+
61
+ const Icon = isOpen ? FolderOpen : Folder;
62
+
63
+ return (
64
+ <Collapsible open={isOpen} onOpenChange={setIsOpen} className="space-y-1">
65
+ <CollapsibleTrigger
66
+ className={cn(
67
+ 'w-full text-left flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors hover:bg-accent',
68
+ isActive && 'bg-primary/10 text-primary'
69
+ )}
70
+ onClick={() => onSelectFolder(folder.path)}
71
+ >
72
+ <ChevronRight
73
+ className={cn(
74
+ 'h-4 w-4 transform transition-transform duration-200',
75
+ isOpen && 'rotate-90',
76
+ subFolders.length === 0 && 'invisible'
77
+ )}
78
+ />
79
+ <Icon className="h-4 w-4" />
80
+ <span>{folder.name}</span>
81
+ </CollapsibleTrigger>
82
+ <CollapsibleContent>
83
+ <div className="pl-6 space-y-1 border-l border-dashed ml-3">
84
+ {subFolders.map((subFolder) => (
85
+ <RecursiveFolder
86
+ key={subFolder.id}
87
+ folder={subFolder}
88
+ currentPath={currentPath}
89
+ onSelectFolder={onSelectFolder}
90
+ level={level + 1}
91
+ />
92
+ ))}
93
+ </div>
94
+ </CollapsibleContent>
95
+ </Collapsible>
96
+ );
97
+ }
src/components/grid-view.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { FolderSearch } from 'lucide-react';
6
+ import type { File as FileType, Folder as FolderType } from '@/app/api/files/route';
7
+ import { FileGridItem } from './file-grid-item';
8
+ import { FolderGridItem } from './folder-grid-item';
9
+
10
+ interface GridViewProps {
11
+ files: FileType[];
12
+ folders: FolderType[];
13
+ searchTerm: string;
14
+ onSelectFolder: (path: string) => void;
15
+ selectedIds: string[];
16
+ onSelectItem: (id: string, isChecked: boolean) => void;
17
+ isSelectionActive: boolean;
18
+ isPublicShare?: boolean;
19
+ }
20
+
21
+ export function GridView({
22
+ files,
23
+ folders,
24
+ searchTerm,
25
+ onSelectFolder,
26
+ selectedIds,
27
+ onSelectItem,
28
+ isSelectionActive,
29
+ isPublicShare,
30
+ }: GridViewProps) {
31
+ const hasItems = files.length > 0 || folders.length > 0;
32
+ const [isAnimating, setIsAnimating] = React.useState(false);
33
+
34
+ React.useEffect(() => {
35
+ setIsAnimating(true);
36
+ const timer = setTimeout(() => setIsAnimating(false), 500);
37
+ return () => clearTimeout(timer);
38
+ }, [files, folders]);
39
+
40
+ if (!hasItems) {
41
+ return (
42
+ <div className="flex flex-col items-center justify-center text-center text-muted-foreground p-8 min-h-[400px]">
43
+ <FolderSearch className="h-16 w-16 mb-4" />
44
+ <h3 className="text-xl font-semibold">
45
+ {searchTerm ? `No results for "${searchTerm}"` : 'This folder is empty'}
46
+ </h3>
47
+ <p className="mt-2">
48
+ {searchTerm ? 'Try a different search term.' : 'There are no files or folders here.'}
49
+ </p>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-4 p-4">
56
+ {folders.map((folder, index) => (
57
+ <FolderGridItem
58
+ key={folder.id}
59
+ folder={folder}
60
+ onSelectFolder={onSelectFolder}
61
+ style={{ animationDelay: `${index * 30}ms` }}
62
+ className={isAnimating ? 'animate-fade-in' : ''}
63
+ isSelected={selectedIds.includes(folder.id)}
64
+ onSelectItem={onSelectItem}
65
+ isSelectionActive={isSelectionActive}
66
+ isPublicShare={isPublicShare}
67
+ />
68
+ ))}
69
+ {files.map((file, index) => (
70
+ <FileGridItem
71
+ key={file.id}
72
+ file={file as FileType}
73
+ style={{ animationDelay: `${(folders.length + index) * 30}ms` }}
74
+ className={isAnimating ? 'animate-fade-in' : ''}
75
+ isSelected={selectedIds.includes(file.id)}
76
+ onSelectItem={onSelectItem}
77
+ isSelectionActive={isSelectionActive}
78
+ isPublicShare={isPublicShare}
79
+ />
80
+ ))}
81
+ </div>
82
+ );
83
+ }
src/components/logo.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FileArchive } from 'lucide-react';
2
+ import Link from 'next/link';
3
+
4
+ export function Logo() {
5
+ return (
6
+ <Link href="/" className="cursor-pointer">
7
+ <div className="flex items-center gap-2 p-2 font-semibold text-primary">
8
+ <FileArchive className="h-6 w-6" />
9
+ <div className="flex flex-col">
10
+ <span className="text-xl leading-none">Medico Docs</span>
11
+ <span className="text-xs font-normal text-muted-foreground">by ztx</span>
12
+ </div>
13
+ </div>
14
+ </Link>
15
+ );
16
+ }
src/components/mobile-sheet.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { Menu } from 'lucide-react';
6
+ import { Button } from '@/components/ui/button';
7
+ import {
8
+ Sheet,
9
+ SheetContent,
10
+ SheetHeader,
11
+ SheetTitle,
12
+ SheetTrigger,
13
+ } from '@/components/ui/sheet';
14
+ import { FolderTree } from './folder-tree';
15
+ import { Logo } from './logo';
16
+ import type { Folder } from '@/app/api/files/route';
17
+
18
+
19
+ interface MobileSheetProps {
20
+ rootFolder: Folder;
21
+ currentPath: string;
22
+ onSelectFolder: (path: string) => void;
23
+ }
24
+
25
+ export function MobileSheet({ rootFolder, currentPath, onSelectFolder }: MobileSheetProps) {
26
+ const [isOpen, setIsOpen] = React.useState(false);
27
+
28
+ const handleSelectFolder = (path: string) => {
29
+ onSelectFolder(path);
30
+ setIsOpen(false);
31
+ };
32
+
33
+ return (
34
+ <Sheet open={isOpen} onOpenChange={setIsOpen}>
35
+ <SheetTrigger asChild>
36
+ <Button variant="outline" size="icon">
37
+ <Menu className="h-5 w-5" />
38
+ <span className="sr-only">Toggle navigation menu</span>
39
+ </Button>
40
+ </SheetTrigger>
41
+ <SheetContent side="left" className="p-0 flex flex-col">
42
+ <SheetHeader className="p-4 border-b">
43
+ <SheetTitle className="sr-only">Navigation Menu</SheetTitle>
44
+ <Logo />
45
+ </SheetHeader>
46
+ <div className="flex-1 overflow-auto py-2">
47
+ <FolderTree
48
+ rootFolder={rootFolder}
49
+ currentPath={currentPath}
50
+ onSelectFolder={handleSelectFolder}
51
+ />
52
+ </div>
53
+ </SheetContent>
54
+ </Sheet>
55
+ );
56
+ }
src/components/pdf-thumbnail.tsx ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import { Document, Page, pdfjs } from 'react-pdf';
6
+ import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
7
+ import 'react-pdf/dist/esm/Page/TextLayer.css';
8
+ import { Loader2, AlertTriangle } from 'lucide-react';
9
+ import { Skeleton } from './ui/skeleton';
10
+ import { cn } from '@/lib/utils';
11
+
12
+ pdfjs.GlobalWorkerOptions.workerSrc = `/pdf.worker.min.js`;
13
+
14
+
15
+ interface PdfThumbnailProps {
16
+ fileUrl: string;
17
+ className?: string;
18
+ }
19
+
20
+ export function PdfThumbnail({ fileUrl, className }: PdfThumbnailProps) {
21
+ const [numPages, setNumPages] = React.useState<number | null>(null);
22
+ const [error, setError] = React.useState<string | null>(null);
23
+ const containerRef = React.useRef<HTMLDivElement>(null);
24
+ const [width, setWidth] = React.useState(200);
25
+
26
+ React.useEffect(() => {
27
+ if (containerRef.current) {
28
+ setWidth(containerRef.current.getBoundingClientRect().width);
29
+ }
30
+ }, []);
31
+
32
+ function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
33
+ setNumPages(numPages);
34
+ setError(null);
35
+ }
36
+
37
+ function onDocumentLoadError(error: Error) {
38
+ console.error('Failed to load PDF for thumbnail:', error);
39
+ setError('Failed to load preview.');
40
+ }
41
+
42
+ const loadingSkeleton = <Skeleton className="w-full aspect-[3/4]" />;
43
+
44
+ if (error) {
45
+ return (
46
+ <div className={cn("w-full aspect-[3/4] flex flex-col items-center justify-center bg-muted text-destructive text-sm p-4", className)}>
47
+ <AlertTriangle className="h-8 w-8 mb-2" />
48
+ <p className="text-center">{error}</p>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <div ref={containerRef} className={cn("w-full aspect-[3/4] overflow-hidden flex items-center justify-center bg-muted rounded-t-md", className)}>
55
+ <Document
56
+ file={fileUrl}
57
+ onLoadSuccess={onDocumentLoadSuccess}
58
+ onLoadError={onDocumentLoadError}
59
+ loading={loadingSkeleton}
60
+ className="flex items-center justify-center"
61
+ >
62
+ <Page
63
+ pageNumber={1}
64
+ width={width}
65
+ renderTextLayer={false}
66
+ renderAnnotationLayer={false}
67
+ loading={loadingSkeleton}
68
+ />
69
+ </Document>
70
+ </div>
71
+ );
72
+ }
src/components/pdf-viewer.tsx ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import * as React from 'react';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogFooter,
11
+ DialogClose,
12
+ } from '@/components/ui/dialog';
13
+ import { Button } from '@/components/ui/button';
14
+ import {
15
+ ChevronLeft,
16
+ ChevronRight,
17
+ ZoomIn,
18
+ ZoomOut,
19
+ Download,
20
+ Loader2,
21
+ Maximize,
22
+ Minimize,
23
+ } from 'lucide-react';
24
+ import { Document, Page, pdfjs } from 'react-pdf';
25
+ import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
26
+ import 'react-pdf/dist/esm/Page/TextLayer.css';
27
+ import { cn } from '@/lib/utils';
28
+
29
+ // Configure the worker to load pdfs.
30
+ pdfjs.GlobalWorkerOptions.workerSrc = `/pdf.worker.min.js`;
31
+
32
+ interface PdfViewerProps {
33
+ isOpen: boolean;
34
+ onOpenChange: (isOpen: boolean) => void;
35
+ fileUrl: string;
36
+ fileName: string;
37
+ }
38
+
39
+ export function PdfViewer({
40
+ isOpen,
41
+ onOpenChange,
42
+ fileUrl,
43
+ fileName,
44
+ }: PdfViewerProps) {
45
+ const [numPages, setNumPages] = React.useState<number | null>(null);
46
+ const [pageNumber, setPageNumber] = React.useState(1);
47
+ const [scale, setScale] = React.useState(1.0);
48
+ const [isLoading, setIsLoading] = React.useState(true);
49
+ const [isFullscreen, setIsFullscreen] = React.useState(false);
50
+ const viewerRef = React.useRef<HTMLDivElement>(null);
51
+
52
+ function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
53
+ setNumPages(numPages);
54
+ setPageNumber(1);
55
+ setIsLoading(false);
56
+ }
57
+
58
+ function onDocumentLoadError(error: Error) {
59
+ console.error('Failed to load PDF:', error);
60
+ setIsLoading(false);
61
+ }
62
+
63
+ const goToPrevPage = () => {
64
+ setPageNumber((prevPageNumber) => Math.max(prevPageNumber - 1, 1));
65
+ };
66
+
67
+ const goToNextPage = () => {
68
+ setPageNumber((prevPageNumber) =>
69
+ Math.min(prevPageNumber + 1, numPages || 1)
70
+ );
71
+ };
72
+
73
+ const zoomIn = () => {
74
+ setScale((prevScale) => Math.min(prevScale + 0.2, 3));
75
+ };
76
+
77
+ const zoomOut = () => {
78
+ setScale((prevScale) => Math.max(prevScale - 0.2, 0.5));
79
+ };
80
+
81
+ const toggleFullscreen = () => {
82
+ if (!viewerRef.current) return;
83
+
84
+ if (!document.fullscreenElement) {
85
+ viewerRef.current.requestFullscreen().catch(err => {
86
+ console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
87
+ });
88
+ } else {
89
+ document.exitFullscreen();
90
+ }
91
+ };
92
+
93
+ React.useEffect(() => {
94
+ const handleFullscreenChange = () => {
95
+ setIsFullscreen(!!document.fullscreenElement);
96
+ };
97
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
98
+ return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
99
+ }, []);
100
+
101
+ React.useEffect(() => {
102
+ if (isOpen) {
103
+ setIsLoading(true);
104
+ setNumPages(null);
105
+ setPageNumber(1);
106
+ setScale(1.0);
107
+ }
108
+ }, [isOpen, fileUrl]);
109
+
110
+ return (
111
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
112
+ <DialogContent className="max-w-4xl w-full h-[90vh] flex flex-col p-0 gap-0">
113
+ <div ref={viewerRef} className="flex flex-col w-full h-full bg-background">
114
+ <DialogHeader className="p-4 border-b shrink-0">
115
+ <DialogTitle className="truncate">{fileName}</DialogTitle>
116
+ </DialogHeader>
117
+ <div className="flex-1 overflow-auto flex items-center justify-center bg-muted/20 relative">
118
+ {isLoading && (
119
+ <div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
120
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
121
+ </div>
122
+ )}
123
+ <Document
124
+ file={fileUrl}
125
+ onLoadSuccess={onDocumentLoadSuccess}
126
+ onLoadError={onDocumentLoadError}
127
+ loading=""
128
+ >
129
+ <Page
130
+ pageNumber={pageNumber}
131
+ scale={scale}
132
+ renderTextLayer={false}
133
+ renderAnnotationLayer={false}
134
+ loading=""
135
+ className="flex justify-center"
136
+ />
137
+ </Document>
138
+ </div>
139
+ <DialogFooter className="p-2 border-t bg-background flex-wrap justify-between shrink-0">
140
+ <div className="flex items-center gap-2">
141
+ <Button
142
+ variant="outline"
143
+ size="icon"
144
+ onClick={zoomOut}
145
+ disabled={!numPages}
146
+ >
147
+ <ZoomOut className="h-4 w-4" />
148
+ </Button>
149
+ <span className="text-sm text-muted-foreground">{Math.round(scale * 100)}%</span>
150
+ <Button
151
+ variant="outline"
152
+ size="icon"
153
+ onClick={zoomIn}
154
+ disabled={!numPages}
155
+ >
156
+ <ZoomIn className="h-4 w-4" />
157
+ </Button>
158
+ <Button variant="outline" size="icon" onClick={toggleFullscreen}>
159
+ {isFullscreen ? <Minimize className="h-4 w-4" /> : <Maximize className="h-4 w-4" />}
160
+ <span className="sr-only">{isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}</span>
161
+ </Button>
162
+ </div>
163
+ <div className="flex items-center gap-2">
164
+ <Button
165
+ variant="outline"
166
+ size="icon"
167
+ onClick={goToPrevPage}
168
+ disabled={pageNumber <= 1}
169
+ >
170
+ <ChevronLeft className="h-4 w-4" />
171
+ </Button>
172
+ <span className="text-sm text-muted-foreground">
173
+ Page {pageNumber} of {numPages || '...'}
174
+ </span>
175
+ <Button
176
+ variant="outline"
177
+ size="icon"
178
+ onClick={goToNextPage}
179
+ disabled={!numPages || pageNumber >= numPages}
180
+ >
181
+ <ChevronRight className="h-4 w-4" />
182
+ </Button>
183
+ </div>
184
+ <div className="flex items-center gap-2">
185
+ <a href={fileUrl} download={fileName}>
186
+ <Button variant="default">
187
+ <Download className="mr-2 h-4 w-4" />
188
+ Download
189
+ </Button>
190
+ </a>
191
+ <DialogClose asChild>
192
+ <Button variant="outline">Close</Button>
193
+ </DialogClose>
194
+ </div>
195
+ </DialogFooter>
196
+ </div>
197
+ </DialogContent>
198
+ </Dialog>
199
+ );
200
+ }
src/components/share-dialog.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+ import * as React from 'react';
4
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Label } from '@/components/ui/label';
8
+ import { Copy } from 'lucide-react';
9
+ import { useToast } from '@/hooks/use-toast';
10
+
11
+ interface ShareDialogProps {
12
+ isOpen: boolean;
13
+ onOpenChange: (isOpen: boolean) => void;
14
+ itemName: string;
15
+ itemPath: string;
16
+ itemType: 'file' | 'folder';
17
+ }
18
+
19
+ function urlSafeBtoa(str: string): string {
20
+ return btoa(str)
21
+ .replace(/\+/g, '-') // Convert '+' to '-'
22
+ .replace(/\//g, '_') // Convert '/' to '_'
23
+ .replace(/=+$/, ''); // Remove ending '='
24
+ }
25
+
26
+ export function ShareDialog({ isOpen, onOpenChange, itemName, itemPath, itemType }: ShareDialogProps) {
27
+ const [generatedLink, setGeneratedLink] = React.useState<string | null>(null);
28
+ const { toast } = useToast();
29
+
30
+ const handleCreateLink = () => {
31
+ try {
32
+ const encodedPath = urlSafeBtoa(itemPath);
33
+ const fullLink = `${window.location.origin}/share/${encodedPath}`;
34
+ setGeneratedLink(fullLink);
35
+ } catch (error) {
36
+ console.error('Error encoding path:', error);
37
+ toast({
38
+ variant: 'destructive',
39
+ title: 'Error Creating Link',
40
+ description: 'Could not create the share link.',
41
+ });
42
+ }
43
+ };
44
+
45
+ const handleCopyToClipboard = () => {
46
+ if (!generatedLink) return;
47
+ navigator.clipboard.writeText(generatedLink);
48
+ toast({ title: 'Link Copied!', description: 'The share link has been copied to your clipboard.' });
49
+ };
50
+
51
+ React.useEffect(() => {
52
+ // Reset state when dialog opens
53
+ if (isOpen) {
54
+ setGeneratedLink(null);
55
+ }
56
+ }, [isOpen]);
57
+
58
+ return (
59
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
60
+ <DialogContent>
61
+ <DialogHeader>
62
+ <DialogTitle>Share "{itemName}"</DialogTitle>
63
+ <DialogDescription>
64
+ Generate a public link to share this {itemType}.
65
+ </DialogDescription>
66
+ </DialogHeader>
67
+
68
+ <div className="space-y-4 py-4">
69
+ {generatedLink ? (
70
+ <div className="space-y-2">
71
+ <Label htmlFor="share-link">Generated Link</Label>
72
+ <div className="flex items-center gap-2">
73
+ <Input id="share-link" readOnly value={generatedLink} className="bg-muted" />
74
+ <Button size="icon" onClick={handleCopyToClipboard}><Copy className="h-4 w-4" /></Button>
75
+ </div>
76
+ </div>
77
+ ) : (
78
+ <div className="flex justify-center">
79
+ <Button onClick={handleCreateLink}>
80
+ Generate Link
81
+ </Button>
82
+ </div>
83
+ )}
84
+ </div>
85
+
86
+ <DialogFooter>
87
+ {generatedLink && (
88
+ <Button variant="outline" onClick={handleCreateLink}>
89
+ Regenerate
90
+ </Button>
91
+ )}
92
+ </DialogFooter>
93
+ </DialogContent>
94
+ </Dialog>
95
+ );
96
+ }
src/components/share-page-client.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import * as React from 'react';
3
+ import { FileBrowser } from '@/components/file-browser';
4
+ import { FileBrowserSkeleton } from '@/components/file-browser-skeleton';
5
+ import type { Folder } from '@/app/api/files/route';
6
+
7
+
8
+ interface SharePageClientProps {
9
+ initialData: Folder | null;
10
+ }
11
+
12
+ export function SharePageClient({ initialData }: SharePageClientProps) {
13
+ const [data, setData] = React.useState<Folder | null>(initialData);
14
+
15
+ // This effect handles cases where the initial data might change or be re-fetched on client,
16
+ // though in a server-first pattern it primarily hydrates the initial state.
17
+ React.useEffect(() => {
18
+ setData(initialData);
19
+ }, [initialData]);
20
+
21
+ if (!data) {
22
+ return <FileBrowserSkeleton />;
23
+ }
24
+
25
+ return <FileBrowser initialData={data} isPublicShare />;
26
+ }
src/components/splash-screen.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 'use client';
3
+
4
+ import { Logo } from "./logo";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface SplashScreenProps {
8
+ isVisible: boolean;
9
+ }
10
+
11
+ export function SplashScreen({ isVisible }: SplashScreenProps) {
12
+ return (
13
+ <div
14
+ className={cn(
15
+ "fixed inset-0 z-50 flex items-center justify-center bg-background transition-opacity duration-500",
16
+ isVisible ? "opacity-100" : "opacity-0 pointer-events-none"
17
+ )}
18
+ >
19
+ <div className="animate-fade-in">
20
+ <Logo />
21
+ </div>
22
+ </div>
23
+ );
24
+ }
src/components/theme-provider.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ThemeProvider as NextThemesProvider } from "next-themes"
5
+ import { type ThemeProviderProps } from "next-themes/dist/types"
6
+
7
+ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8
+ return <NextThemesProvider {...props}>{children}</NextThemesProvider>
9
+ }
src/components/theme-toggle.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Moon, Sun } from "lucide-react"
5
+ import { useTheme } from "next-themes"
6
+
7
+ import { Button } from "@/components/ui/button"
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuTrigger,
13
+ } from "@/components/ui/dropdown-menu"
14
+
15
+ export function ThemeToggle() {
16
+ const { setTheme } = useTheme()
17
+
18
+ return (
19
+ <DropdownMenu>
20
+ <DropdownMenuTrigger asChild>
21
+ <Button variant="outline" size="icon">
22
+ <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
23
+ <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
24
+ <span className="sr-only">Toggle theme</span>
25
+ </Button>
26
+ </DropdownMenuTrigger>
27
+ <DropdownMenuContent align="end">
28
+ <DropdownMenuItem onClick={() => setTheme("light")}>
29
+ Light
30
+ </DropdownMenuItem>
31
+ <DropdownMenuItem onClick={() => setTheme("dark")}>
32
+ Dark
33
+ </DropdownMenuItem>
34
+ <DropdownMenuItem onClick={() => setTheme("system")}>
35
+ System
36
+ </DropdownMenuItem>
37
+ </DropdownMenuContent>
38
+ </DropdownMenu>
39
+ )
40
+ }
src/components/ui/accordion.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AccordionPrimitive from "@radix-ui/react-accordion"
5
+ import { ChevronDown } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const Accordion = AccordionPrimitive.Root
10
+
11
+ const AccordionItem = React.forwardRef<
12
+ React.ElementRef<typeof AccordionPrimitive.Item>,
13
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
14
+ >(({ className, ...props }, ref) => (
15
+ <AccordionPrimitive.Item
16
+ ref={ref}
17
+ className={cn("border-b", className)}
18
+ {...props}
19
+ />
20
+ ))
21
+ AccordionItem.displayName = "AccordionItem"
22
+
23
+ const AccordionTrigger = React.forwardRef<
24
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
25
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
26
+ >(({ className, children, ...props }, ref) => (
27
+ <AccordionPrimitive.Header className="flex">
28
+ <AccordionPrimitive.Trigger
29
+ ref={ref}
30
+ className={cn(
31
+ "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
32
+ className
33
+ )}
34
+ {...props}
35
+ >
36
+ {children}
37
+ <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
38
+ </AccordionPrimitive.Trigger>
39
+ </AccordionPrimitive.Header>
40
+ ))
41
+ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42
+
43
+ const AccordionContent = React.forwardRef<
44
+ React.ElementRef<typeof AccordionPrimitive.Content>,
45
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
46
+ >(({ className, children, ...props }, ref) => (
47
+ <AccordionPrimitive.Content
48
+ ref={ref}
49
+ className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
50
+ {...props}
51
+ >
52
+ <div className={cn("pb-4 pt-0", className)}>{children}</div>
53
+ </AccordionPrimitive.Content>
54
+ ))
55
+
56
+ AccordionContent.displayName = AccordionPrimitive.Content.displayName
57
+
58
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
src/components/ui/alert-dialog.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5
+
6
+ import { cn } from "@/lib/utils"
7
+ import { buttonVariants } from "@/components/ui/button"
8
+
9
+ const AlertDialog = AlertDialogPrimitive.Root
10
+
11
+ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12
+
13
+ const AlertDialogPortal = AlertDialogPrimitive.Portal
14
+
15
+ const AlertDialogOverlay = React.forwardRef<
16
+ React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
17
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
18
+ >(({ className, ...props }, ref) => (
19
+ <AlertDialogPrimitive.Overlay
20
+ className={cn(
21
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
+ className
23
+ )}
24
+ {...props}
25
+ ref={ref}
26
+ />
27
+ ))
28
+ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29
+
30
+ const AlertDialogContent = React.forwardRef<
31
+ React.ElementRef<typeof AlertDialogPrimitive.Content>,
32
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
33
+ >(({ className, ...props }, ref) => (
34
+ <AlertDialogPortal>
35
+ <AlertDialogOverlay />
36
+ <AlertDialogPrimitive.Content
37
+ ref={ref}
38
+ className={cn(
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ </AlertDialogPortal>
45
+ ))
46
+ AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47
+
48
+ const AlertDialogHeader = ({
49
+ className,
50
+ ...props
51
+ }: React.HTMLAttributes<HTMLDivElement>) => (
52
+ <div
53
+ className={cn(
54
+ "flex flex-col space-y-2 text-center sm:text-left",
55
+ className
56
+ )}
57
+ {...props}
58
+ />
59
+ )
60
+ AlertDialogHeader.displayName = "AlertDialogHeader"
61
+
62
+ const AlertDialogFooter = ({
63
+ className,
64
+ ...props
65
+ }: React.HTMLAttributes<HTMLDivElement>) => (
66
+ <div
67
+ className={cn(
68
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
69
+ className
70
+ )}
71
+ {...props}
72
+ />
73
+ )
74
+ AlertDialogFooter.displayName = "AlertDialogFooter"
75
+
76
+ const AlertDialogTitle = React.forwardRef<
77
+ React.ElementRef<typeof AlertDialogPrimitive.Title>,
78
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
79
+ >(({ className, ...props }, ref) => (
80
+ <AlertDialogPrimitive.Title
81
+ ref={ref}
82
+ className={cn("text-lg font-semibold", className)}
83
+ {...props}
84
+ />
85
+ ))
86
+ AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87
+
88
+ const AlertDialogDescription = React.forwardRef<
89
+ React.ElementRef<typeof AlertDialogPrimitive.Description>,
90
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
91
+ >(({ className, ...props }, ref) => (
92
+ <AlertDialogPrimitive.Description
93
+ ref={ref}
94
+ className={cn("text-sm text-muted-foreground", className)}
95
+ {...props}
96
+ />
97
+ ))
98
+ AlertDialogDescription.displayName =
99
+ AlertDialogPrimitive.Description.displayName
100
+
101
+ const AlertDialogAction = React.forwardRef<
102
+ React.ElementRef<typeof AlertDialogPrimitive.Action>,
103
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
104
+ >(({ className, ...props }, ref) => (
105
+ <AlertDialogPrimitive.Action
106
+ ref={ref}
107
+ className={cn(buttonVariants(), className)}
108
+ {...props}
109
+ />
110
+ ))
111
+ AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112
+
113
+ const AlertDialogCancel = React.forwardRef<
114
+ React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
115
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
116
+ >(({ className, ...props }, ref) => (
117
+ <AlertDialogPrimitive.Cancel
118
+ ref={ref}
119
+ className={cn(
120
+ buttonVariants({ variant: "outline" }),
121
+ "mt-2 sm:mt-0",
122
+ className
123
+ )}
124
+ {...props}
125
+ />
126
+ ))
127
+ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128
+
129
+ export {
130
+ AlertDialog,
131
+ AlertDialogPortal,
132
+ AlertDialogOverlay,
133
+ AlertDialogTrigger,
134
+ AlertDialogContent,
135
+ AlertDialogHeader,
136
+ AlertDialogFooter,
137
+ AlertDialogTitle,
138
+ AlertDialogDescription,
139
+ AlertDialogAction,
140
+ AlertDialogCancel,
141
+ }
src/components/ui/alert.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const alertVariants = cva(
7
+ "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",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-background text-foreground",
12
+ destructive:
13
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ }
20
+ )
21
+
22
+ const Alert = React.forwardRef<
23
+ HTMLDivElement,
24
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
25
+ >(({ className, variant, ...props }, ref) => (
26
+ <div
27
+ ref={ref}
28
+ role="alert"
29
+ className={cn(alertVariants({ variant }), className)}
30
+ {...props}
31
+ />
32
+ ))
33
+ Alert.displayName = "Alert"
34
+
35
+ const AlertTitle = React.forwardRef<
36
+ HTMLParagraphElement,
37
+ React.HTMLAttributes<HTMLHeadingElement>
38
+ >(({ className, ...props }, ref) => (
39
+ <h5
40
+ ref={ref}
41
+ className={cn("mb-1 font-medium leading-none tracking-tight", className)}
42
+ {...props}
43
+ />
44
+ ))
45
+ AlertTitle.displayName = "AlertTitle"
46
+
47
+ const AlertDescription = React.forwardRef<
48
+ HTMLParagraphElement,
49
+ React.HTMLAttributes<HTMLParagraphElement>
50
+ >(({ className, ...props }, ref) => (
51
+ <div
52
+ ref={ref}
53
+ className={cn("text-sm [&_p]:leading-relaxed", className)}
54
+ {...props}
55
+ />
56
+ ))
57
+ AlertDescription.displayName = "AlertDescription"
58
+
59
+ export { Alert, AlertTitle, AlertDescription }
src/components/ui/avatar.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AvatarPrimitive from "@radix-ui/react-avatar"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const Avatar = React.forwardRef<
9
+ React.ElementRef<typeof AvatarPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
11
+ >(({ className, ...props }, ref) => (
12
+ <AvatarPrimitive.Root
13
+ ref={ref}
14
+ className={cn(
15
+ "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
16
+ className
17
+ )}
18
+ {...props}
19
+ />
20
+ ))
21
+ Avatar.displayName = AvatarPrimitive.Root.displayName
22
+
23
+ const AvatarImage = React.forwardRef<
24
+ React.ElementRef<typeof AvatarPrimitive.Image>,
25
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
26
+ >(({ className, ...props }, ref) => (
27
+ <AvatarPrimitive.Image
28
+ ref={ref}
29
+ className={cn("aspect-square h-full w-full", className)}
30
+ {...props}
31
+ />
32
+ ))
33
+ AvatarImage.displayName = AvatarPrimitive.Image.displayName
34
+
35
+ const AvatarFallback = React.forwardRef<
36
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
37
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
38
+ >(({ className, ...props }, ref) => (
39
+ <AvatarPrimitive.Fallback
40
+ ref={ref}
41
+ className={cn(
42
+ "flex h-full w-full items-center justify-center rounded-full bg-muted",
43
+ className
44
+ )}
45
+ {...props}
46
+ />
47
+ ))
48
+ AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49
+
50
+ export { Avatar, AvatarImage, AvatarFallback }