mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
Version 2
This commit is contained in:
+6
-3
@@ -1,5 +1,8 @@
|
|||||||
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
|
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
|
||||||
|
|
||||||
NEXT_PUBLIC_ENCRYPTION_KEY="Rj3wRGB9nUElQ7OA"
|
NEXT_PUBLIC_ENCRYPTION_KEY=
|
||||||
NEXT_PUBLIC_SITE_PASSWORD="mbaharip"
|
NEXT_PUBLIC_SITE_PASSWORD=
|
||||||
NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
|
|
||||||
|
# Only fill with the domain name, without the protocol and trailing slash
|
||||||
|
# Example: https://www.mbaharip.com -> mbaharip.com
|
||||||
|
NEXT_PUBLIC_DOMAIN=
|
||||||
|
|||||||
+2
-1
@@ -2,5 +2,6 @@
|
|||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"rules": {
|
"rules": {
|
||||||
"@next/next/no-img-element": "off"
|
"@next/next/no-img-element": "off"
|
||||||
}
|
},
|
||||||
|
"ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_pages/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -34,8 +34,10 @@ next-env.d.ts
|
|||||||
# personal docs
|
# personal docs
|
||||||
/docs
|
/docs
|
||||||
/src/pages/api/legacy/
|
/src/pages/api/legacy/
|
||||||
|
|
||||||
/**/*/_legacy/
|
/**/*/_legacy/
|
||||||
|
/data
|
||||||
/.*/
|
/.*/
|
||||||
/src/_app
|
/src/_app
|
||||||
|
/src/_pages
|
||||||
|
/src/utils/_legacy
|
||||||
|
/src/components/_legacy
|
||||||
|
|||||||
+13
-6
@@ -1,6 +1,9 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: [require("@trivago/prettier-plugin-sort-imports"), require("prettier-plugin-tailwindcss")],
|
plugins: [
|
||||||
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
|
"prettier-plugin-tailwindcss",
|
||||||
|
],
|
||||||
printWidth: 80,
|
printWidth: 80,
|
||||||
tabWidth: 2,
|
tabWidth: 2,
|
||||||
useTabs: false,
|
useTabs: false,
|
||||||
@@ -17,15 +20,19 @@ module.exports = {
|
|||||||
endOfLine: "lf",
|
endOfLine: "lf",
|
||||||
embeddedLanguageFormatting: "auto",
|
embeddedLanguageFormatting: "auto",
|
||||||
singleAttributePerLine: true,
|
singleAttributePerLine: true,
|
||||||
|
|
||||||
|
tailwindAttributes: ["classNames", "wrapperClassName", "rootClassName"],
|
||||||
|
tailwindFunctions: ["twsx", "cn"],
|
||||||
|
tailwindConfig: "./tailwind.config.ts",
|
||||||
|
|
||||||
importOrder: [
|
importOrder: [
|
||||||
"<THIRD_PARTY_MODULES>",
|
"<THIRD_PARTY_MODULES>",
|
||||||
"^components/(.*)$",
|
"^~/components/(.*)$",
|
||||||
"^(utils|hooks|context)/(.*)$",
|
"^~/(utils|hooks|context)/(.*)$",
|
||||||
"^types/(.*)$",
|
"^~/types/(.*)$",
|
||||||
"^(config|styles)/(.*)$",
|
"^~/(config|styles)/(.*)$",
|
||||||
"^[./]",
|
"^[./]",
|
||||||
],
|
],
|
||||||
importOrderSeparation: true,
|
importOrderSeparation: true,
|
||||||
importOrderSortSpecifiers: true,
|
importOrderSortSpecifiers: true,
|
||||||
tailwindFunctions: ["twsx", "cn"],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,15 +8,64 @@
|
|||||||
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ'>FAQ</a>
|
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ'>FAQ</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Version 2
|
## 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.
|
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:
|
Here's what changed in version 2:
|
||||||
|
|
||||||
- Upgrade to `Next.js 14` app directory structure.
|
### New Features
|
||||||
- Use `shadcn/ui` for the UI components.
|
|
||||||
- Add light and dark mode.
|
- **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 `{{ masterKey }}`.
|
||||||
|
|
||||||
|
### 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?
|
## What is this?
|
||||||
|
|
||||||
@@ -54,30 +103,43 @@ I know there are a lot of people selling cheap edu account for Google Drive and
|
|||||||
|
|
||||||
## File Security
|
## File Security
|
||||||
|
|
||||||
All files fetched from Google Drive **NEED TO BE SHARED** with `Anyone with the link can view` permission.
|
> This only apply if `maxFileSize` is enabled.
|
||||||
This is because Google Drive API can only access files that are shared with `Anyone with the link can view` permission.
|
|
||||||
|
|
||||||
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.
|
You need to set the file sharing permission to `Anyone with the link can view` for the files that you want to share.
|
||||||
Except, if the files are larger than the `fileSizeLimit` (default: 4MB for vercel), then the download will be redirected to the raw file link.
|
|
||||||
|
**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
|
## Known Issue
|
||||||
|
|
||||||
### File size limit
|
### File size limit
|
||||||
|
|
||||||
|
> 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.
|
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
|
### 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]`)
|
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`.
|
||||||
- Fetch the password, readme, and files from Google Drive.
|
|
||||||
- Show to the user.
|
|
||||||
|
|
||||||
So, if you have a lot of files in your Google Drive, it might take a long time to fetch all the files.
|
~~The flow of the project are:~~
|
||||||
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.
|
- ~~Validate the path is valid or not. (Only applied for `/...[path]`)~~
|
||||||
|
- ~~Fetch the password, readme, and files from Google Drive.~~
|
||||||
|
- ~~Show to the user.~~
|
||||||
|
|
||||||
|
~~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.~~
|
||||||
|
|
||||||
### Shared Drive is now supported
|
### Shared Drive is now supported
|
||||||
|
|
||||||
@@ -88,3 +150,39 @@ 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.
|
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.
|
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`
|
||||||
|
|||||||
@@ -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
|
|
||||||
+5
-17
@@ -1,21 +1,10 @@
|
|||||||
/** @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 = {
|
const nextConfig = {
|
||||||
// pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
|
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
webpack: (config) => {
|
||||||
|
config.resolve.alias.canvas = false;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
// async headers() {
|
// async headers() {
|
||||||
// return [
|
// return [
|
||||||
// {
|
// {
|
||||||
@@ -31,5 +20,4 @@ const nextConfig = {
|
|||||||
// },
|
// },
|
||||||
};
|
};
|
||||||
|
|
||||||
// module.exports = withMDX(nextConfig);
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
+3
-3
@@ -45,7 +45,7 @@
|
|||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.0.0",
|
"embla-carousel-react": "^8.0.1",
|
||||||
"googleapis": "^118.0.0",
|
"googleapis": "^118.0.0",
|
||||||
"input-otp": "^1.2.3",
|
"input-otp": "^1.2.3",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
"eslint": "8.38.0",
|
"eslint": "8.38.0",
|
||||||
"eslint-config-next": "^14.1.4",
|
"eslint-config-next": "^14.1.4",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^2.8.7",
|
"prettier": "3.0.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.2.7",
|
"prettier-plugin-tailwindcss": "0.5.12",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "5.0.4"
|
"typescript": "5.0.4"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,4 +3,4 @@ module.exports = {
|
|||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
+6
-9
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import Button from "components/Button";
|
import Button from "components/Button";
|
||||||
import LoaderLayout from "components/Layout/Loader";
|
import LoaderLayout from "components/Layout/Loader";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
@@ -10,24 +9,22 @@ export default function NotFoundPage() {
|
|||||||
title: "Not Found",
|
title: "Not Found",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className='w-full flex h-full flex-col gap-4 relative items-center justify-center'>
|
<div className='relative flex h-full w-full flex-col items-center justify-center gap-4'>
|
||||||
<figure>
|
<figure>
|
||||||
<img
|
<img
|
||||||
src='/images/404.png'
|
src='/images/404.png'
|
||||||
alt='Page not found'
|
alt='Page not found'
|
||||||
className='w-64 h-64 tablet:w-96 tablet:h-96 object-contain'
|
className='h-64 w-64 object-contain tablet:h-96 tablet:w-96'
|
||||||
/>
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<div className='w-full flex items-center justify-center flex-col my-8'>
|
<div className='my-8 flex w-full flex-col items-center justify-center'>
|
||||||
<span className='text-lg font-medium'>
|
<span className='text-lg font-medium'>
|
||||||
The page you are trying to access is not found.
|
The page you are trying to access is not found.
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>Please check the URL or go back to the homepage.</span>
|
||||||
Please check the URL or go back to the homepage.
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between flex-col tablet:flex-row gap-2 tablet:gap-4 my-4 tablet:my-12'>
|
<div className='my-4 flex flex-col items-center justify-between gap-2 tablet:my-12 tablet:flex-row tablet:gap-4'>
|
||||||
<Link href='/'>
|
<Link href='/'>
|
||||||
<Button
|
<Button
|
||||||
variant='accent'
|
variant='accent'
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import gIndexConfig from "config";
|
|
||||||
import { GetServerSidePropsContext } from "next";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import ExplorerLayout from "components/Layout/Explorer";
|
import ExplorerLayout from "components/Layout/Explorer";
|
||||||
import LoaderLayout from "components/Layout/Loader";
|
import LoaderLayout from "components/Layout/Loader";
|
||||||
import PasswordLayout from "components/Layout/Password";
|
import PasswordLayout from "components/Layout/Password";
|
||||||
import PreviewLayout from "components/Layout/Preview";
|
import PreviewLayout from "components/Layout/Preview";
|
||||||
|
import gIndexConfig from "config";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { IGDriveFiles } from "types/api/files";
|
||||||
|
import {
|
||||||
|
APIGetFileResponse,
|
||||||
|
APIGetPasswordResponse,
|
||||||
|
APIGetReadmeResponse,
|
||||||
|
} from "types/api/response";
|
||||||
|
import { Constant } from "types/constant";
|
||||||
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
|
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
|
||||||
import { gdriveFilesList } from "utils/gdrive";
|
import { gdriveFilesList } from "utils/gdrive";
|
||||||
import gdrive from "utils/gdriveInstance";
|
import gdrive from "utils/gdriveInstance";
|
||||||
import { addNewPassword, checkPathPassword } from "utils/passwordHelper";
|
import { addNewPassword, checkPathPassword } from "utils/passwordHelper";
|
||||||
|
|
||||||
import { IGDriveFiles } from "types/api/files";
|
|
||||||
import { APIGetFileResponse, APIGetPasswordResponse, APIGetReadmeResponse } from "types/api/response";
|
|
||||||
import { Constant } from "types/constant";
|
|
||||||
|
|
||||||
interface FilePathPageProps {
|
interface FilePathPageProps {
|
||||||
mappedEncryptedPath: Record<"name" | "id" | "mimeType", string>[];
|
mappedEncryptedPath: Record<"name" | "id" | "mimeType", string>[];
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,9 @@ export default function FilePathPage(props: FilePathPageProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoadingData(true);
|
setIsLoadingData(true);
|
||||||
|
|
||||||
const lastPathMimeType = props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].mimeType;
|
const lastPathMimeType =
|
||||||
|
props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1]
|
||||||
|
.mimeType;
|
||||||
let isLastPathFile = false;
|
let isLastPathFile = false;
|
||||||
if (lastPathMimeType === "application/vnd.google-apps.folder") {
|
if (lastPathMimeType === "application/vnd.google-apps.folder") {
|
||||||
isLastPathFile = false;
|
isLastPathFile = false;
|
||||||
@@ -61,13 +64,15 @@ export default function FilePathPage(props: FilePathPageProps) {
|
|||||||
});
|
});
|
||||||
const _getData = axios.get<APIGetFileResponse>("/api/getData", {
|
const _getData = axios.get<APIGetFileResponse>("/api/getData", {
|
||||||
params: {
|
params: {
|
||||||
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
|
encryptedId:
|
||||||
|
props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
|
||||||
isFile: isLastPathFile ? "1" : undefined,
|
isFile: isLastPathFile ? "1" : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const _getReadme = axios.get<APIGetReadmeResponse>("/api/getReadme", {
|
const _getReadme = axios.get<APIGetReadmeResponse>("/api/getReadme", {
|
||||||
params: {
|
params: {
|
||||||
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
|
encryptedId:
|
||||||
|
props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,13 +82,18 @@ export default function FilePathPage(props: FilePathPageProps) {
|
|||||||
if (passwordData.data.data && passwordData.data.data.length) {
|
if (passwordData.data.data && passwordData.data.data.length) {
|
||||||
let isPathProtected = true;
|
let isPathProtected = true;
|
||||||
const savedPasswordCookie =
|
const savedPasswordCookie =
|
||||||
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
|
document.cookie
|
||||||
undefined;
|
.split(";")
|
||||||
const savedPasswordValue = savedPasswordCookie?.split("=")[1] ?? undefined;
|
.find((cookie) =>
|
||||||
|
cookie.startsWith(`${Constant.cookies_SitePassword}=`),
|
||||||
|
) ?? undefined;
|
||||||
|
const savedPasswordValue =
|
||||||
|
savedPasswordCookie?.split("=")[1] ?? undefined;
|
||||||
|
|
||||||
if (savedPasswordValue) {
|
if (savedPasswordValue) {
|
||||||
// Check only nearest path
|
// Check only nearest path
|
||||||
const nearestPassword = passwordData.data.data[passwordData.data.data.length - 1];
|
const nearestPassword =
|
||||||
|
passwordData.data.data[passwordData.data.data.length - 1];
|
||||||
setNearestProtected(nearestPassword.relativePath);
|
setNearestProtected(nearestPassword.relativePath);
|
||||||
const isPasswordValid = checkPathPassword(
|
const isPasswordValid = checkPathPassword(
|
||||||
nearestPassword.relativePath,
|
nearestPassword.relativePath,
|
||||||
@@ -119,10 +129,15 @@ export default function FilePathPage(props: FilePathPageProps) {
|
|||||||
return (
|
return (
|
||||||
<LoaderLayout
|
<LoaderLayout
|
||||||
seo={{
|
seo={{
|
||||||
title: props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].name,
|
title:
|
||||||
|
props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1]
|
||||||
|
.name,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
title: props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].name,
|
title:
|
||||||
|
props.mappedEncryptedPath[
|
||||||
|
(props.mappedEncryptedPath.length ?? 1) - 1
|
||||||
|
].name,
|
||||||
description: gIndexConfig.siteConfig.siteDescription,
|
description: gIndexConfig.siteConfig.siteDescription,
|
||||||
url: process.env.NEXT_PUBLIC_VERCEL_URL,
|
url: process.env.NEXT_PUBLIC_VERCEL_URL,
|
||||||
siteName: gIndexConfig.siteConfig.siteName,
|
siteName: gIndexConfig.siteConfig.siteName,
|
||||||
@@ -137,15 +152,15 @@ export default function FilePathPage(props: FilePathPageProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoadingData ? (
|
{isLoadingData ? (
|
||||||
<div className='w-full h-full flex items-center justify-center'>
|
<div className='flex h-full w-full items-center justify-center'>
|
||||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
<div className='flex animate-pulse flex-col items-center justify-center gap-4'>
|
||||||
<div className='w-fit h-fit relative grid place-items-center'>
|
<div className='relative grid h-fit w-fit place-items-center'>
|
||||||
<img
|
<img
|
||||||
src={gIndexConfig.siteConfig.siteIcon}
|
src={gIndexConfig.siteConfig.siteIcon}
|
||||||
alt={gIndexConfig.siteConfig.siteName}
|
alt={gIndexConfig.siteConfig.siteName}
|
||||||
className='w-12 -top-1 relative'
|
className='relative -top-1 w-12'
|
||||||
/>
|
/>
|
||||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
<div className='border-primary-50 absolute h-12 w-12 animate-ping rounded-full border bg-transparent' />
|
||||||
</div>
|
</div>
|
||||||
<span>Fetching {isFile ? "file info" : "folder contents"}...</span>
|
<span>Fetching {isFile ? "file info" : "folder contents"}...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,9 +172,16 @@ export default function FilePathPage(props: FilePathPageProps) {
|
|||||||
path={nearestProtected}
|
path={nearestProtected}
|
||||||
onSubmitted={(password) => {
|
onSubmitted={(password) => {
|
||||||
const cookie =
|
const cookie =
|
||||||
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
|
document.cookie
|
||||||
undefined;
|
.split(";")
|
||||||
addNewPassword(decodeURIComponent(nearestProtected), password, cookie?.split("=")[1] ?? undefined);
|
.find((cookie) =>
|
||||||
|
cookie.startsWith(`${Constant.cookies_SitePassword}=`),
|
||||||
|
) ?? undefined;
|
||||||
|
addNewPassword(
|
||||||
|
decodeURIComponent(nearestProtected),
|
||||||
|
password,
|
||||||
|
cookie?.split("=")[1] ?? undefined,
|
||||||
|
);
|
||||||
router.reload();
|
router.reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -171,7 +193,11 @@ export default function FilePathPage(props: FilePathPageProps) {
|
|||||||
data={data}
|
data={data}
|
||||||
isProtected={false}
|
isProtected={false}
|
||||||
isFileProtected={isFileProtected}
|
isFileProtected={isFileProtected}
|
||||||
encryptedId={props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].id}
|
encryptedId={
|
||||||
|
props.mappedEncryptedPath[
|
||||||
|
(props.mappedEncryptedPath.length ?? 1) - 1
|
||||||
|
].id
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ExplorerLayout
|
<ExplorerLayout
|
||||||
@@ -180,7 +206,11 @@ export default function FilePathPage(props: FilePathPageProps) {
|
|||||||
readmeFile={readmeFile}
|
readmeFile={readmeFile}
|
||||||
isProtected={false}
|
isProtected={false}
|
||||||
isFileProtected={isFileProtected}
|
isFileProtected={isFileProtected}
|
||||||
encryptedId={props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].id}
|
encryptedId={
|
||||||
|
props.mappedEncryptedPath[
|
||||||
|
(props.mappedEncryptedPath.length ?? 1) - 1
|
||||||
|
].id
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -223,7 +253,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const [rootId, pathId] = await Promise.all([fetchRootId, Promise.all(fetchPathId)]);
|
const [rootId, pathId] = await Promise.all([
|
||||||
|
fetchRootId,
|
||||||
|
Promise.all(fetchPathId),
|
||||||
|
]);
|
||||||
|
|
||||||
if (pathId.some((path) => !path.data.length)) {
|
if (pathId.some((path) => !path.data.length)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+7
-10
@@ -1,4 +1,10 @@
|
|||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
|
import Button from "components/Button";
|
||||||
|
import ButtonGroup from "components/ButtonGroup";
|
||||||
|
import ButtonIcon from "components/ButtonIcon";
|
||||||
|
import ClickAway from "components/ClickAway";
|
||||||
|
import Modal from "components/Modal";
|
||||||
|
import Tooltip from "components/Tooltip";
|
||||||
import gIndexConfig from "config";
|
import gIndexConfig from "config";
|
||||||
import { DefaultSeo } from "next-seo";
|
import { DefaultSeo } from "next-seo";
|
||||||
import { AppProps } from "next/app";
|
import { AppProps } from "next/app";
|
||||||
@@ -8,21 +14,12 @@ import { useRouter } from "next/router";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import "react-toastify/dist/ReactToastify.css";
|
import "react-toastify/dist/ReactToastify.css";
|
||||||
|
import "styles/globals.css";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import twColor from "tailwindcss/colors";
|
import twColor from "tailwindcss/colors";
|
||||||
|
|
||||||
import Button from "components/Button";
|
|
||||||
import ButtonGroup from "components/ButtonGroup";
|
|
||||||
import ButtonIcon from "components/ButtonIcon";
|
|
||||||
import ClickAway from "components/ClickAway";
|
|
||||||
import Modal from "components/Modal";
|
|
||||||
import Tooltip from "components/Tooltip";
|
|
||||||
|
|
||||||
import hexToRgb from "utils/hexToRGB";
|
import hexToRgb from "utils/hexToRGB";
|
||||||
import { removeAllPassword } from "utils/passwordHelper";
|
import { removeAllPassword } from "utils/passwordHelper";
|
||||||
|
|
||||||
import "styles/globals.css";
|
|
||||||
|
|
||||||
const kanit = Kanit({
|
const kanit = Kanit({
|
||||||
display: "auto",
|
display: "auto",
|
||||||
weight: ["300", "400", "500", "600", "700", "800", "900"],
|
weight: ["300", "400", "500", "600", "700", "800", "900"],
|
||||||
|
|||||||
+12
-23
@@ -1,16 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import ExplorerLayout from "components/Layout/Explorer";
|
||||||
|
import LoaderLayout from "components/Layout/Loader";
|
||||||
import gIndexConfig from "config";
|
import gIndexConfig from "config";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import ExplorerLayout from "components/Layout/Explorer";
|
|
||||||
import LoaderLayout from "components/Layout/Loader";
|
|
||||||
|
|
||||||
import { IGDriveFiles } from "types/api/files";
|
import { IGDriveFiles } from "types/api/files";
|
||||||
import {
|
import { APIGetFileResponse, APIGetReadmeResponse } from "types/api/response";
|
||||||
APIGetFileResponse,
|
|
||||||
APIGetReadmeResponse,
|
|
||||||
} from "types/api/response";
|
|
||||||
|
|
||||||
interface StateDataProps {
|
interface StateDataProps {
|
||||||
file: IGDriveFiles | null;
|
file: IGDriveFiles | null;
|
||||||
@@ -26,19 +21,13 @@ export default function RootPage() {
|
|||||||
folders: [],
|
folders: [],
|
||||||
pageToken: null,
|
pageToken: null,
|
||||||
});
|
});
|
||||||
const [readmeFile, setReadmeFile] = useState<
|
const [readmeFile, setReadmeFile] = useState<string | null>(null);
|
||||||
string | null
|
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
|
||||||
>(null);
|
|
||||||
const [isLoadingData, setIsLoadingData] =
|
|
||||||
useState<boolean>(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoadingData(true);
|
setIsLoadingData(true);
|
||||||
const _getData =
|
const _getData = axios.get<APIGetFileResponse>("/api/getData");
|
||||||
axios.get<APIGetFileResponse>("/api/getData");
|
const _getReadme = axios.get<APIGetReadmeResponse>("/api/getReadme");
|
||||||
const _getReadme = axios.get<APIGetReadmeResponse>(
|
|
||||||
"/api/getReadme",
|
|
||||||
);
|
|
||||||
|
|
||||||
Promise.all([_getData, _getReadme])
|
Promise.all([_getData, _getReadme])
|
||||||
.then(([fileData, readmeData]) => {
|
.then(([fileData, readmeData]) => {
|
||||||
@@ -60,15 +49,15 @@ export default function RootPage() {
|
|||||||
return (
|
return (
|
||||||
<LoaderLayout>
|
<LoaderLayout>
|
||||||
{isLoadingData ? (
|
{isLoadingData ? (
|
||||||
<div className='w-full h-full flex items-center justify-center'>
|
<div className='flex h-full w-full items-center justify-center'>
|
||||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
<div className='flex animate-pulse flex-col items-center justify-center gap-4'>
|
||||||
<div className='w-fit h-fit relative grid place-items-center'>
|
<div className='relative grid h-fit w-fit place-items-center'>
|
||||||
<img
|
<img
|
||||||
src={gIndexConfig.siteConfig.siteIcon}
|
src={gIndexConfig.siteConfig.siteIcon}
|
||||||
alt={gIndexConfig.siteConfig.siteName}
|
alt={gIndexConfig.siteConfig.siteName}
|
||||||
className='w-12 -top-1 relative'
|
className='relative -top-1 w-12'
|
||||||
/>
|
/>
|
||||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
<div className='border-primary-50 absolute h-12 w-12 animate-ping rounded-full border bg-transparent' />
|
||||||
</div>
|
</div>
|
||||||
<span>Fetching folder contents...</span>
|
<span>Fetching folder contents...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+51
-13
@@ -1,14 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useContext, useEffect, useState } from "react";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useContext, useEffect, useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Schema_File } from "~/schema";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import Icon from "~/components/Icon";
|
import Icon from "~/components/Icon";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
|
||||||
import { LayoutContext } from "~/context/layoutContext";
|
import { LayoutContext } from "~/context/layoutContext";
|
||||||
import { Schema_File } from "~/schema";
|
|
||||||
import { cn } from "~/utils";
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
import FileGrid from "./@file.grid";
|
import FileGrid from "./@file.grid";
|
||||||
import FileList from "./@file.list";
|
import FileList from "./@file.list";
|
||||||
@@ -17,9 +23,21 @@ import { GetFiles } from "./actions";
|
|||||||
type Props = {
|
type Props = {
|
||||||
files: z.infer<typeof Schema_File>[];
|
files: z.infer<typeof Schema_File>[];
|
||||||
nextPageToken?: string;
|
nextPageToken?: string;
|
||||||
|
root?: boolean;
|
||||||
};
|
};
|
||||||
export default function FileBrowser({ files, nextPageToken }: Props) {
|
export default function FileBrowser({ files, nextPageToken, root }: Props) {
|
||||||
const { layout } = useContext(LayoutContext);
|
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] =
|
const [fileList, setFileList] =
|
||||||
useState<z.infer<typeof Schema_File>[]>(files);
|
useState<z.infer<typeof Schema_File>[]>(files);
|
||||||
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
|
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
|
||||||
@@ -36,8 +54,13 @@ export default function FileBrowser({ files, nextPageToken }: Props) {
|
|||||||
try {
|
try {
|
||||||
if (!nextToken) throw new Error("No more files to load");
|
if (!nextToken) throw new Error("No more files to load");
|
||||||
const data = await GetFiles({ pageToken: nextToken });
|
const data = await GetFiles({ pageToken: nextToken });
|
||||||
const uniqueData = new Set([...fileList, ...data.files]);
|
const uniqueData = [...fileList, ...data.files].filter(
|
||||||
setFileList(Array.from(uniqueData));
|
(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);
|
setNextToken(data.nextPageToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const e = error as Error;
|
const e = error as Error;
|
||||||
@@ -76,20 +99,35 @@ export default function FileBrowser({ files, nextPageToken }: Props) {
|
|||||||
"gap-3 p-3",
|
"gap-3 p-3",
|
||||||
"flex-grow-0",
|
"flex-grow-0",
|
||||||
"flex flex-col",
|
"flex flex-col",
|
||||||
// layout === "grid"
|
|
||||||
// ? "grid grid-cols-1 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4"
|
|
||||||
// : "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 && (
|
{!fileList.length && (
|
||||||
<div className='col-span-full flex flex-col items-center justify-center py-6'>
|
<div className='col-span-full flex h-full min-h-[25vh] flex-col items-center justify-center gap-3'>
|
||||||
<p className='large'>
|
|
||||||
<Icon
|
<Icon
|
||||||
name='Frown'
|
name='Frown'
|
||||||
size={32}
|
size={32}
|
||||||
|
className='text-muted-foreground'
|
||||||
/>
|
/>
|
||||||
</p>
|
<span className='text-center text-muted-foreground'>
|
||||||
<p className='muted'>No files found</p>
|
There are no files in this folder
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{layout === "list" && (
|
{layout === "list" && (
|
||||||
|
|||||||
+197
-19
@@ -2,34 +2,197 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import Icon from "~/components/Icon";
|
|
||||||
import { Separator } from "~/components/ui/separator";
|
|
||||||
import { Schema_File } from "~/schema";
|
import { Schema_File } from "~/schema";
|
||||||
import { cn } from "~/utils";
|
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 bytesToReadable from "~/utils/bytesFormat";
|
||||||
import { durationToReadable } from "~/utils/durationFormat";
|
import { durationToReadable } from "~/utils/durationFormat";
|
||||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||||
|
|
||||||
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
|
import { CreateDownloadToken } from "./actions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: z.infer<typeof Schema_File>;
|
data: z.infer<typeof Schema_File>;
|
||||||
};
|
};
|
||||||
export default function FileGrid({ data }: Props) {
|
export default function FileGrid({ data }: Props) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const filePath = useMemo<string>(() => {
|
const filePath = useMemo<string>(() => {
|
||||||
const path = [pathname, encodeURIComponent(data.name)].join("/");
|
const path = [pathname, encodeURIComponent(data.name)]
|
||||||
|
.join("/")
|
||||||
|
.replace(/\/+/g, "/");
|
||||||
|
|
||||||
// If start with /, remove it
|
return new URL(path, config.basePath).pathname;
|
||||||
if (path.startsWith("/")) {
|
|
||||||
return path.slice(1);
|
|
||||||
} else {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}, [data, 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 (
|
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
|
<Link
|
||||||
href={filePath}
|
href={filePath}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -49,7 +212,7 @@ export default function FileGrid({ data }: Props) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* If it's media, show thumbnail */}
|
{/* If it's media, show thumbnail */}
|
||||||
<div className='relative grid h-32 w-full flex-shrink-0 place-items-center bg-muted/25'>
|
<div className='relative grid h-32 w-full flex-shrink-0 place-items-center overflow-hidden bg-muted/25'>
|
||||||
{data.thumbnailLink &&
|
{data.thumbnailLink &&
|
||||||
(data.mimeType.startsWith("video") ||
|
(data.mimeType.startsWith("video") ||
|
||||||
data.mimeType.startsWith("image")) ? (
|
data.mimeType.startsWith("image")) ? (
|
||||||
@@ -57,7 +220,12 @@ export default function FileGrid({ data }: Props) {
|
|||||||
<img
|
<img
|
||||||
src={`/api/thumb/${data.encryptedId}`}
|
src={`/api/thumb/${data.encryptedId}`}
|
||||||
alt={data.name}
|
alt={data.name}
|
||||||
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain'
|
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") && (
|
{data.mimeType.startsWith("video") && (
|
||||||
@@ -89,27 +257,37 @@ export default function FileGrid({ data }: Props) {
|
|||||||
|
|
||||||
{/* File data */}
|
{/* File data */}
|
||||||
<div className='flex h-full w-full flex-col justify-between gap-1.5 px-3 py-1.5'>
|
<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-balance break-all'>
|
<span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all'>
|
||||||
{data.fileExtension
|
{data.fileExtension
|
||||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||||
: data.name}
|
: data.name}
|
||||||
</span>
|
</span>
|
||||||
<Separator />
|
<div className='muted flex items-center gap-1'>
|
||||||
<div className='muted flex items-center justify-between gap-1'>
|
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
|
||||||
<span className='whitespace-nowrap text-sm'>
|
{data.mimeType.includes("folder")
|
||||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
? "folder"
|
||||||
|
: data.fileExtension}
|
||||||
</span>
|
</span>
|
||||||
{!data.mimeType.includes("folder") && (
|
{!data.mimeType.includes("folder") && (
|
||||||
<>
|
<>
|
||||||
|
<Icon
|
||||||
|
name='Slash'
|
||||||
|
size={"0.875rem"}
|
||||||
|
/>
|
||||||
<span className='whitespace-nowrap text-sm'>
|
<span className='whitespace-nowrap text-sm'>
|
||||||
{bytesToReadable(data.size || 0)}
|
{bytesToReadable(data.size || 0)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+184
-95
@@ -2,23 +2,37 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Schema_File } from "~/schema";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import Icon from "~/components/Icon";
|
import Icon from "~/components/Icon";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "~/components/ui/drawer";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import config from "~/config/gIndex.config";
|
|
||||||
import { Schema_File } from "~/schema";
|
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||||
import { cn } from "~/utils";
|
|
||||||
import bytesToReadable from "~/utils/bytesFormat";
|
import bytesToReadable from "~/utils/bytesFormat";
|
||||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||||
|
|
||||||
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
import { CreateDownloadToken } from "./actions";
|
import { CreateDownloadToken } from "./actions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -26,26 +40,170 @@ type Props = {
|
|||||||
};
|
};
|
||||||
export default function FileList({ data }: Props) {
|
export default function FileList({ data }: Props) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const filePath = useMemo<string>(() => {
|
|
||||||
const path = [pathname, encodeURIComponent(data.name)].join("/");
|
|
||||||
|
|
||||||
// If start with /, remove it
|
const filePath = useMemo<string>(() => {
|
||||||
if (path.startsWith("/")) {
|
const path = [pathname, encodeURIComponent(data.name)]
|
||||||
return path.slice(1);
|
.join("/")
|
||||||
} else {
|
.replace(/\/+/g, "/");
|
||||||
return path;
|
|
||||||
}
|
return new URL(path, config.basePath).pathname;
|
||||||
}, [data, 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 (
|
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
|
<Link
|
||||||
href={filePath}
|
href={filePath}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full",
|
"relative",
|
||||||
"px-1.5 py-1",
|
// "w-full",
|
||||||
|
"px-1.5 py-1 pr-10", // since action button is size-8
|
||||||
"rounded-[var(--radius)]",
|
"rounded-[var(--radius)]",
|
||||||
"flex items-center justify-between gap-3",
|
"flex flex-grow items-center justify-between gap-3",
|
||||||
"hover:bg-muted/50",
|
"hover:bg-muted/50",
|
||||||
"transition",
|
"transition",
|
||||||
)}
|
)}
|
||||||
@@ -94,18 +252,15 @@ export default function FileList({ data }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
<div className='muted flex items-center gap-1'>
|
<div className='muted flex items-center gap-1'>
|
||||||
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
|
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
|
||||||
{data.mimeType.includes("folder") ? null : data.mimeType}
|
{data.mimeType.includes("folder")
|
||||||
</span>
|
? "folder"
|
||||||
</div>
|
: data.fileExtension}
|
||||||
<div className='muted flex items-center gap-1'>
|
|
||||||
<span className='whitespace-nowrap text-sm'>
|
|
||||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
|
||||||
</span>
|
</span>
|
||||||
{!data.mimeType.includes("folder") && (
|
{!data.mimeType.includes("folder") && (
|
||||||
<>
|
<>
|
||||||
<Icon
|
<Icon
|
||||||
name='Slash'
|
name='Slash'
|
||||||
size={14}
|
size={"0.875rem"}
|
||||||
/>
|
/>
|
||||||
<span className='whitespace-nowrap text-sm'>
|
<span className='whitespace-nowrap text-sm'>
|
||||||
{bytesToReadable(data.size || 0)}
|
{bytesToReadable(data.size || 0)}
|
||||||
@@ -113,80 +268,14 @@ export default function FileList({ data }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className='muted flex items-center gap-1'>
|
||||||
|
<span className='whitespace-nowrap text-sm'>
|
||||||
|
{new Date(data.modifiedTime).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<Button
|
|
||||||
size={"icon"}
|
|
||||||
variant={"ghost"}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name='EllipsisVertical'
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align='end'>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={async (e) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy link
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={data.mimeType.includes("folder")}
|
|
||||||
onClick={async (e) => {
|
|
||||||
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}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Schema_Breadcrumb } from "~/schema";
|
||||||
|
|
||||||
import Icon from "~/components/Icon";
|
import Icon from "~/components/Icon";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -31,9 +33,10 @@ import {
|
|||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import config from "~/config/gIndex.config";
|
|
||||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||||
import { Schema_Breadcrumb } from "~/schema";
|
|
||||||
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: z.infer<typeof Schema_Breadcrumb>[];
|
data: z.infer<typeof Schema_Breadcrumb>[];
|
||||||
@@ -43,14 +46,15 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
|
|||||||
const [open, setOpen] = useState<boolean>(false);
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
if (loading) return <Skeleton className='my-1.5 h-6 w-1/2' />;
|
if (loading) return <Skeleton className='h-5 w-1/2' />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex w-full flex-nowrap items-center'>
|
<div className='flex w-full flex-nowrap items-center'>
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink href={"/"}>
|
<BreadcrumbLink asChild>
|
||||||
|
<Link href={"/"}>
|
||||||
<div className='flex items-center gap-1'>
|
<div className='flex items-center gap-1'>
|
||||||
<Icon
|
<Icon
|
||||||
name='FolderRoot'
|
name='FolderRoot'
|
||||||
@@ -58,8 +62,9 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
|
|||||||
/>
|
/>
|
||||||
~
|
~
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
{!!data.length ? (
|
{!!data.length ? (
|
||||||
<>
|
<>
|
||||||
<BreadcrumbSeparator />
|
<BreadcrumbSeparator />
|
||||||
@@ -81,14 +86,16 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={`${item.label}/${item.href}`}
|
key={`${item.label}/${item.href}`}
|
||||||
>
|
>
|
||||||
<BreadcrumbLink
|
<BreadcrumbLink asChild>
|
||||||
href={array
|
<Link
|
||||||
|
href={`/${array
|
||||||
.slice(0, array.indexOf(item) + 1)
|
.slice(0, array.indexOf(item) + 1)
|
||||||
.map((item) => item.href)
|
.map((item) => item.href)
|
||||||
.join("/")}
|
.join("/")}`}
|
||||||
className='w-full'
|
className='w-full'
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -116,26 +123,24 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
|
|||||||
<BreadcrumbItem
|
<BreadcrumbItem
|
||||||
key={`${item.label}/${item.href}`}
|
key={`${item.label}/${item.href}`}
|
||||||
>
|
>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
<Link
|
<Link
|
||||||
href={array
|
href={`/${array
|
||||||
.slice(0, array.indexOf(item) + 1)
|
.slice(0, array.indexOf(item) + 1)
|
||||||
.map((item) => item.href)
|
.map((item) => item.href)
|
||||||
.join("/")}
|
.join("/")}`}
|
||||||
className='w-full py-1.5'
|
className='w-full py-1.5'
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<DrawerFooter className='pt-4'>
|
|
||||||
|
<DrawerFooter>
|
||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<Button
|
<Button variant={"secondary"}>Close</Button>
|
||||||
size={"sm"}
|
|
||||||
variant={"outline"}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DrawerClose>
|
</DrawerClose>
|
||||||
</DrawerFooter>
|
</DrawerFooter>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
@@ -146,20 +151,20 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{data
|
{data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
|
||||||
.slice(-config.siteConfig.breadcrumbMax + 1)
|
|
||||||
.map((item) => (
|
|
||||||
<Fragment key={`${item.label}/${item.href}`}>
|
<Fragment key={`${item.label}/${item.href}`}>
|
||||||
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
|
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||||
{item.href ? (
|
{item.href ? (
|
||||||
<>
|
<>
|
||||||
<BreadcrumbLink
|
<BreadcrumbLink asChild>
|
||||||
href={data
|
<Link
|
||||||
|
href={`/${data
|
||||||
.map((item) => item.href)
|
.map((item) => item.href)
|
||||||
.join("/")
|
.join("/")
|
||||||
.replace(/\/\//g, "/")}
|
.replace(/\/\//g, "/")}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -173,7 +178,6 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+542
-35
@@ -1,38 +1,115 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PropsWithChildren, useContext } from "react";
|
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 Icon from "~/components/Icon";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Drawer,
|
||||||
SelectContent,
|
DrawerClose,
|
||||||
SelectItem,
|
DrawerContent,
|
||||||
SelectTrigger,
|
DrawerDescription,
|
||||||
SelectValue,
|
DrawerFooter,
|
||||||
} from "~/components/ui/select";
|
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 { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { LayoutContext, TLayout } from "~/context/layoutContext";
|
|
||||||
import { cn } from "~/utils";
|
|
||||||
|
|
||||||
export default function HeaderButton({
|
import { LayoutContext } from "~/context/layoutContext";
|
||||||
children,
|
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||||
loading,
|
import useRouter from "~/hooks/usePRouter";
|
||||||
}: PropsWithChildren<{ loading?: boolean }>) {
|
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 { 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", "flex items-center justify-end gap-1.5")}>
|
<div className={cn("w-fit", "flex items-center justify-end gap-1.5")}>
|
||||||
<Skeleton className='my-0.5 h-8 w-16 tablet:w-24' />{" "}
|
<Skeleton className='my-0.5 h-8 w-16 mobile:w-24' />{" "}
|
||||||
<Skeleton className='my-0.5 h-8 w-8' />
|
<Skeleton className='my-0.5 h-8 w-8' />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -41,33 +118,147 @@ export default function HeaderButton({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto max-w-screen-desktop flex-grow",
|
// "mx-auto max-w-screen-desktop flex-grow",
|
||||||
|
"w-fit",
|
||||||
"flex items-center justify-end gap-1.5",
|
"flex items-center justify-end gap-1.5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Select
|
{isDesktop ? (
|
||||||
value={layout}
|
<DropdownMenu
|
||||||
onValueChange={(value) => setLayout(value as TLayout)}
|
open={layoutOpen}
|
||||||
|
onOpenChange={setLayoutOpen}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
className='flex w-24 items-center justify-between gap-1.5'
|
||||||
>
|
>
|
||||||
<SelectTrigger className='w-16 tablet:w-24'>
|
|
||||||
<SelectValue placeholder={"Layout"}>
|
|
||||||
<div className='flex items-center gap-1.5'>
|
<div className='flex items-center gap-1.5'>
|
||||||
<Icon
|
<Icon
|
||||||
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
||||||
size={16}
|
size={16}
|
||||||
/>
|
/>
|
||||||
<span className={cn("hidden", "tablet:block")}>
|
<span>{layout === "grid" ? "Grid" : "List"}</span>
|
||||||
{layout === "grid" ? "Grid" : "List"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</SelectValue>
|
<Icon
|
||||||
</SelectTrigger>
|
name='ChevronDown'
|
||||||
<SelectContent align='end'>
|
className={cn("transition", layoutOpen && "rotate-180")}
|
||||||
<SelectItem value='grid'>Grid</SelectItem>
|
size={14}
|
||||||
<SelectItem value='list'>List</SelectItem>
|
/>
|
||||||
</SelectContent>
|
</Button>
|
||||||
</Select>
|
</DropdownMenuTrigger>
|
||||||
<Dialog>
|
<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>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size={"icon"}
|
size={"icon"}
|
||||||
@@ -87,11 +278,327 @@ export default function HeaderButton({
|
|||||||
Search for files in your drive
|
Search for files in your drive
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className='flex flex-col'>
|
<div className='flex h-full flex-col gap-6'>
|
||||||
<Input placeholder='Input your query...' />
|
<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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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",
|
||||||
|
"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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|||||||
+1
-2
@@ -6,7 +6,6 @@ import { Schema_Breadcrumb } from "~/schema";
|
|||||||
import { cn } from "~/utils";
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import HeaderBreadcrumb from "./@header.breadcrumb";
|
import HeaderBreadcrumb from "./@header.breadcrumb";
|
||||||
import HeaderButton from "./@header.button";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -38,7 +37,7 @@ export default function Header({ name, breadcrumb }: Props) {
|
|||||||
data={breadcrumb || []}
|
data={breadcrumb || []}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
<HeaderButton loading={loading} />
|
{/* <HeaderButton loading={loading} /> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import remarkGfm from "remark-gfm";
|
|||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
import remarkSlug from "remark-slug";
|
import remarkSlug from "remark-slug";
|
||||||
import remarkToc from "remark-toc";
|
import remarkToc from "remark-toc";
|
||||||
|
|
||||||
import Icon from "~/components/Icon";
|
import Icon from "~/components/Icon";
|
||||||
|
|
||||||
import "./highlight.css";
|
import "./highlight.css";
|
||||||
|
|||||||
+138
-56
@@ -6,6 +6,8 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import Icon from "~/components/Icon";
|
import Icon from "~/components/Icon";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,16 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "~/components/ui/drawer";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -31,9 +43,10 @@ import {
|
|||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "~/components/ui/sheet";
|
} from "~/components/ui/sheet";
|
||||||
import { Tooltip, TooltipContent } from "~/components/ui/tooltip";
|
import { Tooltip, TooltipContent } from "~/components/ui/tooltip";
|
||||||
import config from "~/config/gIndex.config";
|
|
||||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||||
import { cn } from "~/utils";
|
|
||||||
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
import { ClearPassword } from "./actions";
|
import { ClearPassword } from "./actions";
|
||||||
|
|
||||||
@@ -43,6 +56,7 @@ export default function Navbar() {
|
|||||||
const { theme, themes, setTheme } = useTheme();
|
const { theme, themes, setTheme } = useTheme();
|
||||||
|
|
||||||
const [open, setOpen] = useState<boolean>(false);
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
const [themeOpen, setThemeOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDesktop) setOpen(false);
|
if (isDesktop) setOpen(false);
|
||||||
@@ -65,27 +79,16 @@ export default function Navbar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("sticky left-0 top-0 z-50", "h-12 w-full")}>
|
||||||
className={cn(
|
|
||||||
"relative left-0 top-0 z-50",
|
|
||||||
"h-12 w-full",
|
|
||||||
// "tablet:h-screen tablet:w-20",
|
|
||||||
"flex-shrink-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<nav
|
<nav
|
||||||
slot='nav'
|
slot='nav'
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background",
|
"bg-background",
|
||||||
"fixed left-0 top-0",
|
// "sticky left-0 top-0",
|
||||||
"h-12 w-full",
|
"h-12 w-full",
|
||||||
// "tablet:h-screen tablet:w-20",
|
|
||||||
"px-6 py-3",
|
"px-6 py-3",
|
||||||
// "tablet:px-3 tablet:py-6",
|
"flex flex-grow flex-row items-center justify-between",
|
||||||
"flex flex-row items-center justify-between",
|
|
||||||
// "tablet:flex-col",
|
|
||||||
"border-b border-border",
|
"border-b border-border",
|
||||||
// "tablet:border-b-0 tablet:border-r",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -94,8 +97,6 @@ export default function Navbar() {
|
|||||||
"mx-auto w-full max-w-screen-desktop",
|
"mx-auto w-full max-w-screen-desktop",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Link
|
<Link
|
||||||
href={"/"}
|
href={"/"}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -104,20 +105,16 @@ export default function Navbar() {
|
|||||||
<img
|
<img
|
||||||
src={config.siteConfig.siteIcon}
|
src={config.siteConfig.siteIcon}
|
||||||
alt={config.siteConfig.siteName}
|
alt={config.siteConfig.siteName}
|
||||||
className={cn("h-6 w-6 invert dark:invert-0")}
|
className={cn("h-10 w-10")}
|
||||||
loading='eager'
|
loading='eager'
|
||||||
/>
|
/>
|
||||||
<span className={cn("large", "hidden", "tablet:block")}>
|
<span className={cn("large", "hidden", "tablet:block")}>
|
||||||
{config.siteConfig.siteName}
|
{config.siteConfig.siteName}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Back to Root folder</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div className={cn("hidden", "tablet:flex")}>
|
{isDesktop ? (
|
||||||
|
<div className='flex'>
|
||||||
{config.siteConfig.navbarItems.map((item) => (
|
{config.siteConfig.navbarItems.map((item) => (
|
||||||
<Tooltip key={item.name}>
|
<Tooltip key={item.name}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -153,7 +150,11 @@ export default function Navbar() {
|
|||||||
className='mx-3'
|
className='mx-3'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu
|
||||||
|
modal={false}
|
||||||
|
open={themeOpen}
|
||||||
|
onOpenChange={setThemeOpen}
|
||||||
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -294,11 +295,8 @@ export default function Navbar() {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>Clear all saved password?</DialogTitle>
|
<DialogTitle>Clear all saved password?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This action will clear all saved password from your browser.
|
You will need to re-enter the password to access the
|
||||||
<br />
|
protected content.
|
||||||
You will need to re-enter the password for each
|
|
||||||
password-protected folder.
|
|
||||||
<br />
|
|
||||||
<br />
|
<br />
|
||||||
<b>Are you sure you want to continue?</b>
|
<b>Are you sure you want to continue?</b>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -318,8 +316,92 @@ export default function Navbar() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</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={cn("flex", "tablet:hidden")}>
|
<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
|
<Sheet
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
@@ -331,6 +413,7 @@ export default function Navbar() {
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name='Menu'
|
name='Menu'
|
||||||
|
size={18}
|
||||||
className='text-foreground'
|
className='text-foreground'
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -446,48 +529,47 @@ export default function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className='w-full'>
|
<SheetFooter className='w-full'>
|
||||||
<Dialog>
|
<Drawer>
|
||||||
<DialogTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={"destructive"}
|
variant={"destructive"}
|
||||||
className='gap-3'
|
className='w-full gap-3'
|
||||||
size={"sm"}
|
size={"sm"}
|
||||||
>
|
>
|
||||||
Clear all saved password
|
Clear all saved password
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
<DialogContent>
|
<DrawerHeader className='text-left'>
|
||||||
<DialogTitle>Clear all saved password?</DialogTitle>
|
<DrawerTitle>Clear all saved password?</DrawerTitle>
|
||||||
<DialogDescription>
|
<DrawerDescription>
|
||||||
This action will clear all saved password from your
|
You will need to re-enter the password to access the
|
||||||
browser.
|
protected content.
|
||||||
<br />
|
|
||||||
You will need to re-enter the password for each
|
|
||||||
password-protected folder.
|
|
||||||
<br />
|
|
||||||
<br />
|
<br />
|
||||||
<b>Are you sure you want to continue?</b>
|
<b>Are you sure you want to continue?</b>
|
||||||
</DialogDescription>
|
</DrawerDescription>
|
||||||
<DialogFooter className='gap-1.5'>
|
</DrawerHeader>
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant={"secondary"}>Cancel</Button>
|
<DrawerFooter className='gap-1.5'>
|
||||||
</DialogClose>
|
<DrawerClose asChild>
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant={"destructive"}
|
variant={"destructive"}
|
||||||
onClick={onClearPassword}
|
onClick={onClearPassword}
|
||||||
>
|
>
|
||||||
Clear all saved password
|
Clear all saved password
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DrawerClose>
|
||||||
</DialogFooter>
|
<DrawerClose asChild>
|
||||||
</DialogContent>
|
<Button variant={"secondary"}>Cancel</Button>
|
||||||
</Dialog>
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import Icon from "~/components/Icon";
|
import Icon from "~/components/Icon";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { cn } from "~/utils";
|
|
||||||
|
|
||||||
import { CheckSitePassword, SetSitePassword } from "./actions";
|
import { CheckSitePassword, SetPassword, SetSitePassword } from "./actions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -29,11 +30,16 @@ export default function Password({ path, errorMessage }: Props) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!input) throw new Error("Password is required");
|
if (!input) throw new Error("Password is required");
|
||||||
|
if (path === "global") {
|
||||||
const set = await SetSitePassword(input);
|
const set = await SetSitePassword(input);
|
||||||
if (!set.success) throw new Error(set.message);
|
if (!set.success) throw new Error(set.message);
|
||||||
|
|
||||||
const check = await CheckSitePassword();
|
const check = await CheckSitePassword();
|
||||||
if (!check.success) throw new Error(check.message);
|
if (!check.success) throw new Error(check.message);
|
||||||
|
} else {
|
||||||
|
const set = await SetPassword(path, input);
|
||||||
|
if (!set.success) throw new Error(set.message);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success("Password accepted! Refreshing...", {
|
toast.success("Password accepted! Refreshing...", {
|
||||||
id: "password",
|
id: "password",
|
||||||
|
|||||||
@@ -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,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import AudioPlayer from "react-h5-audio-player";
|
||||||
|
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 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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,214 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ encryptedId }: { encryptedId: string },
|
|
||||||
) {
|
|
||||||
return NextResponse.json({
|
|
||||||
encryptedId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+445
-14
@@ -3,12 +3,15 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import config from "~/config/gIndex.config";
|
|
||||||
import { Schema_File } from "~/schema";
|
import { Schema_File } from "~/schema";
|
||||||
import { Constant } from "~/types/constant";
|
|
||||||
import { decryptData, encryptData } from "~/utils/encryptionHelper/hash";
|
import { decryptData, encryptData } from "~/utils/encryptionHelper/hash";
|
||||||
import gdrive from "~/utils/gdriveInstance";
|
import gdrive from "~/utils/gdriveInstance";
|
||||||
|
|
||||||
|
import { Constant } from "~/types/constant";
|
||||||
|
|
||||||
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
export async function CheckSitePassword(): Promise<{
|
export async function CheckSitePassword(): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -60,6 +63,8 @@ export async function SetSitePassword(password: string): Promise<{
|
|||||||
secure: true,
|
secure: true,
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 365,
|
||||||
});
|
});
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
|
|
||||||
@@ -103,6 +108,220 @@ export async function ClearPassword(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 new Error(
|
||||||
|
`Password for '${currentFolder.path}' is incorrect, please re-enter the password`,
|
||||||
|
);
|
||||||
|
|
||||||
|
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({
|
export async function GetFiles({
|
||||||
id,
|
id,
|
||||||
pageToken,
|
pageToken,
|
||||||
@@ -116,15 +335,13 @@ export async function GetFiles({
|
|||||||
try {
|
try {
|
||||||
let decryptedId: string | undefined;
|
let decryptedId: string | undefined;
|
||||||
if (id) decryptedId = await decryptData(id);
|
if (id) decryptedId = await decryptData(id);
|
||||||
const filterName: string = config.apiConfig.hiddenFiles
|
else decryptedId = await decryptData(config.apiConfig.rootFolder);
|
||||||
.map((item) => `name != '${item}'`)
|
const filterName = config.apiConfig.hiddenFiles
|
||||||
|
.map((item) => `not name = '${item}'`)
|
||||||
.join(" and ");
|
.join(" and ");
|
||||||
const query: string = [
|
const query: string = [
|
||||||
...config.apiConfig.defaultQuery,
|
...config.apiConfig.defaultQuery,
|
||||||
// `'1owGkP0NsJ4am8vVYebpjnoxzY5VOc7Vh' in parents`,
|
`'${decryptedId}' in parents`,
|
||||||
decryptedId
|
|
||||||
? `'${decryptedId}' in parents`
|
|
||||||
: `'${config.apiConfig.rootFolder}' in parents`,
|
|
||||||
`${filterName}`,
|
`${filterName}`,
|
||||||
].join(" and ");
|
].join(" and ");
|
||||||
|
|
||||||
@@ -160,7 +377,7 @@ export async function GetFiles({
|
|||||||
? {
|
? {
|
||||||
width: Number(file.imageMediaMetadata.width),
|
width: Number(file.imageMediaMetadata.width),
|
||||||
height: Number(file.imageMediaMetadata.height),
|
height: Number(file.imageMediaMetadata.height),
|
||||||
rotation: Number(file.imageMediaMetadata.rotation),
|
rotation: Number(file.imageMediaMetadata.rotation || 0),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
videoMediaMetadata: file.videoMediaMetadata
|
videoMediaMetadata: file.videoMediaMetadata
|
||||||
@@ -184,13 +401,184 @@ export async function GetFiles({
|
|||||||
throw new 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(
|
export async function GetReadme(
|
||||||
encryptedId: string | undefined,
|
encryptedId: string | undefined,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
let decryptedId: string | undefined;
|
let decryptedId;
|
||||||
if (encryptedId) decryptedId = await decryptData(encryptedId);
|
if (encryptedId) decryptedId = await decryptData(encryptedId);
|
||||||
else decryptedId = config.apiConfig.rootFolder;
|
else decryptedId = await decryptData(config.apiConfig.rootFolder);
|
||||||
|
|
||||||
const query: string[] = [
|
const query: string[] = [
|
||||||
...config.apiConfig.defaultQuery,
|
...config.apiConfig.defaultQuery,
|
||||||
@@ -199,10 +587,12 @@ export async function GetReadme(
|
|||||||
];
|
];
|
||||||
const { data } = await gdrive.files.list({
|
const { data } = await gdrive.files.list({
|
||||||
q: query.join(" and "),
|
q: query.join(" and "),
|
||||||
fields: `files(${config.apiConfig.defaultField}), nextPageToken`,
|
fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
|
||||||
orderBy: config.apiConfig.defaultOrder,
|
orderBy: config.apiConfig.defaultOrder,
|
||||||
pageSize: config.apiConfig.itemsPerPage,
|
pageSize: config.apiConfig.itemsPerPage,
|
||||||
pageToken: undefined,
|
pageToken: undefined,
|
||||||
|
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||||
|
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
|
||||||
});
|
});
|
||||||
if (!data.files?.length) return null;
|
if (!data.files?.length) return null;
|
||||||
|
|
||||||
@@ -223,13 +613,55 @@ export async function GetReadme(
|
|||||||
throw new 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(
|
export async function CreateDownloadToken(
|
||||||
duration: number = config.apiConfig.temporaryTokenDuration,
|
duration: number = config.apiConfig.temporaryTokenDuration,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
expiredAt: Date.now() + duration * 60 * 60 * 1000,
|
expiredAt:
|
||||||
|
Date.now() +
|
||||||
|
duration * 60 * 60 * 1000 * config.apiConfig.temporaryTokenDuration,
|
||||||
};
|
};
|
||||||
const token = await encryptData(JSON.stringify(data));
|
const token = await encryptData(JSON.stringify(data));
|
||||||
return token;
|
return token;
|
||||||
@@ -239,7 +671,6 @@ export async function CreateDownloadToken(
|
|||||||
throw new Error(e.message);
|
throw new Error(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function CheckDownloadToken(token: string): Promise<{
|
export async function CheckDownloadToken(token: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|||||||
@@ -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,162 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
CheckDownloadToken,
|
||||||
|
CheckPassword,
|
||||||
|
CheckPaths,
|
||||||
|
CheckSitePassword,
|
||||||
|
RedirectSearchFile,
|
||||||
|
} from "~/app/actions";
|
||||||
|
|
||||||
|
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||||
|
import gdrive from "~/utils/gdriveInstance";
|
||||||
|
|
||||||
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
|
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,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { encryptData } from "~/utils/encryptionHelper/hash";
|
||||||
|
|
||||||
|
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,63 @@
|
|||||||
|
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 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import config from "~/config/gIndex.config";
|
|
||||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||||
import gdrive from "~/utils/gdriveInstance";
|
import gdrive from "~/utils/gdriveInstance";
|
||||||
|
|
||||||
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: {
|
params: {
|
||||||
encryptedId: string;
|
encryptedId: string;
|
||||||
@@ -14,14 +16,93 @@ export async function GET(
|
|||||||
{ params: { encryptedId } }: Props,
|
{ params: { encryptedId } }: Props,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const defaultImage = NextResponse.redirect(
|
||||||
|
new URL("/og.png", config.basePath),
|
||||||
|
{
|
||||||
|
status: 302,
|
||||||
|
},
|
||||||
|
);
|
||||||
const decryptedId = await decryptData(encryptedId);
|
const decryptedId = await decryptData(encryptedId);
|
||||||
const { data } = await gdrive.files.get({
|
const _fileMeta = gdrive.files.get({
|
||||||
fileId: decryptedId,
|
fileId: decryptedId,
|
||||||
fields: "id, name, mimeType, thumbnailLink",
|
fields:
|
||||||
|
"id, name, mimeType, fileExtension, webContentLink, thumbnailLink",
|
||||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||||
});
|
});
|
||||||
if (!data.thumbnailLink) throw new Error("No thumbnail for this file");
|
const _fileContent = gdrive.files.get(
|
||||||
return NextResponse.redirect(data.thumbnailLink);
|
{
|
||||||
|
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) {
|
} catch (error) {
|
||||||
const e = error as Error;
|
const e = error as Error;
|
||||||
console.error(e.message);
|
console.error(e.message);
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "Hello World!",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -77,7 +77,7 @@ body {
|
|||||||
@apply bg-background;
|
@apply bg-background;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply bg-primary/50 hover:bg-primary/75;
|
@apply rounded-full bg-primary/25 hover:bg-primary/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
|
|||||||
+5
-12
@@ -1,8 +1,9 @@
|
|||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
|
import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
|
||||||
import config from "~/config/gIndex.config";
|
|
||||||
import { cn } from "~/utils";
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
import Footer from "./@footer";
|
import Footer from "./@footer";
|
||||||
import Navbar from "./@navbar";
|
import Navbar from "./@navbar";
|
||||||
import Password from "./@password";
|
import Password from "./@password";
|
||||||
@@ -41,7 +42,7 @@ export const metadata: Metadata = {
|
|||||||
creator: "mbaharip",
|
creator: "mbaharip",
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
url: config.siteConfig.siteIcon,
|
url: config.siteConfig.favIcon,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
keywords: ["gdrive", "index", "nextjs", "reactjs"],
|
keywords: ["gdrive", "index", "nextjs", "reactjs"],
|
||||||
@@ -90,7 +91,7 @@ export default async function RootLayout({
|
|||||||
<html lang='en'>
|
<html lang='en'>
|
||||||
<body
|
<body
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background font-sans text-foreground",
|
"h-full bg-background font-sans text-foreground",
|
||||||
jetbrainsMono.variable,
|
jetbrainsMono.variable,
|
||||||
sourceSans3.variable,
|
sourceSans3.variable,
|
||||||
)}
|
)}
|
||||||
@@ -100,19 +101,12 @@ export default async function RootLayout({
|
|||||||
defaultTheme='system'
|
defaultTheme='system'
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-full min-h-screen w-full overflow-x-hidden",
|
|
||||||
"flex flex-col items-start",
|
|
||||||
// "tablet:flex-row",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main
|
<main
|
||||||
slot='content'
|
slot='content'
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto h-full min-h-screen w-full max-w-screen-desktop",
|
"mx-auto h-full w-full max-w-screen-desktop",
|
||||||
"relative left-0 top-0",
|
"relative left-0 top-0",
|
||||||
"flex flex-grow flex-col gap-3 p-6",
|
"flex flex-grow flex-col gap-3 p-6",
|
||||||
"tablet:gap-6",
|
"tablet:gap-6",
|
||||||
@@ -130,7 +124,6 @@ export default async function RootLayout({
|
|||||||
{config.siteConfig.footer && (
|
{config.siteConfig.footer && (
|
||||||
<Footer content={formatFooter(config.siteConfig.footer)} />
|
<Footer content={formatFooter(config.siteConfig.footer)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Icon from "~/components/Icon";
|
|
||||||
import { cn } from "~/utils";
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
|
import Icon from "~/components/Icon";
|
||||||
|
|
||||||
export default function RootLoading() {
|
export default function RootLoading() {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 NotFound() {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-3
@@ -1,13 +1,15 @@
|
|||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { cn } from "~/utils";
|
|
||||||
|
|
||||||
import FileBrowser from "./@explorer";
|
import FileBrowser from "./@explorer";
|
||||||
import Header from "./@header";
|
import Header from "./@header";
|
||||||
|
import HeaderButton from "./@header.button";
|
||||||
import Markdown from "./@markdown";
|
import Markdown from "./@markdown";
|
||||||
import { GetFiles, GetReadme } from "./actions";
|
import { GetFiles, GetReadme } from "./actions";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 300;
|
||||||
|
|
||||||
export default async function RootPage() {
|
export default async function RootPage() {
|
||||||
const [data, readme] = await Promise.all([
|
const [data, readme] = await Promise.all([
|
||||||
@@ -24,13 +26,17 @@ export default async function RootPage() {
|
|||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className='pb-0'>
|
<CardHeader className='pb-0'>
|
||||||
<CardTitle>Browse files</CardTitle>
|
<div className='flex w-full items-center justify-between gap-3'>
|
||||||
|
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||||
|
<HeaderButton />
|
||||||
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
files={data.files}
|
files={data.files}
|
||||||
nextPageToken={data.nextPageToken}
|
nextPageToken={data.nextPageToken}
|
||||||
|
root
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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,13 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We don't actually need RSS
|
||||||
|
* But console always show "Path not found" error on rss.xml
|
||||||
|
* since we are using [...rest] route
|
||||||
|
*
|
||||||
|
* So this is just a empty route to prevent that
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return new NextResponse();
|
||||||
|
}
|
||||||
@@ -2,19 +2,38 @@
|
|||||||
|
|
||||||
import { ThemeProvider as NextThemeProvider } from "next-themes";
|
import { ThemeProvider as NextThemeProvider } from "next-themes";
|
||||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||||
|
import NextTopLoader from "nextjs-toploader";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import { TooltipProvider } from "~/components/ui/tooltip";
|
import { TooltipProvider } from "~/components/ui/tooltip";
|
||||||
import config from "~/config/gIndex.config";
|
|
||||||
import LayoutProvider from "~/context/layoutContext";
|
import LayoutProvider from "~/context/layoutContext";
|
||||||
|
|
||||||
|
import config from "~/config/gIndex.config";
|
||||||
|
|
||||||
export default function ThemeProvider({
|
export default function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: ThemeProviderProps) {
|
}: ThemeProviderProps) {
|
||||||
return (
|
return (
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
|
<NextTopLoader color='hsl(var(--primary))' />
|
||||||
<NextThemeProvider {...props}>
|
<NextThemeProvider {...props}>
|
||||||
<TooltipProvider delayDuration={250}>{children}</TooltipProvider>
|
<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
|
<Toaster
|
||||||
position={config.siteConfig.toaster?.position}
|
position={config.siteConfig.toaster?.position}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import * as React from "react"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
import { cn } from "~/utils"
|
|
||||||
|
|
||||||
const Breadcrumb = React.forwardRef<
|
const Breadcrumb = React.forwardRef<
|
||||||
HTMLElement,
|
HTMLElement,
|
||||||
React.ComponentPropsWithoutRef<"nav"> & {
|
React.ComponentPropsWithoutRef<"nav"> & {
|
||||||
separator?: React.ReactNode
|
separator?: React.ReactNode;
|
||||||
}
|
}
|
||||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
>(({ ...props }, ref) => (
|
||||||
Breadcrumb.displayName = "Breadcrumb"
|
<nav
|
||||||
|
ref={ref}
|
||||||
|
aria-label='breadcrumb'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Breadcrumb.displayName = "Breadcrumb";
|
||||||
|
|
||||||
const BreadcrumbList = React.forwardRef<
|
const BreadcrumbList = React.forwardRef<
|
||||||
HTMLOListElement,
|
HTMLOListElement,
|
||||||
@@ -19,13 +24,13 @@ const BreadcrumbList = React.forwardRef<
|
|||||||
<ol
|
<ol
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-zinc-500 sm:gap-2.5 dark:text-zinc-400",
|
"flex flex-wrap items-center gap-1.5 break-words text-sm text-zinc-500 dark:text-zinc-400 sm:gap-2.5",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
BreadcrumbList.displayName = "BreadcrumbList"
|
BreadcrumbList.displayName = "BreadcrumbList";
|
||||||
|
|
||||||
const BreadcrumbItem = React.forwardRef<
|
const BreadcrumbItem = React.forwardRef<
|
||||||
HTMLLIElement,
|
HTMLLIElement,
|
||||||
@@ -36,26 +41,29 @@ const BreadcrumbItem = React.forwardRef<
|
|||||||
className={cn("inline-flex items-center gap-1.5", className)}
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||||
|
|
||||||
const BreadcrumbLink = React.forwardRef<
|
const BreadcrumbLink = React.forwardRef<
|
||||||
HTMLAnchorElement,
|
HTMLAnchorElement,
|
||||||
React.ComponentPropsWithoutRef<"a"> & {
|
React.ComponentPropsWithoutRef<"a"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
>(({ asChild, className, ...props }, ref) => {
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "a"
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("transition-colors hover:text-zinc-950 dark:hover:text-zinc-50", className)}
|
className={cn(
|
||||||
|
"transition-colors hover:text-zinc-950 dark:hover:text-zinc-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||||
|
|
||||||
const BreadcrumbPage = React.forwardRef<
|
const BreadcrumbPage = React.forwardRef<
|
||||||
HTMLSpanElement,
|
HTMLSpanElement,
|
||||||
@@ -63,14 +71,14 @@ const BreadcrumbPage = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<span
|
<span
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role="link"
|
role='link'
|
||||||
aria-disabled="true"
|
aria-disabled='true'
|
||||||
aria-current="page"
|
aria-current='page'
|
||||||
className={cn("font-normal text-zinc-950 dark:text-zinc-50", className)}
|
className={cn("font-normal text-zinc-950 dark:text-zinc-50", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||||
|
|
||||||
const BreadcrumbSeparator = ({
|
const BreadcrumbSeparator = ({
|
||||||
children,
|
children,
|
||||||
@@ -78,31 +86,31 @@ const BreadcrumbSeparator = ({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"li">) => (
|
}: React.ComponentProps<"li">) => (
|
||||||
<li
|
<li
|
||||||
role="presentation"
|
role='presentation'
|
||||||
aria-hidden="true"
|
aria-hidden='true'
|
||||||
className={cn("[&>svg]:size-3.5", className)}
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children ?? <ChevronRight />}
|
{children ?? <ChevronRight />}
|
||||||
</li>
|
</li>
|
||||||
)
|
);
|
||||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||||
|
|
||||||
const BreadcrumbEllipsis = ({
|
const BreadcrumbEllipsis = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span">) => (
|
}: React.ComponentProps<"span">) => (
|
||||||
<span
|
<span
|
||||||
role="presentation"
|
role='presentation'
|
||||||
aria-hidden="true"
|
aria-hidden='true'
|
||||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className='h-4 w-4' />
|
||||||
<span className="sr-only">More</span>
|
<span className='sr-only'>More</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -112,4 +120,4 @@ export {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
BreadcrumbEllipsis,
|
BreadcrumbEllipsis,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import * as React from "react"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
import { cn } from "~/utils"
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
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",
|
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:
|
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",
|
"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:
|
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",
|
"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:
|
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",
|
"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",
|
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",
|
link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
@@ -30,27 +31,27 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Button.displayName = "Button"
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
+29
-19
@@ -1,6 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
import { cn } from "~/utils"
|
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -10,12 +9,12 @@ const Card = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card";
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
const CardHeader = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -26,8 +25,8 @@ const CardHeader = React.forwardRef<
|
|||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
@@ -37,12 +36,12 @@ const CardTitle = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
@@ -53,16 +52,20 @@ const CardDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardDescription.displayName = "CardDescription"
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
const CardContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
<div
|
||||||
))
|
ref={ref}
|
||||||
CardContent.displayName = "CardContent"
|
className={cn("p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
const CardFooter = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -73,7 +76,14 @@ const CardFooter = React.forwardRef<
|
|||||||
className={cn("flex items-center p-6 pt-0", className)}
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardFooter.displayName = "CardFooter"
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
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,
|
||||||
|
};
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import { X } from "lucide-react";
|
||||||
import { X } from "lucide-react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import { cn } from "~/utils"
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
const DialogPortal = DialogPrimitive.Portal
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
const DialogClose = DialogPrimitive.Close
|
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
@@ -22,12 +21,12 @@ const DialogOverlay = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
@@ -39,19 +38,19 @@ const DialogContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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">
|
<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" />
|
<X className='h-4 w-4' />
|
||||||
<span className="sr-only">Close</span>
|
<span className='sr-only'>Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
));
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -60,12 +59,12 @@ const DialogHeader = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogHeader.displayName = "DialogHeader"
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
const DialogFooter = ({
|
const DialogFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -74,12 +73,12 @@ const DialogFooter = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogFooter.displayName = "DialogFooter"
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
@@ -89,12 +88,12 @@ const DialogTitle = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
@@ -105,8 +104,8 @@ const DialogDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -119,4 +118,4 @@ export {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Drawer as DrawerPrimitive } from "vaul"
|
import { Drawer as DrawerPrimitive } from "vaul";
|
||||||
|
import { cn } from "~/utils";
|
||||||
import { cn } from "~/utils"
|
|
||||||
|
|
||||||
const Drawer = ({
|
const Drawer = ({
|
||||||
shouldScaleBackground = true,
|
shouldScaleBackground = true,
|
||||||
@@ -13,14 +12,14 @@ const Drawer = ({
|
|||||||
shouldScaleBackground={shouldScaleBackground}
|
shouldScaleBackground={shouldScaleBackground}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
Drawer.displayName = "Drawer"
|
Drawer.displayName = "Drawer";
|
||||||
|
|
||||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||||
|
|
||||||
const DrawerPortal = DrawerPrimitive.Portal
|
const DrawerPortal = DrawerPrimitive.Portal;
|
||||||
|
|
||||||
const DrawerClose = DrawerPrimitive.Close
|
const DrawerClose = DrawerPrimitive.Close;
|
||||||
|
|
||||||
const DrawerOverlay = React.forwardRef<
|
const DrawerOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||||
@@ -31,8 +30,8 @@ const DrawerOverlay = React.forwardRef<
|
|||||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DrawerContent = React.forwardRef<
|
const DrawerContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||||
@@ -44,16 +43,16 @@ const DrawerContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
<div className='mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted' />
|
||||||
{children}
|
{children}
|
||||||
</DrawerPrimitive.Content>
|
</DrawerPrimitive.Content>
|
||||||
</DrawerPortal>
|
</DrawerPortal>
|
||||||
))
|
));
|
||||||
DrawerContent.displayName = "DrawerContent"
|
DrawerContent.displayName = "DrawerContent";
|
||||||
|
|
||||||
const DrawerHeader = ({
|
const DrawerHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -63,8 +62,8 @@ const DrawerHeader = ({
|
|||||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DrawerHeader.displayName = "DrawerHeader"
|
DrawerHeader.displayName = "DrawerHeader";
|
||||||
|
|
||||||
const DrawerFooter = ({
|
const DrawerFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -74,8 +73,8 @@ const DrawerFooter = ({
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DrawerFooter.displayName = "DrawerFooter"
|
DrawerFooter.displayName = "DrawerFooter";
|
||||||
|
|
||||||
const DrawerTitle = React.forwardRef<
|
const DrawerTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||||
@@ -85,12 +84,12 @@ const DrawerTitle = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||||
|
|
||||||
const DrawerDescription = React.forwardRef<
|
const DrawerDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||||
@@ -101,8 +100,8 @@ const DrawerDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -115,4 +114,4 @@ export {
|
|||||||
DrawerFooter,
|
DrawerFooter,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerDescription,
|
DrawerDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import { cn } from "~/utils"
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
@@ -29,16 +28,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
className={cn(
|
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",
|
"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",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className='ml-auto h-4 w-4' />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
))
|
));
|
||||||
DropdownMenuSubTrigger.displayName =
|
DropdownMenuSubTrigger.displayName =
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
@@ -48,13 +47,13 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuSubContent.displayName =
|
DropdownMenuSubContent.displayName =
|
||||||
DropdownMenuPrimitive.SubContent.displayName
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
@@ -66,18 +65,18 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
))
|
));
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
@@ -85,12 +84,12 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
className={cn(
|
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",
|
"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",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
@@ -100,21 +99,21 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<Check className='h-4 w-4' />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
))
|
));
|
||||||
DropdownMenuCheckboxItem.displayName =
|
DropdownMenuCheckboxItem.displayName =
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
@@ -124,24 +123,24 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
<Circle className='h-2 w-2 fill-current' />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
))
|
));
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
@@ -149,12 +148,12 @@ const DropdownMenuLabel = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
@@ -165,8 +164,8 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
const DropdownMenuShortcut = ({
|
||||||
className,
|
className,
|
||||||
@@ -177,9 +176,9 @@ const DropdownMenuShortcut = ({
|
|||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -197,4 +196,4 @@ export {
|
|||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
import { cn } from "~/utils"
|
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
@@ -12,14 +11,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { 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 };
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import { cn } from "~/utils"
|
const MenubarMenu = MenubarPrimitive.Menu;
|
||||||
|
|
||||||
const MenubarMenu = MenubarPrimitive.Menu
|
const MenubarGroup = MenubarPrimitive.Group;
|
||||||
|
|
||||||
const MenubarGroup = MenubarPrimitive.Group
|
const MenubarPortal = MenubarPrimitive.Portal;
|
||||||
|
|
||||||
const MenubarPortal = MenubarPrimitive.Portal
|
const MenubarSub = MenubarPrimitive.Sub;
|
||||||
|
|
||||||
const MenubarSub = MenubarPrimitive.Sub
|
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||||
|
|
||||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
|
|
||||||
|
|
||||||
const Menubar = React.forwardRef<
|
const Menubar = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||||
@@ -24,12 +23,12 @@ const Menubar = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
|
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||||
|
|
||||||
const MenubarTrigger = React.forwardRef<
|
const MenubarTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||||
@@ -39,17 +38,17 @@ const MenubarTrigger = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const MenubarSubTrigger = React.forwardRef<
|
const MenubarSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
<MenubarPrimitive.SubTrigger
|
<MenubarPrimitive.SubTrigger
|
||||||
@@ -57,15 +56,15 @@ const MenubarSubTrigger = React.forwardRef<
|
|||||||
className={cn(
|
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",
|
"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",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className='ml-auto h-4 w-4' />
|
||||||
</MenubarPrimitive.SubTrigger>
|
</MenubarPrimitive.SubTrigger>
|
||||||
))
|
));
|
||||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
const MenubarSubContent = React.forwardRef<
|
const MenubarSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||||
@@ -75,12 +74,12 @@ const MenubarSubContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
const MenubarContent = React.forwardRef<
|
const MenubarContent = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||||
@@ -88,7 +87,7 @@ const MenubarContent = React.forwardRef<
|
|||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||||
ref
|
ref,
|
||||||
) => (
|
) => (
|
||||||
<MenubarPrimitive.Portal>
|
<MenubarPrimitive.Portal>
|
||||||
<MenubarPrimitive.Content
|
<MenubarPrimitive.Content
|
||||||
@@ -98,19 +97,19 @@ const MenubarContent = React.forwardRef<
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</MenubarPrimitive.Portal>
|
</MenubarPrimitive.Portal>
|
||||||
)
|
),
|
||||||
)
|
);
|
||||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||||
|
|
||||||
const MenubarItem = React.forwardRef<
|
const MenubarItem = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<MenubarPrimitive.Item
|
<MenubarPrimitive.Item
|
||||||
@@ -118,12 +117,12 @@ const MenubarItem = React.forwardRef<
|
|||||||
className={cn(
|
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",
|
"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",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||||
|
|
||||||
const MenubarCheckboxItem = React.forwardRef<
|
const MenubarCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||||
@@ -133,20 +132,20 @@ const MenubarCheckboxItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||||
<MenubarPrimitive.ItemIndicator>
|
<MenubarPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<Check className='h-4 w-4' />
|
||||||
</MenubarPrimitive.ItemIndicator>
|
</MenubarPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</MenubarPrimitive.CheckboxItem>
|
</MenubarPrimitive.CheckboxItem>
|
||||||
))
|
));
|
||||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
const MenubarRadioItem = React.forwardRef<
|
const MenubarRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||||
@@ -156,24 +155,24 @@ const MenubarRadioItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||||
<MenubarPrimitive.ItemIndicator>
|
<MenubarPrimitive.ItemIndicator>
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
<Circle className='h-2 w-2 fill-current' />
|
||||||
</MenubarPrimitive.ItemIndicator>
|
</MenubarPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</MenubarPrimitive.RadioItem>
|
</MenubarPrimitive.RadioItem>
|
||||||
))
|
));
|
||||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
const MenubarLabel = React.forwardRef<
|
const MenubarLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<MenubarPrimitive.Label
|
<MenubarPrimitive.Label
|
||||||
@@ -181,12 +180,12 @@ const MenubarLabel = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||||
|
|
||||||
const MenubarSeparator = React.forwardRef<
|
const MenubarSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||||
@@ -197,8 +196,8 @@ const MenubarSeparator = React.forwardRef<
|
|||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const MenubarShortcut = ({
|
const MenubarShortcut = ({
|
||||||
className,
|
className,
|
||||||
@@ -208,13 +207,13 @@ const MenubarShortcut = ({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
MenubarShortcut.displayname = "MenubarShortcut"
|
MenubarShortcut.displayname = "MenubarShortcut";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Menubar,
|
Menubar,
|
||||||
@@ -233,4 +232,4 @@ export {
|
|||||||
MenubarGroup,
|
MenubarGroup,
|
||||||
MenubarSub,
|
MenubarSub,
|
||||||
MenubarShortcut,
|
MenubarShortcut,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import { cn } from "~/utils"
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
const SelectTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
@@ -20,17 +19,17 @@ const SelectTrigger = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<ChevronDown className='h-4 w-4 opacity-50' />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
))
|
));
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
@@ -40,14 +39,14 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className='h-4 w-4' />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
))
|
));
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
@@ -57,15 +56,15 @@ const SelectScrollDownButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className='h-4 w-4' />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
))
|
));
|
||||||
SelectScrollDownButton.displayName =
|
SelectScrollDownButton.displayName =
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
@@ -78,7 +77,7 @@ const SelectContent = React.forwardRef<
|
|||||||
"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",
|
"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" &&
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -88,7 +87,7 @@ const SelectContent = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -96,8 +95,8 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
))
|
));
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
@@ -108,8 +107,8 @@ const SelectLabel = React.forwardRef<
|
|||||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
const SelectItem = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
@@ -119,20 +118,20 @@ const SelectItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<Check className='h-4 w-4' />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
))
|
));
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
const SelectSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
@@ -143,8 +142,8 @@ const SelectSeparator = React.forwardRef<
|
|||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
@@ -157,4 +156,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
import { cn } from "~/utils"
|
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
const Separator = React.forwardRef<
|
||||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
@@ -11,7 +10,7 @@ const Separator = React.forwardRef<
|
|||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
ref
|
ref,
|
||||||
) => (
|
) => (
|
||||||
<SeparatorPrimitive.Root
|
<SeparatorPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -20,12 +19,12 @@ const Separator = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 bg-border",
|
"shrink-0 bg-border",
|
||||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
)
|
);
|
||||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|||||||
+31
-32
@@ -1,19 +1,18 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { X } from "lucide-react";
|
||||||
import { X } from "lucide-react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import { cn } from "~/utils"
|
const Sheet = SheetPrimitive.Root;
|
||||||
|
|
||||||
const Sheet = SheetPrimitive.Root
|
const SheetTrigger = SheetPrimitive.Trigger;
|
||||||
|
|
||||||
const SheetTrigger = SheetPrimitive.Trigger
|
const SheetClose = SheetPrimitive.Close;
|
||||||
|
|
||||||
const SheetClose = SheetPrimitive.Close
|
const SheetPortal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
const SheetPortal = SheetPrimitive.Portal
|
|
||||||
|
|
||||||
const SheetOverlay = React.forwardRef<
|
const SheetOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
@@ -22,13 +21,13 @@ const SheetOverlay = React.forwardRef<
|
|||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const sheetVariants = cva(
|
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",
|
"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",
|
||||||
@@ -46,8 +45,8 @@ const sheetVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
interface SheetContentProps
|
interface SheetContentProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
@@ -65,14 +64,14 @@ const SheetContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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">
|
<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" />
|
<X className='h-4 w-4' />
|
||||||
<span className="sr-only">Close</span>
|
<span className='sr-only'>Close</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
))
|
));
|
||||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SheetHeader = ({
|
const SheetHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -81,12 +80,12 @@ const SheetHeader = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
SheetHeader.displayName = "SheetHeader"
|
SheetHeader.displayName = "SheetHeader";
|
||||||
|
|
||||||
const SheetFooter = ({
|
const SheetFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -95,12 +94,12 @@ const SheetFooter = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
SheetFooter.displayName = "SheetFooter"
|
SheetFooter.displayName = "SheetFooter";
|
||||||
|
|
||||||
const SheetTitle = React.forwardRef<
|
const SheetTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
@@ -111,8 +110,8 @@ const SheetTitle = React.forwardRef<
|
|||||||
className={cn("text-lg font-semibold text-foreground", className)}
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||||
|
|
||||||
const SheetDescription = React.forwardRef<
|
const SheetDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
@@ -123,8 +122,8 @@ const SheetDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -137,4 +136,4 @@ export {
|
|||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cn } from "~/utils"
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
function Skeleton({
|
function Skeleton({
|
||||||
className,
|
className,
|
||||||
@@ -9,7 +9,7 @@ function Skeleton({
|
|||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton }
|
export { Skeleton };
|
||||||
|
|||||||
+28
-25
@@ -1,28 +1,31 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
import { cn } from "~/utils"
|
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="relative w-full overflow-auto">
|
<div className='relative w-full overflow-auto'>
|
||||||
<table
|
<table
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
));
|
||||||
Table.displayName = "Table"
|
Table.displayName = "Table";
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
const TableHeader = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
<thead
|
||||||
))
|
ref={ref}
|
||||||
TableHeader.displayName = "TableHeader"
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
const TableBody = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
@@ -33,8 +36,8 @@ const TableBody = React.forwardRef<
|
|||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableBody.displayName = "TableBody"
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
const TableFooter = React.forwardRef<
|
const TableFooter = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
@@ -44,12 +47,12 @@ const TableFooter = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableFooter.displayName = "TableFooter"
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
const TableRow = React.forwardRef<
|
const TableRow = React.forwardRef<
|
||||||
HTMLTableRowElement,
|
HTMLTableRowElement,
|
||||||
@@ -59,12 +62,12 @@ const TableRow = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableRow.displayName = "TableRow"
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
const TableHead = React.forwardRef<
|
||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
@@ -74,12 +77,12 @@ const TableHead = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableHead.displayName = "TableHead"
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
const TableCell = React.forwardRef<
|
||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
@@ -90,8 +93,8 @@ const TableCell = React.forwardRef<
|
|||||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableCell.displayName = "TableCell"
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<
|
const TableCaption = React.forwardRef<
|
||||||
HTMLTableCaptionElement,
|
HTMLTableCaptionElement,
|
||||||
@@ -102,8 +105,8 @@ const TableCaption = React.forwardRef<
|
|||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableCaption.displayName = "TableCaption"
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Table,
|
Table,
|
||||||
@@ -114,4 +117,4 @@ export {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
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 };
|
||||||
+30
-31
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
import { type VariantProps, cva } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { X } from "lucide-react";
|
||||||
import { X } from "lucide-react"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import { cn } from "~/utils"
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
const ToastProvider = ToastPrimitives.Provider
|
|
||||||
|
|
||||||
const ToastViewport = React.forwardRef<
|
const ToastViewport = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
@@ -17,12 +16,12 @@ const ToastViewport = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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]",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
const toastVariants = cva(
|
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",
|
"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",
|
||||||
@@ -37,8 +36,8 @@ const toastVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
const Toast = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
@@ -51,9 +50,9 @@ const Toast = React.forwardRef<
|
|||||||
className={cn(toastVariants({ variant }), className)}
|
className={cn(toastVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
Toast.displayName = ToastPrimitives.Root.displayName
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
const ToastAction = React.forwardRef<
|
const ToastAction = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
@@ -63,12 +62,12 @@ const ToastAction = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
const ToastClose = React.forwardRef<
|
const ToastClose = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
@@ -78,15 +77,15 @@ const ToastClose = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
toast-close=""
|
toast-close=''
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className='h-4 w-4' />
|
||||||
</ToastPrimitives.Close>
|
</ToastPrimitives.Close>
|
||||||
))
|
));
|
||||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
const ToastTitle = React.forwardRef<
|
const ToastTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
@@ -97,8 +96,8 @@ const ToastTitle = React.forwardRef<
|
|||||||
className={cn("text-sm font-semibold", className)}
|
className={cn("text-sm font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
const ToastDescription = React.forwardRef<
|
const ToastDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
@@ -109,12 +108,12 @@ const ToastDescription = React.forwardRef<
|
|||||||
className={cn("text-sm opacity-90", className)}
|
className={cn("text-sm opacity-90", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type ToastProps,
|
type ToastProps,
|
||||||
@@ -126,4 +125,4 @@ export {
|
|||||||
ToastDescription,
|
ToastDescription,
|
||||||
ToastClose,
|
ToastClose,
|
||||||
ToastAction,
|
ToastAction,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Toast,
|
Toast,
|
||||||
@@ -7,18 +7,21 @@ import {
|
|||||||
ToastProvider,
|
ToastProvider,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastViewport,
|
ToastViewport,
|
||||||
} from "~/components/ui/toast"
|
} from "~/components/ui/toast";
|
||||||
import { useToast } from "~/components/ui/use-toast"
|
import { useToast } from "~/components/ui/use-toast";
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const { toasts } = useToast()
|
const { toasts } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
return (
|
return (
|
||||||
<Toast key={id} {...props}>
|
<Toast
|
||||||
<div className="grid gap-1">
|
key={id}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className='grid gap-1'>
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
{description && (
|
{description && (
|
||||||
<ToastDescription>{description}</ToastDescription>
|
<ToastDescription>{description}</ToastDescription>
|
||||||
@@ -27,9 +30,9 @@ export function Toaster() {
|
|||||||
{action}
|
{action}
|
||||||
<ToastClose />
|
<ToastClose />
|
||||||
</Toast>
|
</Toast>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
<ToastViewport />
|
<ToastViewport />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
import * as React from "react";
|
||||||
|
import { cn } from "~/utils";
|
||||||
|
|
||||||
import { cn } from "~/utils"
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
const Tooltip = TooltipPrimitive.Root
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
const TooltipContent = React.forwardRef<
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
@@ -20,11 +19,11 @@ const TooltipContent = React.forwardRef<
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
// Inspired by react-hot-toast library
|
// Inspired by react-hot-toast library
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import type { ToastActionElement, ToastProps } from "~/components/ui/toast";
|
import type { ToastActionElement, ToastProps } from "~/components/ui/toast";
|
||||||
|
|
||||||
const TOAST_LIMIT = 3;
|
const TOAST_LIMIT = 3;
|
||||||
|
|||||||
+107
-74
@@ -1,9 +1,7 @@
|
|||||||
import { icons } from "lucide-react";
|
import { z } from "zod";
|
||||||
import { Metadata } from "next";
|
import { Schema_Config } from "~/schema";
|
||||||
import { ToastPosition } from "react-hot-toast";
|
|
||||||
import colors from "tailwindcss/colors";
|
|
||||||
|
|
||||||
const config: gIndexConfig = {
|
const config: z.input<typeof Schema_Config> = {
|
||||||
/**
|
/**
|
||||||
* If possible, please don't change this value
|
* If possible, please don't change this value
|
||||||
* Even if you're creating a PR, just let me change it myself
|
* Even if you're creating a PR, just let me change it myself
|
||||||
@@ -11,19 +9,19 @@ const config: gIndexConfig = {
|
|||||||
version: "2.0",
|
version: "2.0",
|
||||||
/**
|
/**
|
||||||
* Base path of the app, used for generating links
|
* Base path of the app, used for generating links
|
||||||
* If you're not using Vercel, you need to change this to your domain name
|
|
||||||
*
|
*
|
||||||
* 2023/09/10: This is not used anymore, edit via env variable instead
|
* If you're using another port for development, you can set it here
|
||||||
*
|
*
|
||||||
* @default process.env.NEXT_PUBLIC_VERCEL_URL
|
* @default process.env.NEXT_PUBLIC_DOMAIN
|
||||||
*/
|
*/
|
||||||
basePath:
|
basePath:
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? "http://localhost:3000"
|
? "http://localhost:3000"
|
||||||
: `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`,
|
: `https://${process.env.NEXT_PUBLIC_DOMAIN}`,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DEPRECATED
|
* DEPRECATED
|
||||||
|
* Since in 2.0 we're using server side data fetching, this is not needed anymore.
|
||||||
*
|
*
|
||||||
* Hashed key for fetching protected files / folders from the server
|
* Hashed key for fetching protected files / folders from the server
|
||||||
* This key will bypass the file / folder password
|
* This key will bypass the file / folder password
|
||||||
@@ -35,9 +33,9 @@ const config: gIndexConfig = {
|
|||||||
* Used for all pages and api routes
|
* Used for all pages and api routes
|
||||||
* Default is 5 minutes (300/60 = 5min)
|
* Default is 5 minutes (300/60 = 5min)
|
||||||
*
|
*
|
||||||
* @default "max-age=300, s-maxage=300, stale-while-revalidate, public"
|
* @default "max-age=0, s-maxage=60, stale-while-revalidate"
|
||||||
*/
|
*/
|
||||||
cacheControl: "max-age=300, s-maxage=300, stale-while-revalidate, public",
|
cacheControl: "max-age=0, s-maxage=60, stale-while-revalidate",
|
||||||
|
|
||||||
apiConfig: {
|
apiConfig: {
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +46,8 @@ const config: gIndexConfig = {
|
|||||||
* You need to create a new folder and share it with the service account
|
* You need to create a new folder and share it with the service account
|
||||||
* Then, copy the folder id and paste it here
|
* Then, copy the folder id and paste it here
|
||||||
*/
|
*/
|
||||||
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j",
|
rootFolder:
|
||||||
|
"49b52b93b109c0f8157af45a6a265fbd33f5e45293a70774780b53c96fee9ee1a6e78b077bea3476c13b9603af47d762",
|
||||||
isTeamDrive: false, // Set this to true if you're using Team Drive
|
isTeamDrive: false, // Set this to true if you're using Team Drive
|
||||||
defaultQuery: [
|
defaultQuery: [
|
||||||
"trashed = false",
|
"trashed = false",
|
||||||
@@ -57,8 +56,23 @@ const config: gIndexConfig = {
|
|||||||
defaultField:
|
defaultField:
|
||||||
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed",
|
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed",
|
||||||
defaultOrder: "folder, name asc, modifiedTime desc",
|
defaultOrder: "folder, name asc, modifiedTime desc",
|
||||||
itemsPerPage: 12,
|
itemsPerPage: 50,
|
||||||
searchResult: 10,
|
searchResult: 5,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* By default, the app will use the thumbnail URL from Google Drive
|
||||||
|
*
|
||||||
|
* Sometimes, the thumbnail can't be accessed because of CORS policy
|
||||||
|
* If you're having this issue, you can set this to true
|
||||||
|
*
|
||||||
|
* This will make the api fetch the thumbnail and serve it from the server
|
||||||
|
* instead of using the Google Drive thumbnail
|
||||||
|
*
|
||||||
|
* This will increase the server load, so use it wisely
|
||||||
|
*
|
||||||
|
* Default: true
|
||||||
|
*/
|
||||||
|
proxyThumbnail: true,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Special file name that will be used for certain purposes
|
* Special file name that will be used for certain purposes
|
||||||
@@ -74,7 +88,22 @@ const config: gIndexConfig = {
|
|||||||
*/
|
*/
|
||||||
banner: ".banner",
|
banner: ".banner",
|
||||||
},
|
},
|
||||||
hiddenFiles: [".password", ".readme.md", ".banner"],
|
/**
|
||||||
|
* Reason why banner has multiple extensions:
|
||||||
|
* - If I use contains query, it will also match the file or folder that contains the word.
|
||||||
|
* (e.g: File / folder with the name of "Test Password" will be matched)
|
||||||
|
* - If I use = query, it will only match the exact name, hence the multiple extensions
|
||||||
|
*
|
||||||
|
* You can add more extensions if you want
|
||||||
|
*/
|
||||||
|
hiddenFiles: [
|
||||||
|
".password",
|
||||||
|
".readme.md",
|
||||||
|
".banner",
|
||||||
|
".banner.jpg",
|
||||||
|
".banner.png",
|
||||||
|
".banner.webp",
|
||||||
|
],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow user to download protected file without password.
|
* Allow user to download protected file without password.
|
||||||
@@ -86,6 +115,13 @@ const config: gIndexConfig = {
|
|||||||
allowDownloadProtectedFile: false,
|
allowDownloadProtectedFile: false,
|
||||||
/**
|
/**
|
||||||
* Duration in hours.
|
* Duration in hours.
|
||||||
|
* In version 2, this will be used for download link expiration.
|
||||||
|
* If you need it under 1 hour, you can use math expression. (e.g: (5 / 60) * 1 = 5 minutes)
|
||||||
|
*
|
||||||
|
* This only affect when the user download the file
|
||||||
|
* For example if you set it for example 30 minutes (0.5)
|
||||||
|
* After 30 minutes, and the user still downloading the file, the download will NOT be interrupted
|
||||||
|
* But if the user refresh the page / trying to download again, the download link will be expired
|
||||||
*
|
*
|
||||||
* Default: 6 hours
|
* Default: 6 hours
|
||||||
*/
|
*/
|
||||||
@@ -116,15 +152,15 @@ const config: gIndexConfig = {
|
|||||||
siteName: "next-gdrive-index",
|
siteName: "next-gdrive-index",
|
||||||
siteNameTemplate: "%s - next-gdrive-index",
|
siteNameTemplate: "%s - next-gdrive-index",
|
||||||
siteDescription: "A simple file browser for Google Drive",
|
siteDescription: "A simple file browser for Google Drive",
|
||||||
siteIcon: "/flaticon.svg",
|
siteIcon: "/logo.svg",
|
||||||
siteAuthor: "mbaharip",
|
siteAuthor: "mbaharip",
|
||||||
favIcon: "/favicon.svg",
|
favIcon: "/favicon.png",
|
||||||
/**
|
/**
|
||||||
* Next.js Metadata robots object
|
* Next.js Metadata robots object
|
||||||
*
|
*
|
||||||
* ref: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#robots
|
* ref: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#robots
|
||||||
*/
|
*/
|
||||||
robots: undefined,
|
robots: "noindex, nofollow",
|
||||||
twitterHandle: "@mbaharip_",
|
twitterHandle: "@mbaharip_",
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,7 +195,7 @@ const config: gIndexConfig = {
|
|||||||
* Tailwind color name.
|
* Tailwind color name.
|
||||||
* Ref: https://tailwindcss.com/docs/customizing-colors
|
* Ref: https://tailwindcss.com/docs/customizing-colors
|
||||||
*/
|
*/
|
||||||
defaultAccentColor: "teal",
|
// defaultAccentColor: "teal",
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site wide password protection
|
* Site wide password protection
|
||||||
@@ -246,65 +282,62 @@ const config: gIndexConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
// export interface gIndexConfig {
|
||||||
|
// version: string;
|
||||||
|
// basePath: string;
|
||||||
|
// cacheControl: string;
|
||||||
|
|
||||||
interface gIndexConfig {
|
// apiConfig: {
|
||||||
version: string;
|
// rootFolder: string;
|
||||||
basePath: string;
|
// isTeamDrive: boolean;
|
||||||
// masterKey: string;
|
// defaultQuery: string[];
|
||||||
cacheControl: string;
|
// defaultField: string;
|
||||||
|
// defaultOrder: string;
|
||||||
|
// itemsPerPage: number;
|
||||||
|
// searchResult: number;
|
||||||
|
// proxyThumbnail: boolean;
|
||||||
|
|
||||||
apiConfig: {
|
// specialFile: {
|
||||||
rootFolder: string;
|
// password: string;
|
||||||
isTeamDrive: boolean;
|
// readme: string;
|
||||||
defaultQuery: string[];
|
// banner: string;
|
||||||
defaultField: string;
|
// };
|
||||||
defaultOrder: string;
|
// hiddenFiles: string[];
|
||||||
itemsPerPage: number;
|
|
||||||
searchResult: number;
|
|
||||||
|
|
||||||
specialFile: {
|
// allowDownloadProtectedFile: boolean;
|
||||||
password: string;
|
// temporaryTokenDuration: number;
|
||||||
readme: string;
|
// maxFileSize: number;
|
||||||
banner: string;
|
// };
|
||||||
};
|
// siteConfig: {
|
||||||
hiddenFiles: string[];
|
// siteName: string;
|
||||||
|
// siteNameTemplate?: string;
|
||||||
|
// siteDescription: string;
|
||||||
|
// siteIcon: string;
|
||||||
|
// siteAuthor?: string;
|
||||||
|
// favIcon: string;
|
||||||
|
// robots?: Metadata["robots"];
|
||||||
|
// twitterHandle?: string;
|
||||||
|
|
||||||
allowDownloadProtectedFile: boolean;
|
// footer: string | string[];
|
||||||
temporaryTokenDuration: number;
|
|
||||||
maxFileSize: number;
|
|
||||||
};
|
|
||||||
siteConfig: {
|
|
||||||
siteName: string;
|
|
||||||
siteNameTemplate?: string;
|
|
||||||
siteDescription: string;
|
|
||||||
siteIcon: string;
|
|
||||||
siteAuthor?: string;
|
|
||||||
favIcon: string;
|
|
||||||
robots?: Metadata["robots"];
|
|
||||||
twitterHandle?: string;
|
|
||||||
|
|
||||||
footer: string | string[];
|
// privateIndex: boolean;
|
||||||
|
// breadcrumbMax: number;
|
||||||
|
|
||||||
defaultAccentColor?: keyof typeof colors;
|
// toaster?: {
|
||||||
|
// position?: ToastPosition;
|
||||||
|
// duration?: number;
|
||||||
|
// };
|
||||||
|
|
||||||
privateIndex: boolean;
|
// navbarItems: {
|
||||||
breadcrumbMax: number;
|
// icon: keyof typeof icons;
|
||||||
|
// name: string;
|
||||||
toaster?: {
|
// href: string;
|
||||||
position?: ToastPosition;
|
// external?: boolean;
|
||||||
duration?: number;
|
// }[];
|
||||||
};
|
// supports: {
|
||||||
|
// name: string;
|
||||||
navbarItems: {
|
// currency: string;
|
||||||
icon: keyof typeof icons;
|
// href: string;
|
||||||
name: string;
|
// }[];
|
||||||
href: string;
|
// };
|
||||||
external?: boolean;
|
// }
|
||||||
}[];
|
|
||||||
supports: {
|
|
||||||
name: string;
|
|
||||||
currency: string;
|
|
||||||
href: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, {
|
import React, { createContext, useEffect, useState } from "react";
|
||||||
createContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
export type TTheme = "dark" | "light";
|
export type TTheme = "dark" | "light";
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import { toast } from "react-toastify";
|
|||||||
export default function useCopyText() {
|
export default function useCopyText() {
|
||||||
return (text: string) => {
|
return (text: string) => {
|
||||||
if (!navigator.clipboard) {
|
if (!navigator.clipboard) {
|
||||||
toast.error(
|
toast.error("Your browser does not support copying to clipboard.");
|
||||||
"Your browser does not support copying to clipboard.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
type SetValue<T> = (value: T | ((val: T) => T)) => void;
|
type SetValue<T> = (value: T | ((val: T) => T)) => void;
|
||||||
|
|
||||||
export default function useLocalStorage<T>(
|
export default function useLocalStorage<T>(key: string, initialValue: T) {
|
||||||
key: string,
|
|
||||||
initialValue: T,
|
|
||||||
) {
|
|
||||||
// First check if there is a value in localStorage
|
// First check if there is a value in localStorage
|
||||||
function readLocalStorage() {
|
function readLocalStorage() {
|
||||||
if (typeof window === "undefined") return initialValue;
|
if (typeof window === "undefined") return initialValue;
|
||||||
@@ -14,30 +11,20 @@ export default function useLocalStorage<T>(
|
|||||||
const item = window.localStorage.getItem(key);
|
const item = window.localStorage.getItem(key);
|
||||||
return item ? JSON.parse(item) : initialValue;
|
return item ? JSON.parse(item) : initialValue;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(`Error reading localStorage key “${key}”:`, error);
|
||||||
`Error reading localStorage key “${key}”:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return initialValue;
|
return initialValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [storedValue, setStoredValue] = useState<T>(
|
const [storedValue, setStoredValue] = useState<T>(readLocalStorage);
|
||||||
readLocalStorage,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create dispatch function to set or remove localStorage
|
// Create dispatch function to set or remove localStorage
|
||||||
const setValue: SetValue<T> = (value) => {
|
const setValue: SetValue<T> = (value) => {
|
||||||
try {
|
try {
|
||||||
const valueToStore =
|
const valueToStore =
|
||||||
value instanceof Function
|
value instanceof Function ? value(storedValue) : value;
|
||||||
? value(storedValue)
|
|
||||||
: value;
|
|
||||||
setStoredValue(valueToStore);
|
setStoredValue(valueToStore);
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
key,
|
|
||||||
JSON.stringify(valueToStore),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error setting localStorage:", error);
|
console.error("Error setting localStorage:", error);
|
||||||
}
|
}
|
||||||
@@ -54,10 +41,7 @@ export default function useLocalStorage<T>(
|
|||||||
const newValue = JSON.parse(event.newValue);
|
const newValue = JSON.parse(event.newValue);
|
||||||
setStoredValue(newValue);
|
setStoredValue(newValue);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error setting localStorage:", error);
|
||||||
"Error setting localStorage:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -65,10 +49,7 @@ export default function useLocalStorage<T>(
|
|||||||
window.addEventListener("storage", handleStorageChange);
|
window.addEventListener("storage", handleStorageChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(
|
window.removeEventListener("storage", handleStorageChange);
|
||||||
"storage",
|
|
||||||
handleStorageChange,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [key]);
|
}, [key]);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useRouter as useNextRouter } from "next/navigation";
|
||||||
|
import NProgress from "nprogress";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom useRouter hook that add NProgress to the router
|
||||||
|
* Why? Because NProgress is not working with Next.js 14 router
|
||||||
|
* So we need to add it manually
|
||||||
|
*/
|
||||||
|
export default function useRouter() {
|
||||||
|
const router = useNextRouter();
|
||||||
|
const { push } = router;
|
||||||
|
|
||||||
|
router.push = (...args: Parameters<typeof push>) => {
|
||||||
|
NProgress.start();
|
||||||
|
return push(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
+89
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { icons } from "lucide-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const Schema_Breadcrumb = z.object({
|
export const Schema_Breadcrumb = z.object({
|
||||||
@@ -19,7 +20,7 @@ export const Schema_File = z.object({
|
|||||||
.object({
|
.object({
|
||||||
width: z.coerce.number(),
|
width: z.coerce.number(),
|
||||||
height: z.coerce.number(),
|
height: z.coerce.number(),
|
||||||
rotation: z.coerce.number(),
|
rotation: z.coerce.number().optional(),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
@@ -32,3 +33,90 @@ export const Schema_File = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const Schema_Config = z.object({
|
||||||
|
version: z.string(),
|
||||||
|
basePath: z.string(),
|
||||||
|
cacheControl: z.string(),
|
||||||
|
|
||||||
|
apiConfig: z.object({
|
||||||
|
rootFolder: z.string(),
|
||||||
|
isTeamDrive: z.boolean(),
|
||||||
|
defaultQuery: z.array(z.string()),
|
||||||
|
defaultField: z.string(),
|
||||||
|
defaultOrder: z.string(),
|
||||||
|
itemsPerPage: z.number().positive(),
|
||||||
|
searchResult: z.number().positive(),
|
||||||
|
proxyThumbnail: z.boolean(),
|
||||||
|
|
||||||
|
specialFile: z.object({
|
||||||
|
password: z.string(),
|
||||||
|
readme: z.string(),
|
||||||
|
banner: z.string(),
|
||||||
|
}),
|
||||||
|
hiddenFiles: z.array(z.string()),
|
||||||
|
|
||||||
|
allowDownloadProtectedFile: z.boolean(),
|
||||||
|
temporaryTokenDuration: z.number().positive(),
|
||||||
|
maxFileSize: z.number().positive(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
siteConfig: z.object({
|
||||||
|
siteName: z.string(),
|
||||||
|
siteNameTemplate: z.string().optional().default("%s"),
|
||||||
|
siteDescription: z.string(),
|
||||||
|
siteIcon: z.string(),
|
||||||
|
siteAuthor: z.string().optional().default("mbaharip"),
|
||||||
|
favIcon: z.string(),
|
||||||
|
robots: z.string().optional().default("noindex, nofollow"),
|
||||||
|
twitterHandle: z.string().optional().default("@__mbaharip__"),
|
||||||
|
|
||||||
|
footer: z
|
||||||
|
.string()
|
||||||
|
.or(z.array(z.string()))
|
||||||
|
.optional()
|
||||||
|
.default([
|
||||||
|
"{{ year }}",
|
||||||
|
"{{ repository }}",
|
||||||
|
"{{ author }}",
|
||||||
|
"{{ version }}",
|
||||||
|
"{{ siteName }}",
|
||||||
|
"{{ creator }}",
|
||||||
|
]),
|
||||||
|
|
||||||
|
privateIndex: z.boolean().optional().default(false),
|
||||||
|
breadcrumbMax: z.number(),
|
||||||
|
|
||||||
|
toaster: z
|
||||||
|
.object({
|
||||||
|
position: z.enum([
|
||||||
|
"top-left",
|
||||||
|
"top-right",
|
||||||
|
"bottom-left",
|
||||||
|
"bottom-right",
|
||||||
|
]),
|
||||||
|
duration: z.number().positive(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({
|
||||||
|
position: "top-right",
|
||||||
|
duration: 5000,
|
||||||
|
}),
|
||||||
|
|
||||||
|
navbarItems: z.array(
|
||||||
|
z.object({
|
||||||
|
icon: z.enum(Object.keys(icons) as [keyof typeof icons]),
|
||||||
|
name: z.string(),
|
||||||
|
href: z.string(),
|
||||||
|
external: z.boolean().optional().default(false),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
supports: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
currency: z.string(),
|
||||||
|
href: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ body {
|
|||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply outline-none;
|
@apply outline-none;
|
||||||
@apply focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500;
|
@apply focus-visible:outline-accent-500 focus-visible:outline-2 focus-visible:-outline-offset-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|||||||
+32
-115
@@ -22,11 +22,7 @@
|
|||||||
--syntax-accent: hsl(230, 100%, 66%);
|
--syntax-accent: hsl(230, 100%, 66%);
|
||||||
/* From syntax-variables.less */
|
/* From syntax-variables.less */
|
||||||
--syntax-selection-color: hsl(230, 1%, 90%);
|
--syntax-selection-color: hsl(230, 1%, 90%);
|
||||||
--syntax-gutter-background-color-selected: hsl(
|
--syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
|
||||||
230,
|
|
||||||
1%,
|
|
||||||
90%
|
|
||||||
);
|
|
||||||
--syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
|
--syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +50,7 @@ body {
|
|||||||
--syntax-accent: hsl(220, 100%, 66%);
|
--syntax-accent: hsl(220, 100%, 66%);
|
||||||
/* From syntax-variables.less */
|
/* From syntax-variables.less */
|
||||||
--syntax-selection-color: hsl(220, 13%, 28%);
|
--syntax-selection-color: hsl(220, 13%, 28%);
|
||||||
--syntax-gutter-background-color-selected: hsl(
|
--syntax-gutter-background-color-selected: hsl(220, 13%, 26%);
|
||||||
220,
|
|
||||||
13%,
|
|
||||||
26%
|
|
||||||
);
|
|
||||||
--syntax-cursor-line: hsla(220, 100%, 80%, 0.04);
|
--syntax-cursor-line: hsla(220, 100%, 80%, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,9 +223,7 @@ pre {
|
|||||||
/* MD overrides */
|
/* MD overrides */
|
||||||
.language-markdown .token.url,
|
.language-markdown .token.url,
|
||||||
.language-markdown .token.url > .token.operator,
|
.language-markdown .token.url > .token.operator,
|
||||||
.language-markdown
|
.language-markdown .token.url-reference.url > .token.string {
|
||||||
.token.url-reference.url
|
|
||||||
> .token.string {
|
|
||||||
color: var(--syntax-fg);
|
color: var(--syntax-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +257,7 @@ pre {
|
|||||||
.language-markdown .token.strike .token.content,
|
.language-markdown .token.strike .token.content,
|
||||||
.language-markdown .token.strike .token.punctuation,
|
.language-markdown .token.strike .token.punctuation,
|
||||||
.language-markdown .token.list.punctuation,
|
.language-markdown .token.list.punctuation,
|
||||||
.language-markdown
|
.language-markdown .token.title.important > .token.punctuation {
|
||||||
.token.title.important
|
|
||||||
> .token.punctuation {
|
|
||||||
color: var(--hue-5);
|
color: var(--hue-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,44 +298,21 @@ div.code-toolbar > .toolbar.toolbar > .toolbar-item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Styling the buttons */
|
/* Styling the buttons */
|
||||||
div.code-toolbar
|
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
|
||||||
> .toolbar.toolbar
|
|
||||||
> .toolbar-item
|
|
||||||
> button,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
|
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
|
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
|
||||||
background: var(
|
background: var(--syntax-gutter-background-color-selected);
|
||||||
--syntax-gutter-background-color-selected
|
|
||||||
);
|
|
||||||
color: var(--mono-2);
|
color: var(--mono-2);
|
||||||
padding: 0.1em 0.4em;
|
padding: 0.1em 0.4em;
|
||||||
border-radius: 0.3em;
|
border-radius: 0.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.code-toolbar
|
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
|
||||||
> .toolbar.toolbar
|
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
|
||||||
> .toolbar-item
|
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
|
||||||
> button:hover,
|
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
|
||||||
div.code-toolbar
|
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
|
||||||
> .toolbar.toolbar
|
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
|
||||||
> .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);
|
background: var(--syntax-selection-color);
|
||||||
color: var(--syntax-fg);
|
color: var(--syntax-fg);
|
||||||
}
|
}
|
||||||
@@ -361,9 +326,7 @@ div.code-toolbar
|
|||||||
/* Default line numbers in Line Highlight plugin */
|
/* Default line numbers in Line Highlight plugin */
|
||||||
.line-highlight.line-highlight:before,
|
.line-highlight.line-highlight:before,
|
||||||
.line-highlight.line-highlight[data-end]:after {
|
.line-highlight.line-highlight[data-end]:after {
|
||||||
background: var(
|
background: var(--syntax-gutter-background-color-selected);
|
||||||
--syntax-gutter-background-color-selected
|
|
||||||
);
|
|
||||||
color: var(--syntax-fg);
|
color: var(--syntax-fg);
|
||||||
padding: 0.1em 0.6em;
|
padding: 0.1em 0.6em;
|
||||||
border-radius: 0.3em;
|
border-radius: 0.3em;
|
||||||
@@ -420,86 +383,40 @@ pre[id].linkable-line-numbers.linkable-line-numbers
|
|||||||
/* Diff Highlight plugin overrides */
|
/* Diff Highlight plugin overrides */
|
||||||
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
|
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
|
||||||
pre.diff-highlight > code .token.token.deleted:not(.prefix),
|
pre.diff-highlight > code .token.token.deleted:not(.prefix),
|
||||||
pre
|
pre > code.diff-highlight .token.token.deleted:not(.prefix) {
|
||||||
> code.diff-highlight
|
|
||||||
.token.token.deleted:not(.prefix) {
|
|
||||||
background-color: hsla(353, 100%, 66%, 0.15);
|
background-color: hsla(353, 100%, 66%, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.diff-highlight
|
pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
|
||||||
> code
|
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection,
|
||||||
.token.token.deleted:not(.prefix)::-moz-selection,
|
pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
|
||||||
pre.diff-highlight
|
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection {
|
||||||
> 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);
|
background-color: hsla(353, 95%, 66%, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.diff-highlight
|
pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
|
||||||
> code
|
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
|
||||||
.token.token.deleted:not(.prefix)::selection,
|
pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
|
||||||
pre.diff-highlight
|
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
|
||||||
> 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);
|
background-color: hsla(353, 95%, 66%, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.diff-highlight
|
pre.diff-highlight > code .token.token.inserted:not(.prefix),
|
||||||
> code
|
pre > code.diff-highlight .token.token.inserted:not(.prefix) {
|
||||||
.token.token.inserted:not(.prefix),
|
|
||||||
pre
|
|
||||||
> code.diff-highlight
|
|
||||||
.token.token.inserted:not(.prefix) {
|
|
||||||
background-color: hsla(137, 100%, 55%, 0.15);
|
background-color: hsla(137, 100%, 55%, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.diff-highlight
|
pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection,
|
||||||
> code
|
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection,
|
||||||
.token.token.inserted:not(.prefix)::-moz-selection,
|
pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection,
|
||||||
pre.diff-highlight
|
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection {
|
||||||
> 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);
|
background-color: hsla(135, 73%, 55%, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.diff-highlight
|
pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
|
||||||
> code
|
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
|
||||||
.token.token.inserted:not(.prefix)::selection,
|
pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
|
||||||
pre.diff-highlight
|
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
|
||||||
> 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);
|
background-color: hsla(135, 73%, 55%, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,15 @@
|
|||||||
.markdown h1,
|
.markdown h1,
|
||||||
.markdown h2,
|
.markdown h2,
|
||||||
.markdown h3 {
|
.markdown h3 {
|
||||||
@apply my-4 border-b-2 border-b-primary-700 first-letter:capitalize;
|
@apply border-b-primary-700 my-4 border-b-2 first-letter:capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown code:not(pre code) {
|
.markdown code:not(pre code) {
|
||||||
@apply rounded-md bg-primary-800 px-2 py-0.5 leading-normal;
|
@apply bg-primary-800 rounded-md px-2 py-0.5 leading-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown a {
|
.markdown a {
|
||||||
@apply text-accent-500 opacity-80 transition-smooth hover:opacity-100;
|
@apply text-accent-500 transition-smooth opacity-80 hover:opacity-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown p {
|
.markdown p {
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
.markdown table th,
|
.markdown table th,
|
||||||
.markdown table td {
|
.markdown table td {
|
||||||
@apply border border-primary-700 px-2 py-1;
|
@apply border-primary-700 border px-2 py-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown table th {
|
.markdown table th {
|
||||||
@@ -93,15 +93,15 @@
|
|||||||
|
|
||||||
.markdown input[type="checkbox"] {
|
.markdown input[type="checkbox"] {
|
||||||
@apply mr-2 aspect-square h-4 w-4 rounded-full p-2;
|
@apply mr-2 aspect-square h-4 w-4 rounded-full p-2;
|
||||||
@apply appearance-none border border-primary-500;
|
@apply border-primary-500 appearance-none border;
|
||||||
@apply disabled:bg-primary-600 disabled:checked:bg-accent-400;
|
@apply disabled:bg-primary-600 disabled:checked:bg-accent-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown blockquote {
|
.markdown blockquote {
|
||||||
@apply my-2 rounded-r-md border-l-4 border-l-info-500 bg-primary-800 py-4 pl-2;
|
@apply border-l-info-500 bg-primary-800 my-2 rounded-r-md border-l-4 py-4 pl-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown hr {
|
.markdown hr {
|
||||||
@apply my-4 border-b border-t-0 border-b-primary-500;
|
@apply border-b-primary-500 my-4 border-b border-t-0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
export default function getSearchParams(
|
|
||||||
url: string,
|
|
||||||
key: string | string[],
|
|
||||||
): Record<string, string | null> {
|
|
||||||
const { searchParams } = new URL(url);
|
|
||||||
if (typeof key === "string") {
|
|
||||||
return { [key]: searchParams.get(key) };
|
|
||||||
} else {
|
|
||||||
const result: Record<string, string | null> = {};
|
|
||||||
for (const k of key) {
|
|
||||||
result[k] = searchParams.get(k);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
import ExtendedError from "utils/extendedError";
|
|
||||||
import { checkPathPassword } from "utils/passwordHelper";
|
|
||||||
|
|
||||||
import { Constant } from "types/constant";
|
|
||||||
|
|
||||||
export default async function privateIndexProtection(
|
|
||||||
request: NextRequest,
|
|
||||||
) {
|
|
||||||
const cookies = request.cookies.get(
|
|
||||||
Constant.cookies_SitePassword,
|
|
||||||
);
|
|
||||||
if (!cookies) {
|
|
||||||
throw new ExtendedError(
|
|
||||||
"Unauthorized",
|
|
||||||
401,
|
|
||||||
"No password found",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = cookies.value;
|
|
||||||
const sitePassword =
|
|
||||||
process.env.NEXT_PUBLIC_SITE_PASSWORD;
|
|
||||||
console.log(sitePassword);
|
|
||||||
if (!sitePassword) {
|
|
||||||
throw new ExtendedError(
|
|
||||||
"Internal Server Error",
|
|
||||||
500,
|
|
||||||
"Can't find site password. Please check your environment variable",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const valid = checkPathPassword(
|
|
||||||
"site-wide",
|
|
||||||
password,
|
|
||||||
sitePassword,
|
|
||||||
);
|
|
||||||
if (!valid) {
|
|
||||||
throw new ExtendedError(
|
|
||||||
"Unauthorized",
|
|
||||||
401,
|
|
||||||
"Invalid password",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,5 @@ export default function bytesToReadable(bytes: number) {
|
|||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
if (bytes === 0) return "0 Byte";
|
if (bytes === 0) return "0 Byte";
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
return (
|
return Math.round(bytes / Math.pow(1024, i)) + " " + sizes[i];
|
||||||
Math.round(bytes / Math.pow(1024, i)) + " " + sizes[i]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
class ExtendedError extends Error {
|
|
||||||
public extendedMessage?: string;
|
|
||||||
public code?: number;
|
|
||||||
public reason?: string;
|
|
||||||
|
|
||||||
constructor(msg: string, code: number, reason: string) {
|
|
||||||
super(msg);
|
|
||||||
this.extendedMessage = msg;
|
|
||||||
this.code = code;
|
|
||||||
this.reason = reason;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExtendedError;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export function stripExtension(
|
|
||||||
filename: string,
|
|
||||||
ext: string,
|
|
||||||
): string {
|
|
||||||
// Make sure the extension is lowercase and also remove the dot
|
|
||||||
const extension = ext.toLowerCase().replace(/^\./, "");
|
|
||||||
return filename.toLowerCase().endsWith(extension)
|
|
||||||
? filename.slice(0, -extension.length - 1)
|
|
||||||
: filename;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import gIndexConfig from "config";
|
|
||||||
import { GaxiosResponse } from "gaxios";
|
|
||||||
import type { drive_v3 } from "googleapis";
|
|
||||||
|
|
||||||
import gdrive from "utils/gdriveInstance";
|
|
||||||
|
|
||||||
interface ListFiles extends drive_v3.Params$Resource$Files$List {}
|
|
||||||
|
|
||||||
export async function gdriveFilesList({
|
|
||||||
q,
|
|
||||||
fields,
|
|
||||||
orderBy,
|
|
||||||
pageSize,
|
|
||||||
pageToken,
|
|
||||||
}: ListFiles): Promise<GaxiosResponse<drive_v3.Schema$FileList>> {
|
|
||||||
const result = await gdrive.files.list({
|
|
||||||
q,
|
|
||||||
fields,
|
|
||||||
orderBy,
|
|
||||||
pageSize,
|
|
||||||
pageToken,
|
|
||||||
...(gIndexConfig.apiConfig.isTeamDrive && {
|
|
||||||
supportsAllDrives: true,
|
|
||||||
includeItemsFromAllDrives: true,
|
|
||||||
driveId: gIndexConfig.apiConfig.rootFolder,
|
|
||||||
corpora: "drive",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -53,13 +53,13 @@ if (!gdrive) {
|
|||||||
gdrive = google.drive({
|
gdrive = google.drive({
|
||||||
version: "v3",
|
version: "v3",
|
||||||
auth: serviceAccountAuth,
|
auth: serviceAccountAuth,
|
||||||
fetchImplementation: (url, init) =>
|
// fetchImplementation: (url, init) =>
|
||||||
fetch(url, {
|
// fetch(url, {
|
||||||
...init,
|
// ...init,
|
||||||
next: {
|
// next: {
|
||||||
revalidate: 3600,
|
// revalidate: 3600,
|
||||||
},
|
// },
|
||||||
}),
|
// }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { IGDriveFiles } from "types/api/files";
|
|
||||||
|
|
||||||
import { encryptData } from "./encryptionHelper/hash";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate download link for the file, so I don't have to repeat the code.
|
|
||||||
* Since it's contains encrypted object, I hope this function will make it easier to read and maintain.
|
|
||||||
*
|
|
||||||
* @param {IGDriveFiles} file - File object from GDrive API response.
|
|
||||||
* @param {boolean} [isProtected = false] - Is the file inside password protected folder or not.
|
|
||||||
* @param {boolean} [includeHost = false] - Include host name or not.
|
|
||||||
* @param {string} [token] - Download token for protected file.
|
|
||||||
* @param {string} [disableLimit] - Disable file size limit.
|
|
||||||
* @returns {string} URL to download the file.
|
|
||||||
*/
|
|
||||||
export function generatedDownloadLink(
|
|
||||||
file: IGDriveFiles,
|
|
||||||
isProtected: boolean = false,
|
|
||||||
includeHost: boolean = false,
|
|
||||||
token?: string | null,
|
|
||||||
disableLimit?: boolean,
|
|
||||||
): string {
|
|
||||||
const data: Record<"id" | "isProtected", string | boolean> = {
|
|
||||||
id: file.encryptedId,
|
|
||||||
isProtected,
|
|
||||||
};
|
|
||||||
const encryptedData = encryptData(JSON.stringify(data, null, 0));
|
|
||||||
// let url = `/api/download/${encryptedData}/${file.name}`;
|
|
||||||
|
|
||||||
const url = new URL(`/api/download/${encryptedData}/${file.name}`, window.location.origin);
|
|
||||||
if (token) url.searchParams.append("token", token);
|
|
||||||
if (disableLimit) url.searchParams.append("disableLimit", "true");
|
|
||||||
|
|
||||||
// if (includeHost) url = `${window.location.origin}${url}`;
|
|
||||||
// if (token) url = `${url}?token=${token}`;
|
|
||||||
// if (disableLimit) url = `${url}&disableLimit=true`;
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* Converts a hex color code to an RGB value.
|
|
||||||
*
|
|
||||||
* @param {string} hex - The hex color code to convert.
|
|
||||||
* @returns {string} The RGB value of the hex color code.
|
|
||||||
*/
|
|
||||||
export default function hexToRgb(hex: string): string {
|
|
||||||
// Remove the # character from the beginning of the hex code
|
|
||||||
hex = hex.replace("#", "");
|
|
||||||
|
|
||||||
// Convert the hex code to RGB values
|
|
||||||
const r = parseInt(hex.substring(0, 2), 16);
|
|
||||||
const g = parseInt(hex.substring(2, 4), 16);
|
|
||||||
const b = parseInt(hex.substring(4, 6), 16);
|
|
||||||
|
|
||||||
// Return the RGB value as a string
|
|
||||||
return `${r} ${g} ${b}`;
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Constant } from "types/constant";
|
|
||||||
|
|
||||||
import {
|
|
||||||
decryptData,
|
|
||||||
encryptData,
|
|
||||||
} from "./encryptionHelper/hash";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the password is correct for the given path
|
|
||||||
* @param cookies - cookies value
|
|
||||||
* @param path - The path to check
|
|
||||||
* @param password - The password to check
|
|
||||||
* @returns true if the password is correct, false otherwise
|
|
||||||
*/
|
|
||||||
export function checkPathPassword(
|
|
||||||
path: string,
|
|
||||||
cookies: string,
|
|
||||||
password: string,
|
|
||||||
): boolean {
|
|
||||||
try {
|
|
||||||
const validCookieToken = decryptData(cookies);
|
|
||||||
const cookieTokenJSON = JSON.parse(validCookieToken);
|
|
||||||
if (!cookieTokenJSON[path]) return false;
|
|
||||||
return cookieTokenJSON[path] === password;
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new password for the given path
|
|
||||||
* @param cookies - cookies value
|
|
||||||
* @param path - The path to add
|
|
||||||
* @param password - The password to add
|
|
||||||
* @returns The new cookies token
|
|
||||||
*/
|
|
||||||
export function addNewPassword(
|
|
||||||
path: string,
|
|
||||||
password: string,
|
|
||||||
cookies?: string,
|
|
||||||
server: boolean = false,
|
|
||||||
): string | void {
|
|
||||||
try {
|
|
||||||
let cookieTokenJSON: any = {};
|
|
||||||
if (cookies) {
|
|
||||||
const validCookieToken = decryptData(cookies);
|
|
||||||
cookieTokenJSON = JSON.parse(validCookieToken);
|
|
||||||
}
|
|
||||||
cookieTokenJSON[path] = password;
|
|
||||||
const encryptedCookieToken = encryptData(
|
|
||||||
JSON.stringify(cookieTokenJSON),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set cookie for 5 years
|
|
||||||
const expires = new Date(
|
|
||||||
Date.now() + 5 * 365 * 24 * 60 * 60 * 1000,
|
|
||||||
).toUTCString();
|
|
||||||
if (!server) {
|
|
||||||
document.cookie = `${Constant.cookies_SitePassword}=${encryptedCookieToken}; expires=${expires}; path=/; SameSite=Strict;`;
|
|
||||||
} else {
|
|
||||||
return `${Constant.cookies_SitePassword}=${encryptedCookieToken}; expires=${expires}; path=/; SameSite=Strict;`;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
removeAllPassword();
|
|
||||||
throw new Error(
|
|
||||||
"Failed to add new password, it's likely because the encryption key are changed. Please refresh the page and try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove all password
|
|
||||||
*/
|
|
||||||
export function removeAllPassword() {
|
|
||||||
document.cookie = `${Constant.cookies_SitePassword}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,7 @@ const extensionsMap: Record<FileTypes, string[]> = {
|
|||||||
audio: ["mp3", "ogg", "wav", "flac", "m4a", "wma", "aac"],
|
audio: ["mp3", "ogg", "wav", "flac", "m4a", "wma", "aac"],
|
||||||
markdown: ["md"],
|
markdown: ["md"],
|
||||||
pdf: ["pdf"],
|
pdf: ["pdf"],
|
||||||
document: ["doc", "docx", "xls", "xlsx", "ppt", "pptx"],
|
document: ["odt", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv"],
|
||||||
executable: ["exe", "msi"],
|
executable: ["exe", "msi"],
|
||||||
code: [
|
code: [
|
||||||
"html",
|
"html",
|
||||||
@@ -110,6 +110,24 @@ const iconMap: Record<FileTypes | "unknown", keyof typeof icons> = {
|
|||||||
// return previewMap[type as keyof typeof previewMap] ?? previewMap["unknown"];
|
// return previewMap[type as keyof typeof previewMap] ?? previewMap["unknown"];
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
export function getFileType(
|
||||||
|
fileExtension: string,
|
||||||
|
mimeType: string,
|
||||||
|
): FileTypes | "unknown" {
|
||||||
|
if (fileExtension === "ts") {
|
||||||
|
if (mimeType.includes("video")) {
|
||||||
|
return "video";
|
||||||
|
} else {
|
||||||
|
return "code";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const type = Object.keys(extensionsMap).find((key) =>
|
||||||
|
extensionsMap[key as keyof typeof extensionsMap].includes(fileExtension),
|
||||||
|
);
|
||||||
|
|
||||||
|
return type ? (type as FileTypes) : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
export function getPreviewIcon(fileExtension: string, mimeType: string) {
|
export function getPreviewIcon(fileExtension: string, mimeType: string) {
|
||||||
if (fileExtension === "ts") {
|
if (fileExtension === "ts") {
|
||||||
if (mimeType.includes("video")) {
|
if (mimeType.includes("video")) {
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import gIndexConfig from "config";
|
|
||||||
|
|
||||||
import { decryptData, encryptData } from "./encryptionHelper/hash";
|
|
||||||
|
|
||||||
export function createDownloadToken(durationH: number = gIndexConfig.apiConfig.temporaryTokenDuration) {
|
|
||||||
const duration = durationH * 60 * 60 * 1000;
|
|
||||||
const expiredAt = Date.now() + duration;
|
|
||||||
return encryptData(expiredAt.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDownloadTokenValid(token: string): boolean {
|
|
||||||
try {
|
|
||||||
const expiredAt = Number(decryptData(token));
|
|
||||||
return expiredAt > Date.now();
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es2021",
|
||||||
"lib": ["dom", "dom.iterable", "esnext", "es2019"],
|
"lib": ["dom", "dom.iterable", "esnext", "es2019"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -35,6 +35,18 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules", ".next", "**/*/_legacy/**/*", "**/*/_app/**/*"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.js",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
".next",
|
||||||
|
"**/*/_legacy/**/*",
|
||||||
|
"**/*/_app/**/*",
|
||||||
|
"**/*/_pages/**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3290,31 +3290,31 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"embla-carousel-react@npm:^8.0.0":
|
"embla-carousel-react@npm:^8.0.1":
|
||||||
version: 8.0.0
|
version: 8.0.1
|
||||||
resolution: "embla-carousel-react@npm:8.0.0"
|
resolution: "embla-carousel-react@npm:8.0.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
embla-carousel: "npm:8.0.0"
|
embla-carousel: "npm:8.0.1"
|
||||||
embla-carousel-reactive-utils: "npm:8.0.0"
|
embla-carousel-reactive-utils: "npm:8.0.1"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.1 || ^18.0.0
|
react: ^16.8.0 || ^17.0.1 || ^18.0.0
|
||||||
checksum: 10c0/43d1f551be6119ba62f29fc281b5f89fabfc731fa7a8a008cb5520639be2775377580ad218c23171877f5812167b2a3bb72fac82918ad78b40ebf6f9214dfb4a
|
checksum: 10c0/a16af76be911133f00ff38491b0ed12f09571949234b22bfe83cbd2d8b0d3cf43b666ffc4fc6aaf17e04691495fdad69fc1bc33db3eeec9ba7f67fe8db056a25
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"embla-carousel-reactive-utils@npm:8.0.0":
|
"embla-carousel-reactive-utils@npm:8.0.1":
|
||||||
version: 8.0.0
|
version: 8.0.1
|
||||||
resolution: "embla-carousel-reactive-utils@npm:8.0.0"
|
resolution: "embla-carousel-reactive-utils@npm:8.0.1"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
embla-carousel: 8.0.0
|
embla-carousel: 8.0.1
|
||||||
checksum: 10c0/d88fb25a59be6bbbb11a9b012bdc42bc1570ae82fd363bda0224cc3131852d469855bc9068bd5aa85e4c8546311de2dd1eae43ea0556dd1443d9b12ff138034c
|
checksum: 10c0/c511dbbcd869f11f102e826aea600f1668f8097792b4e185678f44240466fe6a224b68833c408bd9eb6c9b26e40321b6f18f70f45bd19df3cd403d964e1fba95
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"embla-carousel@npm:8.0.0":
|
"embla-carousel@npm:8.0.1":
|
||||||
version: 8.0.0
|
version: 8.0.1
|
||||||
resolution: "embla-carousel@npm:8.0.0"
|
resolution: "embla-carousel@npm:8.0.1"
|
||||||
checksum: 10c0/51634c07816a5eef142b6bdfa1104da8232015de3260ff7c4b21a952abeac4035d2d02ea87bb5cec06290ec4429e606543e898b50aa9ccf6e636e74baf2951a6
|
checksum: 10c0/9ce30759a77e75ff4ce490102c429794fd46f03bbcc4a6af4ecefbe55a5de5289b7ac0f7607d1774a9b23c241d5781bf3d45459590768b15679a9da5b56ef6df
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -6516,7 +6516,7 @@ __metadata:
|
|||||||
clsx: "npm:^2.1.0"
|
clsx: "npm:^2.1.0"
|
||||||
cmdk: "npm:^1.0.0"
|
cmdk: "npm:^1.0.0"
|
||||||
date-fns: "npm:^3.6.0"
|
date-fns: "npm:^3.6.0"
|
||||||
embla-carousel-react: "npm:^8.0.0"
|
embla-carousel-react: "npm:^8.0.1"
|
||||||
encoding: "npm:^0.1.13"
|
encoding: "npm:^0.1.13"
|
||||||
eslint: "npm:8.38.0"
|
eslint: "npm:8.38.0"
|
||||||
eslint-config-next: "npm:^14.1.4"
|
eslint-config-next: "npm:^14.1.4"
|
||||||
@@ -6529,8 +6529,8 @@ __metadata:
|
|||||||
next-themes: "npm:^0.3.0"
|
next-themes: "npm:^0.3.0"
|
||||||
nextjs-toploader: "npm:^1.6.11"
|
nextjs-toploader: "npm:^1.6.11"
|
||||||
postcss: "npm:^8.4.38"
|
postcss: "npm:^8.4.38"
|
||||||
prettier: "npm:^2.8.7"
|
prettier: "npm:3.0.0"
|
||||||
prettier-plugin-tailwindcss: "npm:^0.2.7"
|
prettier-plugin-tailwindcss: "npm:0.5.12"
|
||||||
react: "npm:^18.2.0"
|
react: "npm:^18.2.0"
|
||||||
react-day-picker: "npm:^8.10.0"
|
react-day-picker: "npm:^8.10.0"
|
||||||
react-dom: "npm:^18.2.0"
|
react-dom: "npm:^18.2.0"
|
||||||
@@ -7159,25 +7159,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"prettier-plugin-tailwindcss@npm:^0.2.7":
|
"prettier-plugin-tailwindcss@npm:0.5.12":
|
||||||
version: 0.2.8
|
version: 0.5.12
|
||||||
resolution: "prettier-plugin-tailwindcss@npm:0.2.8"
|
resolution: "prettier-plugin-tailwindcss@npm:0.5.12"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@ianvs/prettier-plugin-sort-imports": "*"
|
"@ianvs/prettier-plugin-sort-imports": "*"
|
||||||
"@prettier/plugin-pug": "*"
|
"@prettier/plugin-pug": "*"
|
||||||
"@shopify/prettier-plugin-liquid": "*"
|
"@shopify/prettier-plugin-liquid": "*"
|
||||||
"@shufo/prettier-plugin-blade": "*"
|
|
||||||
"@trivago/prettier-plugin-sort-imports": "*"
|
"@trivago/prettier-plugin-sort-imports": "*"
|
||||||
prettier: ">=2.2.0"
|
prettier: ^3.0
|
||||||
prettier-plugin-astro: "*"
|
prettier-plugin-astro: "*"
|
||||||
prettier-plugin-css-order: "*"
|
prettier-plugin-css-order: "*"
|
||||||
prettier-plugin-import-sort: "*"
|
prettier-plugin-import-sort: "*"
|
||||||
prettier-plugin-jsdoc: "*"
|
prettier-plugin-jsdoc: "*"
|
||||||
|
prettier-plugin-marko: "*"
|
||||||
prettier-plugin-organize-attributes: "*"
|
prettier-plugin-organize-attributes: "*"
|
||||||
prettier-plugin-organize-imports: "*"
|
prettier-plugin-organize-imports: "*"
|
||||||
|
prettier-plugin-sort-imports: "*"
|
||||||
prettier-plugin-style-order: "*"
|
prettier-plugin-style-order: "*"
|
||||||
prettier-plugin-svelte: "*"
|
prettier-plugin-svelte: "*"
|
||||||
prettier-plugin-twig-melody: "*"
|
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@ianvs/prettier-plugin-sort-imports":
|
"@ianvs/prettier-plugin-sort-imports":
|
||||||
optional: true
|
optional: true
|
||||||
@@ -7185,8 +7185,6 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
"@shopify/prettier-plugin-liquid":
|
"@shopify/prettier-plugin-liquid":
|
||||||
optional: true
|
optional: true
|
||||||
"@shufo/prettier-plugin-blade":
|
|
||||||
optional: true
|
|
||||||
"@trivago/prettier-plugin-sort-imports":
|
"@trivago/prettier-plugin-sort-imports":
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-astro:
|
prettier-plugin-astro:
|
||||||
@@ -7197,26 +7195,30 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-jsdoc:
|
prettier-plugin-jsdoc:
|
||||||
optional: true
|
optional: true
|
||||||
|
prettier-plugin-marko:
|
||||||
|
optional: true
|
||||||
prettier-plugin-organize-attributes:
|
prettier-plugin-organize-attributes:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-organize-imports:
|
prettier-plugin-organize-imports:
|
||||||
optional: true
|
optional: true
|
||||||
|
prettier-plugin-sort-imports:
|
||||||
|
optional: true
|
||||||
prettier-plugin-style-order:
|
prettier-plugin-style-order:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-svelte:
|
prettier-plugin-svelte:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-twig-melody:
|
prettier-plugin-twig-melody:
|
||||||
optional: true
|
optional: true
|
||||||
checksum: 10c0/b025983a88ecfd53e8d7cf601b54feb2583c62e8f8b87533200f5c4291fee4a60801e1463caa639fd56e3c7a0cf28fea00feed4e93c1fe27a1136f1385907ba6
|
checksum: 10c0/984b79fd33aed89f09bb19c3659079eb19dad6c38a78150f8b1a3082d7331f4faf46e6c561e0e54f47954679c105ef8e674e9c56e22db318e1493b468aa29f5b
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"prettier@npm:^2.8.7":
|
"prettier@npm:3.0.0":
|
||||||
version: 2.8.8
|
version: 3.0.0
|
||||||
resolution: "prettier@npm:2.8.8"
|
resolution: "prettier@npm:3.0.0"
|
||||||
bin:
|
bin:
|
||||||
prettier: bin-prettier.js
|
prettier: bin/prettier.cjs
|
||||||
checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a
|
checksum: 10c0/113e2828c6487f10e19e7c30598e8810684b827f2a1b7747f113728d1049da15583b77cc5e907f37b371b08c49cfdd900aebe5c2a2077be3f0cccb73cca1133b
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user