mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
+7
-3
@@ -1,5 +1,9 @@
|
||||
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
|
||||
|
||||
NEXT_PUBLIC_ENCRYPTION_KEY="Rj3wRGB9nUElQ7OA"
|
||||
NEXT_PUBLIC_SITE_PASSWORD="mbaharip"
|
||||
NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_ENCRYPTION_KEY=
|
||||
NEXT_PUBLIC_SITE_PASSWORD=
|
||||
|
||||
# Only fill with the domain name, without the protocol and trailing slash
|
||||
# This will be used as a fallback if VERCEL_URL is not available
|
||||
# Example: https://drive-demo.mbaharip.com -> drive.mbaharip.com
|
||||
NEXT_PUBLIC_DOMAIN=
|
||||
|
||||
+2
-1
@@ -2,5 +2,6 @@
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@next/next/no-img-element": "off"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_page/**/*"]
|
||||
}
|
||||
|
||||
+6
-3
@@ -34,8 +34,11 @@ next-env.d.ts
|
||||
# personal docs
|
||||
/docs
|
||||
/src/pages/api/legacy/
|
||||
|
||||
/**/*/_legacy/
|
||||
|
||||
/data
|
||||
/.*/
|
||||
/src/_app
|
||||
/src/_app
|
||||
/src/_page
|
||||
/src/utils/_legacy
|
||||
/src/components/_legacy
|
||||
.env.production
|
||||
|
||||
+12
-7
@@ -1,10 +1,10 @@
|
||||
"use strict";
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require("prettier-plugin-tailwindcss"),
|
||||
require("@trivago/prettier-plugin-sort-imports"),
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss",
|
||||
],
|
||||
printWidth: 120,
|
||||
printWidth: 80,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
@@ -20,12 +20,17 @@ module.exports = {
|
||||
endOfLine: "lf",
|
||||
embeddedLanguageFormatting: "auto",
|
||||
singleAttributePerLine: true,
|
||||
|
||||
tailwindAttributes: ["classNames", "wrapperClassName", "rootClassName"],
|
||||
tailwindFunctions: ["twsx", "cn"],
|
||||
tailwindConfig: "./tailwind.config.ts",
|
||||
|
||||
importOrder: [
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^components/(.*)$",
|
||||
"^(utils|hooks|context)/(.*)$",
|
||||
"^types/(.*)$",
|
||||
"^(config|styles)/(.*)$",
|
||||
"^~/(app|components)/(.*)$",
|
||||
"^~/(utils|hooks|context)/(.*)$",
|
||||
"^~/types/(.*)$",
|
||||
"^~/(config|styles)/(.*)$",
|
||||
"^[./]",
|
||||
],
|
||||
importOrderSeparation: true,
|
||||
|
||||
@@ -8,6 +8,65 @@
|
||||
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ'>FAQ</a>
|
||||
</p>
|
||||
|
||||
## Version 2 is here!
|
||||
|
||||
It seems there are some bugs on mobile devices that prevent the files list from rendering properly.
|
||||
So I will use this chance to upgrade the project to `Next.js 14`, use `shadcn/ui`, and also add some new features.
|
||||
|
||||
There also some changes on the configuration file, so make sure to read the [Migration Guide](#migration-guide) if you're upgrading from version 1.x.
|
||||
|
||||
Here's what changed in version 2:
|
||||
|
||||
### New Features
|
||||
|
||||
- **Theme Select**, light mode? dark mode? you choose.
|
||||
- **[shadcn/ui](https://ui.shadcn.com/)**, change the UI library to `shadcn/ui`.
|
||||
- **Faster Response Time**, added cache to improve the response time.
|
||||
- **Revalidate Data**, data fetching now utilizing revalidate. (can be changed from config file)
|
||||
> You can manually revalidate the data by sending POST request to `/api/revalidate` with `Authorization` header set to `{{ process.env.SITE_PASSWORD }}`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **General Layout**, New fresh look with `shadcn/ui`.
|
||||
- **Download Link**, Added token that will be expired after x hour(s). (can be changed from config file)
|
||||
- **Raw Link**, Raw link only available for image, video, and audio files. Raw link are recommended for hosting web project assets or comments on forum.
|
||||
- **Footer**, now you can customize the footer from config file, also added support for Markdown.
|
||||
- **Data Fetching**, now using `Server Actions` instead of `API Routes`.
|
||||
- **Encryption**, moved to `Server Actions`.
|
||||
- **Typescript Config**, target changed from `ES5` to `ES2021`.
|
||||
- **Environment Variable**, now focus on Server Side Environment Variable.
|
||||
- `NEXT_PUBLIC_ENCRYPTION_KEY` changed to `ENCRYPTION_KEY`.
|
||||
- `NEXT_PUBLIC_SITE_PASSWORD` changed to `SITE_PASSWORD`.
|
||||
- `MASTER_KEY` added.
|
||||
- **Configuration File**, things are changed:
|
||||
- `version` bumped to `2.0`.
|
||||
- `masterKey` moved to `Environment Variable`.
|
||||
- `apiConfig.revalidate` added.
|
||||
- `siteConfig.siteNameTemplate` added.
|
||||
- `siteConfig.siteAuthor` added.
|
||||
- `siteConfig.robots` added.
|
||||
- `siteConfig.footer` added.
|
||||
- `siteConfig.defaultAccentColor` deprecated.
|
||||
- `siteConfig.breadcrumbMax` added.
|
||||
- `siteConfig.toaster` added.
|
||||
- `siteConfig.navbarItems[number].icons` changed to `lucide icons`.
|
||||
- `siteConfig.supports` added.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rendering issue on mobile devices.
|
||||
|
||||
### Dependency Update
|
||||
|
||||
- `@iconify/react` switched to `lucide-react`.
|
||||
- `@mdx-js/loader` removed.
|
||||
- `@mdx-js/react` removed.
|
||||
- `@next/mdx` removed.
|
||||
- `@popperjs/core` removed.
|
||||
- `next` updated to `^14.1.4`.
|
||||
- `next-seo` removed.
|
||||
- `tailwind-merge` updated to `^2.2.2`.
|
||||
|
||||
## What is this?
|
||||
|
||||
`next-gdrive-index` is an indexer for Google Drive, it's a simple project that I made to index my files in Google Drive.
|
||||
@@ -44,32 +103,50 @@ I know there are a lot of people selling cheap edu account for Google Drive and
|
||||
|
||||
## File Security
|
||||
|
||||
All files fetched from Google Drive **NEED TO BE SHARED** with `Anyone with the link can view` permission.
|
||||
This is because Google Drive API can only access files that are shared with `Anyone with the link can view` permission.
|
||||
> This only apply if `maxFileSize` is enabled.
|
||||
|
||||
But, every files `id` and `webContentLink` are encrypted with `AES-256-CBC` using your own key, so no one can access your files without the key.
|
||||
Except, if the files are larger than the `fileSizeLimit` (default: 4MB for vercel), then the download will be redirected to the raw file link.
|
||||
You need to set the file sharing permission to `Anyone with the link can view` for the files that you want to share.
|
||||
|
||||
**Why?** because the download link will be redirected to the Google Drive download link, and it can only be accessed if the file is shared with `Anyone with the link can view` permission.
|
||||
|
||||
If not, the download link will be redirected to the Google Drive web content link, and it can be accessed without sharing the file.
|
||||
|
||||
But this might expose the file ID, so people might be able to access the files directly from Google Drive. (But I'm sure they can't access the folder itself, only the file that are being downloaded)
|
||||
|
||||
## Known Issue
|
||||
|
||||
### File size limit
|
||||
|
||||
File size limit causing some files can't be previewed, and the download will be redirected to the raw file link.
|
||||
> This only apply if you're using platform that have file size limit, like Vercel.
|
||||
> If you're using VPS or other platform that doesn't have file size limit, you can disable `maxFileSize` inside config file.
|
||||
|
||||
File size limit causing some files can't be previewed, and the download will be redirected to the raw file link.
|
||||
**This won't be fixed**, because it's a limitation from the deployment platform itself.
|
||||
|
||||
### Long Response Time
|
||||
|
||||
The flow of the project are:
|
||||
From version 2.0, We are using Cache to improve the response time.
|
||||
It might take a long time for deep nested folders, but once the data is fetched, it will be cached and the response time will be faster.
|
||||
|
||||
- Validate the path is valid or not. (Only applied for `/...[path]`)
|
||||
- Fetch the password, readme, and files from Google Drive.
|
||||
- Show to the user.
|
||||
I also add [revalidate](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#revalidating-data) config on page file to 5 minutes. You can change the value inside `src/app/page.tsx` & `src/app/[...rest]/page.tsx`.
|
||||
|
||||
So, if you have a lot of files in your Google Drive, it might take a long time to fetch all the files.
|
||||
To improve the response time, I added a cache to the response, so the next time you access the same path, it will be faster.
|
||||
~~The flow of the project are:~~
|
||||
|
||||
ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive.
|
||||
- ~~Validate the path is valid or not. (Only applied for `/...[path]`)~~
|
||||
- ~~Fetch the password, readme, and files from Google Drive.~~
|
||||
- ~~Show to the user.~~
|
||||
|
||||
### Shared Drive is now supported
|
||||
~~So, if you have a lot of files in your Google Drive, it might take a long time to fetch all the files.
|
||||
To improve the response time, I added a cache to the response, so the next time you access the same path, it will be faster.~~
|
||||
|
||||
~~ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive.~~
|
||||
|
||||
### Can't seek on audio and video preview
|
||||
|
||||
It looks like the audio and video preview can't seek, so you need to listen/watch from the beginning.
|
||||
I'm still looking for a solution for this.
|
||||
|
||||
### ~~Shared Drive is now supported~~
|
||||
|
||||
~~I don't have Google Shared Drive, so I can't test it and implement it.~~
|
||||
Implemented by [@loadingthedev](https://github.com/loadingthedev) [(PR #4)](https://github.com/mbahArip/next-gdrive-index/pull/4)
|
||||
@@ -78,3 +155,47 @@ Implemented by [@loadingthedev](https://github.com/loadingthedev) [(PR #4)](http
|
||||
|
||||
For now, I don't have any plan to implement this, because I think it's not necessary.
|
||||
All Google Drive files like Docs, Sheets, and Slides are hidden from the list.
|
||||
|
||||
**PR are welcome if you want to implement this.**
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you're upgrading from version 1.x to version 2, there are some changes that you need to do:
|
||||
|
||||
### Configuration File
|
||||
|
||||
> You can check the new Configuration Schema on `/src/schema.ts`
|
||||
|
||||
There are some changes on the configuration file, here's the list of changes:
|
||||
|
||||
- `masterKey` **REMOVED**, we don't need this anymore
|
||||
|
||||
- `apiConfig.rootFolder` **CHANGED**, you need to encrypt the folder ID first
|
||||
`https://drive-demo.mbaharip.com/api/internal/encrypt?q={{ folderId }}`
|
||||
|
||||
- `apiConfig.proxyThumbnail` **ADDED**, default value is `true`
|
||||
- `siteConfig.siteNameTemplate` **ADDED**, default value is `%s - next-gdrive-index`
|
||||
- `siteConfig.siteAuthor` **ADDED**, default value is `mbahArip`
|
||||
- `siteConfig.robots` **ADDED**, default value is `noindex, nofollow`
|
||||
- `siteConfig.footer` **ADDED**, default value is `Powered by next-gdrive-index`
|
||||
- `siteConfig.defaultAccentColor` **REMOVED**, you can set the theme from `/src/app/globals.css` (more information about [shadcn/ui theme](https://ui.shadcn.com/docs/theming))
|
||||
- `siteConfig.breadcrumbMax` **ADDED**, default value is `3`
|
||||
- `siteConfig.toaster` **ADDED**, default value is `{ position: "bottom-right', duration: 3000 }`
|
||||
- `siteConfig.navbarItems[number].icons` **CHANGED**, you need to use [Lucide Icons](https://lucide.dev/icons) now
|
||||
- `siteConfig.supports` **ADDED**, you can leave it empty if you don't want to use it
|
||||
|
||||
### Environment Variable
|
||||
|
||||
Now all the environment variable are on the server side, so you need to change the environment variable:
|
||||
|
||||
- `NEXT_PUBLIC_ENCRYPTION_KEY` **CHANGED** to `ENCRYPTION_KEY`
|
||||
- `NEXT_PUBLIC_SITE_PASSWORD` **CHANGED** to `SITE_PASSWORD`
|
||||
- `NEXT_PUBLIC_VERCEL_URL` **CHANGED** to `NEXT_PUBLIC_DOMAIN`
|
||||
|
||||
## Sponsors and Donations
|
||||
|
||||
If you think I deserve it, you can support me by:
|
||||
|
||||
- [Paypal (USD)](https://paypal.me/mbaharip)
|
||||
- [Ko-fi (USD)](https://ko-fi.com/mbaharip)
|
||||
- [Saweria (IDR)](https://saweria.co/mbaharip)
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
- Redo how to handle error
|
||||
|
||||
# /
|
||||
- ~~Override opengraph using banner image~~
|
||||
- ~~Render readme file~~
|
||||
- ~~Copy folder url~~
|
||||
- ~~Copy file direct url~~
|
||||
- ~~Download file url~~
|
||||
- ~~Index password~~
|
||||
|
||||
# /:path
|
||||
- ~~Override opengraph using banner image / thumbnail image~~
|
||||
- ~~Render readme file~~
|
||||
- ~~Copy folder url~~
|
||||
- ~~Copy file direct url~~
|
||||
- ~~Download file url~~
|
||||
- ~~Password protected~~
|
||||
- Issues: Return 500 error if it's wrong password
|
||||
- File Preview
|
||||
- 3D > 3DPreview
|
||||
- Audio > AudioPreview
|
||||
- Archive > UnknownPreview
|
||||
- RichText > MarkdownPreview
|
||||
- OfficeWord > OfficePreview
|
||||
- OfficeExcel > OfficePreview
|
||||
- OfficePowerPoint > OfficePreview
|
||||
- PDF > PDFPreview
|
||||
- Database > UnknownPreview
|
||||
- Image > ImagePreview
|
||||
- Code > CodePreview
|
||||
- Text > TextPreview
|
||||
- Video > VideoPreview
|
||||
- Font > UnknownPreview
|
||||
- Default > UnknownPreview
|
||||
- Binary > UnknownPreview
|
||||
|
||||
# /download/:path
|
||||
- ~~Download file based on path~~
|
||||
- ~~Validate path~~
|
||||
- Protected file
|
||||
- Token based for protected file
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "~/components",
|
||||
"utils": "~/utils"
|
||||
}
|
||||
}
|
||||
+18
-29
@@ -1,34 +1,23 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const withMDX = require("@next/mdx")({
|
||||
extension: /\.mdx?$/,
|
||||
options: {
|
||||
// If you use remark-gfm, you'll need to use next.config.mjs
|
||||
// as the package is ESM only
|
||||
// https://github.com/remarkjs/remark-gfm#install
|
||||
remarkPlugins: [],
|
||||
rehypePlugins: [],
|
||||
// If you use `MDXProvider`, uncomment the following line.
|
||||
providerImportSource: "@mdx-js/react",
|
||||
},
|
||||
});
|
||||
|
||||
const nextConfig = {
|
||||
pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
|
||||
reactStrictMode: true,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
webpack: (config) => {
|
||||
config.resolve.alias.canvas = false;
|
||||
|
||||
return config;
|
||||
},
|
||||
// async headers() {
|
||||
// return [
|
||||
// {
|
||||
// source: "/:path*",
|
||||
// headers: [
|
||||
// {
|
||||
// key: "Cache-Control",
|
||||
// value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ];
|
||||
// },
|
||||
};
|
||||
|
||||
module.exports = withMDX(nextConfig);
|
||||
module.exports = nextConfig;
|
||||
|
||||
+63
-24
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "drive-manager",
|
||||
"version": "0.1.0",
|
||||
"name": "next-gdrive-index",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
@@ -12,24 +12,59 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@cyntler/react-doc-viewer": "^1.13.0",
|
||||
"@iconify/react": "^4.1.1",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@next/mdx": "^13.4.19",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-context-menu": "^2.1.5",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-menubar": "^1.0.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"axios": "^1.3.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.0.1",
|
||||
"googleapis": "^118.0.0",
|
||||
"input-otp": "^1.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"next": "^13.4.13",
|
||||
"next-seo": "^6.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-h5-audio-player": "^3.8.6",
|
||||
"lucide-react": "^0.363.0",
|
||||
"next": "^14.1.4",
|
||||
"next-themes": "^0.3.0",
|
||||
"nextjs-toploader": "^1.6.11",
|
||||
"react": "^18",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^18",
|
||||
"react-h5-audio-player": "^3.9.1",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-is": "^18.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-player": "^2.13.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"react-resizable-panels": "^2.0.16",
|
||||
"react-toastify": "^9.1.3",
|
||||
"rehype-katex": "^6.0.3",
|
||||
"rehype-prism-plus": "^1.6.3",
|
||||
@@ -37,7 +72,12 @@
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"remark-slug": "^7.0.1",
|
||||
"remark-toc": "^8.0.1"
|
||||
"remark-toc": "^8.0.1",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
@@ -46,19 +86,18 @@
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/jsonwebtoken": "^9.0.1",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/react": "18.0.35",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/three": "^0.150.2",
|
||||
"autoprefixer": "10.4.14",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "8.38.0",
|
||||
"eslint-config-next": "^13.4.3",
|
||||
"postcss": "8.4.22",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-tailwindcss": "^0.2.7",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss": "3.3.1",
|
||||
"typescript": "5.0.4"
|
||||
"eslint-config-next": "^14.1.4",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "3.0.0",
|
||||
"prettier-plugin-tailwindcss": "0.5.12",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ module.exports = {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import FileGrid from "./@file.grid";
|
||||
import FileList from "./@file.list";
|
||||
import { GetFiles } from "./actions";
|
||||
|
||||
type Props = {
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
root?: boolean;
|
||||
};
|
||||
export default function FileBrowser({ files, nextPageToken, root }: Props) {
|
||||
const { layout } = useContext(LayoutContext);
|
||||
const pathname = usePathname();
|
||||
const prevPath = useMemo<string>(() => {
|
||||
const path = pathname
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.join("/")
|
||||
.replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const [fileList, setFileList] =
|
||||
useState<z.infer<typeof Schema_File>[]>(files);
|
||||
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
|
||||
const [loadMoreLoading, setLoadMoreLoading] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const onLoadMore = async () => {
|
||||
setLoadMoreLoading(true);
|
||||
try {
|
||||
if (!nextToken) throw new Error("No more files to load");
|
||||
const data = await GetFiles({ pageToken: nextToken });
|
||||
const uniqueData = [...fileList, ...data.files].filter(
|
||||
(item, index, array) =>
|
||||
index === array.findIndex((i) => i.encryptedId === item.encryptedId),
|
||||
);
|
||||
setFileList(uniqueData);
|
||||
// const uniqueData = new Set([...fileList, ...data.files]);
|
||||
// setFileList([...uniqueData]);
|
||||
setNextToken(data.nextPageToken);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadMoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-[50dvh] w-full max-w-screen-desktop",
|
||||
"rounded-[var(--radius)] bg-card",
|
||||
"gap-3 p-3",
|
||||
"flex-grow-0",
|
||||
"flex flex-col items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin'
|
||||
/>
|
||||
<p>Wait a moment while we load your files...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-screen-desktop",
|
||||
"gap-3 p-3",
|
||||
"flex-grow-0",
|
||||
"flex flex-col",
|
||||
)}
|
||||
>
|
||||
{!root && (
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
className='justify-start'
|
||||
asChild
|
||||
>
|
||||
<Link href={prevPath}>
|
||||
<Icon
|
||||
name='CornerLeftUp'
|
||||
size={14}
|
||||
className='mr-3'
|
||||
/>
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{!fileList.length && (
|
||||
<div className='col-span-full flex h-full min-h-[25vh] flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
There are no files in this folder
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{layout === "list" && (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{fileList.map((file) => (
|
||||
<div
|
||||
key={file.encryptedId}
|
||||
className='group'
|
||||
>
|
||||
<FileList
|
||||
key={file.encryptedId}
|
||||
data={file}
|
||||
/>
|
||||
<Separator className='mt-1.5 group-last:hidden' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{layout === "grid" && (
|
||||
<div className='grid grid-cols-1 gap-3 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4'>
|
||||
{fileList.map((file) => (
|
||||
<div
|
||||
key={file.encryptedId}
|
||||
className='group'
|
||||
>
|
||||
<FileGrid
|
||||
key={file.encryptedId}
|
||||
data={file}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextToken && (
|
||||
<Button
|
||||
className='col-span-full gap-3'
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
disabled={loadMoreLoading}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
{loadMoreLoading ? (
|
||||
<>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={16}
|
||||
className='animate-spin'
|
||||
/>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>Load More</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function FileGrid({ data }: Props) {
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
// const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
|
||||
// Set to pathname to remove the /e prefix
|
||||
const path = [pathname, encodeURIComponent(data.name)]
|
||||
.join("/")
|
||||
.replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [actionOpen, setActionOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.promise(
|
||||
navigator.clipboard.writeText(
|
||||
new URL(filePath, config.basePath).toString(),
|
||||
),
|
||||
{
|
||||
loading: "Copying link...",
|
||||
success: "Link copied!",
|
||||
error: "Failed to copy link",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full'>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={onCopy}>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</DropdownMenuItem>
|
||||
{data.mimeType.includes("folder") ? null : (
|
||||
<DropdownMenuItem onClick={onDownload}>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>Actions</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
What would you like to do with this file?
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='grid gap-1.5 px-4'>
|
||||
<DrawerClose asChild>
|
||||
<Button onClick={onCopy}>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
{data.mimeType.includes("folder") ? null : (
|
||||
<DrawerClose asChild>
|
||||
<Button onClick={onDownload}>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
<Link
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
"rounded-[var(--radius)]",
|
||||
"flex flex-col content-stretch justify-stretch gap-3",
|
||||
"hover:bg-muted/50",
|
||||
"transition",
|
||||
"border border-border",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink flex-grow",
|
||||
"flex flex-col items-center gap-3",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative grid h-32 w-full flex-shrink-0 place-items-center overflow-hidden bg-muted/25'>
|
||||
{data.thumbnailLink &&
|
||||
(data.mimeType.startsWith("video") ||
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={`/api/thumb/${data.encryptedId}`}
|
||||
alt={data.name}
|
||||
className='rounded-top-[var(--radius)] absolute -z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-cover opacity-50'
|
||||
/>
|
||||
<img
|
||||
src={`/api/thumb/${data.encryptedId}`}
|
||||
alt={data.name}
|
||||
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain backdrop-blur'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={32}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-xs text-foreground'>
|
||||
{durationToReadable(
|
||||
data.videoMediaMetadata?.durationMillis || 0,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder")
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex h-full w-full flex-col justify-between gap-1.5 px-3 py-1.5'>
|
||||
<span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all'>
|
||||
{data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
|
||||
{data.mimeType.includes("folder")
|
||||
? "folder"
|
||||
: data.fileExtension}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{bytesToReadable(data.size || 0)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function FileList({ data }: Props) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const filePath = useMemo<string>(() => {
|
||||
// const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
|
||||
// Set to pathname to remove the /e prefix
|
||||
const path = [pathname, encodeURIComponent(data.name)]
|
||||
.join("/")
|
||||
.replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
const [actionOpen, setActionOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.promise(
|
||||
navigator.clipboard.writeText(
|
||||
new URL(filePath, config.basePath).toString(),
|
||||
),
|
||||
{
|
||||
loading: "Copying link...",
|
||||
success: "Link copied!",
|
||||
error: "Failed to copy link",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full w-full flex-row-reverse items-center'>
|
||||
<div className='absolute bottom-auto right-0 top-auto grid flex-grow place-items-center'>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"ghost"}
|
||||
// className='size-8'
|
||||
className='absolute right-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={onCopy}>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</DropdownMenuItem>
|
||||
{data.mimeType.includes("folder") ? null : (
|
||||
<DropdownMenuItem onClick={onDownload}>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"ghost"}
|
||||
// className='size-8'
|
||||
className='z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>Actions</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
What would you like to do with this file?
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='grid gap-1.5 px-4'>
|
||||
<DrawerClose asChild>
|
||||
<Button onClick={onCopy}>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
{data.mimeType.includes("folder") ? null : (
|
||||
<DrawerClose asChild>
|
||||
<Button onClick={onDownload}>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"relative",
|
||||
// "w-full",
|
||||
"px-1.5 py-1 pr-10", // since action button is size-8
|
||||
"rounded-[var(--radius)]",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
"hover:bg-muted/50",
|
||||
"transition",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
|
||||
{data.thumbnailLink &&
|
||||
(data.mimeType.startsWith("video") ||
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={`/api/thumb/${data.encryptedId}`}
|
||||
alt={data.name}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-12'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={24}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder")
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-12'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex w-full flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
|
||||
{data.mimeType.includes("folder")
|
||||
? "folder"
|
||||
: data.fileExtension}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{bytesToReadable(data.size || 0)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
};
|
||||
export default function Footer({ content }: Props) {
|
||||
return (
|
||||
<footer className='w-full pb-3'>
|
||||
<ReactMarkdown
|
||||
className='flex w-full select-none flex-col items-center justify-center'
|
||||
components={{
|
||||
p: ({ node, children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='muted text-balance text-sm'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ node, children, ...props }) => {
|
||||
const isExternal = props.href?.startsWith("http");
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_Breadcrumb } from "~/schema";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_Breadcrumb>[];
|
||||
loading?: boolean;
|
||||
};
|
||||
export default function HeaderBreadcrumb({ data, loading }: Props) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
if (loading) return <Skeleton className='h-5 w-1/2' />;
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-nowrap items-center'>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={"/"}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon
|
||||
name='FolderRoot'
|
||||
size={16}
|
||||
/>
|
||||
~
|
||||
</div>
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{!!data.length ? (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
|
||||
{data.length > config.siteConfig.breadcrumbMax ? (
|
||||
<>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<BreadcrumbEllipsis className='w-4' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
{data
|
||||
.slice(0, -config.siteConfig.breadcrumbMax + 1)
|
||||
.map((item, _, array) => (
|
||||
<DropdownMenuItem
|
||||
key={`${item.label}/${item.href}`}
|
||||
>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`}
|
||||
className='w-full'
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DrawerTrigger>
|
||||
<BreadcrumbEllipsis className='w-4' />
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>
|
||||
Navigate to parent directories
|
||||
</DrawerTitle>
|
||||
<Separator />
|
||||
</DrawerHeader>
|
||||
<div className='grid gap-1.5 px-4'>
|
||||
{data
|
||||
.slice(0, -config.siteConfig.breadcrumbMax + 1)
|
||||
.map((item, _, array) => (
|
||||
<BreadcrumbItem
|
||||
key={`${item.label}/${item.href}`}
|
||||
>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`}
|
||||
className='w-full py-1.5'
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
|
||||
<Fragment key={`${item.label}/${item.href}`}>
|
||||
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.href ? (
|
||||
<>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${data
|
||||
.map((item) => item.href)
|
||||
.join("/")
|
||||
.replace(/\/\//g, "/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbPage className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.label}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{item.href && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, useContext, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { RedirectSearchFile, SearchFile } from "./actions";
|
||||
|
||||
export default function HeaderButton({ children }: PropsWithChildren) {
|
||||
const { layout, setLayout } = useContext(LayoutContext);
|
||||
|
||||
const [layoutOpen, setLayoutOpen] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [snap, setSnap] = useState<number | string | null>(0.3);
|
||||
const [searchOpen, setSearchOpen] = useState<boolean>(false);
|
||||
const [searchInput, setSearchInput] = useState<string>("");
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState<string>("");
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchError, setSearchError] = useState<string>("");
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
z.infer<typeof Schema_File>[]
|
||||
>([]);
|
||||
const [nextPageToken, setNextPageToken] = useState<string>();
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInput) {
|
||||
setSearchLoading(true);
|
||||
setDebouncedSearchInput("");
|
||||
setSearchError("");
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchInput(searchInput);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [searchInput]);
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchInput) return;
|
||||
onSearch();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const onSearch = async () => {
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const data = await SearchFile(debouncedSearchInput, nextPageToken);
|
||||
setSearchResults(data.files);
|
||||
setNextPageToken(data.nextPageToken);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setSearchError(e.message);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("w-fit", "flex items-center justify-end gap-1.5")}>
|
||||
<Skeleton className='my-0.5 h-8 w-16 mobile:w-24' />{" "}
|
||||
<Skeleton className='my-0.5 h-8 w-8' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// "mx-auto max-w-screen-desktop flex-grow",
|
||||
"w-fit",
|
||||
"flex items-center justify-end gap-1.5",
|
||||
)}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='flex w-24 items-center justify-between gap-1.5'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Icon
|
||||
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
||||
size={16}
|
||||
/>
|
||||
<span>{layout === "grid" ? "Grid" : "List"}</span>
|
||||
</div>
|
||||
<Icon
|
||||
name='ChevronDown'
|
||||
className={cn("transition", layoutOpen && "rotate-180")}
|
||||
size={14}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"gap-1.5",
|
||||
layout === "grid" && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
onClick={() => setLayout("grid")}
|
||||
>
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
size={16}
|
||||
/>
|
||||
<span>Grid</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"gap-1.5",
|
||||
layout === "list" && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
onClick={() => setLayout("list")}
|
||||
>
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
size={16}
|
||||
/>
|
||||
<span>List</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
>
|
||||
<Icon
|
||||
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Layout</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Choose a layout for the files explorer
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='flex h-full flex-col gap-3 px-4'>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"justify-start gap-3",
|
||||
layout === "grid" && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
setLayout("grid");
|
||||
setLayoutOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
size={16}
|
||||
/>
|
||||
<span>Grid</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"justify-start gap-3",
|
||||
layout === "list" && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
setLayout("list");
|
||||
setLayoutOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
size={16}
|
||||
/>
|
||||
<span>List</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
{isDesktop ? (
|
||||
<Dialog
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for files in your drive
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-1.5'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<Separator />
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-full max-h-96 flex-grow flex-col gap-1.5 overflow-y-auto'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={24}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
Start typing to search
|
||||
</span>
|
||||
</div>
|
||||
) : searchLoading ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={24}
|
||||
className='animate-spin text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
Searching...
|
||||
</span>
|
||||
</div>
|
||||
) : searchError ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>
|
||||
{searchError}
|
||||
</span>
|
||||
</div>
|
||||
) : !searchResults.length ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={24}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
We couldn't find any results
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
shouldScaleBackground
|
||||
fadeFromIndex={0}
|
||||
snapPoints={[0.3, 1]}
|
||||
activeSnapPoint={snap}
|
||||
setActiveSnapPoint={setSnap}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className='h-full max-h-[95%]'>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Search</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Search for files in your drive
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-1.5 px-4'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
<Separator />
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-fit flex-grow flex-col gap-1.5 overflow-y-auto px-4'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
Start typing to search
|
||||
</span>
|
||||
</div>
|
||||
) : searchLoading ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
Searching...
|
||||
</span>
|
||||
</div>
|
||||
) : searchError ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={32}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>
|
||||
{searchError}
|
||||
</span>
|
||||
</div>
|
||||
) : !searchResults.length ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
We couldn't find any results
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
// "w-full",
|
||||
"px-1.5 py-1 pr-10", // since action button is size-8
|
||||
"rounded-[var(--radius)]",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
"hover:bg-muted/50",
|
||||
"cursor-pointer transition",
|
||||
)}
|
||||
onClick={async () => {
|
||||
toast.loading("Getting file path...", {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const paths = await RedirectSearchFile(data.encryptedId);
|
||||
router.push(`/${paths}`);
|
||||
toast.success("Redirecting...", {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message, {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
|
||||
{data.thumbnailLink &&
|
||||
(data.mimeType.startsWith("video") ||
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={`/api/thumb/${data.encryptedId}`}
|
||||
alt={data.name}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-12'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={24}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder")
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-12'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex w-full flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
|
||||
{data.mimeType.includes("folder") ? "folder" : data.fileExtension}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{bytesToReadable(data.size || 0)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
export default function HeaderTitle({
|
||||
children,
|
||||
loading,
|
||||
}: PropsWithChildren<{ loading?: boolean }>) {
|
||||
if (loading) return <Skeleton className='my-0.5 h-8 w-full' />;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h2 className='line-clamp-1'>{children}</h2>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{children}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_Breadcrumb } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import HeaderBreadcrumb from "./@header.breadcrumb";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
breadcrumb?: z.infer<typeof Schema_Breadcrumb>[];
|
||||
};
|
||||
export default function Header({ name, breadcrumb }: Props) {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
"flex flex-col items-center justify-between gap-3",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
)}
|
||||
>
|
||||
{/* <HeaderTitle loading={loading}>{name}</HeaderTitle> */}
|
||||
|
||||
<HeaderBreadcrumb
|
||||
data={breadcrumb || []}
|
||||
loading={loading}
|
||||
/>
|
||||
{/* <HeaderButton loading={loading} /> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { jetbrainsMono } from "pages/_app";
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "react-toastify";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypePrism from "rehype-prism-plus";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
@@ -10,10 +11,50 @@ import remarkMath from "remark-math";
|
||||
import remarkSlug from "remark-slug";
|
||||
import remarkToc from "remark-toc";
|
||||
|
||||
import Button from "components/Button";
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
import "styles/highlight.css";
|
||||
import "styles/markdown.css";
|
||||
import "./highlight.css";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
};
|
||||
export default function Markdown({ content }: Props) {
|
||||
return (
|
||||
<div className='markdown w-full rounded-[var(--radius)] bg-muted p-3'>
|
||||
<ReactMarkdown
|
||||
className='w-full'
|
||||
disallowedElements={["script"]}
|
||||
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
|
||||
rehypePlugins={[
|
||||
rehypeKatex,
|
||||
rehypeRaw,
|
||||
[rehypePrism, { ignoreMissing: true }],
|
||||
]}
|
||||
components={{
|
||||
p: ({ node, children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='paragraph text-balance'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
pre: PreComponent,
|
||||
code: ({ node, inline, className, children, ...props }) => (
|
||||
<code
|
||||
className={`${className} font-mono !text-sm`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreProps extends React.HTMLAttributes<HTMLPreElement> {}
|
||||
function PreComponent(props: PreProps) {
|
||||
@@ -25,7 +66,12 @@ function PreComponent(props: PreProps) {
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!codeRef.current) return;
|
||||
if (isCopied || isError) return;
|
||||
if (copyTimeout) clearTimeout(copyTimeout);
|
||||
const toastId = `copy-${Math.random() * 1000}`;
|
||||
toast.loading("Copying code to clipboard...", {
|
||||
id: toastId,
|
||||
});
|
||||
try {
|
||||
navigator.clipboard.writeText(codeRef.current.textContent as string);
|
||||
setIsError(false);
|
||||
@@ -35,7 +81,9 @@ function PreComponent(props: PreProps) {
|
||||
setIsCopied(false);
|
||||
}, 2000),
|
||||
);
|
||||
toast.success("Code copied to clipboard");
|
||||
toast.success("Code copied to clipboard", {
|
||||
id: toastId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
setIsCopied(false);
|
||||
setIsError(true);
|
||||
@@ -44,13 +92,15 @@ function PreComponent(props: PreProps) {
|
||||
setIsError(false);
|
||||
}, 2000),
|
||||
);
|
||||
toast.error("Failed to copy code to clipboard");
|
||||
toast.error("Failed to copy code to clipboard", {
|
||||
id: toastId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full h-full relative`}
|
||||
className={`relative h-full w-full`}
|
||||
onMouseEnter={() => {
|
||||
setShowCopy(true);
|
||||
}}
|
||||
@@ -60,54 +110,33 @@ function PreComponent(props: PreProps) {
|
||||
setIsError(false);
|
||||
}}
|
||||
>
|
||||
<div className={`absolute top-0 right-0 p-2 ${showCopy ? "opacity-100" : "opacity-0"} transition-smooth`}>
|
||||
<Button
|
||||
size='small'
|
||||
rounded='medium'
|
||||
variant={isError ? "danger" : isCopied ? "success" : "primary"}
|
||||
startIcon={isCopied ? "ion:checkmark" : isError ? "ion:close" : "ion:copy"}
|
||||
disabled={isCopied || isError}
|
||||
<div className={`transition-smooth absolute right-0 top-0 p-1.5`}>
|
||||
<div
|
||||
className='cursor-pointer rounded-[var(--radius)] bg-background p-1.5'
|
||||
onClick={() => {
|
||||
handleCopy();
|
||||
}}
|
||||
>
|
||||
{isCopied ? "Copied!" : isError ? "Failed to copy" : "Copy"}
|
||||
</Button>
|
||||
<Icon
|
||||
name={isCopied ? "Check" : isError ? "X" : "Copy"}
|
||||
className={
|
||||
isCopied
|
||||
? "text-green-500"
|
||||
: isError
|
||||
? "text-red-500"
|
||||
: "text-foreground"
|
||||
}
|
||||
size={16}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
ref={codeRef}
|
||||
{...props}
|
||||
className='overflow-x-auto'
|
||||
className='overflow-x-auto border border-border shadow shadow-background'
|
||||
>
|
||||
{props.children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
}
|
||||
export default function Markdown(props: MarkdownProps) {
|
||||
return (
|
||||
<div className='markdown w-full'>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw, [rehypePrism, { ignoreMissing: true }]]}
|
||||
components={{
|
||||
pre: PreComponent,
|
||||
code: ({ node, inline, className, children, ...props }) => (
|
||||
<code
|
||||
className={`${className} ${jetbrainsMono.className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{props.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
"use client";
|
||||
|
||||
import { TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetTrigger,
|
||||
} from "~/components/ui/sheet";
|
||||
import { Tooltip, TooltipContent } from "~/components/ui/tooltip";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { ClearPassword } from "./actions";
|
||||
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const { theme, themes, setTheme } = useTheme();
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [themeOpen, setThemeOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop) setOpen(false);
|
||||
}, [isDesktop]);
|
||||
|
||||
async function onClearPassword() {
|
||||
const promise = new Promise(async (resolve, reject) => {
|
||||
const clear = await ClearPassword();
|
||||
if (!clear.success) reject(new Error(clear.message));
|
||||
resolve(clear.message);
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Clearing all saved password...",
|
||||
success: () => {
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
return "All saved password cleared successfully!";
|
||||
},
|
||||
error: (error) => error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("sticky left-0 top-0 z-50", "h-12 w-full")}>
|
||||
<nav
|
||||
slot='nav'
|
||||
className={cn(
|
||||
"bg-background",
|
||||
// "sticky left-0 top-0",
|
||||
"h-12 w-full",
|
||||
"px-6 py-3",
|
||||
"flex flex-grow flex-row items-center justify-between",
|
||||
"border-b border-border",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-between",
|
||||
"mx-auto w-full max-w-screen-desktop",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={"/"}
|
||||
onClick={() => setOpen(false)}
|
||||
className='flex items-center gap-3'
|
||||
>
|
||||
<img
|
||||
src={config.siteConfig.siteIcon}
|
||||
alt={config.siteConfig.siteName}
|
||||
className={cn("h-10 w-10")}
|
||||
loading='eager'
|
||||
/>
|
||||
<span className={cn("large", "hidden", "tablet:block")}>
|
||||
{config.siteConfig.siteName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{isDesktop ? (
|
||||
<div className='flex'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<Tooltip key={item.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
pathname === item.href
|
||||
? "cursor-default opacity-100"
|
||||
: "cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
size={20}
|
||||
/>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>{item.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<Separator
|
||||
orientation='vertical'
|
||||
className='mx-3'
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
theme === "system"
|
||||
? "LaptopMinimal"
|
||||
: theme === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Site theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align='end'>
|
||||
{themes.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item}
|
||||
disabled={item === theme}
|
||||
className='w-full'
|
||||
onClick={() => setTheme(item)}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span
|
||||
className={cn(
|
||||
"capitalize",
|
||||
// item === theme && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
className={
|
||||
cn()
|
||||
// item === theme && "text-muted-foreground",
|
||||
}
|
||||
size={16}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{config.siteConfig.supports.length && (
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Heart'
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Support</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align='end'>
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<DropdownMenuItem key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={"_blank"}
|
||||
rel={"noopener noreferrer"}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
<span className='muted'>{item.currency}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<Dialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LogOut'
|
||||
className='text-red-500'
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Clear all saved password</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DialogContent>
|
||||
<DialogTitle>Clear all saved password?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You will need to re-enter the password to access the
|
||||
protected content.
|
||||
<br />
|
||||
<b>Are you sure you want to continue?</b>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={onClearPassword}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Drawer
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
theme === "system"
|
||||
? "LaptopMinimal"
|
||||
: theme === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={18}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Theme</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Choose your preferred theme
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='flex flex-col px-4'>
|
||||
{themes.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
className='w-full'
|
||||
disabled={item === theme}
|
||||
onClick={() => {
|
||||
setTheme(item);
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span
|
||||
className={cn(
|
||||
"capitalize",
|
||||
// item === theme && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
className={
|
||||
cn()
|
||||
// item === theme && "text-muted-foreground",
|
||||
}
|
||||
size={16}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name='Menu'
|
||||
size={18}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex flex-col items-center gap-3 pt-12'>
|
||||
<div className='flex w-full flex-grow flex-col gap-6 overflow-y-scroll'>
|
||||
<div className='flex flex-col'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={
|
||||
item.external ? "noopener noreferrer" : undefined
|
||||
}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
size={18}
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.external && (
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
size={12}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<p className='large'>Theme</p>
|
||||
<Separator className='my-1.5' />
|
||||
{themes.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
className='w-full'
|
||||
disabled={item === theme}
|
||||
onClick={() => {
|
||||
setTheme(item);
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<span
|
||||
className={cn(
|
||||
"capitalize",
|
||||
// item === theme && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
className={
|
||||
cn()
|
||||
// item === theme && "text-muted-foreground",
|
||||
}
|
||||
size={18}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{config.siteConfig.supports.length && (
|
||||
<div className='flex flex-col'>
|
||||
<p className='large'>Support & Donation</p>
|
||||
<Separator className='my-1.5' />
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={"_blank"}
|
||||
rel={"noopener noreferrer"}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
>
|
||||
<small>{item.name}</small>
|
||||
<small className='muted'>{item.currency}</small>
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className='w-full'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
className='w-full gap-3'
|
||||
size={"sm"}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>Clear all saved password?</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
You will need to re-enter the password to access the
|
||||
protected content.
|
||||
<br />
|
||||
<b>Are you sure you want to continue?</b>
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerFooter className='gap-1.5'>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={onClearPassword}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
export default function NotFoundComponent() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
The file you are looking for does not exist
|
||||
</span>
|
||||
|
||||
<div className='flex w-full flex-col items-center gap-3 pt-8'>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Back to previous page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
import {
|
||||
CheckPassword,
|
||||
CheckSitePassword,
|
||||
SetPassword,
|
||||
SetSitePassword,
|
||||
} from "./actions";
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
checkPaths?: { path: string; id: string }[];
|
||||
errorMessage?: string;
|
||||
};
|
||||
export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [submitLoading, setSubmitLoading] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setSubmitLoading(true);
|
||||
toast.loading("Checking password...", {
|
||||
id: "password",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!input) throw new Error("Password is required");
|
||||
if (path === "global") {
|
||||
const set = await SetSitePassword(input);
|
||||
if (!set.success) throw new Error(set.message);
|
||||
|
||||
const check = await CheckSitePassword();
|
||||
if (!check.success) throw new Error(check.message);
|
||||
} else {
|
||||
if (!checkPaths)
|
||||
throw new Error("No path found, try to refresh the page.");
|
||||
const set = await SetPassword(path, input);
|
||||
if (!set.success) throw new Error(set.message);
|
||||
|
||||
const check = await CheckPassword(checkPaths);
|
||||
if (!check.success) throw new Error(check.message);
|
||||
}
|
||||
|
||||
toast.success("Password accepted! Refreshing...", {
|
||||
id: "password",
|
||||
});
|
||||
console.log("Password accepted! Refreshing...");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: "password",
|
||||
});
|
||||
setSubmitLoading(false);
|
||||
} finally {
|
||||
setInput("");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Checking password...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src='/images/undraw_vault.svg'
|
||||
alt='Password illustration'
|
||||
loading='eager'
|
||||
className={cn("h-48 w-64 object-contain")}
|
||||
/>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<h3 className='text-pretty text-center'>
|
||||
{path === "global"
|
||||
? "This site are password protected"
|
||||
: "The folder or file you are trying to access is password protected"}
|
||||
</h3>
|
||||
<span className='muted text-pretty text-center'>
|
||||
Please enter the password to access the content
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={cn("w-full", "grid grid-cols-1 gap-3", "tablet:grid-cols-4")}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
name='password'
|
||||
type='password'
|
||||
placeholder='Password'
|
||||
className={cn("tablet:col-span-3")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className='gap-3'
|
||||
type='submit'
|
||||
disabled={submitLoading}
|
||||
>
|
||||
{submitLoading ? (
|
||||
<>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={16}
|
||||
className='animate-spin'
|
||||
/>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>Submit</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<span
|
||||
className={cn(
|
||||
"muted text-pretty text-center text-red-500",
|
||||
errorMessage ? "visible" : "invisible",
|
||||
)}
|
||||
>
|
||||
{errorMessage || "Nothing wrong here, just a password wall"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewAction({ file }: Props) {
|
||||
const pathname = usePathname();
|
||||
const showRaw = useMemo<boolean>(() => {
|
||||
return (
|
||||
file.mimeType.startsWith("image") ||
|
||||
file.mimeType.startsWith("video") ||
|
||||
file.mimeType.startsWith("audio")
|
||||
);
|
||||
}, [file]);
|
||||
const fileInfo = useMemo<{ label: string; value: string }[]>(() => {
|
||||
const value = [
|
||||
{
|
||||
label: "File Name",
|
||||
value: file.name,
|
||||
},
|
||||
{
|
||||
label: "Mime Type",
|
||||
value: file.mimeType,
|
||||
},
|
||||
{
|
||||
label: "Size",
|
||||
value: bytesToReadable(file.size || 0),
|
||||
},
|
||||
{
|
||||
label: "Last Modified",
|
||||
value: file.modifiedTime,
|
||||
},
|
||||
];
|
||||
if (file.imageMediaMetadata) {
|
||||
value.push({
|
||||
label: "Dimension",
|
||||
value: `${file.imageMediaMetadata.width} x ${file.imageMediaMetadata.height}`,
|
||||
});
|
||||
}
|
||||
if (file.videoMediaMetadata) {
|
||||
value.push({
|
||||
label: "Dimension",
|
||||
value: `${file.videoMediaMetadata.width} x ${file.videoMediaMetadata.height}`,
|
||||
});
|
||||
value.push({
|
||||
label: "Duration",
|
||||
value: durationToReadable(file.videoMediaMetadata.durationMillis),
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}, [file]);
|
||||
|
||||
const [downloading, setDownloading] = useState<boolean>(false);
|
||||
const [diffOpen, setDiffOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const onCopyRaw = async () => {
|
||||
try {
|
||||
const rawURL = new URL(
|
||||
`/api/raw/${pathname}`.replace(/\/+/g, "/"),
|
||||
config.basePath,
|
||||
);
|
||||
rawURL.searchParams.append("token", file.encryptedId);
|
||||
toast.promise(navigator.clipboard.writeText(rawURL.toString()), {
|
||||
loading: "Copying link...",
|
||||
success: "Link copied!",
|
||||
error: "Failed to copy link",
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
}
|
||||
};
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
await navigator.clipboard.writeText(
|
||||
new URL(
|
||||
`/api/download/${file.encryptedId}?token=${token}`,
|
||||
config.basePath,
|
||||
).toString(),
|
||||
);
|
||||
toast.success("Link copied...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDownloading(true);
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${file.encryptedId}?token=${token}`);
|
||||
setDownloading(false);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
<Card slot='file-actions'>
|
||||
<div className='flex flex-col-reverse gap-3 p-6 tablet:flex-row tablet:items-center tablet:justify-between'>
|
||||
<div className={cn("flex gap-3", !showRaw && "invisible")}>
|
||||
{isDesktop ? (
|
||||
<Dialog
|
||||
open={diffOpen}
|
||||
onOpenChange={setDiffOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<span className='cursor-pointer text-center text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400 tablet:text-start'>
|
||||
Difference between download link and raw link?
|
||||
</span>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className='flex h-fit flex-col gap-3'>
|
||||
<h4>Why?</h4>
|
||||
<p>
|
||||
Some services need the file extension to be present in the
|
||||
URL to properly embed the file.
|
||||
<br />
|
||||
- The download link only have encrypted file id.
|
||||
<br />
|
||||
- The raw link will have the whole path includes the file
|
||||
name and extension.
|
||||
<br />
|
||||
<br />
|
||||
So if you want to embed the file, it's recommended to
|
||||
use the raw link instead of the download link.
|
||||
</p>
|
||||
<p className='muted'>
|
||||
Note: This is only applicable for video, audio, and image
|
||||
files.
|
||||
</p>
|
||||
<h4>Ref</h4>
|
||||
<p>
|
||||
This information is based on the onedrive-vercel-index
|
||||
project documentation.
|
||||
<br />
|
||||
<Link
|
||||
href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
Customise Direct Link - onedrive-vercel-index
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer
|
||||
open={diffOpen}
|
||||
onOpenChange={setDiffOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<span className='cursor-pointer text-center text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400 tablet:text-start'>
|
||||
Difference between download link and raw link?
|
||||
</span>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader></DrawerHeader>
|
||||
<div className='flex h-fit flex-col gap-3 px-4'>
|
||||
<h4>Why?</h4>
|
||||
<p>
|
||||
Some services need the file extension to be present in the
|
||||
URL to properly embed the file.
|
||||
<br />
|
||||
- The download link only have encrypted file id.
|
||||
<br />
|
||||
- The raw link will have the whole path includes the file
|
||||
name and extension.
|
||||
<br />
|
||||
<br />
|
||||
So if you want to embed the file, it's recommended to
|
||||
use the raw link instead of the download link.
|
||||
</p>
|
||||
<h4>Ref</h4>
|
||||
<p>
|
||||
This information is based on the onedrive-vercel-index
|
||||
project documentation.
|
||||
<br />
|
||||
<Link
|
||||
href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
Customise Direct Link - onedrive-vercel-index
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-col-reverse gap-3 tablet:flex-row tablet:items-center'>
|
||||
{showRaw ? (
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='gap-3'
|
||||
onClick={onCopyRaw}
|
||||
>
|
||||
<Icon
|
||||
name='Copy'
|
||||
size={16}
|
||||
/>
|
||||
Raw Link
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='gap-3'
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Icon
|
||||
name='Copy'
|
||||
size={16}
|
||||
/>
|
||||
Download Link
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
className='gap-3'
|
||||
onClick={onDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
<Icon
|
||||
name={downloading ? "LoaderCircle" : "Download"}
|
||||
size={16}
|
||||
className={cn(downloading && "animate-spin")}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card slot='file-info'>
|
||||
<CardHeader>
|
||||
<CardTitle>File Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col'>
|
||||
{fileInfo.map((info) => (
|
||||
<div
|
||||
key={info.label}
|
||||
className='group flex flex-col'
|
||||
>
|
||||
<span className='font-semibold'>{info.label}:</span>
|
||||
<span className='col-span-3 whitespace-pre-wrap break-all'>
|
||||
{info.value}
|
||||
</span>
|
||||
<Separator className='my-1.5 group-last-of-type:hidden' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import AudioPlayer from "react-h5-audio-player";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
import "./r5-style.css";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewAudio({ file }: Props) {
|
||||
const [audioSrc, setAudioSrc] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No audio to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setAudioSrc(`/api/download/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading player...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='w-full'>
|
||||
<AudioPlayer
|
||||
autoPlay={false}
|
||||
layout='stacked-reverse'
|
||||
src={audioSrc}
|
||||
showJumpControls={false}
|
||||
showFilledVolume
|
||||
style={{
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error(e);
|
||||
setError(
|
||||
"Could not preview this audio, try downloading the file",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
|
||||
export default function PreviewDoc({ file }: Props) {
|
||||
const [docSrc, setDocSrc] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("Nothing to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setDocSrc(`/api/download/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading document...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<DocViewer
|
||||
key={file.encryptedId}
|
||||
documents={[
|
||||
{
|
||||
uri: docSrc,
|
||||
},
|
||||
]}
|
||||
pluginRenderers={DocViewerRenderers}
|
||||
config={{
|
||||
header: {
|
||||
disableHeader: true,
|
||||
},
|
||||
loadingRenderer: {
|
||||
overrideComponent: () => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
showLoadingTimeout: 10000,
|
||||
},
|
||||
noRenderer: {
|
||||
overrideComponent: () => (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>
|
||||
Error loading document
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}}
|
||||
className={cn(
|
||||
"h-full max-h-[70dvh] min-h-[70dvh] w-full rounded-[var(--radius)] border border-border !text-black",
|
||||
)}
|
||||
theme={{
|
||||
disableThemeScrollbar: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewImage({ file }: Props) {
|
||||
const [imgSrc, setImgSrc] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No image to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
await fetch(`/api/download/${file.encryptedId}?token=${token}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch image");
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImgSrc(reader.result as string);
|
||||
};
|
||||
reader.onerror = (e) => {
|
||||
console.error(e);
|
||||
setError(
|
||||
"Could not preview this image, try downloading the file",
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e.message);
|
||||
setError(e.message);
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading image...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={file.name}
|
||||
className='max-h-[70dvh] w-full rounded-[var(--radius)] bg-muted object-contain object-center'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import JSZip from "jszip";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "~/components/ui/carousel";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
|
||||
export default function PreviewManga({ file }: Props) {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [images, setImages] = useState<{ name: string; blob: string }[]>([]);
|
||||
const [currentImage, setCurrentImage] = useState<number>(1);
|
||||
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No video to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
const manga = await fetch(
|
||||
`/api/download/${file.encryptedId}?token=${token}`,
|
||||
);
|
||||
const archiveBlob = await manga.blob();
|
||||
const zipData = await JSZip.loadAsync(archiveBlob);
|
||||
|
||||
const tempArray: { name: string; blob: string }[] = [];
|
||||
const files = Object.values(zipData.files);
|
||||
for (const file of files) {
|
||||
const f = zipData.file(file.name);
|
||||
if (!f) continue;
|
||||
const blob = await f.async("blob");
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImages((prev) => {
|
||||
const exist = prev.find((p) => p.name === file.name);
|
||||
if (exist) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ name: file.name, blob: reader.result as string },
|
||||
];
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
api.on("select", (embla, e) => {
|
||||
const index = embla.selectedScrollSnap();
|
||||
setCurrentImage(index + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading manga content...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='h-full w-full tablet:px-8'>
|
||||
<Carousel
|
||||
className={cn(
|
||||
"w-full",
|
||||
viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full",
|
||||
)}
|
||||
opts={{
|
||||
loop: false,
|
||||
}}
|
||||
setApi={setApi}
|
||||
>
|
||||
<CarouselContent className='h-full w-full'>
|
||||
{images.map((image, index) => (
|
||||
<CarouselItem
|
||||
key={index}
|
||||
className='flex h-full flex-col items-center justify-center'
|
||||
>
|
||||
<img
|
||||
src={image.blob}
|
||||
alt={`${image.name} - Page ${index + 1}`}
|
||||
className={cn(
|
||||
"w-full object-contain",
|
||||
viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full",
|
||||
)}
|
||||
/>
|
||||
<span className='muted text-center text-xs'>
|
||||
{image.name}
|
||||
</span>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
{isDesktop ? (
|
||||
<>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</>
|
||||
) : null}
|
||||
</Carousel>
|
||||
</div>
|
||||
<div className='flex w-full items-center justify-end gap-1.5'>
|
||||
<span className='muted text-sm'>
|
||||
{currentImage}/{images.length}
|
||||
</span>
|
||||
<div
|
||||
className='relative z-10 cursor-pointer p-0.5 text-foreground/80 transition hover:text-foreground'
|
||||
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
|
||||
>
|
||||
<Icon
|
||||
name={viewSize === "fit" ? "Maximize" : "Minimize"}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import Markdown from "./@markdown";
|
||||
import { GetContent } from "./actions";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
code?: boolean;
|
||||
};
|
||||
export default function PreviewRich({ file, code }: Props) {
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [expand, setExpand] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const text = await GetContent(file.encryptedId);
|
||||
if (!text) {
|
||||
setError("Looks like there is no content to preview");
|
||||
return;
|
||||
}
|
||||
if (code) {
|
||||
setContent(`\`\`\`${file.fileExtension}\n${text}\`\`\``);
|
||||
} else {
|
||||
setContent(text);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file, code]);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading content...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn("relative w-full", expand ? "h-full" : "max-h-[50dvh]")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
expand
|
||||
? " [mask-image:linear-gradient(180deg,white_65%,white]"
|
||||
: "max-h-[50dvh] [mask-image:linear-gradient(180deg,white_65%,rgba(255,255,255,0))]",
|
||||
)}
|
||||
>
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
|
||||
expand
|
||||
? "pointer-events-none opacity-0"
|
||||
: "pointer-events-auto opacity-100",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
setExpand(true);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name='ChevronDown'
|
||||
size={16}
|
||||
/>
|
||||
Expand
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
export default function PreviewUnknown() {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[33dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading image...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[33dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<h4 className='text-muted-foreground'>Preview not available</h4>
|
||||
<p className='text-center text-muted-foreground tablet:text-sm'>
|
||||
This file type is not supported for preview, try downloading the
|
||||
file instead.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactPlayer from "react-player";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewVideo({ file }: Props) {
|
||||
const [videoSrc, setVideoSrc] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No video to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setVideoSrc(`/api/download/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading video...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<ReactPlayer
|
||||
key={file.encryptedId}
|
||||
url={videoSrc}
|
||||
controls
|
||||
pip
|
||||
wrapper={({ children }) => (
|
||||
<div className='h-[60dvh] w-full overflow-hidden rounded-[var(--radius)] bg-muted'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
maxHeight: "60vh",
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error(error.message);
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError("An unknown error occurred");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Metadata, ResolvedMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
import FileBrowser from "../@explorer";
|
||||
import Header from "../@header";
|
||||
import HeaderButton from "../@header.button";
|
||||
import Markdown from "../@markdown";
|
||||
import Password from "../@password";
|
||||
import PreviewAction from "../@preview.action";
|
||||
import PreviewAudio from "../@preview.audio";
|
||||
import PreviewDoc from "../@preview.doc";
|
||||
import PreviewImage from "../@preview.image";
|
||||
import PreviewManga from "../@preview.manga";
|
||||
import PreviewRich from "../@preview.rich";
|
||||
import PreviewUnknown from "../@preview.unknown";
|
||||
import PreviewVideo from "../@preview.video";
|
||||
import {
|
||||
CheckPassword,
|
||||
CheckPaths,
|
||||
GetBanner,
|
||||
GetFile,
|
||||
GetFiles,
|
||||
GetReadme,
|
||||
} from "../actions";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
rest: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params: { rest } }: Props,
|
||||
parent: ResolvedMetadata,
|
||||
): Promise<Metadata> {
|
||||
const paths = await CheckPaths(rest);
|
||||
if (!paths.success) return { title: "Not Found" };
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId) return { title: "Not Found" };
|
||||
const data = await GetFile(encryptedId);
|
||||
|
||||
const banner = await GetBanner(encryptedId);
|
||||
|
||||
return {
|
||||
title: data.name,
|
||||
description: data.mimeType?.includes("folder")
|
||||
? `Browse ${data.name} files`
|
||||
: `View ${data.name}`,
|
||||
openGraph: {
|
||||
images: banner
|
||||
? [
|
||||
{
|
||||
url: `/api/og/${banner}`,
|
||||
// url: `/api/og/${
|
||||
// data.mimeType.startsWith("image") ? data.encryptedId : banner
|
||||
// }`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
]
|
||||
: parent.openGraph?.images,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RestPage({ params: { rest } }: Props) {
|
||||
const paths = await CheckPaths(rest);
|
||||
if (!paths.success) notFound();
|
||||
const unlocked = await CheckPassword(paths.data);
|
||||
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path)
|
||||
throw new Error("No path returned from password checking");
|
||||
return (
|
||||
<Password
|
||||
path={unlocked.path}
|
||||
checkPaths={paths.data}
|
||||
errorMessage={unlocked.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId)
|
||||
throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
|
||||
const promise = [];
|
||||
const { data: file } = await gdrive.files.get({
|
||||
fileId: await decryptData(encryptedId),
|
||||
fields: "mimeType, fileExtension",
|
||||
});
|
||||
if (!file.mimeType?.includes("folder")) {
|
||||
promise.push(GetFile(encryptedId));
|
||||
} else {
|
||||
promise.push(GetFiles({ id: encryptedId }));
|
||||
}
|
||||
promise.push(GetReadme(encryptedId));
|
||||
|
||||
const [data, readme] = await Promise.all(promise).then((values) => {
|
||||
const file = Schema_File.safeParse(values[0]);
|
||||
|
||||
if (file.success) {
|
||||
return values as [z.infer<typeof Schema_File>, string];
|
||||
} else {
|
||||
return values as [
|
||||
{ files: z.infer<typeof Schema_File>[]; nextPageToken?: string },
|
||||
string,
|
||||
];
|
||||
}
|
||||
});
|
||||
let fileType;
|
||||
if (file.fileExtension && file.mimeType) {
|
||||
fileType = getFileType(file.fileExtension, file.mimeType);
|
||||
}
|
||||
const isFile = !("files" in data);
|
||||
|
||||
return (
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
|
||||
<Header
|
||||
name='Root'
|
||||
breadcrumb={rest.map((item, index, array) => ({
|
||||
label: decodeURIComponent(item),
|
||||
href: index === array.length - 1 ? undefined : `${item}`,
|
||||
}))}
|
||||
/>
|
||||
<div
|
||||
slot='content'
|
||||
className='w-full'
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
{isFile ? (
|
||||
<div className='flex w-full gap-3'>
|
||||
<CardTitle className='line-clamp-2 flex-grow whitespace-pre-wrap break-all'>
|
||||
{data.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<HeaderButton />
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
{isFile ? (
|
||||
<div className='px-3'>
|
||||
{fileType === "image" ? (
|
||||
<PreviewImage file={data} />
|
||||
) : fileType === "audio" ? (
|
||||
<PreviewAudio file={data} />
|
||||
) : fileType === "video" ? (
|
||||
<PreviewVideo file={data} />
|
||||
) : fileType === "code" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
code
|
||||
/>
|
||||
) : fileType === "text" ? (
|
||||
<PreviewRich file={data} />
|
||||
) : fileType === "markdown" ? (
|
||||
<PreviewRich file={data} />
|
||||
) : fileType === "document" ? (
|
||||
<PreviewDoc file={data} />
|
||||
) : fileType === "pdf" ? (
|
||||
<PreviewDoc file={data} />
|
||||
) : fileType === "manga" ? (
|
||||
<PreviewManga file={data} />
|
||||
) : (
|
||||
<PreviewUnknown />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FileBrowser
|
||||
files={data.files}
|
||||
nextPageToken={data.nextPageToken}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{readme && (
|
||||
<div
|
||||
slot='readme'
|
||||
className='w-full'
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<CardTitle>README.md</CardTitle>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Markdown content={readme} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
{isFile && <PreviewAction file={data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
|
||||
import { decryptData, encryptData } from "~/utils/encryptionHelper/hash";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
|
||||
import { Constant } from "~/types/constant";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
export async function CheckSitePassword(): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
// Skip if the index is public
|
||||
if (!config.siteConfig.privateIndex)
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
if (!process.env.SITE_PASSWORD)
|
||||
throw new Error(
|
||||
"Index password not set, please set the SITE_PASSWORD environment variable or disable privateIndex in the config file",
|
||||
);
|
||||
|
||||
const store = cookies();
|
||||
if (!store.has(Constant.cookies_SitePassword))
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
const password = store.get(Constant.cookies_SitePassword)?.value || "";
|
||||
const decryptedPassword = await decryptData(password);
|
||||
if (decryptedPassword !== process.env.SITE_PASSWORD)
|
||||
throw new Error(
|
||||
"Saved password is incorrect, please re-enter the password",
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function SetSitePassword(password: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const store = cookies();
|
||||
const encryptedPassword = await encryptData(password);
|
||||
store.set(Constant.cookies_SitePassword, encryptedPassword, {
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
httpOnly: true,
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
|
||||
maxAge: 1000 * 60 * 60 * 24 * 365,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Password set",
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function ClearPassword(): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const store = cookies();
|
||||
if (store.has(Constant.cookies_FolderPassword)) {
|
||||
store.delete(Constant.cookies_FolderPassword);
|
||||
}
|
||||
if (store.has(Constant.cookies_SitePassword)) {
|
||||
store.delete(Constant.cookies_SitePassword);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "Password cleared",
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function CheckPaths(paths: string[]): Promise<
|
||||
| {
|
||||
success: false;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
success: true;
|
||||
data: { path: string; id: string }[];
|
||||
message?: string;
|
||||
}
|
||||
> {
|
||||
try {
|
||||
const promises = [];
|
||||
for (const path of paths) {
|
||||
promises.push(
|
||||
gdrive.files
|
||||
.list({
|
||||
q: `name = '${decodeURIComponent(path)}' and trashed = false`,
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
if (!data.files?.length) return null;
|
||||
return {
|
||||
path,
|
||||
data: data.files.map((file) => ({
|
||||
id: file.id,
|
||||
parents: file.parents?.[0],
|
||||
mimeType: file.mimeType,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const data = await Promise.all(promises);
|
||||
const notFoundIndex = data.findIndex((item) => !item);
|
||||
if (notFoundIndex !== -1)
|
||||
throw new Error(`Path not found: ${paths[notFoundIndex]}`);
|
||||
|
||||
// Check if each path is valid
|
||||
let valid = true;
|
||||
let invalidPath: string | undefined;
|
||||
const decryptedRootId = await decryptData(config.apiConfig.rootFolder);
|
||||
for (const [index, path] of data.entries()) {
|
||||
if (!valid) break;
|
||||
// if first path, check if it's in root
|
||||
if (index === 0) {
|
||||
if (path?.data[0].parents === decryptedRootId) break;
|
||||
} else {
|
||||
if (path?.data[0].parents === data[index - 1]?.data[0].id) break;
|
||||
}
|
||||
valid = false;
|
||||
invalidPath = data[index]?.path;
|
||||
}
|
||||
if (!valid) throw new Error(`Invalid path: ${invalidPath}`);
|
||||
|
||||
const ids: { path: string; id: string }[] = [];
|
||||
for (const item of data) {
|
||||
if (item) {
|
||||
const encryptedId = await encryptData(item.data[0].id as string);
|
||||
ids.push({
|
||||
path: decodeURIComponent(item.path),
|
||||
id: encryptedId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: ids,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function CheckPassword(
|
||||
paths: { path: string; id: string }[],
|
||||
): Promise<
|
||||
| {
|
||||
success: false;
|
||||
message?: string;
|
||||
path?: string;
|
||||
}
|
||||
| {
|
||||
success: true;
|
||||
message?: string;
|
||||
}
|
||||
> {
|
||||
try {
|
||||
const ids: string[] = [];
|
||||
for (const path of paths) {
|
||||
ids.push(await decryptData(path.id));
|
||||
}
|
||||
const query: string[] = [
|
||||
`name = '${config.apiConfig.specialFile.password}'`,
|
||||
`trashed = false`,
|
||||
`(${ids.map((id) => `'${id}' in parents`).join(" or ")})`, // Filter by paths id
|
||||
];
|
||||
const { data: password } = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
pageSize: 1000,
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
|
||||
// To save processing time, skip if password file not found
|
||||
if (!password.files?.length) return { success: true };
|
||||
|
||||
const protectedFolder = password.files
|
||||
.filter((file) => ids.includes(file.parents?.[0] as string))
|
||||
?.shift(); // Shift to get only the nearest folder
|
||||
|
||||
// To save processing time, skip if all of the paths are not protected
|
||||
if (!protectedFolder) return { success: true };
|
||||
|
||||
const store = cookies();
|
||||
const folderIndex = ids.findIndex(
|
||||
(item) => item === protectedFolder.parents?.[0],
|
||||
);
|
||||
|
||||
const cookiesValue = JSON.parse(
|
||||
store.get(Constant.cookies_FolderPassword)?.value || "{}",
|
||||
);
|
||||
const currentFolder = paths[folderIndex];
|
||||
if (!cookiesValue[currentFolder.id])
|
||||
throw {
|
||||
message: `Password for '${currentFolder.path}' is not set, please enter the password`,
|
||||
path: currentFolder.id,
|
||||
};
|
||||
|
||||
const savedPassword = await decryptData(cookiesValue[currentFolder.id]);
|
||||
const { data: passwordFile } = await gdrive.files.get(
|
||||
{
|
||||
fileId: protectedFolder.id as string,
|
||||
alt: "media",
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
if (savedPassword !== passwordFile)
|
||||
throw {
|
||||
message: `Password for '${currentFolder.path}' is incorrect, please re-enter the password`,
|
||||
path: currentFolder.id,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
} else {
|
||||
const e = error as { message: string; path: string };
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
path: e.path,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function SetPassword(
|
||||
path: string,
|
||||
password: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const store = cookies();
|
||||
const cookiesValue = JSON.parse(
|
||||
store.get(Constant.cookies_FolderPassword)?.value || "{}",
|
||||
);
|
||||
const updatedValue = {
|
||||
...cookiesValue,
|
||||
[path]: await encryptData(password),
|
||||
};
|
||||
store.set(Constant.cookies_FolderPassword, JSON.stringify(updatedValue), {
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
httpOnly: true,
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
|
||||
maxAge: 1000 * 60 * 60 * 24 * 365,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Password set",
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function GetFiles({
|
||||
id,
|
||||
pageToken,
|
||||
}: {
|
||||
id?: string;
|
||||
pageToken?: string;
|
||||
}): Promise<{
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
}> {
|
||||
try {
|
||||
let decryptedId: string | undefined;
|
||||
if (id) decryptedId = await decryptData(id);
|
||||
else decryptedId = await decryptData(config.apiConfig.rootFolder);
|
||||
|
||||
const filterName = config.apiConfig.hiddenFiles
|
||||
.map((item) => `not name = '${item}'`)
|
||||
.join(" and ");
|
||||
const query: string = [
|
||||
...config.apiConfig.defaultQuery,
|
||||
`'${decryptedId}' in parents`,
|
||||
`${filterName}`,
|
||||
].join(" and ");
|
||||
|
||||
const data = await gdrive.files.list({
|
||||
q: query,
|
||||
fields: `files(${config.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: pageToken,
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
|
||||
const encryptedData: z.infer<typeof Schema_File>[] = [];
|
||||
if (!data.data.files?.length)
|
||||
return { files: [], nextPageToken: undefined };
|
||||
for (const file of data.data.files) {
|
||||
encryptedData.push({
|
||||
mimeType: file.mimeType as string,
|
||||
encryptedId: await encryptData(file.id as string),
|
||||
name: file.name as string,
|
||||
trashed: (file.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(
|
||||
file.modifiedTime as string,
|
||||
).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension || undefined,
|
||||
encryptedWebContentLink: file.webContentLink
|
||||
? await encryptData(file.webContentLink)
|
||||
: undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink || undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation || 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
const parsedContent = Schema_File.array().parse(encryptedData);
|
||||
|
||||
return {
|
||||
files: parsedContent,
|
||||
nextPageToken: data.data.nextPageToken ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function GetFile(
|
||||
encryptedId: string,
|
||||
): Promise<z.infer<typeof Schema_File>> {
|
||||
try {
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const { data: file } = await gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: config.apiConfig.defaultField,
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
|
||||
const payload: z.infer<typeof Schema_File> = {
|
||||
encryptedId,
|
||||
mimeType: file.mimeType as string,
|
||||
name: file.name as string,
|
||||
trashed: (file.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(file.modifiedTime as string).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension || undefined,
|
||||
encryptedWebContentLink: file.webContentLink
|
||||
? await encryptData(file.webContentLink)
|
||||
: undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink || undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation || 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
const parsedContent = Schema_File.parse(payload);
|
||||
|
||||
return parsedContent;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function SearchFile(
|
||||
keyword: string,
|
||||
nextPageToken?: string,
|
||||
): Promise<{
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
}> {
|
||||
try {
|
||||
const query: string[] = [
|
||||
...config.apiConfig.defaultQuery,
|
||||
`name contains '${keyword}'`,
|
||||
config.apiConfig.hiddenFiles
|
||||
.map((item) => `not name = '${item}'`)
|
||||
.join(" and "),
|
||||
];
|
||||
const data = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${config.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: "name_natural desc",
|
||||
pageSize: config.apiConfig.searchResult,
|
||||
pageToken: nextPageToken,
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
const encryptedData: z.infer<typeof Schema_File>[] = [];
|
||||
if (!data.data.files?.length)
|
||||
return { files: [], nextPageToken: undefined };
|
||||
for (const file of data.data.files) {
|
||||
encryptedData.push({
|
||||
mimeType: file.mimeType as string,
|
||||
encryptedId: await encryptData(file.id as string),
|
||||
name: file.name as string,
|
||||
trashed: (file.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(
|
||||
file.modifiedTime as string,
|
||||
).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension || undefined,
|
||||
encryptedWebContentLink: file.webContentLink
|
||||
? await encryptData(file.webContentLink)
|
||||
: undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink || undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation || 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
const parsedContent = Schema_File.array().parse(encryptedData);
|
||||
|
||||
return {
|
||||
files: parsedContent,
|
||||
nextPageToken: data.data.nextPageToken ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function RedirectSearchFile(encryptedId: string): Promise<string> {
|
||||
try {
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: await decryptData(encryptedId),
|
||||
fields: "id, name, parents",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
|
||||
const paths: string[] = [data.name as string];
|
||||
let parentId = data.parents?.[0];
|
||||
const decryptedRootId = await decryptData(config.apiConfig.rootFolder);
|
||||
while (parentId) {
|
||||
if (parentId === decryptedRootId) break;
|
||||
const { data: parent } = await gdrive.files.get({
|
||||
fileId: parentId,
|
||||
fields: "id, name, parents",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
paths.unshift(parent.name as string);
|
||||
parentId = parent.parents?.[0];
|
||||
}
|
||||
|
||||
return paths.join("/");
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GetContent(encryptedId: string): Promise<string> {
|
||||
try {
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const { data: file } = await gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
if (typeof file !== "string")
|
||||
throw new Error("It seems the file is not a text file");
|
||||
|
||||
return file as string;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function GetReadme(
|
||||
encryptedId: string | undefined,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
let decryptedId;
|
||||
if (encryptedId) decryptedId = await decryptData(encryptedId);
|
||||
else decryptedId = await decryptData(config.apiConfig.rootFolder);
|
||||
|
||||
const query: string[] = [
|
||||
...config.apiConfig.defaultQuery,
|
||||
`name = '${config.apiConfig.specialFile.readme}'`,
|
||||
`'${decryptedId}' in parents`,
|
||||
];
|
||||
const { data } = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (!data.files?.length) return null;
|
||||
|
||||
const { data: content } = await gdrive.files.get(
|
||||
{
|
||||
fileId: data.files[0].id as string,
|
||||
alt: "media",
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
return content as string;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function GetBanner(
|
||||
encryptedId: string | undefined,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
let decryptedId;
|
||||
if (encryptedId) decryptedId = await decryptData(encryptedId);
|
||||
else decryptedId = await decryptData(config.apiConfig.rootFolder);
|
||||
|
||||
const query: string[] = [
|
||||
...config.apiConfig.defaultQuery,
|
||||
`name contains '${config.apiConfig.specialFile.banner}'`,
|
||||
`'${decryptedId}' in parents`,
|
||||
];
|
||||
const { data } = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (!data.files?.length) return null;
|
||||
|
||||
return await encryptData(data.files[0].id as string);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GetWebContent(encrypted: string): Promise<string> {
|
||||
try {
|
||||
return await decryptData(encrypted);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function CreateDownloadToken(
|
||||
duration: number = config.apiConfig.temporaryTokenDuration,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const data = {
|
||||
expiredAt:
|
||||
Date.now() +
|
||||
duration * 60 * 60 * 1000 * config.apiConfig.temporaryTokenDuration,
|
||||
};
|
||||
const token = await encryptData(JSON.stringify(data));
|
||||
return token;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function CheckDownloadToken(token: string): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
const decryptToken = JSON.parse(await decryptData(token));
|
||||
if (decryptToken.expiredAt < Date.now()) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"Download token expired, please click the download button again to get a new token",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import { decryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
import { isDownloadTokenValid } from "utils/tokenHelper";
|
||||
|
||||
import { ErrorResponse } from "types/api/response";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{
|
||||
params,
|
||||
}: {
|
||||
params: {
|
||||
encryptedId: string;
|
||||
fileName: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const reqStart = Date.now();
|
||||
const { encryptedId, fileName } = params;
|
||||
try {
|
||||
const data = JSON.parse(decryptData(encryptedId)) as {
|
||||
id: string;
|
||||
isProtected: boolean;
|
||||
};
|
||||
const { token, preview, disableLimit } = getSearchParams(request.url, ["token", "preview", "disableLimit"]);
|
||||
|
||||
if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && !token)
|
||||
throw new ExtendedError("Missing download token", 403, "You are not authorized to download this file");
|
||||
if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && token && !isDownloadTokenValid(token))
|
||||
throw new ExtendedError("Invalid download token", 403, "You are not authorized to download this file");
|
||||
|
||||
const _fileMeta = gdrive.files.get({
|
||||
fileId: decryptData(data.id),
|
||||
fields: "id, name, size, mimeType, webContentLink",
|
||||
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
|
||||
});
|
||||
const _fileContent = gdrive.files.get(
|
||||
{
|
||||
fileId: decryptData(data.id),
|
||||
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
|
||||
alt: "media",
|
||||
},
|
||||
{ responseType: "stream" },
|
||||
);
|
||||
|
||||
const [fileMeta, fileContent] = await Promise.all([_fileMeta, _fileContent]);
|
||||
const fileSize = Number(fileMeta.data.size) ?? 0;
|
||||
if (fileSize > gIndexConfig.apiConfig.maxFileSize && !disableLimit) {
|
||||
return NextResponse.redirect(fileMeta.data.webContentLink!, {
|
||||
status: 302,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
}
|
||||
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
fileContent.data.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
fileContent.data.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
fileContent.data.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": fileMeta.data.mimeType ?? "application/octet-stream",
|
||||
"Content-Disposition": `${preview ? "inline;" : "attachment;"} filename="${encodeURIComponent(fileName)}"`,
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CheckDownloadToken,
|
||||
CheckPassword,
|
||||
CheckPaths,
|
||||
CheckSitePassword,
|
||||
RedirectSearchFile,
|
||||
} from "~/app/actions";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{
|
||||
params: { encryptedId },
|
||||
}: {
|
||||
params: {
|
||||
encryptedId: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
try {
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const token = sp.get("token");
|
||||
if (!token) throw new Error("Token not found");
|
||||
|
||||
const tokenValidity = await CheckDownloadToken(token);
|
||||
if (!tokenValidity.success) throw new Error(tokenValidity.message);
|
||||
|
||||
if (config.siteConfig.privateIndex) {
|
||||
const unlocked = await CheckSitePassword();
|
||||
if (!unlocked.success) {
|
||||
return new NextResponse(
|
||||
`It seems like this site is protected by password, and you haven't entered the password yet.
|
||||
|
||||
If you've already entered the password, please make sure your browser is not blocking cookies from this site.`,
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const _filePaths = RedirectSearchFile(encryptedId);
|
||||
const _fileMeta = gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: "id, name, mimeType, fileExtension, webContentLink",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
const _fileContent = gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
},
|
||||
);
|
||||
|
||||
const [fileMeta, fileContent, filePaths] = await Promise.all([
|
||||
_fileMeta,
|
||||
_fileContent,
|
||||
_filePaths,
|
||||
]);
|
||||
if (!config.apiConfig.allowDownloadProtectedFile) {
|
||||
const checkPath = await CheckPaths(filePaths.split("/"));
|
||||
if (!checkPath.success) throw new Error("File not found");
|
||||
const unlocked = await CheckPassword(checkPath.data);
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path)
|
||||
throw new Error("No path returned from password checking");
|
||||
|
||||
const lockedIndex = checkPath.data.findIndex(
|
||||
(path) => path.id === unlocked.path,
|
||||
);
|
||||
// Get all path until the locked index, then join them
|
||||
const path = checkPath.data
|
||||
.slice(0, lockedIndex + 1)
|
||||
.map((path) => path.path)
|
||||
.join("/");
|
||||
return new NextResponse(
|
||||
`The file you're trying to access is protected by password.
|
||||
Please open the file link and enter the password to access the file, then try to download the file again.
|
||||
|
||||
Protected Path: ${new URL(path, config.basePath).toString()}
|
||||
|
||||
If you've already entered the password, please make sure your browser is not blocking cookies from this site.`,
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fileSize = Number(fileMeta.data.size || 0);
|
||||
if (!fileMeta.data.webContentLink)
|
||||
throw new Error("No download link found");
|
||||
|
||||
if (
|
||||
config.apiConfig.maxFileSize &&
|
||||
fileSize > config.apiConfig.maxFileSize
|
||||
) {
|
||||
return NextResponse.redirect(fileMeta.data.webContentLink, {
|
||||
status: 302,
|
||||
headers: {
|
||||
...request.headers,
|
||||
"Cache-Control": config.cacheControl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
fileContent.data.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
fileContent.data.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
fileContent.data.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
...request.headers,
|
||||
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(
|
||||
fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
|
||||
)}"`,
|
||||
"Cache-Control": config.cacheControl,
|
||||
},
|
||||
});
|
||||
// const data = await GetFile(encryptedId);
|
||||
// if (data.mimeType?.includes("folder"))
|
||||
// throw new Error("Can't download folder");
|
||||
// if (!data.encryptedWebContentLink)
|
||||
// throw new Error("No download link found");
|
||||
|
||||
// const decryptedWebContent = await decryptData(data.encryptedWebContentLink);
|
||||
// return new NextResponse(null, {
|
||||
// status: 302,
|
||||
// headers: {
|
||||
// Location: decryptedWebContent,
|
||||
// },
|
||||
// });
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return new NextResponse(e.message, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import gIndexConfig from "config";
|
||||
import { drive_v3 } from "googleapis";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import { gdriveFilesList } from "utils/gdrive";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APIGetFileResponse, ErrorResponse } from "types/api/response";
|
||||
|
||||
function isHiddenFile(name: string) {
|
||||
return gIndexConfig.apiConfig.hiddenFiles.find((item) => name.startsWith(item)) ? true : false;
|
||||
}
|
||||
function generateFileObject(data: drive_v3.Schema$File): IGDriveFiles {
|
||||
return {
|
||||
mimeType: data.mimeType as string,
|
||||
fileExtension: (data.fileExtension as string) ?? null,
|
||||
encryptedId: encryptData(data.id as string),
|
||||
name: data.name as string,
|
||||
trashed: (data.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(),
|
||||
encryptedWebContentLink: encryptData(data.webContentLink as string),
|
||||
size: Number(data.size ?? 0),
|
||||
thumbnailLink: (data.thumbnailLink as string) ?? null,
|
||||
imageMediaMetadata: data.imageMediaMetadata
|
||||
? {
|
||||
width: Number(data.imageMediaMetadata.width ?? 0),
|
||||
height: Number(data.imageMediaMetadata.height ?? 0),
|
||||
rotation: Number(data.imageMediaMetadata.rotation ?? 0),
|
||||
}
|
||||
: null,
|
||||
videoMediaMetadata: data.videoMediaMetadata
|
||||
? {
|
||||
width: Number(data.videoMediaMetadata.width ?? 0),
|
||||
height: Number(data.videoMediaMetadata.height ?? 0),
|
||||
durationMillis: Number(data.videoMediaMetadata.durationMillis ?? 0),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
function generateFolderObject(data: drive_v3.Schema$File): IGDriveFiles {
|
||||
return {
|
||||
mimeType: data.mimeType as string,
|
||||
encryptedId: encryptData(data.id as string),
|
||||
name: data.name as string,
|
||||
trashed: (data.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
try {
|
||||
const { encryptedId, isFile, pageToken } = getSearchParams(request.url, ["encryptedId", "isFile", "pageToken"]);
|
||||
|
||||
let data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
} = {
|
||||
file: null,
|
||||
files: [],
|
||||
folders: [],
|
||||
pageToken: null,
|
||||
};
|
||||
if (isFile && encryptedId) {
|
||||
const fileContent = await gdrive.files.get({
|
||||
fileId: decryptData(encryptedId),
|
||||
fields: gIndexConfig.apiConfig.defaultField,
|
||||
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (isHiddenFile(fileContent.data.name as string))
|
||||
throw new ExtendedError("File not found", 404, "File you are looking for is not found");
|
||||
|
||||
const payload: IGDriveFiles = generateFileObject(fileContent.data);
|
||||
data.file = payload;
|
||||
} else {
|
||||
const filterName = gIndexConfig.apiConfig.hiddenFiles.map((item) => `name != '${item}'`).join(" and ");
|
||||
const query: string[] = [
|
||||
...gIndexConfig.apiConfig.defaultQuery,
|
||||
`'${encryptedId ? decryptData(encryptedId) : gIndexConfig.apiConfig.rootFolder}' in parents`,
|
||||
`${filterName}`,
|
||||
];
|
||||
const folderContent = await gdriveFilesList({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: gIndexConfig.apiConfig.defaultOrder,
|
||||
pageSize: gIndexConfig.apiConfig.itemsPerPage,
|
||||
pageToken: pageToken ?? undefined,
|
||||
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
|
||||
includeItemsFromAllDrives: gIndexConfig.apiConfig.isTeamDrive,
|
||||
});
|
||||
|
||||
const listFolders: IGDriveFiles[] =
|
||||
folderContent.data.files
|
||||
?.filter((item) => item.mimeType === "application/vnd.google-apps.folder")
|
||||
.map((item) => generateFolderObject(item)) ?? [];
|
||||
const listFiles: IGDriveFiles[] =
|
||||
folderContent.data.files
|
||||
?.filter((item) => item.mimeType !== "application/vnd.google-apps.folder")
|
||||
.map((item) => generateFileObject(item)) ?? [];
|
||||
|
||||
data = {
|
||||
...data,
|
||||
files: listFiles,
|
||||
folders: listFolders,
|
||||
pageToken: folderContent.data.nextPageToken ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const payload: APIGetFileResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
data,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import { gdriveFilesList } from "utils/gdrive";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import { APIGetPasswordResponse, ErrorResponse } from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
|
||||
try {
|
||||
const { path } = getSearchParams(request.url, ["path"]);
|
||||
if (!path) throw new ExtendedError("Path is required", 400, "Can't find path in query, please check your query");
|
||||
const mappedPath: Record<"name" | "id" | "mimeType", string>[] = JSON.parse(decryptData(path));
|
||||
const passwordParents = mappedPath.map((path) => `'${decryptData(path.id)}' in parents`);
|
||||
const query: string[] = [
|
||||
"trashed = false",
|
||||
`name = '${gIndexConfig.apiConfig.specialFile.password}' and (${passwordParents.join(" or ")})`,
|
||||
];
|
||||
const passwordData = await gdriveFilesList({
|
||||
q: query.join(" and "),
|
||||
fields: "files(id, name, parents)",
|
||||
});
|
||||
|
||||
const protectedPaths = mappedPath.map((path) => {
|
||||
const isPasswordExist = passwordData.data.files?.find((file) => file.parents?.[0] === decryptData(path.id));
|
||||
if (isPasswordExist)
|
||||
return {
|
||||
name: path.name,
|
||||
passwordId: isPasswordExist.id,
|
||||
};
|
||||
});
|
||||
|
||||
const _passwordContent: Promise<{
|
||||
path: string;
|
||||
password: string;
|
||||
}>[] = [];
|
||||
protectedPaths.forEach((path) => {
|
||||
if (!path) return;
|
||||
_passwordContent.push(
|
||||
gdrive.files
|
||||
.get(
|
||||
{
|
||||
fileId: path.passwordId as string,
|
||||
alt: "media",
|
||||
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
|
||||
},
|
||||
{ responseType: "text" },
|
||||
)
|
||||
.then((res) => ({
|
||||
path: path.name as string,
|
||||
password: res.data as string,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
const passwordContent = await Promise.all(_passwordContent);
|
||||
|
||||
let prevPath = [""];
|
||||
let password: Record<"relativePath" | "password", string>[] = [];
|
||||
|
||||
mappedPath.forEach((path) => {
|
||||
prevPath.push(path.name);
|
||||
const isPasswordExist = passwordContent.find((password) => password.path === path.name);
|
||||
if (isPasswordExist) {
|
||||
password.push({
|
||||
relativePath: prevPath.join("/"),
|
||||
password: encryptData(isPasswordExist.password),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const payload: APIGetPasswordResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
data: password,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import { decryptData } from "utils/encryptionHelper/hash";
|
||||
import { gdriveFilesList } from "utils/gdrive";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import { APIGetReadmeResponse, ErrorResponse } from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
try {
|
||||
const { encryptedId } = getSearchParams(request.url, ["encryptedId"]);
|
||||
|
||||
const query: string[] = [
|
||||
...gIndexConfig.apiConfig.defaultQuery,
|
||||
`'${encryptedId ? decryptData(encryptedId) : gIndexConfig.apiConfig.rootFolder}' in parents`,
|
||||
];
|
||||
const folderContent = await gdriveFilesList({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: gIndexConfig.apiConfig.defaultOrder,
|
||||
pageSize: gIndexConfig.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
});
|
||||
const isReadmeExist = folderContent.data.files?.find(
|
||||
(file) => file.name === gIndexConfig.apiConfig.specialFile.readme,
|
||||
);
|
||||
let data: string | null = null;
|
||||
|
||||
if (isReadmeExist) {
|
||||
const fileContent = await gdrive.files.get(
|
||||
{
|
||||
fileId: isReadmeExist.id as string,
|
||||
alt: "media",
|
||||
},
|
||||
{ responseType: "text" },
|
||||
);
|
||||
data = fileContent.data as string;
|
||||
}
|
||||
|
||||
const payload: APIGetReadmeResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
data,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { encryptData } from "~/utils/encryptionHelper/hash";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const query = sp.get("q");
|
||||
if (!query)
|
||||
return new NextResponse(
|
||||
"Add query parameter 'q' with the value to encrypt",
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const encrypted = await encryptData(query);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
value: encrypted,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
return new Response(e.message, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
encryptedId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params: { encryptedId } }: Props,
|
||||
) {
|
||||
try {
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: "id, name, mimeType, webContentLink",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (!data.webContentLink) throw new Error("No thumbnail for this file");
|
||||
const downloadThumb = await fetch(data.webContentLink, {
|
||||
cache: "force-cache",
|
||||
});
|
||||
const buffer = await downloadThumb.arrayBuffer();
|
||||
const bufferData = Buffer.from(buffer);
|
||||
|
||||
return new NextResponse(bufferData, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": data.mimeType || "application/octet-stream",
|
||||
"Content-Length": bufferData.length.toString(),
|
||||
"Content-Disposition": `inline; filename="${data.name}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: e.message,
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { CheckPassword, CheckPaths, GetFile } from "~/app/actions";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params: { rest } }: { params: { rest: string[] } },
|
||||
) {
|
||||
try {
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const token = sp.get("token");
|
||||
if (!token) throw new Error("Token not found");
|
||||
|
||||
const paths = await CheckPaths(rest);
|
||||
if (!paths.success) throw new Error(paths.message);
|
||||
|
||||
if (!config.apiConfig.allowDownloadProtectedFile) {
|
||||
const unlocked = await CheckPassword(paths.data);
|
||||
if (!unlocked.success)
|
||||
throw new Error(
|
||||
unlocked.path
|
||||
? unlocked.message
|
||||
: "No path returned from password checking",
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId)
|
||||
throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
if (token !== encryptedId) throw new Error("Invalid token");
|
||||
|
||||
const data = await GetFile(encryptedId);
|
||||
if (data.mimeType?.includes("folder"))
|
||||
throw new Error("Can't download folder");
|
||||
if (
|
||||
!data.mimeType.includes("video") &&
|
||||
!data.mimeType.includes("image") &&
|
||||
!data.mimeType.includes("audio")
|
||||
)
|
||||
throw new Error(
|
||||
"Raw link only available for video, image, and audio files",
|
||||
);
|
||||
if (!data.encryptedWebContentLink)
|
||||
throw new Error("No download link found");
|
||||
|
||||
const decryptedWebContent = await decryptData(data.encryptedWebContentLink);
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: decryptedWebContent,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return new NextResponse(e.message, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import { ErrorResponse } from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { encryptedId: string } }) {
|
||||
const reqStart = Date.now();
|
||||
const { encryptedId } = params;
|
||||
try {
|
||||
const id = decryptData(encryptedId);
|
||||
if (!id) throw new ExtendedError("Invalid encryptedId", 400, "EncryptedId provided is invalid");
|
||||
|
||||
console.log(id);
|
||||
const path: string[] = [];
|
||||
let lastId = id;
|
||||
const fileContent = await gdrive.files.get({
|
||||
fileId: lastId,
|
||||
fields: "id, name, mimeType, parents",
|
||||
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (fileContent) {
|
||||
path.push(fileContent.data.name as string);
|
||||
if (fileContent.data.parents) {
|
||||
lastId = fileContent.data.parents[0] as string;
|
||||
while (lastId) {
|
||||
const folderContent = await gdrive.files.get({
|
||||
fileId: lastId,
|
||||
fields: "id, name, mimeType, parents",
|
||||
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (folderContent.data.id === gIndexConfig.apiConfig.rootFolder) {
|
||||
lastId = "";
|
||||
break;
|
||||
}
|
||||
if (folderContent) {
|
||||
path.push(folderContent.data.name as string);
|
||||
if (folderContent.data.parents) {
|
||||
if (folderContent.data.parents[0] === gIndexConfig.apiConfig.rootFolder) {
|
||||
lastId = "";
|
||||
break;
|
||||
} else {
|
||||
lastId = folderContent.data.parents[0] as string;
|
||||
}
|
||||
} else {
|
||||
lastId = "";
|
||||
}
|
||||
} else {
|
||||
lastId = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const redirectURL = new URL(path.reverse().join("/"), request.nextUrl.origin ?? process.env.VERCEL_URL);
|
||||
console.log(redirectURL);
|
||||
return NextResponse.redirect(redirectURL, {
|
||||
status: 302,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorCode = isNaN(Number(error.code)) ? 500 : Number(error.code);
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: errorCode,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: errorCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import { encryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import { gdriveFilesList } from "utils/gdrive";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APISearchResponse, ErrorResponse } from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
try {
|
||||
const { query } = getSearchParams(request.url, ["query"]);
|
||||
if (!query) throw new ExtendedError("Missing query", 400, "Search query is required");
|
||||
|
||||
const searchFiles = await gdriveFilesList({
|
||||
q: [...gIndexConfig.apiConfig.defaultQuery, `name contains '${query}'`].join(" and "),
|
||||
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: "name_natural desc",
|
||||
pageSize: gIndexConfig.apiConfig.searchResult,
|
||||
pageToken: undefined,
|
||||
});
|
||||
|
||||
const files: (IGDriveFiles & { redirect: string })[] =
|
||||
searchFiles.data.files?.map((data) => ({
|
||||
mimeType: data.mimeType ?? "Unknown",
|
||||
encryptedId: encryptData(data.id as string),
|
||||
name: data.name as string,
|
||||
trashed: data.trashed ?? false,
|
||||
modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(),
|
||||
fileExtension: (data.fileExtension as string) ?? null,
|
||||
encryptedWebContentLink: undefined,
|
||||
size: Number(data.size) ?? 0,
|
||||
thumbnailLink: (data.thumbnailLink as string) ?? null,
|
||||
imageMediaMetadata: null,
|
||||
videoMediaMetadata: null,
|
||||
redirect: `/api/search/redirect/${encryptData(data.id as string)}`,
|
||||
})) ?? [];
|
||||
|
||||
const payload: APISearchResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
files: files,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
encryptedId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params: { encryptedId } }: Props,
|
||||
) {
|
||||
try {
|
||||
const defaultImage = NextResponse.redirect(
|
||||
new URL("/og.png", config.basePath),
|
||||
{
|
||||
status: 302,
|
||||
},
|
||||
);
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const _fileMeta = gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields:
|
||||
"id, name, mimeType, fileExtension, webContentLink, thumbnailLink",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
const _fileContent = gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
},
|
||||
);
|
||||
|
||||
const [fileMeta, fileContent] = await Promise.all([
|
||||
_fileMeta,
|
||||
_fileContent,
|
||||
]);
|
||||
const fileSize = Number(fileMeta.data.size || 0);
|
||||
|
||||
if (!fileMeta.data.webContentLink) return defaultImage;
|
||||
if (!fileMeta.data.thumbnailLink) return defaultImage;
|
||||
if (
|
||||
!fileMeta.data.mimeType?.startsWith("image") &&
|
||||
!fileMeta.data.mimeType?.startsWith("video")
|
||||
)
|
||||
return defaultImage;
|
||||
|
||||
// If svg, return actual image since there is no thumbnail for svg
|
||||
if (
|
||||
fileMeta.data.mimeType?.includes("svg") &&
|
||||
fileSize <= config.apiConfig.maxFileSize
|
||||
) {
|
||||
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
fileContent.data.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
fileContent.data.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
fileContent.data.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(
|
||||
fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
|
||||
)}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (config.apiConfig.proxyThumbnail) {
|
||||
const downloadThumb = await fetch(fileMeta.data.thumbnailLink, {
|
||||
cache: "force-cache",
|
||||
});
|
||||
const buffer = await downloadThumb.arrayBuffer();
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
|
||||
"Content-Length": buffer.byteLength.toString(),
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(
|
||||
fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
|
||||
)}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.redirect(fileMeta.data.thumbnailLink);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: e.message,
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={32}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-center text-destructive'>
|
||||
Something went wrong
|
||||
</span>
|
||||
<span className='text-center text-sm text-destructive'>
|
||||
More details can be found in the console
|
||||
</span>
|
||||
</div>
|
||||
<code className='w-full whitespace-pre-wrap rounded-[var(--radius)] bg-muted px-3 py-1.5 text-sm text-muted-foreground'>
|
||||
{error.message}
|
||||
</code>
|
||||
|
||||
<div className='flex w-full flex-col items-center gap-3 pt-8'>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Back to previous page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Shadcn/ui theme */
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 89.8%;
|
||||
/* --ring: 0 0% 3.9%; */
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 14.9%;
|
||||
/* --ring: 0 0% 83.1%; */
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@layer base {
|
||||
:root {
|
||||
@apply text-[14px] tablet:text-[16px];
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
/* @apply outline outline-1 outline-red-500; */
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply h-1.5 w-1.5;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-background;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-primary/25 hover:bg-primary/50;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||
}
|
||||
h2 {
|
||||
@apply scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0;
|
||||
}
|
||||
h3 {
|
||||
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
|
||||
}
|
||||
h4 {
|
||||
@apply scroll-m-20 text-xl font-semibold tracking-tight;
|
||||
}
|
||||
.paragraph {
|
||||
@apply leading-7 [&:not(:first-child)]:mt-6;
|
||||
}
|
||||
blockquote {
|
||||
@apply mt-6 border-l-2 pl-6 italic;
|
||||
}
|
||||
ul {
|
||||
@apply my-6 ml-6 list-disc [&>li]:mt-2;
|
||||
}
|
||||
.lead {
|
||||
@apply text-xl text-muted-foreground;
|
||||
}
|
||||
.large {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
.muted {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
small {
|
||||
@apply text-sm font-medium leading-none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
:root {
|
||||
/**
|
||||
* One Light theme for prism.js
|
||||
* Based on Atom's One Light theme: https://github.com/atom/atom/tree/master/packages/one-light-syntax
|
||||
*/
|
||||
/* From colors.less */
|
||||
--mono-1: hsl(230, 8%, 24%);
|
||||
--mono-2: hsl(230, 6%, 44%);
|
||||
--mono-3: hsl(230, 4%, 64%);
|
||||
--hue-1: hsl(198, 99%, 37%);
|
||||
--hue-2: hsl(221, 87%, 60%);
|
||||
--hue-3: hsl(301, 63%, 40%);
|
||||
--hue-4: hsl(119, 34%, 47%);
|
||||
--hue-5: hsl(5, 74%, 59%);
|
||||
--hue-5-2: hsl(344, 84%, 43%);
|
||||
--hue-6: hsl(35, 99%, 36%);
|
||||
--hue-6-2: hsl(35, 99%, 40%);
|
||||
--syntax-fg: hsl(230, 8%, 24%);
|
||||
--syntax-bg: hsl(230, 1%, 98%);
|
||||
--syntax-gutter: hsl(230, 1%, 62%);
|
||||
--syntax-guide: hsla(230, 8%, 24%, 0.2);
|
||||
--syntax-accent: hsl(230, 100%, 66%);
|
||||
/* From syntax-variables.less */
|
||||
--syntax-selection-color: hsl(230, 1%, 90%);
|
||||
--syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
|
||||
--syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/**
|
||||
* One Dark theme for prism.js
|
||||
* Based on Atom's One Dark theme: https://github.com/atom/atom/tree/master/packages/one-dark-syntax
|
||||
*/
|
||||
/* From colors.less */
|
||||
--mono-1: hsl(220, 14%, 71%);
|
||||
--mono-2: hsl(220, 9%, 55%);
|
||||
--mono-3: hsl(220, 10%, 40%);
|
||||
--hue-1: hsl(187, 47%, 55%);
|
||||
--hue-2: hsl(207, 82%, 66%);
|
||||
--hue-3: hsl(286, 60%, 67%);
|
||||
--hue-4: hsl(95, 38%, 62%);
|
||||
--hue-5: hsl(355, 65%, 65%);
|
||||
--hue-5-2: hsl(5, 48%, 51%);
|
||||
--hue-6: hsl(29, 54%, 61%);
|
||||
--hue-6-2: hsl(39, 67%, 69%);
|
||||
--syntax-fg: hsl(220, 14%, 71%);
|
||||
--syntax-bg: hsl(220, 13%, 18%);
|
||||
--syntax-gutter: hsl(220, 14%, 45%);
|
||||
--syntax-guide: hsla(220, 14%, 71%, 0.15);
|
||||
--syntax-accent: hsl(220, 100%, 66%);
|
||||
/* From syntax-variables.less */
|
||||
--syntax-selection-color: hsl(220, 13%, 28%);
|
||||
--syntax-gutter-background-color-selected: hsl(220, 13%, 26%);
|
||||
--syntax-cursor-line: hsla(220, 100%, 80%, 0.04);
|
||||
}
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"],
|
||||
pre {
|
||||
background: var(--syntax-bg);
|
||||
color: var(--syntax-fg);
|
||||
text-shadow: 0 1px rgba(0, 0, 0, 0);
|
||||
font-size: inherit;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
line-height: 1.5;
|
||||
-moz-tab-size: 2;
|
||||
tab-size: 2;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
code[class*="language-"]::-moz-selection,
|
||||
code[class*="language-"] *::-moz-selection,
|
||||
pre[class*="language-"] *::-moz-selection {
|
||||
background: var(--syntax-selection-color);
|
||||
color: inherit;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
code[class*="language-"]::selection,
|
||||
code[class*="language-"] *::selection,
|
||||
pre[class*="language-"] *::selection {
|
||||
background: var(--syntax-selection-color);
|
||||
color: inherit;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"],
|
||||
pre {
|
||||
padding: 1em;
|
||||
margin: 0.5em 0;
|
||||
overflow: auto;
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: 0.2em 0.3em;
|
||||
border-radius: 0.3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.cdata {
|
||||
color: var(--mono-3);
|
||||
}
|
||||
|
||||
.token.doctype,
|
||||
.token.punctuation,
|
||||
.token.entity {
|
||||
color: var(--syntax-fg);
|
||||
}
|
||||
|
||||
.token.attr-name,
|
||||
.token.class-name,
|
||||
.token.boolean,
|
||||
.token.constant,
|
||||
.token.number,
|
||||
.token.atrule {
|
||||
color: var(--hue-6);
|
||||
}
|
||||
|
||||
.token.keyword {
|
||||
color: var(--hue-3);
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.symbol,
|
||||
.token.deleted,
|
||||
.token.important {
|
||||
color: var(--hue-5);
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted,
|
||||
.token.regex,
|
||||
.token.attr-value,
|
||||
.token.attr-value > .token.punctuation {
|
||||
color: var(--hue-4);
|
||||
}
|
||||
|
||||
.token.variable,
|
||||
.token.operator,
|
||||
.token.function {
|
||||
color: var(--hue-2);
|
||||
}
|
||||
|
||||
.token.url {
|
||||
color: var(--hue-1);
|
||||
}
|
||||
|
||||
/* HTML overrides */
|
||||
.token.attr-value > .token.punctuation.attr-equals,
|
||||
.token.special-attr > .token.attr-value > .token.value.css {
|
||||
color: var(--syntax-fg);
|
||||
}
|
||||
|
||||
/* CSS overrides */
|
||||
.language-css .token.selector {
|
||||
color: var(--hue-5);
|
||||
}
|
||||
|
||||
.language-css .token.property {
|
||||
color: var(--syntax-fg);
|
||||
}
|
||||
|
||||
.language-css .token.function,
|
||||
.language-css .token.url > .token.function {
|
||||
color: var(--hue-1);
|
||||
}
|
||||
|
||||
.language-css .token.url > .token.string.url {
|
||||
color: var(--hue-4);
|
||||
}
|
||||
|
||||
.language-css .token.important,
|
||||
.language-css .token.atrule .token.rule {
|
||||
color: var(--hue-3);
|
||||
}
|
||||
|
||||
/* JS overrides */
|
||||
.language-javascript .token.operator {
|
||||
color: var(--hue-3);
|
||||
}
|
||||
|
||||
.language-javascript
|
||||
.token.template-string
|
||||
> .token.interpolation
|
||||
> .token.interpolation-punctuation.punctuation {
|
||||
color: var(--hue-5-2);
|
||||
}
|
||||
|
||||
/* JSON overrides */
|
||||
.language-json .token.operator {
|
||||
color: var(--syntax-fg);
|
||||
}
|
||||
|
||||
.language-json .token.null.keyword {
|
||||
color: var(--hue-6);
|
||||
}
|
||||
|
||||
/* MD overrides */
|
||||
.language-markdown .token.url,
|
||||
.language-markdown .token.url > .token.operator,
|
||||
.language-markdown .token.url-reference.url > .token.string {
|
||||
color: var(--syntax-fg);
|
||||
}
|
||||
|
||||
.language-markdown .token.url > .token.content {
|
||||
color: var(--hue-2);
|
||||
}
|
||||
|
||||
.language-markdown .token.url > .token.url,
|
||||
.language-markdown .token.url-reference.url {
|
||||
color: var(--hue-1);
|
||||
}
|
||||
|
||||
.language-markdown .token.blockquote.punctuation,
|
||||
.language-markdown .token.hr.punctuation {
|
||||
color: var(--mono-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.language-markdown .token.code-snippet {
|
||||
color: var(--hue-4);
|
||||
}
|
||||
|
||||
.language-markdown .token.bold .token.content {
|
||||
color: var(--hue-6);
|
||||
}
|
||||
|
||||
.language-markdown .token.italic .token.content {
|
||||
color: var(--hue-3);
|
||||
}
|
||||
|
||||
.language-markdown .token.strike .token.content,
|
||||
.language-markdown .token.strike .token.punctuation,
|
||||
.language-markdown .token.list.punctuation,
|
||||
.language-markdown .token.title.important > .token.punctuation {
|
||||
color: var(--hue-5);
|
||||
}
|
||||
|
||||
/* General */
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.token.namespace {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Plugin overrides */
|
||||
/* Selectors should have higher specificity than those in the plugins' default stylesheets */
|
||||
|
||||
/* Show Invisible plugin overrides */
|
||||
.token.token.tab:not(:empty):before,
|
||||
.token.token.cr:before,
|
||||
.token.token.lf:before,
|
||||
.token.token.space:before {
|
||||
color: var(--syntax-guide);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Toolbar plugin overrides */
|
||||
/* Space out all buttons and move them away from the right edge of the code block */
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
|
||||
/* Styling the buttons */
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
|
||||
background: var(--syntax-gutter-background-color-selected);
|
||||
color: var(--mono-2);
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
|
||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
|
||||
background: var(--syntax-selection-color);
|
||||
color: var(--syntax-fg);
|
||||
}
|
||||
|
||||
/* Line Highlight plugin overrides */
|
||||
/* The highlighted line itself */
|
||||
.line-highlight.line-highlight {
|
||||
background: var(--syntax-cursor-line);
|
||||
}
|
||||
|
||||
/* Default line numbers in Line Highlight plugin */
|
||||
.line-highlight.line-highlight:before,
|
||||
.line-highlight.line-highlight[data-end]:after {
|
||||
background: var(--syntax-gutter-background-color-selected);
|
||||
color: var(--syntax-fg);
|
||||
padding: 0.1em 0.6em;
|
||||
border-radius: 0.3em;
|
||||
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */
|
||||
}
|
||||
|
||||
/* Hovering over a linkable line number (in the gutter area) */
|
||||
/* Requires Line Numbers plugin as well */
|
||||
pre[id].linkable-line-numbers.linkable-line-numbers
|
||||
span.line-numbers-rows
|
||||
> span:hover:before {
|
||||
background-color: var(--syntax-cursor-line);
|
||||
}
|
||||
|
||||
/* Line Numbers and Command Line plugins overrides */
|
||||
/* Line separating gutter from coding area */
|
||||
.line-numbers.line-numbers .line-numbers-rows,
|
||||
.command-line .command-line-prompt {
|
||||
border-right-color: var(--syntax-guide);
|
||||
}
|
||||
|
||||
/* Stuff in the gutter */
|
||||
.line-numbers .line-numbers-rows > span:before,
|
||||
.command-line .command-line-prompt > span:before {
|
||||
color: var(--syntax-gutter);
|
||||
}
|
||||
|
||||
/* Match Braces plugin overrides */
|
||||
/* Note: Outline colour is inherited from the braces */
|
||||
.rainbow-braces .token.token.punctuation.brace-level-1,
|
||||
.rainbow-braces .token.token.punctuation.brace-level-5,
|
||||
.rainbow-braces .token.token.punctuation.brace-level-9 {
|
||||
color: var(--hue-5);
|
||||
}
|
||||
|
||||
.rainbow-braces .token.token.punctuation.brace-level-2,
|
||||
.rainbow-braces .token.token.punctuation.brace-level-6,
|
||||
.rainbow-braces .token.token.punctuation.brace-level-10 {
|
||||
color: var(--hue-4);
|
||||
}
|
||||
|
||||
.rainbow-braces .token.token.punctuation.brace-level-3,
|
||||
.rainbow-braces .token.token.punctuation.brace-level-7,
|
||||
.rainbow-braces .token.token.punctuation.brace-level-11 {
|
||||
color: var(--hue-2);
|
||||
}
|
||||
|
||||
.rainbow-braces .token.token.punctuation.brace-level-4,
|
||||
.rainbow-braces .token.token.punctuation.brace-level-8,
|
||||
.rainbow-braces .token.token.punctuation.brace-level-12 {
|
||||
color: var(--hue-3);
|
||||
}
|
||||
|
||||
/* Diff Highlight plugin overrides */
|
||||
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
|
||||
pre.diff-highlight > code .token.token.deleted:not(.prefix),
|
||||
pre > code.diff-highlight .token.token.deleted:not(.prefix) {
|
||||
background-color: hsla(353, 100%, 66%, 0.15);
|
||||
}
|
||||
|
||||
pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
|
||||
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection,
|
||||
pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
|
||||
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection {
|
||||
background-color: hsla(353, 95%, 66%, 0.25);
|
||||
}
|
||||
|
||||
pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
|
||||
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
|
||||
pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
|
||||
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
|
||||
background-color: hsla(353, 95%, 66%, 0.25);
|
||||
}
|
||||
|
||||
pre.diff-highlight > code .token.token.inserted:not(.prefix),
|
||||
pre > code.diff-highlight .token.token.inserted:not(.prefix) {
|
||||
background-color: hsla(137, 100%, 55%, 0.15);
|
||||
}
|
||||
|
||||
pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection,
|
||||
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection,
|
||||
pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection,
|
||||
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection {
|
||||
background-color: hsla(135, 73%, 55%, 0.25);
|
||||
}
|
||||
|
||||
pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
|
||||
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
|
||||
pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
|
||||
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
|
||||
background-color: hsla(135, 73%, 55%, 0.25);
|
||||
}
|
||||
|
||||
/* Previewers plugin overrides */
|
||||
/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */
|
||||
/* Border around popup */
|
||||
.prism-previewer.prism-previewer:before,
|
||||
.prism-previewer-gradient.prism-previewer-gradient div {
|
||||
border-color: hsl(224, 13%, 17%);
|
||||
}
|
||||
|
||||
/* Angle and time should remain as circles and are hence not included */
|
||||
.prism-previewer-color.prism-previewer-color:before,
|
||||
.prism-previewer-gradient.prism-previewer-gradient div,
|
||||
.prism-previewer-easing.prism-previewer-easing:before {
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
/* Triangles pointing to the code */
|
||||
.prism-previewer.prism-previewer:after {
|
||||
border-top-color: hsl(224, 13%, 17%);
|
||||
}
|
||||
|
||||
.prism-previewer-flipped.prism-previewer-flipped.after {
|
||||
border-bottom-color: hsl(224, 13%, 17%);
|
||||
}
|
||||
|
||||
/* Background colour within the popup */
|
||||
.prism-previewer-angle.prism-previewer-angle:before,
|
||||
.prism-previewer-time.prism-previewer-time:before,
|
||||
.prism-previewer-easing.prism-previewer-easing {
|
||||
background: hsl(219, 13%, 22%);
|
||||
}
|
||||
|
||||
/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */
|
||||
/* For time, this is the alternate colour */
|
||||
.prism-previewer-angle.prism-previewer-angle circle,
|
||||
.prism-previewer-time.prism-previewer-time circle {
|
||||
stroke: var(--syntax-fg);
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
|
||||
/* Stroke colours of the handle, direction point, and vector itself */
|
||||
.prism-previewer-easing.prism-previewer-easing circle,
|
||||
.prism-previewer-easing.prism-previewer-easing path,
|
||||
.prism-previewer-easing.prism-previewer-easing line {
|
||||
stroke: var(--syntax-fg);
|
||||
}
|
||||
|
||||
/* Fill colour of the handle */
|
||||
.prism-previewer-easing.prism-previewer-easing circle {
|
||||
fill: transparent;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Metadata } from "next";
|
||||
import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import Footer from "./@footer";
|
||||
import Navbar from "./@navbar";
|
||||
import Password from "./@password";
|
||||
import { CheckSitePassword } from "./actions";
|
||||
import "./globals.css";
|
||||
import "./markdown.css";
|
||||
import ThemeProvider from "./theme-provider";
|
||||
|
||||
const sourceSans3 = Source_Sans_3({
|
||||
weight: ["300", "400", "600", "700"],
|
||||
style: ["normal", "italic"],
|
||||
display: "auto",
|
||||
subsets: ["latin", "latin-ext"],
|
||||
variable: "--font-source-sans-3",
|
||||
});
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
weight: ["300", "400", "600", "700"],
|
||||
style: ["normal", "italic"],
|
||||
display: "auto",
|
||||
subsets: ["latin", "latin-ext"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(config.basePath),
|
||||
title: {
|
||||
default: config.siteConfig.siteName,
|
||||
template: config.siteConfig.siteNameTemplate || "%s",
|
||||
},
|
||||
description: config.siteConfig.siteDescription,
|
||||
authors: config.siteConfig.siteAuthor
|
||||
? {
|
||||
name: config.siteConfig.siteAuthor,
|
||||
}
|
||||
: undefined,
|
||||
creator: "mbaharip",
|
||||
icons: [
|
||||
{
|
||||
url: config.siteConfig.favIcon,
|
||||
},
|
||||
],
|
||||
keywords: ["gdrive", "index", "nextjs", "reactjs"],
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: config.siteConfig.siteName,
|
||||
images: [
|
||||
{
|
||||
url: "/og.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
creator: config.siteConfig.twitterHandle,
|
||||
},
|
||||
robots: config.siteConfig.robots,
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const unlocked = await CheckSitePassword();
|
||||
const formatFooter = (text: string | string[]): string => {
|
||||
let toFormat: string;
|
||||
if (Array.isArray(text)) {
|
||||
toFormat = text.join(`\n\n`);
|
||||
} else {
|
||||
toFormat = text;
|
||||
}
|
||||
return toFormat
|
||||
.replaceAll("{{ year }}", new Date().getFullYear().toString())
|
||||
.replaceAll(
|
||||
"{{ repository }}",
|
||||
"[Repository](https://github.com/mbaharip/next-gdrive-index)",
|
||||
)
|
||||
.replaceAll("{{ author }}", config.siteConfig.siteAuthor || "mbaharip")
|
||||
.replaceAll("{{ version }}", config.version || "0.0.0")
|
||||
.replaceAll("{{ siteName }}", config.siteConfig.siteName)
|
||||
.replaceAll("{{ creator }}", "mbaharip");
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang='en'>
|
||||
<body
|
||||
className={cn(
|
||||
"h-full bg-background font-sans text-foreground",
|
||||
jetbrainsMono.variable,
|
||||
sourceSans3.variable,
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Navbar />
|
||||
<main
|
||||
slot='content'
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-screen-desktop",
|
||||
"relative left-0 top-0",
|
||||
"flex flex-grow flex-col gap-3 p-6",
|
||||
"tablet:gap-6",
|
||||
)}
|
||||
>
|
||||
{config.siteConfig.privateIndex && !unlocked.success ? (
|
||||
<Password
|
||||
path='global'
|
||||
errorMessage={unlocked.message}
|
||||
/>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
</main>
|
||||
{config.siteConfig.footer && (
|
||||
<Footer content={formatFooter(config.siteConfig.footer)} />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
export default function RootLoading() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.markdown {
|
||||
@apply bg-card text-base leading-relaxed tracking-wide;
|
||||
}
|
||||
|
||||
.markdown p {
|
||||
@apply leading-loose;
|
||||
}
|
||||
|
||||
/* .markdown h1 {
|
||||
@apply text-4xl font-medium;
|
||||
}
|
||||
.markdown h2 {
|
||||
@apply text-3xl font-medium;
|
||||
}
|
||||
.markdown h3 {
|
||||
@apply text-2xl font-medium;
|
||||
}
|
||||
.markdown h4 {
|
||||
@apply text-xl font-medium;
|
||||
}
|
||||
.markdown h5 {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
.markdown h6 {
|
||||
@apply text-base;
|
||||
} */
|
||||
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3 {
|
||||
@apply mt-4 border-b-2 border-b-border first-letter:capitalize;
|
||||
}
|
||||
|
||||
.markdown code:not(pre code) {
|
||||
@apply whitespace-nowrap rounded-[var(--radius)] bg-muted px-2 py-0.5 leading-normal text-foreground;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
@apply text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.markdown p {
|
||||
@apply whitespace-normal text-pretty;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
@apply rounded-[var(--radius)];
|
||||
}
|
||||
|
||||
.markdown ul {
|
||||
@apply my-1.5 list-disc leading-loose;
|
||||
}
|
||||
|
||||
.markdown ol {
|
||||
@apply my-1.5 list-decimal leading-loose;
|
||||
}
|
||||
|
||||
.markdown li {
|
||||
@apply my-0 ml-2;
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
@apply mb-4 block w-full border-collapse border-spacing-0 overflow-auto;
|
||||
}
|
||||
|
||||
.markdown table th,
|
||||
.markdown table td {
|
||||
@apply border border-border px-2 py-1;
|
||||
}
|
||||
|
||||
.markdown table th {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
.markdown table tr {
|
||||
@apply bg-secondary/50 text-foreground;
|
||||
}
|
||||
|
||||
.markdown table tr:nth-child(2n) {
|
||||
@apply bg-secondary/25 text-foreground;
|
||||
}
|
||||
|
||||
.markdown ul.contains-task-list {
|
||||
@apply list-none;
|
||||
}
|
||||
|
||||
.markdown ul.contains-task-list li {
|
||||
@apply ml-1 flex items-center;
|
||||
}
|
||||
|
||||
.markdown input[type="checkbox"] {
|
||||
@apply mr-2 aspect-square h-4 w-4 rounded-full p-2;
|
||||
@apply appearance-none border border-border;
|
||||
@apply disabled:bg-primary disabled:checked:bg-blue-600 dark:disabled:checked:bg-blue-400;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
@apply my-2 rounded-r-[var(--radius)] border-l-4 border-l-primary bg-muted py-4 pl-2 text-foreground;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
@apply my-4 border-b border-t-0 border-b-border;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import NotFoundComponent from "./@not-found";
|
||||
|
||||
export default function NotFound() {
|
||||
return <NotFoundComponent />;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import FileBrowser from "./@explorer";
|
||||
import Header from "./@header";
|
||||
import HeaderButton from "./@header.button";
|
||||
import Markdown from "./@markdown";
|
||||
import { GetFiles, GetReadme } from "./actions";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RootPage() {
|
||||
const [data, readme] = await Promise.all([
|
||||
GetFiles({}),
|
||||
GetReadme(undefined),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
|
||||
<Header name='Root' />
|
||||
<div
|
||||
slot='content'
|
||||
className='w-full'
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<HeaderButton />
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<FileBrowser
|
||||
files={data.files}
|
||||
nextPageToken={data.nextPageToken}
|
||||
root
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{readme && (
|
||||
<div
|
||||
slot='readme'
|
||||
className='w-full'
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<CardTitle>README.md</CardTitle>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Markdown content={readme} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
.rhap_container {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
padding: 10px 15px;
|
||||
/* background-color: #fff; */
|
||||
background-color: hsl(var(--muted));
|
||||
/* box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.2); */
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
.rhap_container:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
.rhap_container svg {
|
||||
vertical-align: initial;
|
||||
}
|
||||
|
||||
.rhap_header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rhap_footer {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.rhap_main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.rhap_stacked .rhap_controls-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.rhap_horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
.rhap_horizontal .rhap_controls-section {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.rhap_horizontal-reverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.rhap_horizontal-reverse .rhap_controls-section {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.rhap_stacked-reverse {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.rhap_stacked-reverse .rhap_controls-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rhap_progress-section {
|
||||
display: flex;
|
||||
flex: 3 1 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
flex: 1 0 auto;
|
||||
align-self: center;
|
||||
margin: 0 calc(10px + 1%);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.rhap_progress-container:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.rhap_time {
|
||||
/* color: #333; */
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.rhap_progress-bar {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
/* background-color: #dddddd; */
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rhap_progress-filled {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
/* background-color: #868686; */
|
||||
background-color: hsl(var(--primary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rhap_progress-bar-show-download {
|
||||
/* background-color: rgba(221, 221, 221, 0.5); */
|
||||
background-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.rhap_download-progress {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
/* background-color: #dddddd; */
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rhap_progress-indicator {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: -10px;
|
||||
top: -8px;
|
||||
/* background: #868686; */
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 50px;
|
||||
/* box-shadow: rgba(134, 134, 134, 0.5) 0 0 5px; */
|
||||
box-shadow: hsl(var(--border) 0 0 5px);
|
||||
}
|
||||
|
||||
.rhap_controls-section {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_additional-controls {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_repeat-button {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
/* color: #868686; */
|
||||
color: hsl(var(--primary));
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.rhap_main-controls {
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_main-controls-button {
|
||||
margin: 0 3px;
|
||||
/* color: #868686; */
|
||||
color: hsl(var(--primary));
|
||||
font-size: 35px;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.rhap_play-pause-button {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.rhap_volume-controls {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_volume-button {
|
||||
flex: 0 0 26px;
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
/* color: #868686; */
|
||||
color: hsl(var(--primary));
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.rhap_volume-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 1 100px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.rhap_volume-bar-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rhap_volume-bar-area:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.rhap_volume-bar {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
/* background: #dddddd; */
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rhap_volume-indicator {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
/* background: #868686; */
|
||||
background: hsl(var(--primary));
|
||||
opacity: 0.9;
|
||||
border-radius: 50px;
|
||||
/* box-shadow: rgba(134, 134, 134, 0.5) 0 0 3px; */
|
||||
box-shadow: hsl(var(--border) 0 0 3px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rhap_volume-indicator:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.rhap_volume-filled {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
/* background-color: #868686; */
|
||||
background-color: hsl(var(--primary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Utils */
|
||||
.rhap_button-clear {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rhap_button-clear:hover {
|
||||
opacity: 0.9;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
.rhap_button-clear:active {
|
||||
opacity: 0.95;
|
||||
}
|
||||
.rhap_button-clear:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemeProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { TooltipProvider } from "~/components/ui/tooltip";
|
||||
|
||||
import LayoutProvider from "~/context/layoutContext";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
export default function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<NextTopLoader color='hsl(var(--primary))' />
|
||||
<NextThemeProvider {...props}>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<div
|
||||
vaul-drawer-wrapper=''
|
||||
className={cn(
|
||||
"bg-background font-sans text-foreground",
|
||||
"h-full min-h-screen w-full",
|
||||
"flex flex-col items-start",
|
||||
// "tablet:flex-row",
|
||||
"pr-1.5",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<Toaster
|
||||
position={config.siteConfig.toaster?.position}
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "hsl(var(--accent))",
|
||||
color: "hsl(var(--accent-foreground))",
|
||||
borderRadius: "var(--radius)",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
},
|
||||
duration: config.siteConfig.toaster?.duration,
|
||||
loading: {
|
||||
duration: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</NextThemeProvider>
|
||||
</LayoutProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Icon, IconifyIcon } from "@iconify/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface AlertProps {
|
||||
variant?: "success" | "error" | "warning" | "info";
|
||||
icon?: string | IconifyIcon;
|
||||
title?: string;
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
export default function Alert(props: AlertProps) {
|
||||
const {
|
||||
variant = "info",
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
className,
|
||||
} = props;
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-full h-fit flex flex-col rounded-lg gap-1 px-4 py-2",
|
||||
variant === "success" &&
|
||||
"bg-success-100 text-success-main",
|
||||
variant === "error" &&
|
||||
"bg-danger-100 text-danger-main",
|
||||
variant === "warning" &&
|
||||
"bg-warning-100 text-warning-main",
|
||||
variant === "info" && "bg-info-100 text-info-main",
|
||||
className && className,
|
||||
)}
|
||||
>
|
||||
{(icon || title) && (
|
||||
<div className='flex gap-1 items-center'>
|
||||
{icon && (
|
||||
<Icon
|
||||
icon={icon}
|
||||
color='currentColor'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
)}
|
||||
{title && (
|
||||
<span className='font-medium text-lg'>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className='text-sm'>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Icon, IconifyIcon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
import ClickAway from "components/ClickAway";
|
||||
|
||||
interface BreadcrumbProps {
|
||||
paths: Record<"label" | "path", string>[];
|
||||
separator?: string | IconifyIcon;
|
||||
maxPath?: number;
|
||||
}
|
||||
|
||||
export default function Breadcrumb(props: BreadcrumbProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
paths,
|
||||
separator = "ion:chevron-forward",
|
||||
maxPath = 2,
|
||||
} = props;
|
||||
const [breadcrumbPaths, setBreadcrumbPaths] =
|
||||
useState<Record<"label" | "path", string>[]>(paths);
|
||||
const [isPathSliced, setIsPathSliced] =
|
||||
useState<boolean>(false);
|
||||
const [hiddenPaths, setHiddenPaths] = useState<
|
||||
Record<"label" | "path", string>[]
|
||||
>([]);
|
||||
const [menuAnchor, setMenuAnchor] =
|
||||
useState<HTMLElement | null>(null);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (paths.length > maxPath) {
|
||||
const newPaths = paths.slice(
|
||||
paths.length - maxPath,
|
||||
paths.length,
|
||||
);
|
||||
const hiddenPaths = paths.slice(
|
||||
0,
|
||||
paths.length - maxPath,
|
||||
);
|
||||
setBreadcrumbPaths(newPaths);
|
||||
setIsPathSliced(true);
|
||||
setHiddenPaths(hiddenPaths);
|
||||
} else {
|
||||
setBreadcrumbPaths(paths);
|
||||
setIsPathSliced(false);
|
||||
}
|
||||
}, [paths, maxPath]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center flex-wrap w-full gap-2'>
|
||||
<Link href={"/"}>
|
||||
<ButtonIcon
|
||||
icon={"ion:home"}
|
||||
variant='transparent'
|
||||
className='flex-shrink-0 flex-grow-0 p-1'
|
||||
/>
|
||||
{/* <Icon
|
||||
icon='ion:home'
|
||||
color='currentColor'
|
||||
width={20}
|
||||
height={20}
|
||||
className='flex-shrink-0 flex-grow-0'
|
||||
/> */}
|
||||
</Link>
|
||||
{isPathSliced && (
|
||||
<>
|
||||
<Icon
|
||||
icon={separator}
|
||||
color='currentColor'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<ButtonIcon
|
||||
icon={"ion:ellipsis-horizontal"}
|
||||
variant='transparent'
|
||||
className='flex-shrink-0 flex-grow-0 p-1'
|
||||
ref={setMenuAnchor}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuOpen(!menuOpen);
|
||||
}}
|
||||
/>
|
||||
{/* <span className='mx-1'>...</span> */}
|
||||
</>
|
||||
)}
|
||||
{breadcrumbPaths.map((path, index) => (
|
||||
<Fragment key={`breadcrumb-${path}@${index}`}>
|
||||
<Icon
|
||||
icon={separator}
|
||||
color='currentColor'
|
||||
width={20}
|
||||
height={20}
|
||||
className='flex-shrink-0 flex-grow-0'
|
||||
/>
|
||||
<Link
|
||||
href={path.path}
|
||||
className={`${
|
||||
path.path === router.asPath
|
||||
? "text-primary-50 pointer-events-none cursor-default"
|
||||
: "text-primary-300 hover:text-primary-100 cursor-pointer"
|
||||
} transition-smooth max-w-md mx-1 w-fit ${
|
||||
index === breadcrumbPaths.length - 1 &&
|
||||
"line-clamp-1"
|
||||
}`}
|
||||
>
|
||||
{decodeURIComponent(path.label)}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ClickAway
|
||||
open={menuOpen}
|
||||
onClickAway={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
|
||||
menuOpen
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
id='more-menu'
|
||||
className={`absolute drop-shadow-md mr-4 w-full max-w-sm bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
|
||||
menuOpen
|
||||
? `opacity-100 pointer-events-auto`
|
||||
: `opacity-0 pointer-events-none `
|
||||
}`}
|
||||
style={{
|
||||
top: menuAnchor
|
||||
? menuAnchor?.getBoundingClientRect()
|
||||
.bottom ?? 0
|
||||
: "unset",
|
||||
left: menuAnchor
|
||||
? menuAnchor?.getBoundingClientRect().left ??
|
||||
0
|
||||
: "unset",
|
||||
}}
|
||||
>
|
||||
<div className='w-full bg-primary-800 flex flex-col gap-1'>
|
||||
{hiddenPaths.map((path, index) => (
|
||||
<Link
|
||||
href={path.path}
|
||||
key={`hidden-path-${path}@${index}`}
|
||||
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2 w-full'
|
||||
title={decodeURIComponent(path.label)}
|
||||
>
|
||||
{decodeURIComponent(path.label)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ClickAway>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Icon, IconifyIcon } from "@iconify/react";
|
||||
import { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
fullWidth?: boolean;
|
||||
variant?:
|
||||
| "primary"
|
||||
| "accent"
|
||||
| "success"
|
||||
| "danger"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "transparent";
|
||||
size?: "small" | "medium" | "large";
|
||||
pill?: boolean;
|
||||
square?: boolean;
|
||||
rounded?: "none" | "small" | "medium" | "large";
|
||||
startIcon?: string | IconifyIcon;
|
||||
endIcon?: string | IconifyIcon;
|
||||
children: ReactNode;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Button(props: ButtonProps) {
|
||||
const {
|
||||
fullWidth = false,
|
||||
variant = "primary",
|
||||
size = "medium",
|
||||
pill = false,
|
||||
square = false,
|
||||
rounded = "none",
|
||||
loading = false,
|
||||
startIcon,
|
||||
endIcon,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={twMerge(
|
||||
"outline-none border-none capitalize transition-smooth",
|
||||
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
|
||||
"rounded-lg w-fit flex items-center justify-center flex-grow",
|
||||
fullWidth && "w-full",
|
||||
rounded === "none" && "rounded-none",
|
||||
rounded === "small" && "rounded-sm",
|
||||
rounded === "medium" && "rounded-md",
|
||||
rounded === "large" && "rounded-lg",
|
||||
pill && "rounded-full",
|
||||
size === "small" && "px-2 py-1 text-sm gap-1",
|
||||
size === "medium" && "px-4 py-2 text-base gap-2",
|
||||
size === "large" && "px-8 py-4 text-lg gap-4",
|
||||
square && "aspect-square",
|
||||
variant === "primary" &&
|
||||
"bg-primary-600 text-primary-50 hover:bg-primary-500 active:bg-primary-700",
|
||||
variant === "accent" &&
|
||||
"bg-accent-600 text-accent-50 hover:bg-accent-500 active:bg-accent-700",
|
||||
variant === "success" &&
|
||||
"bg-success-600 text-success-50 hover:bg-success-500 active:bg-success-700",
|
||||
variant === "danger" &&
|
||||
"bg-danger-600 text-danger-50 hover:bg-danger-500 active:bg-danger-700",
|
||||
variant === "warning" &&
|
||||
"bg-warning-600 text-warning-50 hover:bg-warning-500 active:bg-warning-700",
|
||||
variant === "info" &&
|
||||
"bg-info-600 text-info-50 hover:bg-info-500 active:bg-info-700",
|
||||
variant === "transparent" &&
|
||||
"bg-transparent text-primary-50 hover:bg-primary-500 active:bg-primary-700",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-primary-700 disabled:hover:bg-primary-700 disabled:active:bg-primary-700 disabled:text-primary-50",
|
||||
className && className,
|
||||
)}
|
||||
disabled={loading ?? props.disabled}
|
||||
{...rest}
|
||||
>
|
||||
{props.loading ? (
|
||||
<Icon
|
||||
icon={"mdi:loading"}
|
||||
color='white'
|
||||
className={twMerge(
|
||||
"w-5 h-5 animate-spin",
|
||||
size === "small" && "w-4 h-4",
|
||||
size === "medium" && "w-5 h-5",
|
||||
size === "large" && "w-6 h-6",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{startIcon && (
|
||||
<Icon
|
||||
icon={startIcon}
|
||||
color='white'
|
||||
className={twMerge(
|
||||
"w-5 h-5",
|
||||
size === "small" && "w-4 h-4",
|
||||
size === "medium" && "w-5 h-5",
|
||||
size === "large" && "w-6 h-6",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{endIcon && (
|
||||
<Icon
|
||||
icon={endIcon}
|
||||
color='white'
|
||||
className={twMerge(
|
||||
"w-5 h-5",
|
||||
size === "small" && "w-4 h-4",
|
||||
size === "medium" && "w-5 h-5",
|
||||
size === "large" && "w-6 h-6",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ButtonGroupProps {
|
||||
children: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
rounded?: "none" | "small" | "medium" | "large";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ButtonGroup(
|
||||
props: ButtonGroupProps,
|
||||
) {
|
||||
const {
|
||||
children,
|
||||
fullWidth = false,
|
||||
rounded = "large",
|
||||
className,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row items-center justify-center overflow-hidden",
|
||||
fullWidth && "w-full",
|
||||
rounded === "none" && "rounded-none",
|
||||
rounded === "small" && "rounded-sm",
|
||||
rounded === "medium" && "rounded-md",
|
||||
rounded === "large" && "rounded-lg",
|
||||
className && className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Icon, IconifyIcon } from "@iconify/react";
|
||||
import { ForwardRefRenderFunction, forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ButtonIconProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: string | IconifyIcon;
|
||||
size?: "small" | "medium" | "large";
|
||||
variant?: "primary" | "accent" | "success" | "danger" | "warning" | "info" | "transparent";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonIcon: ForwardRefRenderFunction<HTMLButtonElement, ButtonIconProps> = (props, ref) => {
|
||||
const { icon, size = "medium", variant = "primary", className, ...rest } = props;
|
||||
let iconSize = 24;
|
||||
switch (size) {
|
||||
case "small":
|
||||
iconSize = 16;
|
||||
break;
|
||||
case "medium":
|
||||
iconSize = 24;
|
||||
break;
|
||||
case "large":
|
||||
iconSize = 32;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"outline-none border-none capitalize transition-smooth aspect-square",
|
||||
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
|
||||
"rounded-full flex items-center justify-center w-fit h-fit p-0",
|
||||
size === "small" && "w-6 h-6",
|
||||
size === "medium" && "w-8 h-8",
|
||||
size === "large" && "w-12 h-12",
|
||||
variant === "primary" && "bg-primary-600/25 text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
|
||||
variant === "accent" && "bg-accent-600/25 text-accent-50 hover:bg-accent-500/50 active:bg-accent-700",
|
||||
variant === "success" && "bg-success-600/25 text-success-50 hover:bg-success-500/50 active:bg-success-700",
|
||||
variant === "danger" && "bg-danger-600/25 text-danger-50 hover:bg-danger-500/50 active:bg-danger-700",
|
||||
variant === "warning" && "bg-warning-600/25 text-warning-50 hover:bg-warning-500/50 active:bg-warning-700",
|
||||
variant === "info" && "bg-info-600/25 text-info-50 hover:bg-info-500/50 active:bg-info-700",
|
||||
variant === "transparent" && "bg-transparent text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
|
||||
props.disabled && "opacity-50 cursor-not-allowed",
|
||||
className && className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
color='white'
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(ButtonIcon);
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ClickAwayProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClickAway: (e: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
export default function ClickAway(props: ClickAwayProps) {
|
||||
const { children, open, onClickAway, className, ...rest } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const body = document.querySelector("body");
|
||||
const isScrollbarRendered = document.documentElement.clientHeight < document.documentElement.scrollHeight;
|
||||
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
if (open && isScrollbarRendered) {
|
||||
body?.style.setProperty("overflow", "hidden");
|
||||
if (!isMobile) body?.style.setProperty("padding-right", "4px");
|
||||
} else {
|
||||
body?.style.removeProperty("overflow");
|
||||
if (!isMobile) body?.style.removeProperty("padding-right");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-full h-full bg-primary-950/50 backdrop-blur-none transition-smooth",
|
||||
className && className,
|
||||
)}
|
||||
onClick={onClickAway}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
import ClickAway from "components/ClickAway";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
import bytesToReadable from "utils/bytesFormat";
|
||||
import { stripExtension } from "utils/filesHelper";
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
import { getPreviewIcon } from "utils/previewHelper";
|
||||
import { createDownloadToken } from "utils/tokenHelper";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
|
||||
interface ListItemProps {
|
||||
file: IGDriveFiles;
|
||||
isProtected: boolean;
|
||||
}
|
||||
export default function ListItem(props: ListItemProps) {
|
||||
const router = useRouter();
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
|
||||
|
||||
const [fileInfo, setFileInfo] = useState<string[]>([]);
|
||||
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
|
||||
const [filePath, setFilePath] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = router.asPath.slice(1);
|
||||
const filePath = props.file.name;
|
||||
const path = [currentPath, encodeURIComponent(filePath)].join("/");
|
||||
|
||||
// If start with /, remove it
|
||||
if (path.startsWith("/")) {
|
||||
setFilePath(path.slice(1));
|
||||
} else {
|
||||
setFilePath(path);
|
||||
}
|
||||
}, [router.asPath, props.file.name]);
|
||||
|
||||
useEffect(() => {
|
||||
const info = [];
|
||||
const extInfo = [];
|
||||
props.file.modifiedTime &&
|
||||
info.push(
|
||||
new Date(props.file.modifiedTime).toLocaleDateString("default", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
);
|
||||
props.file.size && info.push(bytesToReadable(props.file.size));
|
||||
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
|
||||
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
|
||||
setExtensionInfo(extInfo);
|
||||
setFileInfo(info);
|
||||
}, [props.file]);
|
||||
|
||||
const handleCopyFileLink = () => {
|
||||
try {
|
||||
const url = `${window.location.origin}/${filePath}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success("Download URL copied to clipboard.");
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to copy Download URL to clipboard.");
|
||||
console.error(error.message);
|
||||
} finally {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
|
||||
try {
|
||||
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success("Download URL copied to clipboard.");
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to copy Download URL to clipboard.");
|
||||
console.error(error.message);
|
||||
} finally {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
|
||||
window.open(
|
||||
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
|
||||
"_blank",
|
||||
);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
title={props.file.name}
|
||||
href={filePath}
|
||||
className='w-full p-1 flex relative items-stretch gap-2 tablet:gap-4 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
|
||||
>
|
||||
<div className='flex flex-col items-stretch gap-2 w-full relative'>
|
||||
{props.file.thumbnailLink &&
|
||||
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
|
||||
<img
|
||||
src={props.file.thumbnailLink}
|
||||
alt={props.file.name}
|
||||
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48'
|
||||
loading='lazy'
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
icon={
|
||||
props.file.mimeType === "application/vnd.google-apps.folder"
|
||||
? "ion:folder"
|
||||
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
|
||||
}
|
||||
color='currentColor'
|
||||
className='flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48 p-8 tablet:p-16'
|
||||
/>
|
||||
)}
|
||||
<div className='flex flex-col p-1'>
|
||||
<span className='line-clamp-1 break-all whitespace-pre-wrap w-full text-center my-1'>
|
||||
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
|
||||
</span>{" "}
|
||||
<div className='flex items-center justify-start gap-2 text-primary-500'>
|
||||
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-start gap-2 text-primary-500'>
|
||||
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute top-1 right-1 z-10'>
|
||||
<Tooltip content='More'>
|
||||
<ButtonIcon
|
||||
className='bg-primary-950/50'
|
||||
ref={setMenuAnchor}
|
||||
icon='ion:ellipsis-vertical'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuOpen(!menuOpen);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<ClickAway
|
||||
open={menuOpen}
|
||||
onClickAway={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
|
||||
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
id='more-menu'
|
||||
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
|
||||
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
|
||||
}`}
|
||||
style={{
|
||||
top: menuAnchor ? menuAnchor?.getBoundingClientRect().bottom ?? 0 : "unset",
|
||||
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 150 : "unset",
|
||||
}}
|
||||
>
|
||||
<div className='w-full bg-primary-800 flex flex-col gap-1'>
|
||||
{props.file.mimeType.includes("folder") ? (
|
||||
<>
|
||||
<div
|
||||
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
|
||||
onClick={handleCopyFileLink}
|
||||
>
|
||||
<Icon
|
||||
icon='ion:share-social'
|
||||
color='currentColor'
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<span>Copy folder link</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
|
||||
onClick={handleCopyFileLink}
|
||||
>
|
||||
<Icon
|
||||
icon='ion:share-social'
|
||||
color='currentColor'
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<span>Copy file link</span>
|
||||
</div>
|
||||
<div
|
||||
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
|
||||
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
|
||||
>
|
||||
<Icon
|
||||
icon='ion:share-social'
|
||||
color='currentColor'
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<span>Copy download link</span>
|
||||
</div>
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
<div
|
||||
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
|
||||
onClick={() => handleDownload(props.file, props.isProtected)}
|
||||
>
|
||||
<Icon
|
||||
icon='ion:download'
|
||||
color='currentColor'
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<span>Download</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ClickAway>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
import ClickAway from "components/ClickAway";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
import bytesToReadable from "utils/bytesFormat";
|
||||
import { stripExtension } from "utils/filesHelper";
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
import { getPreviewIcon } from "utils/previewHelper";
|
||||
import { createDownloadToken } from "utils/tokenHelper";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
|
||||
interface ListItemProps {
|
||||
file: IGDriveFiles;
|
||||
isProtected: boolean;
|
||||
}
|
||||
export default function ListItem(props: ListItemProps) {
|
||||
const router = useRouter();
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
|
||||
|
||||
const [fileInfo, setFileInfo] = useState<string[]>([]);
|
||||
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
|
||||
const [filePath, setFilePath] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = router.asPath.slice(1);
|
||||
const filePath = props.file.name;
|
||||
const path = [currentPath, encodeURIComponent(filePath)].join("/");
|
||||
|
||||
// If start with /, remove it
|
||||
if (path.startsWith("/")) {
|
||||
setFilePath(path.slice(1));
|
||||
} else {
|
||||
setFilePath(path);
|
||||
}
|
||||
}, [router.asPath, props.file.name]);
|
||||
|
||||
useEffect(() => {
|
||||
const info = [];
|
||||
const extInfo = [];
|
||||
props.file.modifiedTime &&
|
||||
info.push(
|
||||
new Date(props.file.modifiedTime).toLocaleDateString("default", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
);
|
||||
props.file.size && info.push(bytesToReadable(props.file.size));
|
||||
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
|
||||
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
|
||||
setExtensionInfo(extInfo);
|
||||
setFileInfo(info);
|
||||
}, [props.file]);
|
||||
|
||||
const handleCopyFileLink = () => {
|
||||
try {
|
||||
const url = `${window.location.origin}/${filePath}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success("Download URL copied to clipboard.");
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to copy Download URL to clipboard.");
|
||||
console.error(error.message);
|
||||
} finally {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
|
||||
try {
|
||||
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success("Download URL copied to clipboard.");
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to copy Download URL to clipboard.");
|
||||
console.error(error.message);
|
||||
} finally {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
|
||||
window.open(
|
||||
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
|
||||
"_blank",
|
||||
);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
title={props.file.name}
|
||||
href={filePath}
|
||||
className='w-full p-1 pr-2 flex items-center gap-4 tablet:gap-8 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
|
||||
>
|
||||
<div className='flex items-center gap-4 w-full flex-grow-0 flex-shrink'>
|
||||
{props.file.thumbnailLink &&
|
||||
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
|
||||
<img
|
||||
src={props.file.thumbnailLink}
|
||||
alt={props.file.name}
|
||||
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
|
||||
loading='lazy'
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
icon={
|
||||
props.file.mimeType === "application/vnd.google-apps.folder"
|
||||
? "ion:folder"
|
||||
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
|
||||
}
|
||||
color='currentColor'
|
||||
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
|
||||
/>
|
||||
)}
|
||||
<div className='flex flex-col'>
|
||||
<span className='line-clamp-1 break-all whitespace-pre-wrap'>
|
||||
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
|
||||
</span>{" "}
|
||||
<div className='flex items-center justify-start gap-2 text-primary-500'>
|
||||
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-start gap-2 text-primary-500'>
|
||||
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative z-10'>
|
||||
<Tooltip content='More'>
|
||||
<ButtonIcon
|
||||
ref={setMenuAnchor}
|
||||
icon='ion:ellipsis-vertical'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuOpen(!menuOpen);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<ClickAway
|
||||
open={menuOpen}
|
||||
onClickAway={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
|
||||
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
id='more-menu'
|
||||
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
|
||||
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
|
||||
}`}
|
||||
style={{
|
||||
top: menuAnchor ? (menuAnchor?.getBoundingClientRect().bottom ?? 0) - 36 : "unset",
|
||||
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 200 : "unset",
|
||||
}}
|
||||
>
|
||||
<div className='w-full bg-primary-800 flex flex-col gap-1'>
|
||||
{props.file.mimeType.includes("folder") ? (
|
||||
<>
|
||||
<div
|
||||
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
|
||||
onClick={handleCopyFileLink}
|
||||
>
|
||||
<Icon
|
||||
icon='ion:share-social'
|
||||
color='currentColor'
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<span>Copy folder link</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
|
||||
onClick={handleCopyFileLink}
|
||||
>
|
||||
<Icon
|
||||
icon='ion:share-social'
|
||||
color='currentColor'
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<span>Copy file link</span>
|
||||
</div>
|
||||
<div
|
||||
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
|
||||
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
|
||||
>
|
||||
<Icon
|
||||
icon='ion:share-social'
|
||||
color='currentColor'
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<span>Copy download link</span>
|
||||
</div>
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
<div
|
||||
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
|
||||
onClick={() => handleDownload(props.file, props.isProtected)}
|
||||
>
|
||||
<Icon
|
||||
icon='ion:download'
|
||||
color='currentColor'
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<span>Download</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ClickAway>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { LucideProps, icons } from "lucide-react";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
type Props = {
|
||||
name: keyof typeof icons;
|
||||
wrapper?: ReactElement<"div">;
|
||||
} & LucideProps;
|
||||
|
||||
export default function Icon({ name, wrapper, ...props }: Props) {
|
||||
const LucideIcon = icons[name];
|
||||
|
||||
return (
|
||||
<div {...wrapper}>
|
||||
<LucideIcon {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import GridItem from "components/File/GridItem";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
|
||||
interface GridViewProps {
|
||||
data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
};
|
||||
isProtected: boolean;
|
||||
}
|
||||
|
||||
export default function ViewGrid(props: GridViewProps) {
|
||||
return (
|
||||
<div className='w-full gap-4 grid grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4'>
|
||||
{props.data.folders.map((folder) => (
|
||||
<GridItem
|
||||
key={folder.encryptedId}
|
||||
file={folder}
|
||||
isProtected={props.isProtected}
|
||||
/>
|
||||
))}
|
||||
{props.data.files.map((file) => (
|
||||
<GridItem
|
||||
key={file.encryptedId}
|
||||
file={file}
|
||||
isProtected={props.isProtected}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import ListItem from "components/File/ListItem";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
|
||||
interface ViewListProps {
|
||||
data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
};
|
||||
isProtected: boolean;
|
||||
}
|
||||
|
||||
export default function ViewList(props: ViewListProps) {
|
||||
return (
|
||||
<div className='w-full flex flex-col gap-2 rounded-lg'>
|
||||
{props.data.folders.map((folder) => (
|
||||
<ListItem
|
||||
key={folder.encryptedId}
|
||||
file={folder}
|
||||
isProtected={props.isProtected}
|
||||
/>
|
||||
))}
|
||||
{props.data.files.map((file) => (
|
||||
<ListItem
|
||||
key={file.encryptedId}
|
||||
file={file}
|
||||
isProtected={props.isProtected}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import { useRouter } from "next/router";
|
||||
import { poppins } from "pages/_app";
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import Button from "components/Button";
|
||||
import Markdown from "components/Markdown";
|
||||
|
||||
import { addNewPassword } from "utils/passwordHelper";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APIGetFileResponse } from "types/api/response";
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
import Header from "../Header";
|
||||
import PasswordLayout from "../Password";
|
||||
import ViewGrid from "./Grid";
|
||||
import ViewList from "./List";
|
||||
|
||||
const Loading = () => (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading view...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ExplorerLayoutProps {
|
||||
data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
};
|
||||
readmeFile: string | null;
|
||||
isProtected: boolean;
|
||||
isFileProtected?: boolean;
|
||||
encryptedId?: string;
|
||||
}
|
||||
|
||||
export default function ExplorerLayout(props: ExplorerLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<ExplorerLayoutProps["data"]>(props.data);
|
||||
const [view, setView] = useState<"grid" | "list">(() => {
|
||||
const viewType = localStorage.getItem(Constant.storage_ViewType);
|
||||
if (!viewType) {
|
||||
localStorage.setItem(Constant.storage_ViewType, "list");
|
||||
return "list";
|
||||
}
|
||||
if (viewType === "grid") {
|
||||
return "grid";
|
||||
} else {
|
||||
return "list";
|
||||
}
|
||||
});
|
||||
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
|
||||
const [isButtonLoading, setIsButtonLoading] = useState<boolean>(false);
|
||||
const [ViewComponent, setViewComponent] = useState<ReactNode>(
|
||||
<ViewList
|
||||
key={"file-explorer"}
|
||||
data={data}
|
||||
isProtected={props.isFileProtected ?? false}
|
||||
/>,
|
||||
);
|
||||
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
|
||||
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
new Promise(async (resolve) => {
|
||||
if (view === "list") {
|
||||
setViewComponent(
|
||||
<ViewList
|
||||
key={"file-explorer"}
|
||||
data={data}
|
||||
isProtected={props.isFileProtected ?? false}
|
||||
/>,
|
||||
);
|
||||
} else if (view === "grid") {
|
||||
setViewComponent(
|
||||
<ViewGrid
|
||||
key={"file-explorer"}
|
||||
data={data}
|
||||
isProtected={props.isFileProtected ?? false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
resolve(true);
|
||||
}).then(() => {
|
||||
setIsViewLoading(false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view, data]);
|
||||
useEffect(() => {
|
||||
setShowPasswordLayout(props.isProtected);
|
||||
}, [props.isProtected]);
|
||||
|
||||
useEffect(() => {
|
||||
const paths = router.asPath.split("/").filter((path) => path !== "");
|
||||
const newPaths: Record<"label" | "path", string>[] = [];
|
||||
paths.forEach((path, index) => {
|
||||
newPaths.push({
|
||||
label: path,
|
||||
path: `/${paths.slice(0, index + 1).join("/")}`,
|
||||
});
|
||||
});
|
||||
|
||||
setBreadcrumbPaths(newPaths);
|
||||
}, [router]);
|
||||
|
||||
const handleChangeView = useCallback(
|
||||
(view: "grid" | "list") => {
|
||||
setIsViewLoading(true);
|
||||
setView(view);
|
||||
localStorage.setItem(Constant.storage_ViewType, view);
|
||||
},
|
||||
[setView],
|
||||
);
|
||||
const handleLoadNextPage = useCallback(
|
||||
async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsButtonLoading(true);
|
||||
if (!data.pageToken) {
|
||||
setIsButtonLoading(false);
|
||||
return;
|
||||
}
|
||||
const newData = await axios.get<APIGetFileResponse>("/api/getData", {
|
||||
params: {
|
||||
encryptedId: props.encryptedId,
|
||||
pageToken: data.pageToken,
|
||||
},
|
||||
});
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
files: [...prev.files, ...newData.data.data.files],
|
||||
folders: [...prev.folders, ...newData.data.data.folders],
|
||||
pageToken: newData.data.data.pageToken,
|
||||
}));
|
||||
setIsButtonLoading(false);
|
||||
},
|
||||
[data, props.encryptedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='w-full flex h-full flex-col gap-4 relative'>
|
||||
{/* Top */}
|
||||
<Header
|
||||
viewSelector={{ view, setView: handleChangeView }}
|
||||
showPasswordLayout={showPasswordLayout}
|
||||
/>
|
||||
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
|
||||
{/* Header */}
|
||||
{!showPasswordLayout && (
|
||||
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb paths={breadcrumbPaths} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{showPasswordLayout ? (
|
||||
<PasswordLayout
|
||||
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 1] ?? "/")}
|
||||
onSubmitted={(password) => {
|
||||
const cookie =
|
||||
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
|
||||
undefined;
|
||||
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
|
||||
router.reload();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4",
|
||||
!props.readmeFile && "grid-cols-1 tablet:grid-cols-12 desktop:grid-cols-12",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"col-span-full desktop:col-span-8 overflow-auto",
|
||||
!props.readmeFile && "desktop:col-span-full",
|
||||
)}
|
||||
>
|
||||
{isViewLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className='w-full flex flex-col gap-4'>
|
||||
{data.files.length || data.folders.length ? (
|
||||
<>{ViewComponent}</>
|
||||
) : (
|
||||
<div className='w-full min-h-[5rem] grid place-items-center'>
|
||||
Can't find any files or folders in this directory
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.pageToken && (
|
||||
<Button
|
||||
variant='accent'
|
||||
fullWidth
|
||||
rounded='large'
|
||||
className='mt-4'
|
||||
loading={isButtonLoading}
|
||||
onClick={handleLoadNextPage}
|
||||
>
|
||||
Load more...
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.readmeFile && (
|
||||
<div className='col-span-full desktop:col-span-4'>
|
||||
<div
|
||||
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 rounded-lg sticky top-4 overflow-auto ${poppins.className}`}
|
||||
>
|
||||
<Markdown content={props.readmeFile} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import Button from "components/Button";
|
||||
import ButtonGroup from "components/ButtonGroup";
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
import Modal from "components/Modal";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
import bytesToReadable from "utils/bytesFormat";
|
||||
import { getPreviewIcon } from "utils/previewHelper";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APISearchResponse } from "types/api/response";
|
||||
|
||||
interface HeaderProps {
|
||||
viewSelector?: {
|
||||
view: "grid" | "list";
|
||||
setView: (view: "grid" | "list") => void;
|
||||
};
|
||||
showPasswordLayout?: boolean;
|
||||
}
|
||||
|
||||
export default function Header(props: HeaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchModalOpen, setSearchModalOpen] = useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>("");
|
||||
const [axiosCancelToken, setAxiosCancelToken] = useState<ReturnType<typeof axios.CancelToken.source> | null>(null); // eslint-disable-line
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchResults, setSearchResults] = useState<(IGDriveFiles & { redirect: string })[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery) {
|
||||
setSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
setSearchLoading(true);
|
||||
if (axiosCancelToken) {
|
||||
axiosCancelToken.cancel();
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchModalOpen) {
|
||||
setSearchResults([]);
|
||||
setSearchQuery("");
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}, [searchModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchQuery) {
|
||||
setSearchLoading(false);
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const source = axios.CancelToken.source();
|
||||
setAxiosCancelToken(source);
|
||||
|
||||
axios
|
||||
.get<APISearchResponse>("/api/search", {
|
||||
params: {
|
||||
query: debouncedSearchQuery,
|
||||
},
|
||||
cancelToken: source.token,
|
||||
})
|
||||
.then((res) => {
|
||||
setSearchResults(res.data.files);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (axios.isCancel(err)) {
|
||||
return;
|
||||
} else {
|
||||
console.error(err);
|
||||
throw new Error(err);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setSearchLoading(false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<h1 className='text-2xl font-medium'>{gIndexConfig.siteConfig.siteName}</h1>
|
||||
|
||||
{!props.showPasswordLayout && (
|
||||
<div className='flex items-center gap-4'>
|
||||
{props.viewSelector && (
|
||||
<ButtonGroup className='border border-primary-700 tablet:flex hidden'>
|
||||
<Button
|
||||
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
|
||||
startIcon={"ion:list"}
|
||||
onClick={() => props.viewSelector?.setView("list")}
|
||||
>
|
||||
List view
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
|
||||
startIcon={"ion:grid"}
|
||||
onClick={() => props.viewSelector?.setView("grid")}
|
||||
>
|
||||
Grid view
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
<Tooltip content='Search file'>
|
||||
<ButtonIcon
|
||||
icon='ion:search'
|
||||
variant='transparent'
|
||||
onClick={() => {
|
||||
setSearchModalOpen(true);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!props.showPasswordLayout && props.viewSelector && (
|
||||
<div className='flex w-full items-center tablet:hidden'>
|
||||
<ButtonGroup className='border border-primary-700 w-full'>
|
||||
<Button
|
||||
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
|
||||
size='small'
|
||||
startIcon={"ion:list"}
|
||||
onClick={() => props.viewSelector?.setView("list")}
|
||||
>
|
||||
List view
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
|
||||
size='small'
|
||||
startIcon={"ion:grid"}
|
||||
onClick={() => props.viewSelector?.setView("grid")}
|
||||
>
|
||||
Grid view
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
key={"search-modal"}
|
||||
open={searchModalOpen}
|
||||
onClose={() => setSearchModalOpen(false)}
|
||||
title='Search'
|
||||
>
|
||||
<div className='w-full flex flex-col gap-2'>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
placeholder='Input search query'
|
||||
className='w-full px-4 py-2 bg-primary-900 outline-none border border-primary-500 rounded-lg'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{searchLoading ? (
|
||||
<div className='w-full py-8 flex items-center justify-center gap-2 animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Searching file...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-2 max-h-96 overflow-auto'>
|
||||
{searchResults.length === 0 ? (
|
||||
<div className='w-full py-8 flex items-center justify-center gap-2'>
|
||||
<span>No results found</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{searchResults.map((file) => (
|
||||
<Link
|
||||
href={file.redirect}
|
||||
key={file.encryptedId}
|
||||
className='flex items-center gap-4 w-full flex-grow-0 flex-shrink bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
|
||||
>
|
||||
{file.thumbnailLink &&
|
||||
(file.mimeType.startsWith("video") || file.mimeType.startsWith("image")) ? (
|
||||
<img
|
||||
src={file.thumbnailLink}
|
||||
alt={file.name}
|
||||
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
|
||||
loading='lazy'
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
icon={
|
||||
file.mimeType === "application/vnd.google-apps.folder"
|
||||
? "ion:folder"
|
||||
: getPreviewIcon(file.fileExtension!, file.mimeType!)
|
||||
}
|
||||
color='currentColor'
|
||||
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
|
||||
/>
|
||||
)}
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-medium'>{file.name}</span>
|
||||
<div className='flex items-center justify-start gap-2 text-primary-500'>
|
||||
<span className='text-sm whitespace-nowrap'>
|
||||
{[
|
||||
file.mimeType && file.mimeType.includes("folder") && "Folder",
|
||||
file.fileExtension && file.fileExtension.toUpperCase(),
|
||||
]
|
||||
.filter((item) => item)
|
||||
.join(" ・ ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-start gap-2 text-primary-500'>
|
||||
<span className='text-sm whitespace-nowrap'>
|
||||
{[
|
||||
new Date(file.modifiedTime).toLocaleDateString("default", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
file.size ? bytesToReadable(file.size) : undefined,
|
||||
]
|
||||
.filter((item) => item)
|
||||
.join(" ・ ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextSeo, NextSeoProps } from "next-seo";
|
||||
import { useRouter } from "next/router";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
addNewPassword,
|
||||
checkPathPassword,
|
||||
} from "utils/passwordHelper";
|
||||
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
import PasswordLayout from "../Password";
|
||||
|
||||
interface LoaderLayoutProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
seo?: NextSeoProps;
|
||||
}
|
||||
|
||||
export default function LoaderLayout(
|
||||
props: LoaderLayoutProps,
|
||||
) {
|
||||
const router = useRouter();
|
||||
const { children, seo, className, ...rest } = props;
|
||||
const sitePassword =
|
||||
process.env.NEXT_PUBLIC_SITE_PASSWORD;
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isPasswordUnlocked, setIsPasswordUnlocked] =
|
||||
useState<boolean>(
|
||||
gIndexConfig.siteConfig.privateIndex ? false : true,
|
||||
);
|
||||
|
||||
const handlePrivateIndexCheck = () => {
|
||||
const cookie =
|
||||
document.cookie
|
||||
.split(";")
|
||||
.find((cookie) =>
|
||||
cookie.startsWith(
|
||||
`${Constant.cookies_SitePassword}=`,
|
||||
),
|
||||
) ?? undefined;
|
||||
if (cookie) {
|
||||
const password = cookie.split("=")[1];
|
||||
if (!password) return setIsPasswordUnlocked(false);
|
||||
if (!sitePassword)
|
||||
throw new Error("Can't find site password");
|
||||
|
||||
const valid = checkPathPassword(
|
||||
"site-wide",
|
||||
password,
|
||||
sitePassword as string,
|
||||
);
|
||||
if (valid) return setIsPasswordUnlocked(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
new Promise((resolve) =>
|
||||
resolve(handlePrivateIndexCheck()),
|
||||
).then(() => setIsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const routeChangeStart = () => setIsLoading(true);
|
||||
const routeChangeComplete = () => {
|
||||
handlePrivateIndexCheck();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", routeChangeStart);
|
||||
router.events.on(
|
||||
"routeChangeComplete",
|
||||
routeChangeComplete,
|
||||
);
|
||||
router.events.on(
|
||||
"routeChangeError",
|
||||
routeChangeComplete,
|
||||
);
|
||||
|
||||
return () => {
|
||||
router.events.off(
|
||||
"routeChangeStart",
|
||||
routeChangeStart,
|
||||
);
|
||||
router.events.off(
|
||||
"routeChangeComplete",
|
||||
routeChangeComplete,
|
||||
);
|
||||
router.events.off(
|
||||
"routeChangeError",
|
||||
routeChangeComplete,
|
||||
);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.events]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo {...seo} />
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Validating path...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`w-full h-auto flex flex-col p-4 tablet:px-8 desktop:px-16 ${className}`}
|
||||
{...rest}
|
||||
key={router.asPath}
|
||||
>
|
||||
{gIndexConfig.siteConfig.privateIndex &&
|
||||
!isPasswordUnlocked ? (
|
||||
<PasswordLayout
|
||||
path='Private Mode Password'
|
||||
onSubmitted={(password) => {
|
||||
const cookie =
|
||||
document.cookie
|
||||
.split(";")
|
||||
.find((cookie) =>
|
||||
cookie.startsWith(
|
||||
`${Constant.cookies_SitePassword}=`,
|
||||
),
|
||||
) ?? undefined;
|
||||
addNewPassword(
|
||||
"site-wide",
|
||||
password,
|
||||
cookie?.split("=")[1] ?? undefined,
|
||||
);
|
||||
setIsLoading(true);
|
||||
const validPassword = () => {
|
||||
const cookie =
|
||||
document.cookie
|
||||
.split(";")
|
||||
.find((cookie) =>
|
||||
cookie.startsWith(
|
||||
`${Constant.cookies_SitePassword}=`,
|
||||
),
|
||||
) ?? undefined;
|
||||
if (cookie) {
|
||||
const password = cookie.split("=")[1];
|
||||
if (!password) return false;
|
||||
if (!sitePassword)
|
||||
throw new Error(
|
||||
"Can't find site password",
|
||||
);
|
||||
|
||||
const valid = checkPathPassword(
|
||||
"site-wide",
|
||||
password,
|
||||
sitePassword as string,
|
||||
);
|
||||
return valid;
|
||||
}
|
||||
};
|
||||
new Promise((resolve) =>
|
||||
resolve(validPassword()),
|
||||
)
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
setIsPasswordUnlocked(true);
|
||||
} else {
|
||||
setIsPasswordUnlocked(false);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "components/Button";
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
|
||||
interface PasswordLayoutProps {
|
||||
onSubmitted: (password: string) => void;
|
||||
path: string;
|
||||
}
|
||||
export default function PasswordLayout(
|
||||
props: PasswordLayoutProps,
|
||||
) {
|
||||
const router = useRouter();
|
||||
const [showPassword, setShowPassword] =
|
||||
useState<boolean>(false);
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [pending, setPending] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className='col-span-full text-center min-h-[50vh] flex-grow flex flex-col items-center justify-center'>
|
||||
<figure>
|
||||
<img
|
||||
src='/images/password.png'
|
||||
alt="You don't have access"
|
||||
className='w-64 h-64 tablet:w-96 tablet:h-96'
|
||||
/>
|
||||
</figure>
|
||||
|
||||
<div className='w-full flex items-center justify-center flex-col my-8'>
|
||||
<span className='text-lg font-medium'>
|
||||
The folder / file you are trying to access is
|
||||
protected with a password.
|
||||
</span>
|
||||
<pre className='max-w-sm w-full tablet:max-w-2xl'>
|
||||
<code className='text-sm'>{props.path}</code>
|
||||
</pre>
|
||||
<span>Please enter the password to continue:</span>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSubmitted(password);
|
||||
setPending(true);
|
||||
}}
|
||||
className='w-full max-w-lg flex items-center border border-primary-500 rounded-lg'
|
||||
>
|
||||
<div className='w-full flex items-center px-2 bg-primary-900 rounded-l-lg overflow-hidden'>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete='off'
|
||||
className='w-full px-2 py-2 bg-primary-900 outline-none'
|
||||
placeholder='Enter password'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<ButtonIcon
|
||||
type='button'
|
||||
variant='transparent'
|
||||
icon={showPassword ? "ion:eye-off" : "ion:eye"}
|
||||
size='small'
|
||||
className='p-1'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='accent'
|
||||
type='submit'
|
||||
className='rounded-r-lg w-24'
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Icon
|
||||
icon='mdi:loading'
|
||||
className='animate-spin'
|
||||
width={24}
|
||||
height={24}
|
||||
color='currentColor'
|
||||
/>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import gIndexConfig from "config";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { poppins } from "pages/_app";
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import Button from "components/Button";
|
||||
|
||||
import bytesToReadable from "utils/bytesFormat";
|
||||
import { durationToReadable } from "utils/durationFormat";
|
||||
import { decryptData } from "utils/encryptionHelper/hash";
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
import { addNewPassword } from "utils/passwordHelper";
|
||||
import { getPreviewComponent } from "utils/previewHelper";
|
||||
import { createDownloadToken } from "utils/tokenHelper";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
import Header from "../Header";
|
||||
import PasswordLayout from "../Password";
|
||||
|
||||
const Loading = () => (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading view...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PreviewLayoutProps {
|
||||
data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
};
|
||||
isProtected: boolean;
|
||||
isFileProtected: boolean;
|
||||
encryptedId?: string;
|
||||
}
|
||||
|
||||
export default function PreviewLayout(props: PreviewLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<PreviewLayoutProps["data"]>(props.data);
|
||||
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
|
||||
const [PreviewComponent, setPreviewComponent] = useState<ReactNode>();
|
||||
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
|
||||
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
|
||||
const [downloadToken] = useState<string | null>(() => {
|
||||
if (gIndexConfig.apiConfig.allowDownloadProtectedFile) return null;
|
||||
|
||||
if (props.isFileProtected) {
|
||||
return createDownloadToken();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [expiredIn, setExpiredIn] = useState<string>("");
|
||||
|
||||
const fileMetadata = useMemo<
|
||||
{
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | number | null;
|
||||
}[]
|
||||
>(() => {
|
||||
const metadata = [];
|
||||
if (data.file) {
|
||||
if (data.file.name)
|
||||
metadata.push({
|
||||
key: "name",
|
||||
label: "Name",
|
||||
value: data.file.name,
|
||||
});
|
||||
if (data.file.mimeType)
|
||||
metadata.push({
|
||||
key: "mimeType",
|
||||
label: "Type",
|
||||
value: data.file.mimeType,
|
||||
});
|
||||
if (data.file.modifiedTime)
|
||||
metadata.push({
|
||||
key: "modifiedTime",
|
||||
label: "Last Modified",
|
||||
value: new Date(data.file.modifiedTime).toLocaleDateString("default", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
});
|
||||
if (data.file.size)
|
||||
metadata.push({
|
||||
key: "size",
|
||||
label: "Size",
|
||||
value: bytesToReadable(data.file.size),
|
||||
});
|
||||
if (data.file.imageMediaMetadata && data.file.imageMediaMetadata.width && data.file.imageMediaMetadata.height)
|
||||
metadata.push({
|
||||
key: "imageMediaMetadata",
|
||||
label: "Dimension",
|
||||
value: `${data.file.imageMediaMetadata.width}x${data.file.imageMediaMetadata.height}`,
|
||||
});
|
||||
if (data.file.videoMediaMetadata)
|
||||
metadata.push(
|
||||
{
|
||||
key: "videoMediaMetadata",
|
||||
label: "Dimension",
|
||||
value: `${data.file.videoMediaMetadata.width}x${data.file.videoMediaMetadata.height}`,
|
||||
},
|
||||
{
|
||||
key: "videoMediaMetadata",
|
||||
label: "Duration",
|
||||
value: durationToReadable(data.file.videoMediaMetadata.durationMillis),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}, [data.file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadToken) return;
|
||||
const interval = setInterval(() => {
|
||||
const expiredAt = Number(decryptData(downloadToken));
|
||||
const date = new Date(expiredAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
const hours = Math.floor(diff / 1000 / 60 / 60);
|
||||
const minutes = Math.floor(diff / 1000 / 60) % 60;
|
||||
const seconds = Math.floor(diff / 1000) % 60;
|
||||
|
||||
setExpiredIn(`${hours}:${minutes}:${seconds}`);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [downloadToken]);
|
||||
|
||||
useEffect(() => {
|
||||
new Promise(async (resolve) => {
|
||||
const Preview = getPreviewComponent(props.data.file?.fileExtension ?? "unknown", props.data.file?.mimeType ?? "");
|
||||
setPreviewComponent(<Preview file={props.data.file!} />);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
resolve(true);
|
||||
}).then(() => {
|
||||
setIsViewLoading(false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
useEffect(() => {
|
||||
setShowPasswordLayout(props.isProtected);
|
||||
}, [props.isProtected]);
|
||||
|
||||
useEffect(() => {
|
||||
const paths = router.asPath.split("/").filter((path) => path !== "");
|
||||
const newPaths: Record<"label" | "path", string>[] = [];
|
||||
paths.forEach((path, index) => {
|
||||
newPaths.push({
|
||||
label: path,
|
||||
path: `/${paths.slice(0, index + 1).join("/")}`,
|
||||
});
|
||||
});
|
||||
|
||||
setBreadcrumbPaths(newPaths);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className='w-full flex h-full flex-col gap-4 relative'>
|
||||
{/* Top */}
|
||||
<Header showPasswordLayout={showPasswordLayout} />
|
||||
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
|
||||
{/* Header */}
|
||||
{!showPasswordLayout && (
|
||||
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb paths={breadcrumbPaths} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{showPasswordLayout ? (
|
||||
<PasswordLayout
|
||||
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 2] ?? "/")}
|
||||
onSubmitted={(password) => {
|
||||
const cookie =
|
||||
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
|
||||
undefined;
|
||||
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
|
||||
router.reload();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={twMerge("w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4")}>
|
||||
<div className={twMerge("col-span-full desktop:col-span-8 overflow-auto")}>
|
||||
{isViewLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className='w-full flex flex-col gap-4'>
|
||||
<div
|
||||
className={`w-full flex flex-col gap-2 bg-primary-900 p-4 mb-4 rounded-lg overflow-auto ${poppins.className}`}
|
||||
>
|
||||
{PreviewComponent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='col-span-full desktop:col-span-4'>
|
||||
<div className='sticky top-4'>
|
||||
<div
|
||||
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 mb-4 rounded-lg overflow-auto ${poppins.className}`}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon
|
||||
icon={"ion:information-circle"}
|
||||
className='w-6 h-6'
|
||||
/>
|
||||
<h2 className='text-2xl font-medium'>File info</h2>
|
||||
</div>
|
||||
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
{fileMetadata.map((item) => (
|
||||
<div
|
||||
className='grid grid-cols-3'
|
||||
key={`${item.key}-${item.label.toLowerCase()}`}
|
||||
>
|
||||
<span className='col-span-1 font-bold'>{item.label}</span>
|
||||
<p className='col-span-2 break-all whitespace-pre-line'>{item.value ?? "-"}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 my-4 rounded-lg overflow-auto ${poppins.className}`}
|
||||
>
|
||||
<Link
|
||||
href={generatedDownloadLink(data.file!, props.isFileProtected, false, downloadToken)}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='accent'
|
||||
startIcon={"ion:download"}
|
||||
rounded='large'
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='primary'
|
||||
startIcon={"ion:copy"}
|
||||
rounded='large'
|
||||
onClick={() => {
|
||||
try {
|
||||
const url = generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken);
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success("URL copied to clipboard.");
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to copy URL to clipboard.");
|
||||
console.error(error.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy URL
|
||||
</Button>
|
||||
<div className='w-full px-2 bg-primary-950 outline-none rounded-lg border border-primary-500 select-all'>
|
||||
<div className='overflow-x-scroll py-2 whitespace-nowrap'>
|
||||
{generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken)}
|
||||
</div>
|
||||
</div>
|
||||
{!gIndexConfig.apiConfig.allowDownloadProtectedFile && props.isFileProtected && (
|
||||
<span className='text-center mt-4 text-sm'>
|
||||
This file is inside protected folder.
|
||||
<br />
|
||||
Download link will be expired in <b>{expiredIn}</b>.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
import ClickAway from "components/ClickAway";
|
||||
|
||||
interface ModalProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
footer?: ReactNode;
|
||||
keepMounted?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal(props: ModalProps) {
|
||||
let DELAY = 10;
|
||||
|
||||
const [showModal, setShowModal] = useState<boolean>(props.open);
|
||||
const [keepMounted, setKeepMounted] = useState<boolean>(false);
|
||||
const [shouldRender, setShouldRender] = useState<boolean>(false);
|
||||
|
||||
const [shouldUnmount, setShouldUnmount] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClose = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleClose);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleClose);
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
if (showModal && !shouldRender) {
|
||||
timeoutId = setTimeout(() => {
|
||||
setShouldRender(true);
|
||||
}, DELAY);
|
||||
} else if (!showModal && shouldRender) {
|
||||
timeoutId = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
}, DELAY);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [showModal, shouldRender, DELAY]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(props.open);
|
||||
if (props.open) setShouldUnmount(false);
|
||||
}, [props.open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal && props.keepMounted) setKeepMounted(true);
|
||||
}, [showModal, props.keepMounted]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModal(false);
|
||||
if (!keepMounted) setKeepMounted(false);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!showModal && !keepMounted && shouldRender) {
|
||||
// const timeout = setTimeout(() => {
|
||||
// setShouldUnmount(true);
|
||||
// }, 250);
|
||||
// return () => {
|
||||
// clearTimeout(timeout);
|
||||
// };
|
||||
// } else {
|
||||
// setShouldUnmount(false);
|
||||
// }
|
||||
// }, [showModal, keepMounted, shouldRender, DELAY]);
|
||||
if (shouldUnmount) return null;
|
||||
|
||||
return (
|
||||
<ClickAway
|
||||
open={showModal}
|
||||
onClickAway={handleClose}
|
||||
className={`fixed grid place-items-center top-0 left-0 z-[60] w-full h-full transition-smooth ${
|
||||
shouldRender ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
onTransitionEnd={() => {
|
||||
if (!showModal && !keepMounted) {
|
||||
setShouldUnmount(true);
|
||||
} else {
|
||||
setShouldUnmount(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='w-full px-4 max-w-screen-lg'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className='bg-primary-900 text-primary-50 rounded-lg py-2 px-4 flex flex-col gap-2 w-full'>
|
||||
{/* Header */}
|
||||
<div className='flex flex-row items-center justify-between py-2'>
|
||||
<span className='text-xl font-medium w-full line-clamp-1'>{props.title}</span>
|
||||
<ButtonIcon
|
||||
icon={"ion:close"}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
|
||||
{/* Body */}
|
||||
<div className='py-2'>{props.children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{props.footer && <div className='py-2'>{props.footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</ClickAway>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import AudioPlayer from "react-h5-audio-player";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function AudioPreview(props: PreviewProps) {
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{isError ? (
|
||||
<ErrorPreview />
|
||||
) : (
|
||||
<AudioPlayer
|
||||
src={generatedDownloadLink(props.file, false)}
|
||||
showJumpControls={false}
|
||||
showFilledVolume
|
||||
style={{
|
||||
borderRadius: "0.375rem",
|
||||
}}
|
||||
onError={() => setIsError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Markdown from "components/Markdown";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function CodePreview(props: PreviewProps) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get(generatedDownloadLink(props.file, false))
|
||||
.then((res) => {
|
||||
const isObject = typeof res.data === "object";
|
||||
setContent(
|
||||
`\`\`\`${props.file.fileExtension}\n${isObject ? JSON.stringify(res.data, null, 2) : res.data}\`\`\``,
|
||||
);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsError(true);
|
||||
});
|
||||
}, [props.file, props.file.fileExtension]);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading content...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
|
||||
import gIndexConfig from "config";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
function LoadingRender() {
|
||||
return (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading content...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ErrorRender() {
|
||||
return <ErrorPreview />;
|
||||
}
|
||||
|
||||
export default function DocumentPreview(props: PreviewProps) {
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<DocViewer
|
||||
documents={[
|
||||
{
|
||||
uri: generatedDownloadLink(props.file, false, true),
|
||||
},
|
||||
]}
|
||||
pluginRenderers={DocViewerRenderers}
|
||||
config={{
|
||||
header: {
|
||||
disableHeader: true,
|
||||
},
|
||||
loadingRenderer: {
|
||||
overrideComponent: LoadingRender,
|
||||
},
|
||||
noRenderer: {
|
||||
overrideComponent: ErrorRender,
|
||||
},
|
||||
}}
|
||||
className='min-h-[70vh] max-h-[70vh] w-full h-full rounded-md'
|
||||
theme={{
|
||||
disableThemeScrollbar: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
interface ErrorPreviewProps {
|
||||
error?: string;
|
||||
}
|
||||
export default function ErrorPreview(props: ErrorPreviewProps) {
|
||||
return (
|
||||
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
|
||||
<h2 className='text-2xl font-bold'>Failed to load file preview</h2>
|
||||
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
|
||||
Error has been occured while loading preview, it may be caused by{" "}
|
||||
<span className='font-medium underline'>invalid file type</span> or{" "}
|
||||
<span className='font-medium underline'>network error</span>.
|
||||
<br />
|
||||
You can try again later or download the file to view.
|
||||
</span>
|
||||
|
||||
{props.error && (
|
||||
<>
|
||||
<span className='font-medium underline'>Error details</span>
|
||||
<span className='text-sm tablet:text-base text-primary-300 my-2'>{props.error}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import gIndexConfig from "config";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function ImagePreview(props: PreviewProps) {
|
||||
const [imgSrc, setImgSrc] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setIsError(false);
|
||||
setIsLoading(true);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setIsError(true);
|
||||
setErrorMessage("Image took too long to load");
|
||||
return;
|
||||
}, 15000);
|
||||
|
||||
fetch(generatedDownloadLink(props.file, false))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImgSrc(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsError(true);
|
||||
setErrorMessage(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [props.file]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading image...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isError ? (
|
||||
<ErrorPreview />
|
||||
) : (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={props.file.name}
|
||||
className='w-full h-full rounded-md'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import JSZip from "jszip";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function MangaPreview(props: PreviewProps) {
|
||||
const [maxPageIndex, setMaxPageIndex] = useState<number>(4);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [viewPageIndex, setViewPageIndex] = useState<number>(0);
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
|
||||
|
||||
useEffect(() => {
|
||||
const mangaImages: string[] = [];
|
||||
axios
|
||||
.get(generatedDownloadLink(props.file, false, false, null, true), {
|
||||
responseType: "blob",
|
||||
})
|
||||
.then(async (res) => {
|
||||
const zipData = await JSZip.loadAsync(res.data);
|
||||
for (let i = 0; i <= maxPageIndex; i++) {
|
||||
const file = zipData.file(Object.keys(zipData.files)[i]);
|
||||
if (file) {
|
||||
const blob = await file.async("blob");
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
mangaImages.push(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
}
|
||||
setImages(mangaImages);
|
||||
setMaxPageIndex(mangaImages.length);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.file]);
|
||||
|
||||
const handleCurrentView = useCallback(
|
||||
(pageNumber: number) => {
|
||||
if (pageNumber < 0 || pageNumber > maxPageIndex) return;
|
||||
setViewPageIndex(pageNumber);
|
||||
},
|
||||
[maxPageIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<blockquote className='w-full h-auto flex items-center my-2 bg-primary-800 border-l-4 border-l-accent-500 pl-3 pr-2 py-2 text-sm tablet:text-base rounded-md'>
|
||||
Preview only shows the first 5 pages. Please download the file to view the rest.
|
||||
</blockquote>
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading content...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isError ? (
|
||||
<ErrorPreview />
|
||||
) : (
|
||||
<div className='flex flex-col'>
|
||||
<div className='w-full h-full relative'>
|
||||
<img
|
||||
src={images[viewPageIndex]}
|
||||
alt={`${props.file.name} - page ${viewPageIndex}`}
|
||||
className={`w-full h-full object-contain bg-primary-950 rounded-md ${
|
||||
viewSize === "fit" ? "max-h-[70vh]" : ""
|
||||
}`}
|
||||
/>
|
||||
<ButtonIcon
|
||||
icon={viewSize === "fit" ? "ion:expand" : "ion:contract"}
|
||||
className='absolute bottom-2 right-2'
|
||||
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-row gap-2 items-center justify-center my-2'>
|
||||
<ButtonIcon
|
||||
variant='transparent'
|
||||
icon={"ion:chevron-back"}
|
||||
onClick={() => handleCurrentView(viewPageIndex - 1)}
|
||||
disabled={viewPageIndex === 0}
|
||||
/>
|
||||
<span>
|
||||
{viewPageIndex + 1} / {maxPageIndex + 1}
|
||||
</span>
|
||||
<ButtonIcon
|
||||
variant='transparent'
|
||||
icon={"ion:chevron-forward"}
|
||||
onClick={() => handleCurrentView(viewPageIndex + 1)}
|
||||
disabled={viewPageIndex === maxPageIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Markdown from "components/Markdown";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function RichPreview(props: PreviewProps) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get(generatedDownloadLink(props.file))
|
||||
.then((res) => {
|
||||
setContent(res.data);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsError(true);
|
||||
});
|
||||
}, [props.file]);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading content...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export default function UnknownPreview() {
|
||||
return (
|
||||
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
|
||||
<h2 className='text-2xl font-bold'>File preview not available</h2>
|
||||
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
|
||||
This file type is not supported for preview.
|
||||
<br />
|
||||
You can download the file to view.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import ReactPlayer from "react-player";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function VideoPreview(props: PreviewProps) {
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{isError ? (
|
||||
<ErrorPreview />
|
||||
) : (
|
||||
<ReactPlayer
|
||||
key={props.file.encryptedId}
|
||||
url={generatedDownloadLink(props.file, false)}
|
||||
controls
|
||||
pip
|
||||
wrapper={({ children }) => (
|
||||
<div className='w-full h-[60vh] bg-primary-950 rounded-md overflow-hidden'>{children}</div>
|
||||
)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
maxHeight: "60vh",
|
||||
}}
|
||||
onError={() => setIsError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Popper, PopperProps } from "react-popper";
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
placement?: PopperProps<unknown>["placement"];
|
||||
delay?: number;
|
||||
}
|
||||
export default function Tooltip(props: TooltipProps) {
|
||||
const [refElement, setRefElement] = useState<HTMLElement | null>(null);
|
||||
const [showTooltip, setShowTooltip] = useState<boolean>(false);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseEnter() {
|
||||
if (props.delay) {
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setShowTooltip(true);
|
||||
}, props.delay);
|
||||
} else {
|
||||
setShowTooltip(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (props.delay) {
|
||||
window.clearTimeout(timeoutRef.current ?? undefined);
|
||||
}
|
||||
setShowTooltip(false);
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
setShowTooltip(false);
|
||||
}
|
||||
|
||||
if (refElement) {
|
||||
refElement.addEventListener("mouseenter", handleMouseEnter);
|
||||
refElement.addEventListener("mouseleave", handleMouseLeave);
|
||||
refElement.addEventListener("click", handleMouseLeave);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refElement) {
|
||||
refElement.removeEventListener("mouseenter", handleMouseEnter);
|
||||
refElement.removeEventListener("mouseleave", handleMouseLeave);
|
||||
refElement.removeEventListener("click", handleMouseLeave);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
};
|
||||
}, [refElement, props.delay]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setRefElement}>{props.children}</div>
|
||||
<Popper
|
||||
placement={props.placement ?? "top"}
|
||||
modifiers={[
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 10],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide",
|
||||
enabled: true,
|
||||
phase: "main",
|
||||
requires: ["computeStyles"],
|
||||
fn: ({ state }) => {
|
||||
const { height, width, x, y } = state.rects.reference;
|
||||
const { innerHeight, innerWidth } = window;
|
||||
state.styles.popper = {
|
||||
...state.styles.popper,
|
||||
visibility: height + y > innerHeight || width + x > innerWidth ? "hidden" : "visible",
|
||||
};
|
||||
},
|
||||
},
|
||||
]}
|
||||
referenceElement={refElement as HTMLElement}
|
||||
strategy='absolute'
|
||||
>
|
||||
{({ ref, style, placement }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
data-placement={placement}
|
||||
className={`bg-primary-800 max-w-sm text-primary-50 text-sm font-medium px-2 py-1 rounded-md shadow-md transition-smooth ${
|
||||
showTooltip ? `opacity-100 pointer-events-auto` : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{props.content}
|
||||
</div>
|
||||
)}
|
||||
</Popper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => (
|
||||
<nav
|
||||
ref={ref}
|
||||
aria-label='breadcrumb'
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-zinc-500 dark:text-zinc-400 sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"transition-colors hover:text-zinc-950 dark:hover:text-zinc-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role='link'
|
||||
aria-disabled='true'
|
||||
aria-current='page'
|
||||
className={cn("font-normal text-zinc-950 dark:text-zinc-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role='presentation'
|
||||
aria-hidden='true'
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role='presentation'
|
||||
aria-hidden='true'
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-zinc-900 text-zinc-50 hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90",
|
||||
destructive:
|
||||
"bg-red-500 text-zinc-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90",
|
||||
outline:
|
||||
"border border-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
|
||||
secondary:
|
||||
"bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
|
||||
ghost:
|
||||
"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
|
||||
link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role='region'
|
||||
aria-roledescription='carousel'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
Carousel.displayName = "Carousel";
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className='overflow-hidden'
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = "CarouselContent";
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role='group'
|
||||
aria-roledescription='slide'
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = "CarouselItem";
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className='h-4 w-4' />
|
||||
<span className='sr-only'>Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = "CarouselPrevious";
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className='h-4 w-4' />
|
||||
<span className='sr-only'>Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className='absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted' />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className='ml-auto h-4 w-4' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className='h-2 w-2 fill-current' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
|
||||
const MenubarPortal = MenubarPrimitive.Portal;
|
||||
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className='ml-auto h-4 w-4' />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className='h-2 w-2 fill-current' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayname = "MenubarShortcut";
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className='h-4 w-4 opacity-50' />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className='h-4 w-4' />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className='absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary'>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "~/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className='relative w-full overflow-auto'>
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=''
|
||||
{...props}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "~/components/ui/toast";
|
||||
import { useToast } from "~/components/ui/use-toast";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast
|
||||
key={id}
|
||||
{...props}
|
||||
>
|
||||
<div className='grid gap-1'>
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react";
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "~/components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 3;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { toast, useToast };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user