Spaces:
Build error
Build error
Zerotracex-Stuff commited on
Commit ·
a5871f0
1
Parent(s): 0f090fe
First model version
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +45 -0
- .modified +0 -0
- Dockerfile +24 -0
- apphosting.yaml +7 -0
- components.json +21 -0
- ncp +0 -0
- next.config.ts +36 -0
- nextn@0.1.0 +0 -0
- npm +0 -0
- npx +0 -0
- package-lock.json +0 -0
- package.json +76 -0
- postcss.config.mjs +8 -0
- public/1.pdf +3 -0
- public/pdf.worker.min.js +0 -0
- public/pdf.worker.min.mjs +0 -0
- public/testing/emerging diseases hesh.pdf +3 -0
- scripts/postinstall.js +14 -0
- src/ai/dev.ts +1 -0
- src/ai/flows/generate-pdf-description.ts +1 -0
- src/ai/genkit.ts +1 -0
- src/app/admin/dashboard/page.tsx +41 -0
- src/app/api/files/route.ts +100 -0
- src/app/favicon.ico +0 -0
- src/app/globals.css +122 -0
- src/app/layout.tsx +36 -0
- src/app/page.tsx +72 -0
- src/app/share/[id]/page.tsx +84 -0
- src/components/file-browser-skeleton.tsx +66 -0
- src/components/file-browser.tsx +314 -0
- src/components/file-grid-item.tsx +179 -0
- src/components/file-item.tsx +303 -0
- src/components/file-list.tsx +116 -0
- src/components/folder-grid-item.tsx +95 -0
- src/components/folder-item.tsx +102 -0
- src/components/folder-tree.tsx +97 -0
- src/components/grid-view.tsx +83 -0
- src/components/logo.tsx +16 -0
- src/components/mobile-sheet.tsx +56 -0
- src/components/pdf-thumbnail.tsx +72 -0
- src/components/pdf-viewer.tsx +200 -0
- src/components/share-dialog.tsx +96 -0
- src/components/share-page-client.tsx +26 -0
- src/components/splash-screen.tsx +24 -0
- src/components/theme-provider.tsx +9 -0
- src/components/theme-toggle.tsx +40 -0
- src/components/ui/accordion.tsx +58 -0
- src/components/ui/alert-dialog.tsx +141 -0
- src/components/ui/alert.tsx +59 -0
- 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 }
|