diff --git a/.env.example b/.env.example
index 8dfdad5..5bea85b 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,9 @@
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
-NEXT_PUBLIC_ENCRYPTION_KEY="Rj3wRGB9nUElQ7OA"
-NEXT_PUBLIC_SITE_PASSWORD="mbaharip"
-NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
\ No newline at end of file
+NEXT_PUBLIC_ENCRYPTION_KEY=
+NEXT_PUBLIC_SITE_PASSWORD=
+
+# Only fill with the domain name, without the protocol and trailing slash
+# This will be used as a fallback if VERCEL_URL is not available
+# Example: https://drive-demo.mbaharip.com -> drive.mbaharip.com
+NEXT_PUBLIC_DOMAIN=
diff --git a/.eslintrc.json b/.eslintrc.json
index f0f3abe..e844b5b 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -2,5 +2,6 @@
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": "off"
- }
+ },
+ "ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_page/**/*"]
}
diff --git a/.gitignore b/.gitignore
index 4f49267..e30d6e4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,8 +34,11 @@ next-env.d.ts
# personal docs
/docs
/src/pages/api/legacy/
-
/**/*/_legacy/
-
+/data
/.*/
-/src/_app
\ No newline at end of file
+/src/_app
+/src/_page
+/src/utils/_legacy
+/src/components/_legacy
+.env.production
diff --git a/.prettierrc.js b/.prettierrc.js
index 99bcabb..1f50f3e 100644
--- a/.prettierrc.js
+++ b/.prettierrc.js
@@ -1,10 +1,10 @@
"use strict";
module.exports = {
plugins: [
- require("prettier-plugin-tailwindcss"),
- require("@trivago/prettier-plugin-sort-imports"),
+ "@trivago/prettier-plugin-sort-imports",
+ "prettier-plugin-tailwindcss",
],
- printWidth: 120,
+ printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: true,
@@ -20,12 +20,17 @@ module.exports = {
endOfLine: "lf",
embeddedLanguageFormatting: "auto",
singleAttributePerLine: true,
+
+ tailwindAttributes: ["classNames", "wrapperClassName", "rootClassName"],
+ tailwindFunctions: ["twsx", "cn"],
+ tailwindConfig: "./tailwind.config.ts",
+
importOrder: [
"",
- "^components/(.*)$",
- "^(utils|hooks|context)/(.*)$",
- "^types/(.*)$",
- "^(config|styles)/(.*)$",
+ "^~/(app|components)/(.*)$",
+ "^~/(utils|hooks|context)/(.*)$",
+ "^~/types/(.*)$",
+ "^~/(config|styles)/(.*)$",
"^[./]",
],
importOrderSeparation: true,
diff --git a/README.md b/README.md
index 08b2e18..016e695 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,65 @@
FAQ
+## Version 2 is here!
+
+It seems there are some bugs on mobile devices that prevent the files list from rendering properly.
+So I will use this chance to upgrade the project to `Next.js 14`, use `shadcn/ui`, and also add some new features.
+
+There also some changes on the configuration file, so make sure to read the [Migration Guide](#migration-guide) if you're upgrading from version 1.x.
+
+Here's what changed in version 2:
+
+### New Features
+
+- **Theme Select**, light mode? dark mode? you choose.
+- **[shadcn/ui](https://ui.shadcn.com/)**, change the UI library to `shadcn/ui`.
+- **Faster Response Time**, added cache to improve the response time.
+- **Revalidate Data**, data fetching now utilizing revalidate. (can be changed from config file)
+ > You can manually revalidate the data by sending POST request to `/api/revalidate` with `Authorization` header set to `{{ process.env.SITE_PASSWORD }}`.
+
+### Changed
+
+- **General Layout**, New fresh look with `shadcn/ui`.
+- **Download Link**, Added token that will be expired after x hour(s). (can be changed from config file)
+- **Raw Link**, Raw link only available for image, video, and audio files. Raw link are recommended for hosting web project assets or comments on forum.
+- **Footer**, now you can customize the footer from config file, also added support for Markdown.
+- **Data Fetching**, now using `Server Actions` instead of `API Routes`.
+- **Encryption**, moved to `Server Actions`.
+- **Typescript Config**, target changed from `ES5` to `ES2021`.
+- **Environment Variable**, now focus on Server Side Environment Variable.
+ - `NEXT_PUBLIC_ENCRYPTION_KEY` changed to `ENCRYPTION_KEY`.
+ - `NEXT_PUBLIC_SITE_PASSWORD` changed to `SITE_PASSWORD`.
+ - `MASTER_KEY` added.
+- **Configuration File**, things are changed:
+ - `version` bumped to `2.0`.
+ - `masterKey` moved to `Environment Variable`.
+ - `apiConfig.revalidate` added.
+ - `siteConfig.siteNameTemplate` added.
+ - `siteConfig.siteAuthor` added.
+ - `siteConfig.robots` added.
+ - `siteConfig.footer` added.
+ - `siteConfig.defaultAccentColor` deprecated.
+ - `siteConfig.breadcrumbMax` added.
+ - `siteConfig.toaster` added.
+ - `siteConfig.navbarItems[number].icons` changed to `lucide icons`.
+ - `siteConfig.supports` added.
+
+### Fixed
+
+- Rendering issue on mobile devices.
+
+### Dependency Update
+
+- `@iconify/react` switched to `lucide-react`.
+- `@mdx-js/loader` removed.
+- `@mdx-js/react` removed.
+- `@next/mdx` removed.
+- `@popperjs/core` removed.
+- `next` updated to `^14.1.4`.
+- `next-seo` removed.
+- `tailwind-merge` updated to `^2.2.2`.
+
## What is this?
`next-gdrive-index` is an indexer for Google Drive, it's a simple project that I made to index my files in Google Drive.
@@ -44,32 +103,50 @@ I know there are a lot of people selling cheap edu account for Google Drive and
## File Security
-All files fetched from Google Drive **NEED TO BE SHARED** with `Anyone with the link can view` permission.
-This is because Google Drive API can only access files that are shared with `Anyone with the link can view` permission.
+> This only apply if `maxFileSize` is enabled.
-But, every files `id` and `webContentLink` are encrypted with `AES-256-CBC` using your own key, so no one can access your files without the key.
-Except, if the files are larger than the `fileSizeLimit` (default: 4MB for vercel), then the download will be redirected to the raw file link.
+You need to set the file sharing permission to `Anyone with the link can view` for the files that you want to share.
+
+**Why?** because the download link will be redirected to the Google Drive download link, and it can only be accessed if the file is shared with `Anyone with the link can view` permission.
+
+If not, the download link will be redirected to the Google Drive web content link, and it can be accessed without sharing the file.
+
+But this might expose the file ID, so people might be able to access the files directly from Google Drive. (But I'm sure they can't access the folder itself, only the file that are being downloaded)
## Known Issue
### File size limit
-File size limit causing some files can't be previewed, and the download will be redirected to the raw file link.
+> This only apply if you're using platform that have file size limit, like Vercel.
+> If you're using VPS or other platform that doesn't have file size limit, you can disable `maxFileSize` inside config file.
+
+File size limit causing some files can't be previewed, and the download will be redirected to the raw file link.
+**This won't be fixed**, because it's a limitation from the deployment platform itself.
### Long Response Time
-The flow of the project are:
+From version 2.0, We are using Cache to improve the response time.
+It might take a long time for deep nested folders, but once the data is fetched, it will be cached and the response time will be faster.
-- Validate the path is valid or not. (Only applied for `/...[path]`)
-- Fetch the password, readme, and files from Google Drive.
-- Show to the user.
+I also add [revalidate](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#revalidating-data) config on page file to 5 minutes. You can change the value inside `src/app/page.tsx` & `src/app/[...rest]/page.tsx`.
-So, if you have a lot of files in your Google Drive, it might take a long time to fetch all the files.
-To improve the response time, I added a cache to the response, so the next time you access the same path, it will be faster.
+~~The flow of the project are:~~
-ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive.
+- ~~Validate the path is valid or not. (Only applied for `/...[path]`)~~
+- ~~Fetch the password, readme, and files from Google Drive.~~
+- ~~Show to the user.~~
-### Shared Drive is now supported
+~~So, if you have a lot of files in your Google Drive, it might take a long time to fetch all the files.
+To improve the response time, I added a cache to the response, so the next time you access the same path, it will be faster.~~
+
+~~ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive.~~
+
+### Can't seek on audio and video preview
+
+It looks like the audio and video preview can't seek, so you need to listen/watch from the beginning.
+I'm still looking for a solution for this.
+
+### ~~Shared Drive is now supported~~
~~I don't have Google Shared Drive, so I can't test it and implement it.~~
Implemented by [@loadingthedev](https://github.com/loadingthedev) [(PR #4)](https://github.com/mbahArip/next-gdrive-index/pull/4)
@@ -78,3 +155,47 @@ Implemented by [@loadingthedev](https://github.com/loadingthedev) [(PR #4)](http
For now, I don't have any plan to implement this, because I think it's not necessary.
All Google Drive files like Docs, Sheets, and Slides are hidden from the list.
+
+**PR are welcome if you want to implement this.**
+
+## Migration Guide
+
+If you're upgrading from version 1.x to version 2, there are some changes that you need to do:
+
+### Configuration File
+
+> You can check the new Configuration Schema on `/src/schema.ts`
+
+There are some changes on the configuration file, here's the list of changes:
+
+- `masterKey` **REMOVED**, we don't need this anymore
+
+- `apiConfig.rootFolder` **CHANGED**, you need to encrypt the folder ID first
+ `https://drive-demo.mbaharip.com/api/internal/encrypt?q={{ folderId }}`
+
+- `apiConfig.proxyThumbnail` **ADDED**, default value is `true`
+- `siteConfig.siteNameTemplate` **ADDED**, default value is `%s - next-gdrive-index`
+- `siteConfig.siteAuthor` **ADDED**, default value is `mbahArip`
+- `siteConfig.robots` **ADDED**, default value is `noindex, nofollow`
+- `siteConfig.footer` **ADDED**, default value is `Powered by next-gdrive-index`
+- `siteConfig.defaultAccentColor` **REMOVED**, you can set the theme from `/src/app/globals.css` (more information about [shadcn/ui theme](https://ui.shadcn.com/docs/theming))
+- `siteConfig.breadcrumbMax` **ADDED**, default value is `3`
+- `siteConfig.toaster` **ADDED**, default value is `{ position: "bottom-right', duration: 3000 }`
+- `siteConfig.navbarItems[number].icons` **CHANGED**, you need to use [Lucide Icons](https://lucide.dev/icons) now
+- `siteConfig.supports` **ADDED**, you can leave it empty if you don't want to use it
+
+### Environment Variable
+
+Now all the environment variable are on the server side, so you need to change the environment variable:
+
+- `NEXT_PUBLIC_ENCRYPTION_KEY` **CHANGED** to `ENCRYPTION_KEY`
+- `NEXT_PUBLIC_SITE_PASSWORD` **CHANGED** to `SITE_PASSWORD`
+- `NEXT_PUBLIC_VERCEL_URL` **CHANGED** to `NEXT_PUBLIC_DOMAIN`
+
+## Sponsors and Donations
+
+If you think I deserve it, you can support me by:
+
+- [Paypal (USD)](https://paypal.me/mbaharip)
+- [Ko-fi (USD)](https://ko-fi.com/mbaharip)
+- [Saweria (IDR)](https://saweria.co/mbaharip)
diff --git a/TODO.md b/TODO.md
deleted file mode 100644
index 3f02df2..0000000
--- a/TODO.md
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..e048066
--- /dev/null
+++ b/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "src/app/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "~/components",
+ "utils": "~/utils"
+ }
+}
diff --git a/next.config.js b/next.config.js
index 8851780..f0754a2 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,34 +1,23 @@
-/** @type {import('next').NextConfig} */
-
-const withMDX = require("@next/mdx")({
- extension: /\.mdx?$/,
- options: {
- // If you use remark-gfm, you'll need to use next.config.mjs
- // as the package is ESM only
- // https://github.com/remarkjs/remark-gfm#install
- remarkPlugins: [],
- rehypePlugins: [],
- // If you use `MDXProvider`, uncomment the following line.
- providerImportSource: "@mdx-js/react",
- },
-});
-
const nextConfig = {
- pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
reactStrictMode: true,
- async headers() {
- return [
- {
- source: "/:path*",
- headers: [
- {
- key: "Cache-Control",
- value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
- },
- ],
- },
- ];
+ webpack: (config) => {
+ config.resolve.alias.canvas = false;
+
+ return config;
},
+ // async headers() {
+ // return [
+ // {
+ // source: "/:path*",
+ // headers: [
+ // {
+ // key: "Cache-Control",
+ // value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
+ // },
+ // ],
+ // },
+ // ];
+ // },
};
-module.exports = withMDX(nextConfig);
+module.exports = nextConfig;
diff --git a/package.json b/package.json
index 5e77880..f507f8e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
- "name": "drive-manager",
- "version": "0.1.0",
+ "name": "next-gdrive-index",
+ "version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
@@ -12,24 +12,59 @@
},
"dependencies": {
"@cyntler/react-doc-viewer": "^1.13.0",
- "@iconify/react": "^4.1.1",
- "@mdx-js/loader": "^2.3.0",
- "@mdx-js/react": "^2.3.0",
- "@next/mdx": "^13.4.19",
- "@popperjs/core": "^2.11.8",
+ "@hookform/resolvers": "^3.3.4",
+ "@radix-ui/react-accordion": "^1.1.2",
+ "@radix-ui/react-alert-dialog": "^1.0.5",
+ "@radix-ui/react-aspect-ratio": "^1.0.3",
+ "@radix-ui/react-avatar": "^1.0.4",
+ "@radix-ui/react-checkbox": "^1.0.4",
+ "@radix-ui/react-collapsible": "^1.0.3",
+ "@radix-ui/react-context-menu": "^2.1.5",
+ "@radix-ui/react-dialog": "^1.0.5",
+ "@radix-ui/react-dropdown-menu": "^2.0.6",
+ "@radix-ui/react-hover-card": "^1.0.7",
+ "@radix-ui/react-label": "^2.0.2",
+ "@radix-ui/react-menubar": "^1.0.4",
+ "@radix-ui/react-navigation-menu": "^1.1.4",
+ "@radix-ui/react-popover": "^1.0.7",
+ "@radix-ui/react-progress": "^1.0.3",
+ "@radix-ui/react-radio-group": "^1.1.3",
+ "@radix-ui/react-scroll-area": "^1.0.5",
+ "@radix-ui/react-select": "^2.0.0",
+ "@radix-ui/react-separator": "^1.0.3",
+ "@radix-ui/react-slider": "^1.1.2",
+ "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-switch": "^1.0.3",
+ "@radix-ui/react-tabs": "^1.0.4",
+ "@radix-ui/react-toast": "^1.1.5",
+ "@radix-ui/react-toggle": "^1.0.3",
+ "@radix-ui/react-toggle-group": "^1.0.4",
+ "@radix-ui/react-tooltip": "^1.0.7",
"axios": "^1.3.5",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.0",
+ "cmdk": "^1.0.0",
+ "date-fns": "^3.6.0",
+ "embla-carousel-react": "^8.0.1",
"googleapis": "^118.0.0",
+ "input-otp": "^1.2.3",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
- "next": "^13.4.13",
- "next-seo": "^6.1.0",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "react-h5-audio-player": "^3.8.6",
+ "lucide-react": "^0.363.0",
+ "next": "^14.1.4",
+ "next-themes": "^0.3.0",
+ "nextjs-toploader": "^1.6.11",
+ "react": "^18",
+ "react-day-picker": "^8.10.0",
+ "react-dom": "^18",
+ "react-h5-audio-player": "^3.9.1",
+ "react-hook-form": "^7.51.2",
+ "react-hot-toast": "^2.4.1",
"react-is": "^18.2.0",
"react-markdown": "^8.0.7",
"react-player": "^2.13.0",
"react-popper": "^2.3.0",
+ "react-resizable-panels": "^2.0.16",
"react-toastify": "^9.1.3",
"rehype-katex": "^6.0.3",
"rehype-prism-plus": "^1.6.3",
@@ -37,7 +72,12 @@
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"remark-slug": "^7.0.1",
- "remark-toc": "^8.0.1"
+ "remark-toc": "^8.0.1",
+ "sonner": "^1.4.41",
+ "tailwind-merge": "^2.2.2",
+ "tailwindcss-animate": "^1.0.7",
+ "vaul": "^0.9.0",
+ "zod": "^3.22.4"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
@@ -46,19 +86,18 @@
"@types/cors": "^2.8.13",
"@types/jsonwebtoken": "^9.0.1",
"@types/mime-types": "^2.1.1",
- "@types/node": "18.15.11",
- "@types/react": "18.0.35",
- "@types/react-dom": "18.0.11",
+ "@types/node": "^20",
+ "@types/react": "^18",
+ "@types/react-dom": "^18",
"@types/three": "^0.150.2",
- "autoprefixer": "10.4.14",
+ "autoprefixer": "^10.4.19",
"encoding": "^0.1.13",
"eslint": "8.38.0",
- "eslint-config-next": "^13.4.3",
- "postcss": "8.4.22",
- "prettier": "^2.8.7",
- "prettier-plugin-tailwindcss": "^0.2.7",
- "tailwind-merge": "^1.14.0",
- "tailwindcss": "3.3.1",
- "typescript": "5.0.4"
+ "eslint-config-next": "^14.1.4",
+ "postcss": "^8.4.38",
+ "prettier": "3.0.0",
+ "prettier-plugin-tailwindcss": "0.5.12",
+ "tailwindcss": "^3.4.1",
+ "typescript": "^5"
}
}
diff --git a/postcss.config.js b/postcss.config.js
index 33ad091..12a703d 100644
--- a/postcss.config.js
+++ b/postcss.config.js
@@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
-}
+};
diff --git a/src/app/@explorer.tsx b/src/app/@explorer.tsx
new file mode 100644
index 0000000..9acdbc8
--- /dev/null
+++ b/src/app/@explorer.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useContext, useEffect, useMemo, useState } from "react";
+import toast from "react-hot-toast";
+import { z } from "zod";
+import { Schema_File } from "~/schema";
+import { cn } from "~/utils";
+
+import Icon from "~/components/Icon";
+import { Button } from "~/components/ui/button";
+import { Separator } from "~/components/ui/separator";
+
+import { LayoutContext } from "~/context/layoutContext";
+
+import config from "~/config/gIndex.config";
+
+import FileGrid from "./@file.grid";
+import FileList from "./@file.list";
+import { GetFiles } from "./actions";
+
+type Props = {
+ files: z.infer[];
+ nextPageToken?: string;
+ root?: boolean;
+};
+export default function FileBrowser({ files, nextPageToken, root }: Props) {
+ const { layout } = useContext(LayoutContext);
+ const pathname = usePathname();
+ const prevPath = useMemo(() => {
+ const path = pathname
+ .split("/")
+ .slice(0, -1)
+ .join("/")
+ .replace(/\/+/g, "/");
+
+ return new URL(path, config.basePath).pathname;
+ }, [pathname]);
+
+ const [fileList, setFileList] =
+ useState[]>(files);
+ const [nextToken, setNextToken] = useState(nextPageToken);
+ const [loadMoreLoading, setLoadMoreLoading] = useState(false);
+
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(false);
+ }, []);
+
+ const onLoadMore = async () => {
+ setLoadMoreLoading(true);
+ try {
+ if (!nextToken) throw new Error("No more files to load");
+ const data = await GetFiles({ pageToken: nextToken });
+ const uniqueData = [...fileList, ...data.files].filter(
+ (item, index, array) =>
+ index === array.findIndex((i) => i.encryptedId === item.encryptedId),
+ );
+ setFileList(uniqueData);
+ // const uniqueData = new Set([...fileList, ...data.files]);
+ // setFileList([...uniqueData]);
+ setNextToken(data.nextPageToken);
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ toast.error(e.message);
+ } finally {
+ setLoadMoreLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
Wait a moment while we load your files...
+
+ );
+ }
+
+ return (
+
+ {!root && (
+
+
+
+ Back
+
+
+ )}
+ {!fileList.length && (
+
+
+
+ There are no files in this folder
+
+
+ )}
+ {layout === "list" && (
+
+ {fileList.map((file) => (
+
+
+
+
+ ))}
+
+ )}
+ {layout === "grid" && (
+
+ {fileList.map((file) => (
+
+
+
+ ))}
+
+ )}
+
+ {nextToken && (
+
+ {loadMoreLoading ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ <>Load More>
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/app/@file.grid.tsx b/src/app/@file.grid.tsx
new file mode 100644
index 0000000..f86b9ca
--- /dev/null
+++ b/src/app/@file.grid.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useMemo, useState } from "react";
+import toast from "react-hot-toast";
+import { z } from "zod";
+import { Schema_File } from "~/schema";
+import { cn } from "~/utils";
+
+import Icon from "~/components/Icon";
+import { Button } from "~/components/ui/button";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "~/components/ui/drawer";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+
+import useMediaQuery from "~/hooks/useMediaQuery";
+import bytesToReadable from "~/utils/bytesFormat";
+import { durationToReadable } from "~/utils/durationFormat";
+import { getPreviewIcon } from "~/utils/previewHelper";
+
+import config from "~/config/gIndex.config";
+
+import { CreateDownloadToken } from "./actions";
+
+type Props = {
+ data: z.infer;
+};
+export default function FileGrid({ data }: Props) {
+ const pathname = usePathname();
+ const filePath = useMemo(() => {
+ // const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
+ // Set to pathname to remove the /e prefix
+ const path = [pathname, encodeURIComponent(data.name)]
+ .join("/")
+ .replace(/\/+/g, "/");
+
+ return new URL(path, config.basePath).pathname;
+ }, [data, pathname]);
+
+ const [actionOpen, setActionOpen] = useState(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 (
+
+ {isDesktop ? (
+
+
+
+
+
+
+
+
+
+ Copy link
+
+ {data.mimeType.includes("folder") ? null : (
+
+
+ Download
+
+ )}
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ Actions
+
+ What would you like to do with this file?
+
+
+
+
+
+
+ Copy link
+
+
+ {data.mimeType.includes("folder") ? null : (
+
+
+
+ Download
+
+
+ )}
+
+
+
+
+ Close
+
+
+
+
+ )}
+
+
+ {/* If it's media, show thumbnail */}
+
+ {data.thumbnailLink &&
+ (data.mimeType.startsWith("video") ||
+ data.mimeType.startsWith("image")) ? (
+ <>
+
+
+
+ {data.mimeType.startsWith("video") && (
+ <>
+
+
+ {durationToReadable(
+ data.videoMediaMetadata?.durationMillis || 0,
+ )}
+
+ >
+ )}
+ >
+ ) : (
+
+ )}
+
+
+ {/* File data */}
+
+
+ {data.fileExtension
+ ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
+ : data.name}
+
+
+
+ {data.mimeType.includes("folder")
+ ? "folder"
+ : data.fileExtension}
+
+ {!data.mimeType.includes("folder") && (
+ <>
+
+
+ {bytesToReadable(data.size || 0)}
+
+ >
+ )}
+
+
+
+ {new Date(data.modifiedTime).toLocaleDateString()}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/@file.list.tsx b/src/app/@file.list.tsx
new file mode 100644
index 0000000..e61a5ac
--- /dev/null
+++ b/src/app/@file.list.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useMemo, useState } from "react";
+import toast from "react-hot-toast";
+import { z } from "zod";
+import { Schema_File } from "~/schema";
+import { cn } from "~/utils";
+
+import Icon from "~/components/Icon";
+import { Button } from "~/components/ui/button";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "~/components/ui/drawer";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+
+import useMediaQuery from "~/hooks/useMediaQuery";
+import bytesToReadable from "~/utils/bytesFormat";
+import { getPreviewIcon } from "~/utils/previewHelper";
+
+import config from "~/config/gIndex.config";
+
+import { CreateDownloadToken } from "./actions";
+
+type Props = {
+ data: z.infer;
+};
+export default function FileList({ data }: Props) {
+ const pathname = usePathname();
+
+ const filePath = useMemo(() => {
+ // const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
+ // Set to pathname to remove the /e prefix
+ const path = [pathname, encodeURIComponent(data.name)]
+ .join("/")
+ .replace(/\/+/g, "/");
+
+ return new URL(path, config.basePath).pathname;
+ }, [data, pathname]);
+ const [actionOpen, setActionOpen] = useState(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 (
+
+
+ {isDesktop ? (
+
+
+
+
+
+
+
+
+
+ Copy link
+
+ {data.mimeType.includes("folder") ? null : (
+
+
+ Download
+
+ )}
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ Actions
+
+ What would you like to do with this file?
+
+
+
+
+
+
+ Copy link
+
+
+ {data.mimeType.includes("folder") ? null : (
+
+
+
+ Download
+
+
+ )}
+
+
+
+
+ Close
+
+
+
+
+ )}
+
+
+
+ {/* If it's media, show thumbnail */}
+
+ {data.thumbnailLink &&
+ (data.mimeType.startsWith("video") ||
+ data.mimeType.startsWith("image")) ? (
+ <>
+
+
+ {data.mimeType.startsWith("video") && (
+ <>
+
+ >
+ )}
+ >
+ ) : (
+
+ )}
+
+
+ {/* File data */}
+
+
+ {data.fileExtension
+ ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
+ : data.name}
+
+
+
+ {data.mimeType.includes("folder")
+ ? "folder"
+ : data.fileExtension}
+
+ {!data.mimeType.includes("folder") && (
+ <>
+
+
+ {bytesToReadable(data.size || 0)}
+
+ >
+ )}
+
+
+
+ {new Date(data.modifiedTime).toLocaleDateString()}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/@footer.tsx b/src/app/@footer.tsx
new file mode 100644
index 0000000..5830490
--- /dev/null
+++ b/src/app/@footer.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import ReactMarkdown from "react-markdown";
+
+type Props = {
+ content: string;
+};
+export default function Footer({ content }: Props) {
+ return (
+
+ (
+
+ {children}
+
+ ),
+ a: ({ node, children, ...props }) => {
+ const isExternal = props.href?.startsWith("http");
+
+ return (
+
+ {children}
+
+ );
+ },
+ }}
+ >
+ {content}
+
+
+ );
+}
diff --git a/src/app/@header.breadcrumb.tsx b/src/app/@header.breadcrumb.tsx
new file mode 100644
index 0000000..d371315
--- /dev/null
+++ b/src/app/@header.breadcrumb.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
+import Link from "next/link";
+import { Fragment, useState } from "react";
+import { z } from "zod";
+import { Schema_Breadcrumb } from "~/schema";
+
+import Icon from "~/components/Icon";
+import {
+ Breadcrumb,
+ BreadcrumbEllipsis,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "~/components/ui/breadcrumb";
+import { Button } from "~/components/ui/button";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "~/components/ui/drawer";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+} from "~/components/ui/dropdown-menu";
+import { Separator } from "~/components/ui/separator";
+import { Skeleton } from "~/components/ui/skeleton";
+
+import useMediaQuery from "~/hooks/useMediaQuery";
+
+import config from "~/config/gIndex.config";
+
+type Props = {
+ data: z.infer[];
+ loading?: boolean;
+};
+export default function HeaderBreadcrumb({ data, loading }: Props) {
+ const [open, setOpen] = useState(false);
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+
+ if (loading) return ;
+
+ return (
+
+
+
+
+
+
+
+
+ ~
+
+
+
+
+ {!!data.length ? (
+ <>
+
+
+ {data.length > config.siteConfig.breadcrumbMax ? (
+ <>
+ {isDesktop ? (
+
+
+
+
+
+ {data
+ .slice(0, -config.siteConfig.breadcrumbMax + 1)
+ .map((item, _, array) => (
+
+
+ item.href)
+ .join("/")}`}
+ className='w-full'
+ >
+ {item.label}
+
+
+
+ ))}
+
+
+ ) : (
+
+
+
+
+
+
+
+ Navigate to parent directories
+
+
+
+
+ {data
+ .slice(0, -config.siteConfig.breadcrumbMax + 1)
+ .map((item, _, array) => (
+
+
+ item.href)
+ .join("/")}`}
+ className='w-full py-1.5'
+ >
+ {item.label}
+
+
+
+ ))}
+
+
+
+
+ Close
+
+
+
+
+ )}
+
+
+ >
+ ) : null}
+
+ {data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
+
+
+ {item.href ? (
+ <>
+
+ item.href)
+ .join("/")
+ .replace(/\/\//g, "/")}`}
+ >
+ {item.label}
+
+
+ >
+ ) : (
+
+ {item.label}
+
+ )}
+
+ {item.href && }
+
+ ))}
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/app/@header.button.tsx b/src/app/@header.button.tsx
new file mode 100644
index 0000000..c8e99f1
--- /dev/null
+++ b/src/app/@header.button.tsx
@@ -0,0 +1,604 @@
+"use client";
+
+import { PropsWithChildren, useContext, useEffect, useState } from "react";
+import toast from "react-hot-toast";
+import { z } from "zod";
+import { Schema_File } from "~/schema";
+import { cn } from "~/utils";
+
+import Icon from "~/components/Icon";
+import { Button } from "~/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "~/components/ui/drawer";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+import { Input } from "~/components/ui/input";
+import { Separator } from "~/components/ui/separator";
+import { Skeleton } from "~/components/ui/skeleton";
+
+import { LayoutContext } from "~/context/layoutContext";
+import useMediaQuery from "~/hooks/useMediaQuery";
+import useRouter from "~/hooks/usePRouter";
+import bytesToReadable from "~/utils/bytesFormat";
+import { getPreviewIcon } from "~/utils/previewHelper";
+
+import { RedirectSearchFile, SearchFile } from "./actions";
+
+export default function HeaderButton({ children }: PropsWithChildren) {
+ const { layout, setLayout } = useContext(LayoutContext);
+
+ const [layoutOpen, setLayoutOpen] = useState(false);
+
+ const [loading, setLoading] = useState(true);
+ const [snap, setSnap] = useState(0.3);
+ const [searchOpen, setSearchOpen] = useState(false);
+ const [searchInput, setSearchInput] = useState("");
+ const [debouncedSearchInput, setDebouncedSearchInput] = useState("");
+ const [searchLoading, setSearchLoading] = useState(false);
+ const [searchError, setSearchError] = useState("");
+ const [searchResults, setSearchResults] = useState<
+ z.infer[]
+ >([]);
+ const [nextPageToken, setNextPageToken] = useState();
+
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+
+ useEffect(() => {
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ if (!searchInput) {
+ setSearchLoading(true);
+ setDebouncedSearchInput("");
+ setSearchError("");
+ setSearchResults([]);
+ return;
+ }
+ const handler = setTimeout(() => {
+ setDebouncedSearchInput(searchInput);
+ }, 300);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [searchInput]);
+ useEffect(() => {
+ if (!debouncedSearchInput) return;
+ onSearch();
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [debouncedSearchInput]);
+
+ const onSearch = async () => {
+ setSearchLoading(true);
+ try {
+ const data = await SearchFile(debouncedSearchInput, nextPageToken);
+ setSearchResults(data.files);
+ setNextPageToken(data.nextPageToken);
+ } catch (error) {
+ const e = error as Error;
+ console.error(e);
+ setSearchError(e.message);
+ } finally {
+ setSearchLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ {" "}
+
+
+ );
+ }
+
+ return (
+
+ {isDesktop ? (
+
+
+
+
+
+ {layout === "grid" ? "Grid" : "List"}
+
+
+
+
+
+ setLayout("grid")}
+ >
+
+ Grid
+
+ setLayout("list")}
+ >
+
+ List
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ Layout
+
+ Choose a layout for the files explorer
+
+
+
+
+ {
+ setLayout("grid");
+ setLayoutOpen(false);
+ }}
+ >
+
+ Grid
+
+
+
+ {
+ setLayout("list");
+ setLayoutOpen(false);
+ }}
+ >
+
+ List
+
+
+
+
+
+ Close
+
+
+
+
+ )}
+ {isDesktop ? (
+
{
+ if (!open) {
+ setSearchInput("");
+ setSnap(0.3);
+ }
+ setSearchOpen(open);
+ }}
+ >
+
+
+
+
+
+
+
+ Search
+
+ Search for files in your drive
+
+
+
+
+ {
+ setSearchError("");
+ if (!e.target.value) {
+ setSearchInput("");
+ setSnap(0.3);
+ } else {
+ setSearchLoading(true);
+ setSearchInput(e.target.value);
+ setSnap(1);
+ }
+ }}
+ />
+
+
+ Close
+
+
+
+
+
+ {!debouncedSearchInput ? (
+
+
+
+ Start typing to search
+
+
+ ) : searchLoading ? (
+
+
+
+ Searching...
+
+
+ ) : searchError ? (
+
+
+
+ {searchError}
+
+
+ ) : !searchResults.length ? (
+
+
+
+ We couldn't find any results
+
+
+ ) : (
+
+ {searchResults.map((result) => (
+
+ ))}
+
+ )}
+
+
+
+
+ ) : (
+
{
+ if (!open) {
+ setSearchInput("");
+ setSnap(0.3);
+ }
+ setSearchOpen(open);
+ }}
+ shouldScaleBackground
+ fadeFromIndex={0}
+ snapPoints={[0.3, 1]}
+ activeSnapPoint={snap}
+ setActiveSnapPoint={setSnap}
+ >
+
+
+
+
+
+
+
+ Search
+
+ Search for files in your drive
+
+
+
+
+ {
+ setSearchError("");
+ if (!e.target.value) {
+ setSearchInput("");
+ setSnap(0.3);
+ } else {
+ setSearchLoading(true);
+ setSearchInput(e.target.value);
+ setSnap(1);
+ }
+ }}
+ />
+
+
+ Close
+
+
+
+
+
+ {!debouncedSearchInput ? (
+
+
+
+ Start typing to search
+
+
+ ) : searchLoading ? (
+
+
+
+ Searching...
+
+
+ ) : searchError ? (
+
+
+
+ {searchError}
+
+
+ ) : !searchResults.length ? (
+
+
+
+ We couldn't find any results
+
+
+ ) : (
+
+ {searchResults.map((result) => (
+
+ ))}
+
+ )}
+
+
+
+
+ )}
+
+ );
+}
+
+function SearchResultItem({ data }: { data: z.infer }) {
+ const router = useRouter();
+ return (
+ {
+ 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}`,
+ });
+ }
+ }}
+ >
+
+ {/* If it's media, show thumbnail */}
+
+ {data.thumbnailLink &&
+ (data.mimeType.startsWith("video") ||
+ data.mimeType.startsWith("image")) ? (
+ <>
+
+
+ {data.mimeType.startsWith("video") && (
+ <>
+
+ >
+ )}
+ >
+ ) : (
+
+ )}
+
+
+ {/* File data */}
+
+
+ {data.fileExtension
+ ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
+ : data.name}
+
+
+
+ {data.mimeType.includes("folder") ? "folder" : data.fileExtension}
+
+ {!data.mimeType.includes("folder") && (
+ <>
+
+
+ {bytesToReadable(data.size || 0)}
+
+ >
+ )}
+
+
+
+ {new Date(data.modifiedTime).toLocaleDateString()}
+
+
+
+
+
+ );
+}
diff --git a/src/app/@header.title.tsx b/src/app/@header.title.tsx
new file mode 100644
index 0000000..3a9da84
--- /dev/null
+++ b/src/app/@header.title.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { PropsWithChildren } from "react";
+
+import { Skeleton } from "~/components/ui/skeleton";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "~/components/ui/tooltip";
+
+export default function HeaderTitle({
+ children,
+ loading,
+}: PropsWithChildren<{ loading?: boolean }>) {
+ if (loading) return ;
+
+ return (
+
+
+ {children}
+
+ {children}
+
+ );
+}
diff --git a/src/app/@header.tsx b/src/app/@header.tsx
new file mode 100644
index 0000000..9ef6071
--- /dev/null
+++ b/src/app/@header.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { z } from "zod";
+import { Schema_Breadcrumb } from "~/schema";
+import { cn } from "~/utils";
+
+import HeaderBreadcrumb from "./@header.breadcrumb";
+
+type Props = {
+ name: string;
+ breadcrumb?: z.infer[];
+};
+export default function Header({ name, breadcrumb }: Props) {
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(false);
+ }, []);
+
+ return (
+
+
+ {/* {name} */}
+
+
+ {/* */}
+
+
+ );
+}
diff --git a/src/components/Markdown/index.tsx b/src/app/@markdown.tsx
similarity index 55%
rename from src/components/Markdown/index.tsx
rename to src/app/@markdown.tsx
index ff7ccd4..57c75c1 100644
--- a/src/components/Markdown/index.tsx
+++ b/src/app/@markdown.tsx
@@ -1,7 +1,8 @@
-import { jetbrainsMono } from "pages/_app";
+"use client";
+
import { useRef, useState } from "react";
+import toast from "react-hot-toast";
import ReactMarkdown from "react-markdown";
-import { toast } from "react-toastify";
import rehypeKatex from "rehype-katex";
import rehypePrism from "rehype-prism-plus";
import rehypeRaw from "rehype-raw";
@@ -10,10 +11,50 @@ import remarkMath from "remark-math";
import remarkSlug from "remark-slug";
import remarkToc from "remark-toc";
-import Button from "components/Button";
+import Icon from "~/components/Icon";
-import "styles/highlight.css";
-import "styles/markdown.css";
+import "./highlight.css";
+
+type Props = {
+ content: string;
+};
+export default function Markdown({ content }: Props) {
+ return (
+
+
(
+
+ {children}
+
+ ),
+ pre: PreComponent,
+ code: ({ node, inline, className, children, ...props }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {content}
+
+
+ );
+}
interface PreProps extends React.HTMLAttributes {}
function PreComponent(props: PreProps) {
@@ -25,7 +66,12 @@ function PreComponent(props: PreProps) {
const handleCopy = () => {
if (!codeRef.current) return;
+ if (isCopied || isError) return;
if (copyTimeout) clearTimeout(copyTimeout);
+ const toastId = `copy-${Math.random() * 1000}`;
+ toast.loading("Copying code to clipboard...", {
+ id: toastId,
+ });
try {
navigator.clipboard.writeText(codeRef.current.textContent as string);
setIsError(false);
@@ -35,7 +81,9 @@ function PreComponent(props: PreProps) {
setIsCopied(false);
}, 2000),
);
- toast.success("Code copied to clipboard");
+ toast.success("Code copied to clipboard", {
+ id: toastId,
+ });
} catch (error: any) {
setIsCopied(false);
setIsError(true);
@@ -44,13 +92,15 @@ function PreComponent(props: PreProps) {
setIsError(false);
}, 2000),
);
- toast.error("Failed to copy code to clipboard");
+ toast.error("Failed to copy code to clipboard", {
+ id: toastId,
+ });
}
};
return (
{
setShowCopy(true);
}}
@@ -60,54 +110,33 @@ function PreComponent(props: PreProps) {
setIsError(false);
}}
>
-
-
+ {
handleCopy();
}}
>
- {isCopied ? "Copied!" : isError ? "Failed to copy" : "Copy"}
-
+
+
{props.children}
);
}
-
-interface MarkdownProps {
- content: string;
-}
-export default function Markdown(props: MarkdownProps) {
- return (
-
- (
-
- {children}
-
- ),
- }}
- >
- {props.content}
-
-
- );
-}
diff --git a/src/app/@navbar.tsx b/src/app/@navbar.tsx
new file mode 100644
index 0000000..0524176
--- /dev/null
+++ b/src/app/@navbar.tsx
@@ -0,0 +1,579 @@
+"use client";
+
+import { TooltipTrigger } from "@radix-ui/react-tooltip";
+import { useTheme } from "next-themes";
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import toast from "react-hot-toast";
+import { cn } from "~/utils";
+
+import Icon from "~/components/Icon";
+import { Button } from "~/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "~/components/ui/drawer";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "~/components/ui/dropdown-menu";
+import { Separator } from "~/components/ui/separator";
+import {
+ Sheet,
+ SheetContent,
+ SheetFooter,
+ SheetTrigger,
+} from "~/components/ui/sheet";
+import { Tooltip, TooltipContent } from "~/components/ui/tooltip";
+
+import useMediaQuery from "~/hooks/useMediaQuery";
+
+import config from "~/config/gIndex.config";
+
+import { ClearPassword } from "./actions";
+
+export default function Navbar() {
+ const pathname = usePathname();
+ const router = useRouter();
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+ const { theme, themes, setTheme } = useTheme();
+
+ const [open, setOpen] = useState(false);
+ const [themeOpen, setThemeOpen] = useState(false);
+
+ useEffect(() => {
+ if (isDesktop) setOpen(false);
+ }, [isDesktop]);
+
+ async function onClearPassword() {
+ const promise = new Promise(async (resolve, reject) => {
+ const clear = await ClearPassword();
+ if (!clear.success) reject(new Error(clear.message));
+ resolve(clear.message);
+ });
+ toast.promise(promise, {
+ loading: "Clearing all saved password...",
+ success: () => {
+ setOpen(false);
+ router.refresh();
+ return "All saved password cleared successfully!";
+ },
+ error: (error) => error.message,
+ });
+ }
+
+ return (
+
+
+
+
setOpen(false)}
+ className='flex items-center gap-3'
+ >
+
+
+ {config.siteConfig.siteName}
+
+
+
+ {isDesktop ? (
+
+ {config.siteConfig.navbarItems.map((item) => (
+
+
+
+
+
+
+
+ {item.name}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Site theme
+
+
+
+ {themes.map((item) => (
+ setTheme(item)}
+ >
+
+
+ {item}
+
+
+
+
+ ))}
+
+
+
+ {config.siteConfig.supports.length && (
+
+
+
+
+
+
+
+
+
+
+ Support
+
+
+
+ {config.siteConfig.supports.map((item) => (
+
+
+ {item.name}
+ {item.currency}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ Clear all saved password
+
+
+
+
+ Clear all saved password?
+
+ You will need to re-enter the password to access the
+ protected content.
+
+ Are you sure you want to continue?
+
+
+
+ Cancel
+
+
+
+ Clear all saved password
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+ Theme
+
+ Choose your preferred theme
+
+
+
+
+ {themes.map((item) => (
+
{
+ setTheme(item);
+ }}
+ >
+
+
+ {item}
+
+
+
+
+ ))}
+
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {config.siteConfig.navbarItems.map((item) => (
+
+ setOpen(false)}
+ >
+
+
+ {item.name}
+
+ {item.external && (
+
+ )}
+
+
+ ))}
+
+
+
+
Theme
+
+ {themes.map((item) => (
+
{
+ setTheme(item);
+ }}
+ >
+
+
+ {item}
+
+
+
+
+ ))}
+
+
+ {config.siteConfig.supports.length && (
+
+
Support & Donation
+
+ {config.siteConfig.supports.map((item) => (
+
+
+ {item.name}
+ {item.currency}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Clear all saved password
+
+
+
+
+ Clear all saved password?
+
+ You will need to re-enter the password to access the
+ protected content.
+
+ Are you sure you want to continue?
+
+
+
+
+
+
+ Clear all saved password
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/@not-found.tsx b/src/app/@not-found.tsx
new file mode 100644
index 0000000..9b73830
--- /dev/null
+++ b/src/app/@not-found.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { cn } from "~/utils";
+
+import Icon from "~/components/Icon";
+import { Button } from "~/components/ui/button";
+
+import useRouter from "~/hooks/usePRouter";
+
+export default function NotFoundComponent() {
+ const router = useRouter();
+ return (
+
+
+
+ The file you are looking for does not exist
+
+
+
+ router.push("/")}
+ >
+ Back to home
+
+ router.back()}
+ >
+ Back to previous page
+
+
+
+ );
+}
diff --git a/src/app/@password.tsx b/src/app/@password.tsx
new file mode 100644
index 0000000..7bc2cac
--- /dev/null
+++ b/src/app/@password.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import toast from "react-hot-toast";
+import { cn } from "~/utils";
+
+import Icon from "~/components/Icon";
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+
+import {
+ CheckPassword,
+ CheckSitePassword,
+ SetPassword,
+ SetSitePassword,
+} from "./actions";
+
+type Props = {
+ path: string;
+ checkPaths?: { path: string; id: string }[];
+ errorMessage?: string;
+};
+export default function Password({ path, checkPaths, errorMessage }: Props) {
+ const router = useRouter();
+
+ const [input, setInput] = useState("");
+ const [submitLoading, setSubmitLoading] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSubmitLoading(true);
+ toast.loading("Checking password...", {
+ id: "password",
+ });
+
+ try {
+ if (!input) throw new Error("Password is required");
+ if (path === "global") {
+ const set = await SetSitePassword(input);
+ if (!set.success) throw new Error(set.message);
+
+ const check = await CheckSitePassword();
+ if (!check.success) throw new Error(check.message);
+ } else {
+ if (!checkPaths)
+ throw new Error("No path found, try to refresh the page.");
+ const set = await SetPassword(path, input);
+ if (!set.success) throw new Error(set.message);
+
+ const check = await CheckPassword(checkPaths);
+ if (!check.success) throw new Error(check.message);
+ }
+
+ toast.success("Password accepted! Refreshing...", {
+ id: "password",
+ });
+ console.log("Password accepted! Refreshing...");
+ router.refresh();
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ toast.error(e.message, {
+ id: "password",
+ });
+ setSubmitLoading(false);
+ } finally {
+ setInput("");
+ }
+ };
+
+ useEffect(() => {
+ setLoading(false);
+ }, []);
+
+ if (loading)
+ return (
+
+
+
Checking password...
+
+ );
+
+ return (
+
+
+
+
+ {path === "global"
+ ? "This site are password protected"
+ : "The folder or file you are trying to access is password protected"}
+
+
+ Please enter the password to access the content
+
+
+
+
+
+ {errorMessage || "Nothing wrong here, just a password wall"}
+
+
+ );
+}
diff --git a/src/app/@preview.action.tsx b/src/app/@preview.action.tsx
new file mode 100644
index 0000000..da4e462
--- /dev/null
+++ b/src/app/@preview.action.tsx
@@ -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;
+};
+export default function PreviewAction({ file }: Props) {
+ const pathname = usePathname();
+ const showRaw = useMemo(() => {
+ 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(false);
+ const [diffOpen, setDiffOpen] = useState(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 (
+
+
+
+
+ {isDesktop ? (
+
+
+
+ Difference between download link and raw link?
+
+
+
+
+
Why?
+
+ Some services need the file extension to be present in the
+ URL to properly embed the file.
+
+ - The download link only have encrypted file id.
+
+ - The raw link will have the whole path includes the file
+ name and extension.
+
+
+ So if you want to embed the file, it's recommended to
+ use the raw link instead of the download link.
+
+
+ Note: This is only applicable for video, audio, and image
+ files.
+
+
Ref
+
+ This information is based on the onedrive-vercel-index
+ project documentation.
+
+
+ Customise Direct Link - onedrive-vercel-index
+
+
+
+
+
+ ) : (
+
+
+
+ Difference between download link and raw link?
+
+
+
+
+
+
Why?
+
+ Some services need the file extension to be present in the
+ URL to properly embed the file.
+
+ - The download link only have encrypted file id.
+
+ - The raw link will have the whole path includes the file
+ name and extension.
+
+
+ So if you want to embed the file, it's recommended to
+ use the raw link instead of the download link.
+
+
Ref
+
+ This information is based on the onedrive-vercel-index
+ project documentation.
+
+
+ Customise Direct Link - onedrive-vercel-index
+
+
+
+
+
+
+ Close
+
+
+
+
+
+ )}
+
+
+ {showRaw ? (
+
+
+ Raw Link
+
+ ) : null}
+
+
+ Download Link
+
+
+
+ Download
+
+
+
+
+
+
+ File Information
+
+
+
+ {fileInfo.map((info) => (
+
+ {info.label}:
+
+ {info.value}
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/@preview.audio.tsx b/src/app/@preview.audio.tsx
new file mode 100644
index 0000000..eaf7e51
--- /dev/null
+++ b/src/app/@preview.audio.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import AudioPlayer from "react-h5-audio-player";
+import "react-h5-audio-player/lib/styles.css";
+import { z } from "zod";
+import { Schema_File } from "~/schema";
+import { cn } from "~/utils";
+
+import Icon from "~/components/Icon";
+
+import { CreateDownloadToken } from "./actions";
+import "./r5-style.css";
+
+type Props = {
+ file: z.infer;
+};
+export default function PreviewAudio({ file }: Props) {
+ const [audioSrc, setAudioSrc] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ 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 (
+
+ {loading ? (
+
+ ) : error ? (
+
+
+ {error}
+
+ ) : (
+
+
{
+ console.error(e);
+ setError(
+ "Could not preview this audio, try downloading the file",
+ );
+ }}
+ />
+
+ )}
+
+ );
+}
diff --git a/src/app/@preview.doc.tsx b/src/app/@preview.doc.tsx
new file mode 100644
index 0000000..e048992
--- /dev/null
+++ b/src/app/@preview.doc.tsx
@@ -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;
+};
+
+export default function PreviewDoc({ file }: Props) {
+ const [docSrc, setDocSrc] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ 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 (
+
+ {loading ? (
+
+
+
Loading document...
+
+ ) : error ? (
+
+
+ {error}
+
+ ) : (
+
(
+
+
+
Loading document...
+
+ ),
+ showLoadingTimeout: 10000,
+ },
+ noRenderer: {
+ overrideComponent: () => (
+
+
+
+ Error loading document
+
+
+ ),
+ },
+ }}
+ className={cn(
+ "h-full max-h-[70dvh] min-h-[70dvh] w-full rounded-[var(--radius)] border border-border !text-black",
+ )}
+ theme={{
+ disableThemeScrollbar: true,
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/app/@preview.image.tsx b/src/app/@preview.image.tsx
new file mode 100644
index 0000000..033a882
--- /dev/null
+++ b/src/app/@preview.image.tsx
@@ -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;
+};
+export default function PreviewImage({ file }: Props) {
+ const [imgSrc, setImgSrc] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ 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 (
+
+ {loading ? (
+
+ ) : error ? (
+
+
+ {error}
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/app/@preview.manga.tsx b/src/app/@preview.manga.tsx
new file mode 100644
index 0000000..447329c
--- /dev/null
+++ b/src/app/@preview.manga.tsx
@@ -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;
+};
+
+export default function PreviewManga({ file }: Props) {
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [images, setImages] = useState<{ name: string; blob: string }[]>([]);
+ const [currentImage, setCurrentImage] = useState(1);
+ const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
+ const [api, setApi] = useState();
+ 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 (
+
+ {loading ? (
+
+
+
Loading manga content...
+
+ ) : error ? (
+
+
+ {error}
+
+ ) : (
+
+
+
+
+ {images.map((image, index) => (
+
+
+
+ {image.name}
+
+
+ ))}
+
+ {isDesktop ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+
+
+ {currentImage}/{images.length}
+
+
setViewSize(viewSize === "fit" ? "full" : "fit")}
+ >
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/@preview.rich.tsx b/src/app/@preview.rich.tsx
new file mode 100644
index 0000000..ff2095f
--- /dev/null
+++ b/src/app/@preview.rich.tsx
@@ -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;
+ code?: boolean;
+};
+export default function PreviewRich({ file, code }: Props) {
+ const [content, setContent] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const [expand, setExpand] = useState(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 (
+
+ {loading ? (
+
+ ) : error ? (
+
+
+ {error}
+
+ ) : (
+
+
+
+
+
+ {
+ setExpand(true);
+ }}
+ >
+
+ Expand
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/@preview.unknown.tsx b/src/app/@preview.unknown.tsx
new file mode 100644
index 0000000..5f8cde0
--- /dev/null
+++ b/src/app/@preview.unknown.tsx
@@ -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(true);
+
+ useEffect(() => {
+ setLoading(false);
+ }, []);
+
+ return (
+
+ {loading ? (
+
+ ) : (
+
+
+
Preview not available
+
+ This file type is not supported for preview, try downloading the
+ file instead.
+
+
+ )}
+
+ );
+}
diff --git a/src/app/@preview.video.tsx b/src/app/@preview.video.tsx
new file mode 100644
index 0000000..ae5605a
--- /dev/null
+++ b/src/app/@preview.video.tsx
@@ -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;
+};
+export default function PreviewVideo({ file }: Props) {
+ const [videoSrc, setVideoSrc] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ 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 (
+
+ {loading ? (
+
+ ) : error ? (
+
+
+ {error}
+
+ ) : (
+
(
+
+ {children}
+
+ )}
+ 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");
+ }
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/app/[...rest]/page.tsx b/src/app/[...rest]/page.tsx
new file mode 100644
index 0000000..97baa60
--- /dev/null
+++ b/src/app/[...rest]/page.tsx
@@ -0,0 +1,216 @@
+import { Metadata, ResolvedMetadata } from "next";
+import { notFound } from "next/navigation";
+import { z } from "zod";
+import { Schema_File } from "~/schema";
+import { cn } from "~/utils";
+
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+import { Separator } from "~/components/ui/separator";
+
+import { decryptData } from "~/utils/encryptionHelper/hash";
+import gdrive from "~/utils/gdriveInstance";
+import { getFileType } from "~/utils/previewHelper";
+
+import FileBrowser from "../@explorer";
+import Header from "../@header";
+import HeaderButton from "../@header.button";
+import Markdown from "../@markdown";
+import Password from "../@password";
+import PreviewAction from "../@preview.action";
+import PreviewAudio from "../@preview.audio";
+import PreviewDoc from "../@preview.doc";
+import PreviewImage from "../@preview.image";
+import PreviewManga from "../@preview.manga";
+import PreviewRich from "../@preview.rich";
+import PreviewUnknown from "../@preview.unknown";
+import PreviewVideo from "../@preview.video";
+import {
+ CheckPassword,
+ CheckPaths,
+ GetBanner,
+ GetFile,
+ GetFiles,
+ GetReadme,
+} from "../actions";
+
+export const revalidate = 300;
+export const dynamic = "force-dynamic";
+
+type Props = {
+ params: {
+ rest: string[];
+ };
+};
+
+export async function generateMetadata(
+ { params: { rest } }: Props,
+ parent: ResolvedMetadata,
+): Promise {
+ 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 (
+
+ );
+ }
+
+ 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, string];
+ } else {
+ return values as [
+ { files: z.infer[]; nextPageToken?: string },
+ string,
+ ];
+ }
+ });
+ let fileType;
+ if (file.fileExtension && file.mimeType) {
+ fileType = getFileType(file.fileExtension, file.mimeType);
+ }
+ const isFile = !("files" in data);
+
+ return (
+
+ );
+}
diff --git a/src/app/actions.ts b/src/app/actions.ts
new file mode 100644
index 0000000..1dc7f91
--- /dev/null
+++ b/src/app/actions.ts
@@ -0,0 +1,701 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { cookies } from "next/headers";
+import { z } from "zod";
+import { Schema_File } from "~/schema";
+
+import { decryptData, encryptData } from "~/utils/encryptionHelper/hash";
+import gdrive from "~/utils/gdriveInstance";
+
+import { Constant } from "~/types/constant";
+
+import config from "~/config/gIndex.config";
+
+export async function CheckSitePassword(): Promise<{
+ success: boolean;
+ message?: string;
+}> {
+ try {
+ // Skip if the index is public
+ if (!config.siteConfig.privateIndex)
+ return {
+ success: true,
+ };
+ if (!process.env.SITE_PASSWORD)
+ throw new Error(
+ "Index password not set, please set the SITE_PASSWORD environment variable or disable privateIndex in the config file",
+ );
+
+ const store = cookies();
+ if (!store.has(Constant.cookies_SitePassword))
+ return {
+ success: false,
+ };
+ const password = store.get(Constant.cookies_SitePassword)?.value || "";
+ const decryptedPassword = await decryptData(password);
+ if (decryptedPassword !== process.env.SITE_PASSWORD)
+ throw new Error(
+ "Saved password is incorrect, please re-enter the password",
+ );
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ return {
+ success: false,
+ message: e.message,
+ };
+ }
+}
+export async function SetSitePassword(password: string): Promise<{
+ success: boolean;
+ message: string;
+}> {
+ try {
+ const store = cookies();
+ const encryptedPassword = await encryptData(password);
+ store.set(Constant.cookies_SitePassword, encryptedPassword, {
+ path: "/",
+ secure: true,
+ sameSite: "strict",
+ httpOnly: true,
+ expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
+ maxAge: 1000 * 60 * 60 * 24 * 365,
+ });
+ revalidatePath("/", "layout");
+
+ return {
+ success: true,
+ message: "Password set",
+ };
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ return {
+ success: false,
+ message: e.message,
+ };
+ }
+}
+
+export async function ClearPassword(): Promise<{
+ success: boolean;
+ message: string;
+}> {
+ try {
+ const store = cookies();
+ if (store.has(Constant.cookies_FolderPassword)) {
+ store.delete(Constant.cookies_FolderPassword);
+ }
+ if (store.has(Constant.cookies_SitePassword)) {
+ store.delete(Constant.cookies_SitePassword);
+ }
+ return {
+ success: true,
+ message: "Password cleared",
+ };
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ return {
+ success: false,
+ message: e.message,
+ };
+ }
+}
+
+export async function CheckPaths(paths: string[]): Promise<
+ | {
+ success: false;
+ message?: string;
+ }
+ | {
+ success: true;
+ data: { path: string; id: string }[];
+ message?: string;
+ }
+> {
+ try {
+ const promises = [];
+ for (const path of paths) {
+ promises.push(
+ gdrive.files
+ .list({
+ q: `name = '${decodeURIComponent(path)}' and trashed = false`,
+ fields: "files(id, name, mimeType, parents)",
+ supportsAllDrives: config.apiConfig.isTeamDrive,
+ includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
+ })
+ .then(({ data }) => {
+ if (!data.files?.length) return null;
+ return {
+ path,
+ data: data.files.map((file) => ({
+ id: file.id,
+ parents: file.parents?.[0],
+ mimeType: file.mimeType,
+ })),
+ };
+ }),
+ );
+ }
+
+ const data = await Promise.all(promises);
+ const notFoundIndex = data.findIndex((item) => !item);
+ if (notFoundIndex !== -1)
+ throw new Error(`Path not found: ${paths[notFoundIndex]}`);
+
+ // Check if each path is valid
+ let valid = true;
+ let invalidPath: string | undefined;
+ const decryptedRootId = await decryptData(config.apiConfig.rootFolder);
+ for (const [index, path] of data.entries()) {
+ if (!valid) break;
+ // if first path, check if it's in root
+ if (index === 0) {
+ if (path?.data[0].parents === decryptedRootId) break;
+ } else {
+ if (path?.data[0].parents === data[index - 1]?.data[0].id) break;
+ }
+ valid = false;
+ invalidPath = data[index]?.path;
+ }
+ if (!valid) throw new Error(`Invalid path: ${invalidPath}`);
+
+ const ids: { path: string; id: string }[] = [];
+ for (const item of data) {
+ if (item) {
+ const encryptedId = await encryptData(item.data[0].id as string);
+ ids.push({
+ path: decodeURIComponent(item.path),
+ id: encryptedId,
+ });
+ }
+ }
+ return {
+ success: true,
+ data: ids,
+ };
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ return {
+ success: false,
+ message: e.message,
+ };
+ }
+}
+export async function CheckPassword(
+ paths: { path: string; id: string }[],
+): Promise<
+ | {
+ success: false;
+ message?: string;
+ path?: string;
+ }
+ | {
+ success: true;
+ message?: string;
+ }
+> {
+ try {
+ const ids: string[] = [];
+ for (const path of paths) {
+ ids.push(await decryptData(path.id));
+ }
+ const query: string[] = [
+ `name = '${config.apiConfig.specialFile.password}'`,
+ `trashed = false`,
+ `(${ids.map((id) => `'${id}' in parents`).join(" or ")})`, // Filter by paths id
+ ];
+ const { data: password } = await gdrive.files.list({
+ q: query.join(" and "),
+ fields: "files(id, name, mimeType, parents)",
+ pageSize: 1000,
+ supportsAllDrives: config.apiConfig.isTeamDrive,
+ includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
+ });
+
+ // To save processing time, skip if password file not found
+ if (!password.files?.length) return { success: true };
+
+ const protectedFolder = password.files
+ .filter((file) => ids.includes(file.parents?.[0] as string))
+ ?.shift(); // Shift to get only the nearest folder
+
+ // To save processing time, skip if all of the paths are not protected
+ if (!protectedFolder) return { success: true };
+
+ const store = cookies();
+ const folderIndex = ids.findIndex(
+ (item) => item === protectedFolder.parents?.[0],
+ );
+
+ const cookiesValue = JSON.parse(
+ store.get(Constant.cookies_FolderPassword)?.value || "{}",
+ );
+ const currentFolder = paths[folderIndex];
+ if (!cookiesValue[currentFolder.id])
+ throw {
+ message: `Password for '${currentFolder.path}' is not set, please enter the password`,
+ path: currentFolder.id,
+ };
+
+ const savedPassword = await decryptData(cookiesValue[currentFolder.id]);
+ const { data: passwordFile } = await gdrive.files.get(
+ {
+ fileId: protectedFolder.id as string,
+ alt: "media",
+ },
+ {
+ responseType: "text",
+ },
+ );
+ if (savedPassword !== passwordFile)
+ throw {
+ message: `Password for '${currentFolder.path}' is incorrect, please re-enter the password`,
+ path: currentFolder.id,
+ };
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ if (error instanceof Error) {
+ const e = error as Error;
+ console.error(e.message);
+ return {
+ success: false,
+ message: e.message,
+ };
+ } else {
+ const e = error as { message: string; path: string };
+ console.error(e.message);
+ return {
+ success: false,
+ message: e.message,
+ path: e.path,
+ };
+ }
+ }
+}
+export async function SetPassword(
+ path: string,
+ password: string,
+): Promise<{
+ success: boolean;
+ message: string;
+}> {
+ try {
+ const store = cookies();
+ const cookiesValue = JSON.parse(
+ store.get(Constant.cookies_FolderPassword)?.value || "{}",
+ );
+ const updatedValue = {
+ ...cookiesValue,
+ [path]: await encryptData(password),
+ };
+ store.set(Constant.cookies_FolderPassword, JSON.stringify(updatedValue), {
+ path: "/",
+ secure: true,
+ sameSite: "strict",
+ httpOnly: true,
+ expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
+ maxAge: 1000 * 60 * 60 * 24 * 365,
+ });
+ revalidatePath("/", "layout");
+
+ return {
+ success: true,
+ message: "Password set",
+ };
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ return {
+ success: false,
+ message: e.message,
+ };
+ }
+}
+
+export async function GetFiles({
+ id,
+ pageToken,
+}: {
+ id?: string;
+ pageToken?: string;
+}): Promise<{
+ files: z.infer[];
+ nextPageToken?: string;
+}> {
+ try {
+ let decryptedId: string | undefined;
+ if (id) decryptedId = await decryptData(id);
+ else decryptedId = await decryptData(config.apiConfig.rootFolder);
+
+ const filterName = config.apiConfig.hiddenFiles
+ .map((item) => `not name = '${item}'`)
+ .join(" and ");
+ const query: string = [
+ ...config.apiConfig.defaultQuery,
+ `'${decryptedId}' in parents`,
+ `${filterName}`,
+ ].join(" and ");
+
+ const data = await gdrive.files.list({
+ q: query,
+ fields: `files(${config.apiConfig.defaultField}), nextPageToken`,
+ orderBy: config.apiConfig.defaultOrder,
+ pageSize: config.apiConfig.itemsPerPage,
+ pageToken: pageToken,
+ supportsAllDrives: config.apiConfig.isTeamDrive,
+ includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
+ });
+
+ const encryptedData: z.infer[] = [];
+ if (!data.data.files?.length)
+ return { files: [], nextPageToken: undefined };
+ for (const file of data.data.files) {
+ encryptedData.push({
+ mimeType: file.mimeType as string,
+ encryptedId: await encryptData(file.id as string),
+ name: file.name as string,
+ trashed: (file.trashed as boolean) ?? false,
+ modifiedTime: new Date(
+ file.modifiedTime as string,
+ ).toLocaleDateString(),
+ fileExtension: file.fileExtension || undefined,
+ encryptedWebContentLink: file.webContentLink
+ ? await encryptData(file.webContentLink)
+ : undefined,
+ size: file.size ? Number(file.size) : undefined,
+ thumbnailLink: file.thumbnailLink || undefined,
+ imageMediaMetadata: file.imageMediaMetadata
+ ? {
+ width: Number(file.imageMediaMetadata.width),
+ height: Number(file.imageMediaMetadata.height),
+ rotation: Number(file.imageMediaMetadata.rotation || 0),
+ }
+ : undefined,
+ videoMediaMetadata: file.videoMediaMetadata
+ ? {
+ width: Number(file.videoMediaMetadata.width),
+ height: Number(file.videoMediaMetadata.height),
+ durationMillis: Number(file.videoMediaMetadata.durationMillis),
+ }
+ : undefined,
+ });
+ }
+ const parsedContent = Schema_File.array().parse(encryptedData);
+
+ return {
+ files: parsedContent,
+ nextPageToken: data.data.nextPageToken ?? undefined,
+ };
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ throw new Error(e.message);
+ }
+}
+export async function GetFile(
+ encryptedId: string,
+): Promise> {
+ 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 = {
+ 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[];
+ 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[] = [];
+ 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 {
+ 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 {
+ try {
+ const decryptedId = await decryptData(encryptedId);
+ const { data: file } = await gdrive.files.get(
+ {
+ fileId: decryptedId,
+ alt: "media",
+ supportsAllDrives: config.apiConfig.isTeamDrive,
+ },
+ {
+ responseType: "text",
+ },
+ );
+
+ if (typeof file !== "string")
+ throw new Error("It seems the file is not a text file");
+
+ return file as string;
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ throw new Error(e.message);
+ }
+}
+export async function GetReadme(
+ encryptedId: string | undefined,
+): Promise {
+ try {
+ let decryptedId;
+ if (encryptedId) decryptedId = await decryptData(encryptedId);
+ else decryptedId = await decryptData(config.apiConfig.rootFolder);
+
+ const query: string[] = [
+ ...config.apiConfig.defaultQuery,
+ `name = '${config.apiConfig.specialFile.readme}'`,
+ `'${decryptedId}' in parents`,
+ ];
+ const { data } = await gdrive.files.list({
+ q: query.join(" and "),
+ fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
+ orderBy: config.apiConfig.defaultOrder,
+ pageSize: config.apiConfig.itemsPerPage,
+ pageToken: undefined,
+ supportsAllDrives: config.apiConfig.isTeamDrive,
+ includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
+ });
+ if (!data.files?.length) return null;
+
+ const { data: content } = await gdrive.files.get(
+ {
+ fileId: data.files[0].id as string,
+ alt: "media",
+ },
+ {
+ responseType: "text",
+ },
+ );
+
+ return content as string;
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ throw new Error(e.message);
+ }
+}
+export async function GetBanner(
+ encryptedId: string | undefined,
+): Promise {
+ 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 {
+ try {
+ return await decryptData(encrypted);
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ throw new Error(e.message);
+ }
+}
+export async function CreateDownloadToken(
+ duration: number = config.apiConfig.temporaryTokenDuration,
+): Promise {
+ try {
+ const data = {
+ expiredAt:
+ Date.now() +
+ duration * 60 * 60 * 1000 * config.apiConfig.temporaryTokenDuration,
+ };
+ const token = await encryptData(JSON.stringify(data));
+ return token;
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ throw new Error(e.message);
+ }
+}
+export async function CheckDownloadToken(token: string): Promise<{
+ success: boolean;
+ message?: string;
+}> {
+ try {
+ const decryptToken = JSON.parse(await decryptData(token));
+ if (decryptToken.expiredAt < Date.now()) {
+ return {
+ success: false,
+ message:
+ "Download token expired, please click the download button again to get a new token",
+ };
+ }
+
+ return {
+ success: true,
+ };
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ return {
+ success: false,
+ message: e.message,
+ };
+ }
+}
diff --git a/src/app/api/download/[encryptedId]/[fileName]/route.ts b/src/app/api/download/[encryptedId]/[fileName]/route.ts
deleted file mode 100644
index f64bd9e..0000000
--- a/src/app/api/download/[encryptedId]/[fileName]/route.ts
+++ /dev/null
@@ -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((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,
- });
- }
-}
diff --git a/src/app/api/download/[encryptedId]/route.ts b/src/app/api/download/[encryptedId]/route.ts
new file mode 100644
index 0000000..bba8c4e
--- /dev/null
+++ b/src/app/api/download/[encryptedId]/route.ts
@@ -0,0 +1,164 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ CheckDownloadToken,
+ CheckPassword,
+ CheckPaths,
+ CheckSitePassword,
+ RedirectSearchFile,
+} from "~/app/actions";
+
+import { decryptData } from "~/utils/encryptionHelper/hash";
+import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
+
+import config from "~/config/gIndex.config";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ request: NextRequest,
+ {
+ params: { encryptedId },
+ }: {
+ params: {
+ encryptedId: string;
+ };
+ },
+) {
+ try {
+ const sp = new URL(request.nextUrl).searchParams;
+ const token = sp.get("token");
+ if (!token) throw new Error("Token not found");
+
+ const tokenValidity = await CheckDownloadToken(token);
+ if (!tokenValidity.success) throw new Error(tokenValidity.message);
+
+ if (config.siteConfig.privateIndex) {
+ const unlocked = await CheckSitePassword();
+ if (!unlocked.success) {
+ return new NextResponse(
+ `It seems like this site is protected by password, and you haven't entered the password yet.
+
+If you've already entered the password, please make sure your browser is not blocking cookies from this site.`,
+ {
+ status: 401,
+ },
+ );
+ }
+ }
+
+ const decryptedId = await decryptData(encryptedId);
+ const _filePaths = RedirectSearchFile(encryptedId);
+ const _fileMeta = gdrive.files.get({
+ fileId: decryptedId,
+ fields: "id, name, mimeType, fileExtension, webContentLink",
+ supportsAllDrives: config.apiConfig.isTeamDrive,
+ });
+ const _fileContent = gdrive.files.get(
+ {
+ fileId: decryptedId,
+ alt: "media",
+ supportsAllDrives: config.apiConfig.isTeamDrive,
+ },
+ {
+ responseType: "stream",
+ },
+ );
+
+ const [fileMeta, fileContent, filePaths] = await Promise.all([
+ _fileMeta,
+ _fileContent,
+ _filePaths,
+ ]);
+ if (!config.apiConfig.allowDownloadProtectedFile) {
+ const checkPath = await CheckPaths(filePaths.split("/"));
+ if (!checkPath.success) throw new Error("File not found");
+ const unlocked = await CheckPassword(checkPath.data);
+ if (!unlocked.success) {
+ if (!unlocked.path)
+ throw new Error("No path returned from password checking");
+
+ const lockedIndex = checkPath.data.findIndex(
+ (path) => path.id === unlocked.path,
+ );
+ // Get all path until the locked index, then join them
+ const path = checkPath.data
+ .slice(0, lockedIndex + 1)
+ .map((path) => path.path)
+ .join("/");
+ return new NextResponse(
+ `The file you're trying to access is protected by password.
+Please open the file link and enter the password to access the file, then try to download the file again.
+
+Protected Path: ${new URL(path, config.basePath).toString()}
+
+If you've already entered the password, please make sure your browser is not blocking cookies from this site.`,
+ {
+ status: 401,
+ },
+ );
+ }
+ }
+
+ const fileSize = Number(fileMeta.data.size || 0);
+ if (!fileMeta.data.webContentLink)
+ throw new Error("No download link found");
+
+ if (
+ config.apiConfig.maxFileSize &&
+ fileSize > config.apiConfig.maxFileSize
+ ) {
+ return NextResponse.redirect(fileMeta.data.webContentLink, {
+ status: 302,
+ headers: {
+ ...request.headers,
+ "Cache-Control": config.cacheControl,
+ },
+ });
+ }
+
+ const fileBuffer = await new Promise((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,
+ });
+ }
+}
diff --git a/src/app/api/getData/route.ts b/src/app/api/getData/route.ts
deleted file mode 100644
index 004ae48..0000000
--- a/src/app/api/getData/route.ts
+++ /dev/null
@@ -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,
- });
- }
-}
diff --git a/src/app/api/getPassword/route.ts b/src/app/api/getPassword/route.ts
deleted file mode 100644
index d3a1afa..0000000
--- a/src/app/api/getPassword/route.ts
+++ /dev/null
@@ -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,
- });
- }
-}
diff --git a/src/app/api/getReadme/route.ts b/src/app/api/getReadme/route.ts
deleted file mode 100644
index cb4b52e..0000000
--- a/src/app/api/getReadme/route.ts
+++ /dev/null
@@ -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,
- });
- }
-}
diff --git a/src/app/api/internal/encrypt/route.ts b/src/app/api/internal/encrypt/route.ts
new file mode 100644
index 0000000..c62f22e
--- /dev/null
+++ b/src/app/api/internal/encrypt/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { encryptData } from "~/utils/encryptionHelper/hash";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest) {
+ try {
+ const sp = new URL(request.nextUrl).searchParams;
+ const query = sp.get("q");
+ if (!query)
+ return new NextResponse(
+ "Add query parameter 'q' with the value to encrypt",
+ { status: 400 },
+ );
+
+ const encrypted = await encryptData(query);
+
+ return NextResponse.json(
+ {
+ value: encrypted,
+ },
+ { status: 200 },
+ );
+ } catch (error) {
+ const e = error as Error;
+ console.error(e);
+ return new Response(e.message, { status: 500 });
+ }
+}
diff --git a/src/app/api/og/[encryptedId]/route.ts b/src/app/api/og/[encryptedId]/route.ts
new file mode 100644
index 0000000..a569935
--- /dev/null
+++ b/src/app/api/og/[encryptedId]/route.ts
@@ -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,
+ },
+ );
+ }
+}
diff --git a/src/app/api/raw/[...rest]/route.ts b/src/app/api/raw/[...rest]/route.ts
new file mode 100644
index 0000000..832c865
--- /dev/null
+++ b/src/app/api/raw/[...rest]/route.ts
@@ -0,0 +1,65 @@
+import { NextRequest, NextResponse } from "next/server";
+import { CheckPassword, CheckPaths, GetFile } from "~/app/actions";
+
+import { decryptData } from "~/utils/encryptionHelper/hash";
+
+import config from "~/config/gIndex.config";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ request: NextRequest,
+ { params: { rest } }: { params: { rest: string[] } },
+) {
+ try {
+ const sp = new URL(request.nextUrl).searchParams;
+ const token = sp.get("token");
+ if (!token) throw new Error("Token not found");
+
+ const paths = await CheckPaths(rest);
+ if (!paths.success) throw new Error(paths.message);
+
+ if (!config.apiConfig.allowDownloadProtectedFile) {
+ const unlocked = await CheckPassword(paths.data);
+ if (!unlocked.success)
+ throw new Error(
+ unlocked.path
+ ? unlocked.message
+ : "No path returned from password checking",
+ );
+ }
+
+ const encryptedId = paths.data.pop()?.id;
+ if (!encryptedId)
+ throw new Error("Failed to get encrypted ID, try to refresh the page.");
+ if (token !== encryptedId) throw new Error("Invalid token");
+
+ const data = await GetFile(encryptedId);
+ if (data.mimeType?.includes("folder"))
+ throw new Error("Can't download folder");
+ if (
+ !data.mimeType.includes("video") &&
+ !data.mimeType.includes("image") &&
+ !data.mimeType.includes("audio")
+ )
+ throw new Error(
+ "Raw link only available for video, image, and audio files",
+ );
+ if (!data.encryptedWebContentLink)
+ throw new Error("No download link found");
+
+ const decryptedWebContent = await decryptData(data.encryptedWebContentLink);
+ return new NextResponse(null, {
+ status: 302,
+ headers: {
+ Location: decryptedWebContent,
+ },
+ });
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ return new NextResponse(e.message, {
+ status: 500,
+ });
+ }
+}
diff --git a/src/app/api/search/redirect/[encryptedId]/route.ts b/src/app/api/search/redirect/[encryptedId]/route.ts
deleted file mode 100644
index 40bc12d..0000000
--- a/src/app/api/search/redirect/[encryptedId]/route.ts
+++ /dev/null
@@ -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,
- });
- }
-}
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
deleted file mode 100644
index 2b448ba..0000000
--- a/src/app/api/search/route.ts
+++ /dev/null
@@ -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,
- });
- }
-}
diff --git a/src/app/api/thumb/[encryptedId]/route.ts b/src/app/api/thumb/[encryptedId]/route.ts
new file mode 100644
index 0000000..ed0cd05
--- /dev/null
+++ b/src/app/api/thumb/[encryptedId]/route.ts
@@ -0,0 +1,118 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { decryptData } from "~/utils/encryptionHelper/hash";
+import gdrive from "~/utils/gdriveInstance";
+
+import config from "~/config/gIndex.config";
+
+type Props = {
+ params: {
+ encryptedId: string;
+ };
+};
+
+export async function GET(
+ request: NextRequest,
+ { params: { encryptedId } }: Props,
+) {
+ try {
+ const defaultImage = NextResponse.redirect(
+ new URL("/og.png", config.basePath),
+ {
+ status: 302,
+ },
+ );
+ const decryptedId = await decryptData(encryptedId);
+ const _fileMeta = gdrive.files.get({
+ fileId: decryptedId,
+ fields:
+ "id, name, mimeType, fileExtension, webContentLink, thumbnailLink",
+ supportsAllDrives: config.apiConfig.isTeamDrive,
+ });
+ const _fileContent = gdrive.files.get(
+ {
+ fileId: decryptedId,
+ alt: "media",
+ supportsAllDrives: config.apiConfig.isTeamDrive,
+ },
+ {
+ responseType: "stream",
+ },
+ );
+
+ const [fileMeta, fileContent] = await Promise.all([
+ _fileMeta,
+ _fileContent,
+ ]);
+ const fileSize = Number(fileMeta.data.size || 0);
+
+ if (!fileMeta.data.webContentLink) return defaultImage;
+ if (!fileMeta.data.thumbnailLink) return defaultImage;
+ if (
+ !fileMeta.data.mimeType?.startsWith("image") &&
+ !fileMeta.data.mimeType?.startsWith("video")
+ )
+ return defaultImage;
+
+ // If svg, return actual image since there is no thumbnail for svg
+ if (
+ fileMeta.data.mimeType?.includes("svg") &&
+ fileSize <= config.apiConfig.maxFileSize
+ ) {
+ const fileBuffer = await new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ fileContent.data.on("data", (chunk) => {
+ chunks.push(chunk);
+ });
+ fileContent.data.on("end", () => {
+ resolve(Buffer.concat(chunks));
+ });
+ fileContent.data.on("error", (err) => {
+ reject(err);
+ });
+ });
+
+ return new NextResponse(fileBuffer, {
+ headers: {
+ "Cache-Control": "public, max-age=31536000, immutable",
+ "Content-Type": fileMeta.data.mimeType || "application/octet-stream",
+ "Content-Length": fileBuffer.length.toString(),
+ "Content-Disposition": `attachment; filename="${encodeURIComponent(
+ fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
+ )}"`,
+ },
+ });
+ }
+
+ if (config.apiConfig.proxyThumbnail) {
+ const downloadThumb = await fetch(fileMeta.data.thumbnailLink, {
+ cache: "force-cache",
+ });
+ const buffer = await downloadThumb.arrayBuffer();
+
+ return new NextResponse(buffer, {
+ headers: {
+ "Cache-Control": "public, max-age=31536000, immutable",
+ "Content-Type": fileMeta.data.mimeType || "application/octet-stream",
+ "Content-Length": buffer.byteLength.toString(),
+ "Content-Disposition": `attachment; filename="${encodeURIComponent(
+ fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
+ )}"`,
+ },
+ });
+ }
+
+ return NextResponse.redirect(fileMeta.data.thumbnailLink);
+ } catch (error) {
+ const e = error as Error;
+ console.error(e.message);
+ return NextResponse.json(
+ {
+ error: e.message,
+ },
+ {
+ status: 500,
+ },
+ );
+ }
+}
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..f35a1dc
--- /dev/null
+++ b/src/app/error.tsx
@@ -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 (
+
+
+
+
+ Something went wrong
+
+
+ More details can be found in the console
+
+
+
+ {error.message}
+
+
+
+ reset()}
+ >
+ Try again
+
+ router.back()}
+ >
+ Back to previous page
+
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..c528669
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,117 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Shadcn/ui theme */
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 0 0% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 0 0% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 0 0% 3.9%;
+ --primary: 0 0% 9%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 89.8%;
+ /* --ring: 0 0% 3.9%; */
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 0 0% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 14.9%;
+ /* --ring: 0 0% 83.1%; */
+ }
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+@layer base {
+ :root {
+ @apply text-[14px] tablet:text-[16px];
+ }
+ * {
+ @apply border-border;
+ /* @apply outline outline-1 outline-red-500; */
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+
+ ::-webkit-scrollbar {
+ @apply h-1.5 w-1.5;
+ }
+ ::-webkit-scrollbar-track {
+ @apply bg-background;
+ }
+ ::-webkit-scrollbar-thumb {
+ @apply rounded-full bg-primary/25 hover:bg-primary/50;
+ }
+
+ /* Typography */
+ h1 {
+ @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
+ }
+ h2 {
+ @apply scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0;
+ }
+ h3 {
+ @apply scroll-m-20 text-2xl font-semibold tracking-tight;
+ }
+ h4 {
+ @apply scroll-m-20 text-xl font-semibold tracking-tight;
+ }
+ .paragraph {
+ @apply leading-7 [&:not(:first-child)]:mt-6;
+ }
+ blockquote {
+ @apply mt-6 border-l-2 pl-6 italic;
+ }
+ ul {
+ @apply my-6 ml-6 list-disc [&>li]:mt-2;
+ }
+ .lead {
+ @apply text-xl text-muted-foreground;
+ }
+ .large {
+ @apply text-lg font-semibold;
+ }
+ .muted {
+ @apply text-sm text-muted-foreground;
+ }
+ small {
+ @apply text-sm font-medium leading-none;
+ }
+}
diff --git a/src/app/highlight.css b/src/app/highlight.css
new file mode 100644
index 0000000..cf8359e
--- /dev/null
+++ b/src/app/highlight.css
@@ -0,0 +1,472 @@
+:root {
+ /**
+ * One Light theme for prism.js
+ * Based on Atom's One Light theme: https://github.com/atom/atom/tree/master/packages/one-light-syntax
+ */
+ /* From colors.less */
+ --mono-1: hsl(230, 8%, 24%);
+ --mono-2: hsl(230, 6%, 44%);
+ --mono-3: hsl(230, 4%, 64%);
+ --hue-1: hsl(198, 99%, 37%);
+ --hue-2: hsl(221, 87%, 60%);
+ --hue-3: hsl(301, 63%, 40%);
+ --hue-4: hsl(119, 34%, 47%);
+ --hue-5: hsl(5, 74%, 59%);
+ --hue-5-2: hsl(344, 84%, 43%);
+ --hue-6: hsl(35, 99%, 36%);
+ --hue-6-2: hsl(35, 99%, 40%);
+ --syntax-fg: hsl(230, 8%, 24%);
+ --syntax-bg: hsl(230, 1%, 98%);
+ --syntax-gutter: hsl(230, 1%, 62%);
+ --syntax-guide: hsla(230, 8%, 24%, 0.2);
+ --syntax-accent: hsl(230, 100%, 66%);
+ /* From syntax-variables.less */
+ --syntax-selection-color: hsl(230, 1%, 90%);
+ --syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
+ --syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
+}
+
+.dark {
+ /**
+ * One Dark theme for prism.js
+ * Based on Atom's One Dark theme: https://github.com/atom/atom/tree/master/packages/one-dark-syntax
+ */
+ /* From colors.less */
+ --mono-1: hsl(220, 14%, 71%);
+ --mono-2: hsl(220, 9%, 55%);
+ --mono-3: hsl(220, 10%, 40%);
+ --hue-1: hsl(187, 47%, 55%);
+ --hue-2: hsl(207, 82%, 66%);
+ --hue-3: hsl(286, 60%, 67%);
+ --hue-4: hsl(95, 38%, 62%);
+ --hue-5: hsl(355, 65%, 65%);
+ --hue-5-2: hsl(5, 48%, 51%);
+ --hue-6: hsl(29, 54%, 61%);
+ --hue-6-2: hsl(39, 67%, 69%);
+ --syntax-fg: hsl(220, 14%, 71%);
+ --syntax-bg: hsl(220, 13%, 18%);
+ --syntax-gutter: hsl(220, 14%, 45%);
+ --syntax-guide: hsla(220, 14%, 71%, 0.15);
+ --syntax-accent: hsl(220, 100%, 66%);
+ /* From syntax-variables.less */
+ --syntax-selection-color: hsl(220, 13%, 28%);
+ --syntax-gutter-background-color-selected: hsl(220, 13%, 26%);
+ --syntax-cursor-line: hsla(220, 100%, 80%, 0.04);
+}
+
+code[class*="language-"],
+pre[class*="language-"],
+pre {
+ background: var(--syntax-bg);
+ color: var(--syntax-fg);
+ text-shadow: 0 1px rgba(0, 0, 0, 0);
+ font-size: inherit;
+ direction: ltr;
+ text-align: left;
+ white-space: pre;
+ word-spacing: normal;
+ word-break: normal;
+ line-height: 1.5;
+ -moz-tab-size: 2;
+ tab-size: 2;
+ -webkit-hyphens: none;
+ -moz-hyphens: none;
+ -ms-hyphens: none;
+ hyphens: none;
+}
+
+/* Selection */
+code[class*="language-"]::-moz-selection,
+code[class*="language-"] *::-moz-selection,
+pre[class*="language-"] *::-moz-selection {
+ background: var(--syntax-selection-color);
+ color: inherit;
+ text-shadow: none;
+}
+
+code[class*="language-"]::selection,
+code[class*="language-"] *::selection,
+pre[class*="language-"] *::selection {
+ background: var(--syntax-selection-color);
+ color: inherit;
+ text-shadow: none;
+}
+
+/* Code blocks */
+pre[class*="language-"],
+pre {
+ padding: 1em;
+ margin: 0.5em 0;
+ overflow: auto;
+ border-radius: 0.3em;
+}
+
+/* Inline code */
+:not(pre) > code[class*="language-"] {
+ padding: 0.2em 0.3em;
+ border-radius: 0.3em;
+ white-space: normal;
+}
+
+/* Print */
+@media print {
+ code[class*="language-"],
+ pre[class*="language-"] {
+ text-shadow: none;
+ }
+}
+
+.token.comment,
+.token.prolog,
+.token.cdata {
+ color: var(--mono-3);
+}
+
+.token.doctype,
+.token.punctuation,
+.token.entity {
+ color: var(--syntax-fg);
+}
+
+.token.attr-name,
+.token.class-name,
+.token.boolean,
+.token.constant,
+.token.number,
+.token.atrule {
+ color: var(--hue-6);
+}
+
+.token.keyword {
+ color: var(--hue-3);
+}
+
+.token.property,
+.token.tag,
+.token.symbol,
+.token.deleted,
+.token.important {
+ color: var(--hue-5);
+}
+
+.token.selector,
+.token.string,
+.token.char,
+.token.builtin,
+.token.inserted,
+.token.regex,
+.token.attr-value,
+.token.attr-value > .token.punctuation {
+ color: var(--hue-4);
+}
+
+.token.variable,
+.token.operator,
+.token.function {
+ color: var(--hue-2);
+}
+
+.token.url {
+ color: var(--hue-1);
+}
+
+/* HTML overrides */
+.token.attr-value > .token.punctuation.attr-equals,
+.token.special-attr > .token.attr-value > .token.value.css {
+ color: var(--syntax-fg);
+}
+
+/* CSS overrides */
+.language-css .token.selector {
+ color: var(--hue-5);
+}
+
+.language-css .token.property {
+ color: var(--syntax-fg);
+}
+
+.language-css .token.function,
+.language-css .token.url > .token.function {
+ color: var(--hue-1);
+}
+
+.language-css .token.url > .token.string.url {
+ color: var(--hue-4);
+}
+
+.language-css .token.important,
+.language-css .token.atrule .token.rule {
+ color: var(--hue-3);
+}
+
+/* JS overrides */
+.language-javascript .token.operator {
+ color: var(--hue-3);
+}
+
+.language-javascript
+ .token.template-string
+ > .token.interpolation
+ > .token.interpolation-punctuation.punctuation {
+ color: var(--hue-5-2);
+}
+
+/* JSON overrides */
+.language-json .token.operator {
+ color: var(--syntax-fg);
+}
+
+.language-json .token.null.keyword {
+ color: var(--hue-6);
+}
+
+/* MD overrides */
+.language-markdown .token.url,
+.language-markdown .token.url > .token.operator,
+.language-markdown .token.url-reference.url > .token.string {
+ color: var(--syntax-fg);
+}
+
+.language-markdown .token.url > .token.content {
+ color: var(--hue-2);
+}
+
+.language-markdown .token.url > .token.url,
+.language-markdown .token.url-reference.url {
+ color: var(--hue-1);
+}
+
+.language-markdown .token.blockquote.punctuation,
+.language-markdown .token.hr.punctuation {
+ color: var(--mono-3);
+ font-style: italic;
+}
+
+.language-markdown .token.code-snippet {
+ color: var(--hue-4);
+}
+
+.language-markdown .token.bold .token.content {
+ color: var(--hue-6);
+}
+
+.language-markdown .token.italic .token.content {
+ color: var(--hue-3);
+}
+
+.language-markdown .token.strike .token.content,
+.language-markdown .token.strike .token.punctuation,
+.language-markdown .token.list.punctuation,
+.language-markdown .token.title.important > .token.punctuation {
+ color: var(--hue-5);
+}
+
+/* General */
+.token.bold {
+ font-weight: bold;
+}
+
+.token.comment,
+.token.italic {
+ font-style: italic;
+}
+
+.token.entity {
+ cursor: help;
+}
+
+.token.namespace {
+ opacity: 0.8;
+}
+
+/* Plugin overrides */
+/* Selectors should have higher specificity than those in the plugins' default stylesheets */
+
+/* Show Invisible plugin overrides */
+.token.token.tab:not(:empty):before,
+.token.token.cr:before,
+.token.token.lf:before,
+.token.token.space:before {
+ color: var(--syntax-guide);
+ text-shadow: none;
+}
+
+/* Toolbar plugin overrides */
+/* Space out all buttons and move them away from the right edge of the code block */
+div.code-toolbar > .toolbar.toolbar > .toolbar-item {
+ margin-right: 0.4em;
+}
+
+/* Styling the buttons */
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
+ background: var(--syntax-gutter-background-color-selected);
+ color: var(--mono-2);
+ padding: 0.1em 0.4em;
+ border-radius: 0.3em;
+}
+
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
+ background: var(--syntax-selection-color);
+ color: var(--syntax-fg);
+}
+
+/* Line Highlight plugin overrides */
+/* The highlighted line itself */
+.line-highlight.line-highlight {
+ background: var(--syntax-cursor-line);
+}
+
+/* Default line numbers in Line Highlight plugin */
+.line-highlight.line-highlight:before,
+.line-highlight.line-highlight[data-end]:after {
+ background: var(--syntax-gutter-background-color-selected);
+ color: var(--syntax-fg);
+ padding: 0.1em 0.6em;
+ border-radius: 0.3em;
+ box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */
+}
+
+/* Hovering over a linkable line number (in the gutter area) */
+/* Requires Line Numbers plugin as well */
+pre[id].linkable-line-numbers.linkable-line-numbers
+ span.line-numbers-rows
+ > span:hover:before {
+ background-color: var(--syntax-cursor-line);
+}
+
+/* Line Numbers and Command Line plugins overrides */
+/* Line separating gutter from coding area */
+.line-numbers.line-numbers .line-numbers-rows,
+.command-line .command-line-prompt {
+ border-right-color: var(--syntax-guide);
+}
+
+/* Stuff in the gutter */
+.line-numbers .line-numbers-rows > span:before,
+.command-line .command-line-prompt > span:before {
+ color: var(--syntax-gutter);
+}
+
+/* Match Braces plugin overrides */
+/* Note: Outline colour is inherited from the braces */
+.rainbow-braces .token.token.punctuation.brace-level-1,
+.rainbow-braces .token.token.punctuation.brace-level-5,
+.rainbow-braces .token.token.punctuation.brace-level-9 {
+ color: var(--hue-5);
+}
+
+.rainbow-braces .token.token.punctuation.brace-level-2,
+.rainbow-braces .token.token.punctuation.brace-level-6,
+.rainbow-braces .token.token.punctuation.brace-level-10 {
+ color: var(--hue-4);
+}
+
+.rainbow-braces .token.token.punctuation.brace-level-3,
+.rainbow-braces .token.token.punctuation.brace-level-7,
+.rainbow-braces .token.token.punctuation.brace-level-11 {
+ color: var(--hue-2);
+}
+
+.rainbow-braces .token.token.punctuation.brace-level-4,
+.rainbow-braces .token.token.punctuation.brace-level-8,
+.rainbow-braces .token.token.punctuation.brace-level-12 {
+ color: var(--hue-3);
+}
+
+/* Diff Highlight plugin overrides */
+/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
+pre.diff-highlight > code .token.token.deleted:not(.prefix),
+pre > code.diff-highlight .token.token.deleted:not(.prefix) {
+ background-color: hsla(353, 100%, 66%, 0.15);
+}
+
+pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
+pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection,
+pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
+pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection {
+ background-color: hsla(353, 95%, 66%, 0.25);
+}
+
+pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
+pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
+pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
+pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
+ background-color: hsla(353, 95%, 66%, 0.25);
+}
+
+pre.diff-highlight > code .token.token.inserted:not(.prefix),
+pre > code.diff-highlight .token.token.inserted:not(.prefix) {
+ background-color: hsla(137, 100%, 55%, 0.15);
+}
+
+pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection,
+pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection,
+pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection,
+pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection {
+ background-color: hsla(135, 73%, 55%, 0.25);
+}
+
+pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
+pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
+pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
+pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
+ background-color: hsla(135, 73%, 55%, 0.25);
+}
+
+/* Previewers plugin overrides */
+/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */
+/* Border around popup */
+.prism-previewer.prism-previewer:before,
+.prism-previewer-gradient.prism-previewer-gradient div {
+ border-color: hsl(224, 13%, 17%);
+}
+
+/* Angle and time should remain as circles and are hence not included */
+.prism-previewer-color.prism-previewer-color:before,
+.prism-previewer-gradient.prism-previewer-gradient div,
+.prism-previewer-easing.prism-previewer-easing:before {
+ border-radius: 0.3em;
+}
+
+/* Triangles pointing to the code */
+.prism-previewer.prism-previewer:after {
+ border-top-color: hsl(224, 13%, 17%);
+}
+
+.prism-previewer-flipped.prism-previewer-flipped.after {
+ border-bottom-color: hsl(224, 13%, 17%);
+}
+
+/* Background colour within the popup */
+.prism-previewer-angle.prism-previewer-angle:before,
+.prism-previewer-time.prism-previewer-time:before,
+.prism-previewer-easing.prism-previewer-easing {
+ background: hsl(219, 13%, 22%);
+}
+
+/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */
+/* For time, this is the alternate colour */
+.prism-previewer-angle.prism-previewer-angle circle,
+.prism-previewer-time.prism-previewer-time circle {
+ stroke: var(--syntax-fg);
+ stroke-opacity: 1;
+}
+
+/* Stroke colours of the handle, direction point, and vector itself */
+.prism-previewer-easing.prism-previewer-easing circle,
+.prism-previewer-easing.prism-previewer-easing path,
+.prism-previewer-easing.prism-previewer-easing line {
+ stroke: var(--syntax-fg);
+}
+
+/* Fill colour of the handle */
+.prism-previewer-easing.prism-previewer-easing circle {
+ fill: transparent;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..8be26be
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,131 @@
+import { Metadata } from "next";
+import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
+import { cn } from "~/utils";
+
+import config from "~/config/gIndex.config";
+
+import Footer from "./@footer";
+import Navbar from "./@navbar";
+import Password from "./@password";
+import { CheckSitePassword } from "./actions";
+import "./globals.css";
+import "./markdown.css";
+import ThemeProvider from "./theme-provider";
+
+const sourceSans3 = Source_Sans_3({
+ weight: ["300", "400", "600", "700"],
+ style: ["normal", "italic"],
+ display: "auto",
+ subsets: ["latin", "latin-ext"],
+ variable: "--font-source-sans-3",
+});
+const jetbrainsMono = JetBrains_Mono({
+ weight: ["300", "400", "600", "700"],
+ style: ["normal", "italic"],
+ display: "auto",
+ subsets: ["latin", "latin-ext"],
+ variable: "--font-jetbrains-mono",
+});
+
+export const metadata: Metadata = {
+ metadataBase: new URL(config.basePath),
+ title: {
+ default: config.siteConfig.siteName,
+ template: config.siteConfig.siteNameTemplate || "%s",
+ },
+ description: config.siteConfig.siteDescription,
+ authors: config.siteConfig.siteAuthor
+ ? {
+ name: config.siteConfig.siteAuthor,
+ }
+ : undefined,
+ creator: "mbaharip",
+ icons: [
+ {
+ url: config.siteConfig.favIcon,
+ },
+ ],
+ keywords: ["gdrive", "index", "nextjs", "reactjs"],
+ openGraph: {
+ type: "website",
+ siteName: config.siteConfig.siteName,
+ images: [
+ {
+ url: "/og.png",
+ width: 1200,
+ height: 630,
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ creator: config.siteConfig.twitterHandle,
+ },
+ robots: config.siteConfig.robots,
+};
+
+export default async function RootLayout({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ const unlocked = await CheckSitePassword();
+ const formatFooter = (text: string | string[]): string => {
+ let toFormat: string;
+ if (Array.isArray(text)) {
+ toFormat = text.join(`\n\n`);
+ } else {
+ toFormat = text;
+ }
+ return toFormat
+ .replaceAll("{{ year }}", new Date().getFullYear().toString())
+ .replaceAll(
+ "{{ repository }}",
+ "[Repository](https://github.com/mbaharip/next-gdrive-index)",
+ )
+ .replaceAll("{{ author }}", config.siteConfig.siteAuthor || "mbaharip")
+ .replaceAll("{{ version }}", config.version || "0.0.0")
+ .replaceAll("{{ siteName }}", config.siteConfig.siteName)
+ .replaceAll("{{ creator }}", "mbaharip");
+ };
+
+ return (
+
+
+
+
+
+ {config.siteConfig.privateIndex && !unlocked.success ? (
+
+ ) : (
+ <>{children}>
+ )}
+
+ {config.siteConfig.footer && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
new file mode 100644
index 0000000..8d188e2
--- /dev/null
+++ b/src/app/loading.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { cn } from "~/utils";
+
+import Icon from "~/components/Icon";
+
+export default function RootLoading() {
+ return (
+
+ );
+}
diff --git a/src/app/markdown.css b/src/app/markdown.css
new file mode 100644
index 0000000..5e1f64a
--- /dev/null
+++ b/src/app/markdown.css
@@ -0,0 +1,107 @@
+@tailwind components;
+
+@layer components {
+ .markdown {
+ @apply bg-card text-base leading-relaxed tracking-wide;
+ }
+
+ .markdown p {
+ @apply leading-loose;
+ }
+
+ /* .markdown h1 {
+ @apply text-4xl font-medium;
+ }
+ .markdown h2 {
+ @apply text-3xl font-medium;
+ }
+ .markdown h3 {
+ @apply text-2xl font-medium;
+ }
+ .markdown h4 {
+ @apply text-xl font-medium;
+ }
+ .markdown h5 {
+ @apply text-lg font-medium;
+ }
+ .markdown h6 {
+ @apply text-base;
+ } */
+
+ .markdown h1,
+ .markdown h2,
+ .markdown h3 {
+ @apply mt-4 border-b-2 border-b-border first-letter:capitalize;
+ }
+
+ .markdown code:not(pre code) {
+ @apply whitespace-nowrap rounded-[var(--radius)] bg-muted px-2 py-0.5 leading-normal text-foreground;
+ }
+
+ .markdown a {
+ @apply text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400;
+ }
+
+ .markdown p {
+ @apply whitespace-normal text-pretty;
+ }
+
+ .markdown img {
+ @apply rounded-[var(--radius)];
+ }
+
+ .markdown ul {
+ @apply my-1.5 list-disc leading-loose;
+ }
+
+ .markdown ol {
+ @apply my-1.5 list-decimal leading-loose;
+ }
+
+ .markdown li {
+ @apply my-0 ml-2;
+ }
+
+ .markdown table {
+ @apply mb-4 block w-full border-collapse border-spacing-0 overflow-auto;
+ }
+
+ .markdown table th,
+ .markdown table td {
+ @apply border border-border px-2 py-1;
+ }
+
+ .markdown table th {
+ @apply bg-background text-foreground;
+ }
+
+ .markdown table tr {
+ @apply bg-secondary/50 text-foreground;
+ }
+
+ .markdown table tr:nth-child(2n) {
+ @apply bg-secondary/25 text-foreground;
+ }
+
+ .markdown ul.contains-task-list {
+ @apply list-none;
+ }
+
+ .markdown ul.contains-task-list li {
+ @apply ml-1 flex items-center;
+ }
+
+ .markdown input[type="checkbox"] {
+ @apply mr-2 aspect-square h-4 w-4 rounded-full p-2;
+ @apply appearance-none border border-border;
+ @apply disabled:bg-primary disabled:checked:bg-blue-600 dark:disabled:checked:bg-blue-400;
+ }
+
+ .markdown blockquote {
+ @apply my-2 rounded-r-[var(--radius)] border-l-4 border-l-primary bg-muted py-4 pl-2 text-foreground;
+ }
+
+ .markdown hr {
+ @apply my-4 border-b border-t-0 border-b-border;
+ }
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..e0d9141
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,5 @@
+import NotFoundComponent from "./@not-found";
+
+export default function NotFound() {
+ return ;
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..3cd1812
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,63 @@
+import { cn } from "~/utils";
+
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+import { Separator } from "~/components/ui/separator";
+
+import FileBrowser from "./@explorer";
+import Header from "./@header";
+import HeaderButton from "./@header.button";
+import Markdown from "./@markdown";
+import { GetFiles, GetReadme } from "./actions";
+
+export const revalidate = 300;
+export const dynamic = "force-dynamic";
+
+export default async function RootPage() {
+ const [data, readme] = await Promise.all([
+ GetFiles({}),
+ GetReadme(undefined),
+ ]);
+
+ return (
+
+
+
+
+
+
+ Browse files
+
+
+
+
+
+
+
+
+
+ {readme && (
+
+
+
+ README.md
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/r5-style.css b/src/app/r5-style.css
new file mode 100644
index 0000000..0710af9
--- /dev/null
+++ b/src/app/r5-style.css
@@ -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;
+}
diff --git a/src/app/theme-provider.tsx b/src/app/theme-provider.tsx
new file mode 100644
index 0000000..e77b9d0
--- /dev/null
+++ b/src/app/theme-provider.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { ThemeProvider as NextThemeProvider } from "next-themes";
+import { type ThemeProviderProps } from "next-themes/dist/types";
+import NextTopLoader from "nextjs-toploader";
+import { Toaster } from "react-hot-toast";
+import { cn } from "~/utils";
+
+import { TooltipProvider } from "~/components/ui/tooltip";
+
+import LayoutProvider from "~/context/layoutContext";
+
+import config from "~/config/gIndex.config";
+
+export default function ThemeProvider({
+ children,
+ ...props
+}: ThemeProviderProps) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Alert/index.tsx b/src/components/Alert/index.tsx
deleted file mode 100644
index d167df1..0000000
--- a/src/components/Alert/index.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Icon, IconifyIcon } from "@iconify/react";
-import { twMerge } from "tailwind-merge";
-
-interface AlertProps {
- variant?: "success" | "error" | "warning" | "info";
- icon?: string | IconifyIcon;
- title?: string;
- message: string;
- className?: string;
-}
-export default function Alert(props: AlertProps) {
- const {
- variant = "info",
- icon,
- title,
- message,
- className,
- } = props;
- return (
-
- {(icon || title) && (
-
- {icon && (
-
- )}
- {title && (
-
- {title}
-
- )}
-
- )}
-
{message}
-
- );
-}
diff --git a/src/components/Breadcrumb/index.tsx b/src/components/Breadcrumb/index.tsx
deleted file mode 100644
index 06437d4..0000000
--- a/src/components/Breadcrumb/index.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-import { Icon, IconifyIcon } from "@iconify/react";
-import Link from "next/link";
-import { useRouter } from "next/router";
-import { Fragment, useEffect, useState } from "react";
-
-import ButtonIcon from "components/ButtonIcon";
-import ClickAway from "components/ClickAway";
-
-interface BreadcrumbProps {
- paths: Record<"label" | "path", string>[];
- separator?: string | IconifyIcon;
- maxPath?: number;
-}
-
-export default function Breadcrumb(props: BreadcrumbProps) {
- const router = useRouter();
- const {
- paths,
- separator = "ion:chevron-forward",
- maxPath = 2,
- } = props;
- const [breadcrumbPaths, setBreadcrumbPaths] =
- useState[]>(paths);
- const [isPathSliced, setIsPathSliced] =
- useState(false);
- const [hiddenPaths, setHiddenPaths] = useState<
- Record<"label" | "path", string>[]
- >([]);
- const [menuAnchor, setMenuAnchor] =
- useState(null);
-
- const [menuOpen, setMenuOpen] = useState(false);
-
- useEffect(() => {
- if (paths.length > maxPath) {
- const newPaths = paths.slice(
- paths.length - maxPath,
- paths.length,
- );
- const hiddenPaths = paths.slice(
- 0,
- paths.length - maxPath,
- );
- setBreadcrumbPaths(newPaths);
- setIsPathSliced(true);
- setHiddenPaths(hiddenPaths);
- } else {
- setBreadcrumbPaths(paths);
- setIsPathSliced(false);
- }
- }, [paths, maxPath]);
-
- return (
- <>
-
-
-
- {/* */}
-
- {isPathSliced && (
- <>
-
- {
- e.preventDefault();
- e.stopPropagation();
- setMenuOpen(!menuOpen);
- }}
- />
- {/* ... */}
- >
- )}
- {breadcrumbPaths.map((path, index) => (
-
-
-
- {decodeURIComponent(path.label)}
-
-
- ))}
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- setMenuOpen(false);
- }}
- className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
- menuOpen
- ? "opacity-100 pointer-events-auto"
- : "opacity-0 pointer-events-none"
- }`}
- >
-
-
- >
- );
-}
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
deleted file mode 100644
index 8f77660..0000000
--- a/src/components/Button/index.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import { Icon, IconifyIcon } from "@iconify/react";
-import { ReactNode } from "react";
-import { twMerge } from "tailwind-merge";
-
-interface ButtonProps
- extends React.ButtonHTMLAttributes {
- fullWidth?: boolean;
- variant?:
- | "primary"
- | "accent"
- | "success"
- | "danger"
- | "warning"
- | "info"
- | "transparent";
- size?: "small" | "medium" | "large";
- pill?: boolean;
- square?: boolean;
- rounded?: "none" | "small" | "medium" | "large";
- startIcon?: string | IconifyIcon;
- endIcon?: string | IconifyIcon;
- children: ReactNode;
- loading?: boolean;
- className?: string;
-}
-
-export default function Button(props: ButtonProps) {
- const {
- fullWidth = false,
- variant = "primary",
- size = "medium",
- pill = false,
- square = false,
- rounded = "none",
- loading = false,
- startIcon,
- endIcon,
- children,
- className,
- ...rest
- } = props;
-
- return (
-
- {props.loading ? (
-
- ) : (
- <>
- {startIcon && (
-
- )}
- {children}
- {endIcon && (
-
- )}
- >
- )}
-
- );
-}
diff --git a/src/components/ButtonGroup/index.tsx b/src/components/ButtonGroup/index.tsx
deleted file mode 100644
index cdfec8d..0000000
--- a/src/components/ButtonGroup/index.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { ReactNode } from "react";
-import { twMerge } from "tailwind-merge";
-
-interface ButtonGroupProps {
- children: ReactNode;
- fullWidth?: boolean;
- rounded?: "none" | "small" | "medium" | "large";
- className?: string;
-}
-
-export default function ButtonGroup(
- props: ButtonGroupProps,
-) {
- const {
- children,
- fullWidth = false,
- rounded = "large",
- className,
- } = props;
-
- return (
-
- {children}
-
- );
-}
diff --git a/src/components/ButtonIcon/index.tsx b/src/components/ButtonIcon/index.tsx
deleted file mode 100644
index b1fbd2f..0000000
--- a/src/components/ButtonIcon/index.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Icon, IconifyIcon } from "@iconify/react";
-import { ForwardRefRenderFunction, forwardRef } from "react";
-import { twMerge } from "tailwind-merge";
-
-interface ButtonIconProps extends React.ButtonHTMLAttributes {
- icon: string | IconifyIcon;
- size?: "small" | "medium" | "large";
- variant?: "primary" | "accent" | "success" | "danger" | "warning" | "info" | "transparent";
- className?: string;
-}
-
-const ButtonIcon: ForwardRefRenderFunction = (props, ref) => {
- const { icon, size = "medium", variant = "primary", className, ...rest } = props;
- let iconSize = 24;
- switch (size) {
- case "small":
- iconSize = 16;
- break;
- case "medium":
- iconSize = 24;
- break;
- case "large":
- iconSize = 32;
- }
-
- return (
-
-
-
- );
-};
-
-export default forwardRef(ButtonIcon);
diff --git a/src/components/ClickAway/index.tsx b/src/components/ClickAway/index.tsx
deleted file mode 100644
index d0785ae..0000000
--- a/src/components/ClickAway/index.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ReactNode, useEffect } from "react";
-import { twMerge } from "tailwind-merge";
-
-interface ClickAwayProps extends React.HTMLAttributes {
- children: ReactNode;
- open: boolean;
- onClickAway: (e: any) => void;
- className?: string;
-}
-export default function ClickAway(props: ClickAwayProps) {
- const { children, open, onClickAway, className, ...rest } = props;
-
- useEffect(() => {
- const body = document.querySelector("body");
- const isScrollbarRendered = document.documentElement.clientHeight < document.documentElement.scrollHeight;
- const isMobile = window.matchMedia("(max-width: 768px)").matches;
- if (open && isScrollbarRendered) {
- body?.style.setProperty("overflow", "hidden");
- if (!isMobile) body?.style.setProperty("padding-right", "4px");
- } else {
- body?.style.removeProperty("overflow");
- if (!isMobile) body?.style.removeProperty("padding-right");
- }
- }, [open]);
-
- return (
-
- {children}
-
- );
-}
diff --git a/src/components/File/GridItem/index.tsx b/src/components/File/GridItem/index.tsx
deleted file mode 100644
index 138a845..0000000
--- a/src/components/File/GridItem/index.tsx
+++ /dev/null
@@ -1,233 +0,0 @@
-import { Icon } from "@iconify/react";
-import Link from "next/link";
-import { useRouter } from "next/router";
-import { useEffect, useState } from "react";
-import { toast } from "react-toastify";
-
-import ButtonIcon from "components/ButtonIcon";
-import ClickAway from "components/ClickAway";
-import Tooltip from "components/Tooltip";
-
-import bytesToReadable from "utils/bytesFormat";
-import { stripExtension } from "utils/filesHelper";
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-import { getPreviewIcon } from "utils/previewHelper";
-import { createDownloadToken } from "utils/tokenHelper";
-
-import { IGDriveFiles } from "types/api/files";
-
-interface ListItemProps {
- file: IGDriveFiles;
- isProtected: boolean;
-}
-export default function ListItem(props: ListItemProps) {
- const router = useRouter();
- const [menuOpen, setMenuOpen] = useState(false);
- const [menuAnchor, setMenuAnchor] = useState();
-
- const [fileInfo, setFileInfo] = useState([]);
- const [extensionInfo, setExtensionInfo] = useState([]);
- const [filePath, setFilePath] = useState("");
-
- useEffect(() => {
- const currentPath = router.asPath.slice(1);
- const filePath = props.file.name;
- const path = [currentPath, encodeURIComponent(filePath)].join("/");
-
- // If start with /, remove it
- if (path.startsWith("/")) {
- setFilePath(path.slice(1));
- } else {
- setFilePath(path);
- }
- }, [router.asPath, props.file.name]);
-
- useEffect(() => {
- const info = [];
- const extInfo = [];
- props.file.modifiedTime &&
- info.push(
- new Date(props.file.modifiedTime).toLocaleDateString("default", {
- year: "numeric",
- month: "short",
- day: "numeric",
- }),
- );
- props.file.size && info.push(bytesToReadable(props.file.size));
- props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
- props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
- setExtensionInfo(extInfo);
- setFileInfo(info);
- }, [props.file]);
-
- const handleCopyFileLink = () => {
- try {
- const url = `${window.location.origin}/${filePath}`;
- navigator.clipboard.writeText(url);
- toast.success("Download URL copied to clipboard.");
- } catch (error: any) {
- toast.error("Failed to copy Download URL to clipboard.");
- console.error(error.message);
- } finally {
- setMenuOpen(false);
- }
- };
- const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
- try {
- const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
- navigator.clipboard.writeText(url);
- toast.success("Download URL copied to clipboard.");
- } catch (error: any) {
- toast.error("Failed to copy Download URL to clipboard.");
- console.error(error.message);
- } finally {
- setMenuOpen(false);
- }
- };
- const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
- window.open(
- generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
- "_blank",
- );
- setMenuOpen(false);
- };
-
- return (
- <>
-
-
- {props.file.thumbnailLink &&
- (props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
-
- ) : (
-
- )}
-
-
- {props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
- {" "}
-
- {extensionInfo.join(" ・ ")}
-
-
- {fileInfo.join(" ・ ")}
-
-
-
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- setMenuOpen(!menuOpen);
- }}
- />
-
-
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- setMenuOpen(false);
- }}
- className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
- menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
- }`}
- >
-
-
- >
- );
-}
diff --git a/src/components/File/ListItem/index.tsx b/src/components/File/ListItem/index.tsx
deleted file mode 100644
index 9c1e2f0..0000000
--- a/src/components/File/ListItem/index.tsx
+++ /dev/null
@@ -1,232 +0,0 @@
-import { Icon } from "@iconify/react";
-import Link from "next/link";
-import { useRouter } from "next/router";
-import { useEffect, useState } from "react";
-import { toast } from "react-toastify";
-
-import ButtonIcon from "components/ButtonIcon";
-import ClickAway from "components/ClickAway";
-import Tooltip from "components/Tooltip";
-
-import bytesToReadable from "utils/bytesFormat";
-import { stripExtension } from "utils/filesHelper";
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-import { getPreviewIcon } from "utils/previewHelper";
-import { createDownloadToken } from "utils/tokenHelper";
-
-import { IGDriveFiles } from "types/api/files";
-
-interface ListItemProps {
- file: IGDriveFiles;
- isProtected: boolean;
-}
-export default function ListItem(props: ListItemProps) {
- const router = useRouter();
- const [menuOpen, setMenuOpen] = useState(false);
- const [menuAnchor, setMenuAnchor] = useState();
-
- const [fileInfo, setFileInfo] = useState([]);
- const [extensionInfo, setExtensionInfo] = useState([]);
- const [filePath, setFilePath] = useState("");
-
- useEffect(() => {
- const currentPath = router.asPath.slice(1);
- const filePath = props.file.name;
- const path = [currentPath, encodeURIComponent(filePath)].join("/");
-
- // If start with /, remove it
- if (path.startsWith("/")) {
- setFilePath(path.slice(1));
- } else {
- setFilePath(path);
- }
- }, [router.asPath, props.file.name]);
-
- useEffect(() => {
- const info = [];
- const extInfo = [];
- props.file.modifiedTime &&
- info.push(
- new Date(props.file.modifiedTime).toLocaleDateString("default", {
- year: "numeric",
- month: "short",
- day: "numeric",
- }),
- );
- props.file.size && info.push(bytesToReadable(props.file.size));
- props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
- props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
- setExtensionInfo(extInfo);
- setFileInfo(info);
- }, [props.file]);
-
- const handleCopyFileLink = () => {
- try {
- const url = `${window.location.origin}/${filePath}`;
- navigator.clipboard.writeText(url);
- toast.success("Download URL copied to clipboard.");
- } catch (error: any) {
- toast.error("Failed to copy Download URL to clipboard.");
- console.error(error.message);
- } finally {
- setMenuOpen(false);
- }
- };
- const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
- try {
- const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
- navigator.clipboard.writeText(url);
- toast.success("Download URL copied to clipboard.");
- } catch (error: any) {
- toast.error("Failed to copy Download URL to clipboard.");
- console.error(error.message);
- } finally {
- setMenuOpen(false);
- }
- };
- const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
- window.open(
- generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
- "_blank",
- );
- setMenuOpen(false);
- };
-
- return (
- <>
-
-
- {props.file.thumbnailLink &&
- (props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
-
- ) : (
-
- )}
-
-
- {props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
- {" "}
-
- {extensionInfo.join(" ・ ")}
-
-
- {fileInfo.join(" ・ ")}
-
-
-
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- setMenuOpen(!menuOpen);
- }}
- />
-
-
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- setMenuOpen(false);
- }}
- className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
- menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
- }`}
- >
-
-
- >
- );
-}
diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx
new file mode 100644
index 0000000..2255689
--- /dev/null
+++ b/src/components/Icon/index.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { LucideProps, icons } from "lucide-react";
+import { ReactElement } from "react";
+
+type Props = {
+ name: keyof typeof icons;
+ wrapper?: ReactElement<"div">;
+} & LucideProps;
+
+export default function Icon({ name, wrapper, ...props }: Props) {
+ const LucideIcon = icons[name];
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/Layout/Explorer/Grid.tsx b/src/components/Layout/Explorer/Grid.tsx
deleted file mode 100644
index 2747965..0000000
--- a/src/components/Layout/Explorer/Grid.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import GridItem from "components/File/GridItem";
-
-import { IGDriveFiles } from "types/api/files";
-
-interface GridViewProps {
- data: {
- file: IGDriveFiles | null;
- files: IGDriveFiles[];
- folders: IGDriveFiles[];
- pageToken: string | null;
- };
- isProtected: boolean;
-}
-
-export default function ViewGrid(props: GridViewProps) {
- return (
-
- {props.data.folders.map((folder) => (
-
- ))}
- {props.data.files.map((file) => (
-
- ))}
-
- );
-}
diff --git a/src/components/Layout/Explorer/List.tsx b/src/components/Layout/Explorer/List.tsx
deleted file mode 100644
index 4d85893..0000000
--- a/src/components/Layout/Explorer/List.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import ListItem from "components/File/ListItem";
-
-import { IGDriveFiles } from "types/api/files";
-
-interface ViewListProps {
- data: {
- file: IGDriveFiles | null;
- files: IGDriveFiles[];
- folders: IGDriveFiles[];
- pageToken: string | null;
- };
- isProtected: boolean;
-}
-
-export default function ViewList(props: ViewListProps) {
- return (
-
- {props.data.folders.map((folder) => (
-
- ))}
- {props.data.files.map((file) => (
-
- ))}
-
- );
-}
diff --git a/src/components/Layout/Explorer/index.tsx b/src/components/Layout/Explorer/index.tsx
deleted file mode 100644
index 1a2478f..0000000
--- a/src/components/Layout/Explorer/index.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import axios from "axios";
-import gIndexConfig from "config";
-import { useRouter } from "next/router";
-import { poppins } from "pages/_app";
-import { ReactNode, useCallback, useEffect, useState } from "react";
-import { twMerge } from "tailwind-merge";
-
-import Breadcrumb from "components/Breadcrumb";
-import Button from "components/Button";
-import Markdown from "components/Markdown";
-
-import { addNewPassword } from "utils/passwordHelper";
-
-import { IGDriveFiles } from "types/api/files";
-import { APIGetFileResponse } from "types/api/response";
-import { Constant } from "types/constant";
-
-import Header from "../Header";
-import PasswordLayout from "../Password";
-import ViewGrid from "./Grid";
-import ViewList from "./List";
-
-const Loading = () => (
-
-
-
-
-
-
-
Loading view...
-
-
-);
-
-interface ExplorerLayoutProps {
- data: {
- file: IGDriveFiles | null;
- files: IGDriveFiles[];
- folders: IGDriveFiles[];
- pageToken: string | null;
- };
- readmeFile: string | null;
- isProtected: boolean;
- isFileProtected?: boolean;
- encryptedId?: string;
-}
-
-export default function ExplorerLayout(props: ExplorerLayoutProps) {
- const router = useRouter();
- const [data, setData] = useState(props.data);
- const [view, setView] = useState<"grid" | "list">(() => {
- const viewType = localStorage.getItem(Constant.storage_ViewType);
- if (!viewType) {
- localStorage.setItem(Constant.storage_ViewType, "list");
- return "list";
- }
- if (viewType === "grid") {
- return "grid";
- } else {
- return "list";
- }
- });
- const [isViewLoading, setIsViewLoading] = useState(false);
- const [isButtonLoading, setIsButtonLoading] = useState(false);
- const [ViewComponent, setViewComponent] = useState(
- ,
- );
- const [showPasswordLayout, setShowPasswordLayout] = useState(props.isProtected);
- const [breadcrumbPaths, setBreadcrumbPaths] = useState[]>([]);
-
- useEffect(() => {
- new Promise(async (resolve) => {
- if (view === "list") {
- setViewComponent(
- ,
- );
- } else if (view === "grid") {
- setViewComponent(
- ,
- );
- }
-
- await new Promise((resolve) => setTimeout(resolve, 50));
- resolve(true);
- }).then(() => {
- setIsViewLoading(false);
- });
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [view, data]);
- useEffect(() => {
- setShowPasswordLayout(props.isProtected);
- }, [props.isProtected]);
-
- useEffect(() => {
- const paths = router.asPath.split("/").filter((path) => path !== "");
- const newPaths: Record<"label" | "path", string>[] = [];
- paths.forEach((path, index) => {
- newPaths.push({
- label: path,
- path: `/${paths.slice(0, index + 1).join("/")}`,
- });
- });
-
- setBreadcrumbPaths(newPaths);
- }, [router]);
-
- const handleChangeView = useCallback(
- (view: "grid" | "list") => {
- setIsViewLoading(true);
- setView(view);
- localStorage.setItem(Constant.storage_ViewType, view);
- },
- [setView],
- );
- const handleLoadNextPage = useCallback(
- async (e: React.MouseEvent) => {
- e.preventDefault();
- setIsButtonLoading(true);
- if (!data.pageToken) {
- setIsButtonLoading(false);
- return;
- }
- const newData = await axios.get("/api/getData", {
- params: {
- encryptedId: props.encryptedId,
- pageToken: data.pageToken,
- },
- });
- setData((prev) => ({
- ...prev,
- files: [...prev.files, ...newData.data.data.files],
- folders: [...prev.folders, ...newData.data.data.folders],
- pageToken: newData.data.data.pageToken,
- }));
- setIsButtonLoading(false);
- },
- [data, props.encryptedId],
- );
-
- return (
-
- {/* Top */}
-
-
-
-
- {/* Header */}
- {!showPasswordLayout && (
-
- {/* Breadcrumb */}
-
-
- )}
-
- {/* Content */}
- {showPasswordLayout ? (
-
{
- const cookie =
- document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
- undefined;
- addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
- router.reload();
- }}
- />
- ) : (
-
-
- {isViewLoading ? (
-
- ) : (
-
- {data.files.length || data.folders.length ? (
- <>{ViewComponent}>
- ) : (
-
- Can't find any files or folders in this directory
-
- )}
-
- {data.pageToken && (
-
- Load more...
-
- )}
-
- )}
-
- {props.readmeFile && (
-
- )}
-
- )}
-
- );
-}
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
deleted file mode 100644
index b01ddcf..0000000
--- a/src/components/Layout/Header/index.tsx
+++ /dev/null
@@ -1,259 +0,0 @@
-import { Icon } from "@iconify/react";
-import axios from "axios";
-import gIndexConfig from "config";
-import Link from "next/link";
-import { useEffect, useRef, useState } from "react";
-
-import Button from "components/Button";
-import ButtonGroup from "components/ButtonGroup";
-import ButtonIcon from "components/ButtonIcon";
-import Modal from "components/Modal";
-import Tooltip from "components/Tooltip";
-
-import bytesToReadable from "utils/bytesFormat";
-import { getPreviewIcon } from "utils/previewHelper";
-
-import { IGDriveFiles } from "types/api/files";
-import { APISearchResponse } from "types/api/response";
-
-interface HeaderProps {
- viewSelector?: {
- view: "grid" | "list";
- setView: (view: "grid" | "list") => void;
- };
- showPasswordLayout?: boolean;
-}
-
-export default function Header(props: HeaderProps) {
- const inputRef = useRef(null);
- const [searchModalOpen, setSearchModalOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
- const [axiosCancelToken, setAxiosCancelToken] = useState | null>(null); // eslint-disable-line
- const [searchLoading, setSearchLoading] = useState(false);
- const [searchResults, setSearchResults] = useState<(IGDriveFiles & { redirect: string })[]>([]);
-
- useEffect(() => {
- if (!searchQuery) {
- setSearchLoading(false);
- return;
- }
- setSearchLoading(true);
- if (axiosCancelToken) {
- axiosCancelToken.cancel();
- }
- const timeout = setTimeout(() => {
- setDebouncedSearchQuery(searchQuery);
- }, 500);
-
- return () => clearTimeout(timeout);
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchQuery]);
-
- useEffect(() => {
- if (!searchModalOpen) {
- setSearchResults([]);
- setSearchQuery("");
- setSearchLoading(false);
- }
- }, [searchModalOpen]);
-
- useEffect(() => {
- if (!debouncedSearchQuery) {
- setSearchLoading(false);
- setSearchResults([]);
- return;
- }
-
- const source = axios.CancelToken.source();
- setAxiosCancelToken(source);
-
- axios
- .get("/api/search", {
- params: {
- query: debouncedSearchQuery,
- },
- cancelToken: source.token,
- })
- .then((res) => {
- setSearchResults(res.data.files);
- })
- .catch((err) => {
- if (axios.isCancel(err)) {
- return;
- } else {
- console.error(err);
- throw new Error(err);
- }
- })
- .finally(() => {
- setSearchLoading(false);
- });
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [debouncedSearchQuery]);
-
- return (
- <>
-
-
{gIndexConfig.siteConfig.siteName}
-
- {!props.showPasswordLayout && (
-
- {props.viewSelector && (
-
- props.viewSelector?.setView("list")}
- >
- List view
-
- props.viewSelector?.setView("grid")}
- >
- Grid view
-
-
- )}
-
-
- {
- setSearchModalOpen(true);
- setTimeout(() => {
- inputRef.current?.focus();
- }, 100);
- }}
- />
-
-
- )}
-
- {!props.showPasswordLayout && props.viewSelector && (
-
-
- props.viewSelector?.setView("list")}
- >
- List view
-
- props.viewSelector?.setView("grid")}
- >
- Grid view
-
-
-
- )}
-
- setSearchModalOpen(false)}
- title='Search'
- >
-
-
setSearchQuery(e.target.value)}
- />
-
- {searchLoading ? (
-
-
-
-
-
-
Searching file...
-
- ) : (
-
- {searchResults.length === 0 ? (
-
- No results found
-
- ) : (
- <>
- {searchResults.map((file) => (
-
- {file.thumbnailLink &&
- (file.mimeType.startsWith("video") || file.mimeType.startsWith("image")) ? (
-
- ) : (
-
- )}
-
-
{file.name}
-
-
- {[
- file.mimeType && file.mimeType.includes("folder") && "Folder",
- file.fileExtension && file.fileExtension.toUpperCase(),
- ]
- .filter((item) => item)
- .join(" ・ ")}
-
-
-
-
- {[
- new Date(file.modifiedTime).toLocaleDateString("default", {
- year: "numeric",
- month: "short",
- day: "numeric",
- }),
- file.size ? bytesToReadable(file.size) : undefined,
- ]
- .filter((item) => item)
- .join(" ・ ")}
-
-
-
-
- ))}
- >
- )}
-
- )}
-
-
- >
- );
-}
diff --git a/src/components/Layout/Loader/index.tsx b/src/components/Layout/Loader/index.tsx
deleted file mode 100644
index 6a71790..0000000
--- a/src/components/Layout/Loader/index.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import gIndexConfig from "config";
-import { NextSeo, NextSeoProps } from "next-seo";
-import { useRouter } from "next/router";
-import { ReactNode, useEffect, useState } from "react";
-
-import {
- addNewPassword,
- checkPathPassword,
-} from "utils/passwordHelper";
-
-import { Constant } from "types/constant";
-
-import PasswordLayout from "../Password";
-
-interface LoaderLayoutProps
- extends React.HTMLAttributes {
- children: ReactNode;
- seo?: NextSeoProps;
-}
-
-export default function LoaderLayout(
- props: LoaderLayoutProps,
-) {
- const router = useRouter();
- const { children, seo, className, ...rest } = props;
- const sitePassword =
- process.env.NEXT_PUBLIC_SITE_PASSWORD;
-
- const [isLoading, setIsLoading] = useState(true);
- const [isPasswordUnlocked, setIsPasswordUnlocked] =
- useState(
- gIndexConfig.siteConfig.privateIndex ? false : true,
- );
-
- const handlePrivateIndexCheck = () => {
- const cookie =
- document.cookie
- .split(";")
- .find((cookie) =>
- cookie.startsWith(
- `${Constant.cookies_SitePassword}=`,
- ),
- ) ?? undefined;
- if (cookie) {
- const password = cookie.split("=")[1];
- if (!password) return setIsPasswordUnlocked(false);
- if (!sitePassword)
- throw new Error("Can't find site password");
-
- const valid = checkPathPassword(
- "site-wide",
- password,
- sitePassword as string,
- );
- if (valid) return setIsPasswordUnlocked(true);
- }
- };
-
- useEffect(() => {
- setIsLoading(true);
- new Promise((resolve) =>
- resolve(handlePrivateIndexCheck()),
- ).then(() => setIsLoading(false));
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- useEffect(() => {
- const routeChangeStart = () => setIsLoading(true);
- const routeChangeComplete = () => {
- handlePrivateIndexCheck();
- setIsLoading(false);
- };
-
- router.events.on("routeChangeStart", routeChangeStart);
- router.events.on(
- "routeChangeComplete",
- routeChangeComplete,
- );
- router.events.on(
- "routeChangeError",
- routeChangeComplete,
- );
-
- return () => {
- router.events.off(
- "routeChangeStart",
- routeChangeStart,
- );
- router.events.off(
- "routeChangeComplete",
- routeChangeComplete,
- );
- router.events.off(
- "routeChangeError",
- routeChangeComplete,
- );
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [router.events]);
-
- return (
- <>
-
- {isLoading ? (
-
-
-
-
-
-
-
Validating path...
-
-
- ) : (
-
- {gIndexConfig.siteConfig.privateIndex &&
- !isPasswordUnlocked ? (
-
{
- const cookie =
- document.cookie
- .split(";")
- .find((cookie) =>
- cookie.startsWith(
- `${Constant.cookies_SitePassword}=`,
- ),
- ) ?? undefined;
- addNewPassword(
- "site-wide",
- password,
- cookie?.split("=")[1] ?? undefined,
- );
- setIsLoading(true);
- const validPassword = () => {
- const cookie =
- document.cookie
- .split(";")
- .find((cookie) =>
- cookie.startsWith(
- `${Constant.cookies_SitePassword}=`,
- ),
- ) ?? undefined;
- if (cookie) {
- const password = cookie.split("=")[1];
- if (!password) return false;
- if (!sitePassword)
- throw new Error(
- "Can't find site password",
- );
-
- const valid = checkPathPassword(
- "site-wide",
- password,
- sitePassword as string,
- );
- return valid;
- }
- };
- new Promise((resolve) =>
- resolve(validPassword()),
- )
- .then((data) => {
- if (data) {
- setIsPasswordUnlocked(true);
- } else {
- setIsPasswordUnlocked(false);
- }
- })
- .finally(() => setIsLoading(false));
- }}
- />
- ) : (
- <>{children}>
- )}
-
- )}
- >
- );
-}
diff --git a/src/components/Layout/Password/index.tsx b/src/components/Layout/Password/index.tsx
deleted file mode 100644
index 67bfa4e..0000000
--- a/src/components/Layout/Password/index.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { Icon } from "@iconify/react";
-import { useRouter } from "next/router";
-import { useState } from "react";
-
-import Button from "components/Button";
-import ButtonIcon from "components/ButtonIcon";
-
-interface PasswordLayoutProps {
- onSubmitted: (password: string) => void;
- path: string;
-}
-export default function PasswordLayout(
- props: PasswordLayoutProps,
-) {
- const router = useRouter();
- const [showPassword, setShowPassword] =
- useState(false);
- const [password, setPassword] = useState("");
- const [pending, setPending] = useState(false);
-
- return (
-
-
-
-
-
-
-
- The folder / file you are trying to access is
- protected with a password.
-
-
- {props.path}
-
-
Please enter the password to continue:
-
-
-
- );
-}
diff --git a/src/components/Layout/Preview/index.tsx b/src/components/Layout/Preview/index.tsx
deleted file mode 100644
index 24c34cd..0000000
--- a/src/components/Layout/Preview/index.tsx
+++ /dev/null
@@ -1,306 +0,0 @@
-import { Icon } from "@iconify/react";
-import gIndexConfig from "config";
-import Link from "next/link";
-import { useRouter } from "next/router";
-import { poppins } from "pages/_app";
-import { ReactNode, useEffect, useMemo, useState } from "react";
-import { toast } from "react-toastify";
-import { twMerge } from "tailwind-merge";
-
-import Breadcrumb from "components/Breadcrumb";
-import Button from "components/Button";
-
-import bytesToReadable from "utils/bytesFormat";
-import { durationToReadable } from "utils/durationFormat";
-import { decryptData } from "utils/encryptionHelper/hash";
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-import { addNewPassword } from "utils/passwordHelper";
-import { getPreviewComponent } from "utils/previewHelper";
-import { createDownloadToken } from "utils/tokenHelper";
-
-import { IGDriveFiles } from "types/api/files";
-import { Constant } from "types/constant";
-
-import Header from "../Header";
-import PasswordLayout from "../Password";
-
-const Loading = () => (
-
-
-
-
-
-
-
Loading view...
-
-
-);
-
-interface PreviewLayoutProps {
- data: {
- file: IGDriveFiles | null;
- files: IGDriveFiles[];
- folders: IGDriveFiles[];
- pageToken: string | null;
- };
- isProtected: boolean;
- isFileProtected: boolean;
- encryptedId?: string;
-}
-
-export default function PreviewLayout(props: PreviewLayoutProps) {
- const router = useRouter();
- const [data, setData] = useState(props.data);
- const [isViewLoading, setIsViewLoading] = useState(false);
- const [PreviewComponent, setPreviewComponent] = useState();
- const [showPasswordLayout, setShowPasswordLayout] = useState(props.isProtected);
- const [breadcrumbPaths, setBreadcrumbPaths] = useState[]>([]);
- const [downloadToken] = useState(() => {
- if (gIndexConfig.apiConfig.allowDownloadProtectedFile) return null;
-
- if (props.isFileProtected) {
- return createDownloadToken();
- } else {
- return null;
- }
- });
- const [expiredIn, setExpiredIn] = useState("");
-
- const fileMetadata = useMemo<
- {
- key: string;
- label: string;
- value: string | number | null;
- }[]
- >(() => {
- const metadata = [];
- if (data.file) {
- if (data.file.name)
- metadata.push({
- key: "name",
- label: "Name",
- value: data.file.name,
- });
- if (data.file.mimeType)
- metadata.push({
- key: "mimeType",
- label: "Type",
- value: data.file.mimeType,
- });
- if (data.file.modifiedTime)
- metadata.push({
- key: "modifiedTime",
- label: "Last Modified",
- value: new Date(data.file.modifiedTime).toLocaleDateString("default", {
- year: "numeric",
- month: "short",
- day: "numeric",
- }),
- });
- if (data.file.size)
- metadata.push({
- key: "size",
- label: "Size",
- value: bytesToReadable(data.file.size),
- });
- if (data.file.imageMediaMetadata && data.file.imageMediaMetadata.width && data.file.imageMediaMetadata.height)
- metadata.push({
- key: "imageMediaMetadata",
- label: "Dimension",
- value: `${data.file.imageMediaMetadata.width}x${data.file.imageMediaMetadata.height}`,
- });
- if (data.file.videoMediaMetadata)
- metadata.push(
- {
- key: "videoMediaMetadata",
- label: "Dimension",
- value: `${data.file.videoMediaMetadata.width}x${data.file.videoMediaMetadata.height}`,
- },
- {
- key: "videoMediaMetadata",
- label: "Duration",
- value: durationToReadable(data.file.videoMediaMetadata.durationMillis),
- },
- );
- }
-
- return metadata;
- }, [data.file]);
-
- useEffect(() => {
- if (!downloadToken) return;
- const interval = setInterval(() => {
- const expiredAt = Number(decryptData(downloadToken));
- const date = new Date(expiredAt);
- const now = new Date();
- const diff = date.getTime() - now.getTime();
-
- const hours = Math.floor(diff / 1000 / 60 / 60);
- const minutes = Math.floor(diff / 1000 / 60) % 60;
- const seconds = Math.floor(diff / 1000) % 60;
-
- setExpiredIn(`${hours}:${minutes}:${seconds}`);
- }, 1000);
-
- return () => clearInterval(interval);
- }, [downloadToken]);
-
- useEffect(() => {
- new Promise(async (resolve) => {
- const Preview = getPreviewComponent(props.data.file?.fileExtension ?? "unknown", props.data.file?.mimeType ?? "");
- setPreviewComponent( );
-
- await new Promise((resolve) => setTimeout(resolve, 50));
- resolve(true);
- }).then(() => {
- setIsViewLoading(false);
- });
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [data]);
- useEffect(() => {
- setShowPasswordLayout(props.isProtected);
- }, [props.isProtected]);
-
- useEffect(() => {
- const paths = router.asPath.split("/").filter((path) => path !== "");
- const newPaths: Record<"label" | "path", string>[] = [];
- paths.forEach((path, index) => {
- newPaths.push({
- label: path,
- path: `/${paths.slice(0, index + 1).join("/")}`,
- });
- });
-
- setBreadcrumbPaths(newPaths);
- }, [router]);
-
- return (
-
- {/* Top */}
-
-
-
-
- {/* Header */}
- {!showPasswordLayout && (
-
- {/* Breadcrumb */}
-
-
- )}
-
- {/* Content */}
- {showPasswordLayout ? (
-
{
- const cookie =
- document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
- undefined;
- addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
- router.reload();
- }}
- />
- ) : (
-
-
- {isViewLoading ? (
-
- ) : (
-
-
- {PreviewComponent}
-
-
- )}
-
-
-
-
-
-
-
File info
-
-
-
-
-
- {fileMetadata.map((item) => (
-
-
{item.label}
-
{item.value ?? "-"}
-
- ))}
-
-
-
-
-
-
- Download
-
-
-
{
- try {
- const url = generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken);
- navigator.clipboard.writeText(url);
- toast.success("URL copied to clipboard.");
- } catch (error: any) {
- toast.error("Failed to copy URL to clipboard.");
- console.error(error.message);
- }
- }}
- >
- Copy URL
-
-
-
- {generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken)}
-
-
- {!gIndexConfig.apiConfig.allowDownloadProtectedFile && props.isFileProtected && (
-
- This file is inside protected folder.
-
- Download link will be expired in {expiredIn} .
-
- )}
-
-
-
-
- )}
-
- );
-}
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
deleted file mode 100644
index bc519ff..0000000
--- a/src/components/Modal/index.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { ReactNode, useEffect, useState } from "react";
-
-import ButtonIcon from "components/ButtonIcon";
-import ClickAway from "components/ClickAway";
-
-interface ModalProps {
- children: ReactNode;
- open: boolean;
- onClose: () => void;
- title: string;
- footer?: ReactNode;
- keepMounted?: boolean;
-}
-
-export default function Modal(props: ModalProps) {
- let DELAY = 10;
-
- const [showModal, setShowModal] = useState(props.open);
- const [keepMounted, setKeepMounted] = useState(false);
- const [shouldRender, setShouldRender] = useState(false);
-
- const [shouldUnmount, setShouldUnmount] = useState(true);
-
- useEffect(() => {
- const handleClose = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- props.onClose();
- }
- };
- window.addEventListener("keydown", handleClose);
- return () => {
- window.removeEventListener("keydown", handleClose);
- };
- }, [props]);
-
- useEffect(() => {
- let timeoutId: NodeJS.Timeout;
- if (showModal && !shouldRender) {
- timeoutId = setTimeout(() => {
- setShouldRender(true);
- }, DELAY);
- } else if (!showModal && shouldRender) {
- timeoutId = setTimeout(() => {
- setShouldRender(false);
- }, DELAY);
- }
-
- return () => {
- clearTimeout(timeoutId);
- };
- }, [showModal, shouldRender, DELAY]);
-
- useEffect(() => {
- setShowModal(props.open);
- if (props.open) setShouldUnmount(false);
- }, [props.open]);
-
- useEffect(() => {
- if (showModal && props.keepMounted) setKeepMounted(true);
- }, [showModal, props.keepMounted]);
-
- const handleClose = () => {
- setShowModal(false);
- if (!keepMounted) setKeepMounted(false);
- props.onClose();
- };
-
- // useEffect(() => {
- // if (!showModal && !keepMounted && shouldRender) {
- // const timeout = setTimeout(() => {
- // setShouldUnmount(true);
- // }, 250);
- // return () => {
- // clearTimeout(timeout);
- // };
- // } else {
- // setShouldUnmount(false);
- // }
- // }, [showModal, keepMounted, shouldRender, DELAY]);
- if (shouldUnmount) return null;
-
- return (
- {
- if (!showModal && !keepMounted) {
- setShouldUnmount(true);
- } else {
- setShouldUnmount(false);
- }
- }}
- >
- {
- e.stopPropagation();
- }}
- >
-
- {/* Header */}
-
- {props.title}
-
-
-
-
-
- {/* Body */}
-
{props.children}
-
- {/* Footer */}
- {props.footer &&
{props.footer}
}
-
-
-
- );
-}
diff --git a/src/components/Preview/Audio.tsx b/src/components/Preview/Audio.tsx
deleted file mode 100644
index 7aa1d46..0000000
--- a/src/components/Preview/Audio.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useState } from "react";
-import AudioPlayer from "react-h5-audio-player";
-import "react-h5-audio-player/lib/styles.css";
-
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-
-import { PreviewProps } from "types/api/files";
-
-import ErrorPreview from "./Error";
-
-export default function AudioPreview(props: PreviewProps) {
- const [isError, setIsError] = useState(false);
-
- return (
-
- {isError ? (
-
- ) : (
-
setIsError(true)}
- />
- )}
-
- );
-}
diff --git a/src/components/Preview/Code.tsx b/src/components/Preview/Code.tsx
deleted file mode 100644
index 20e3e5d..0000000
--- a/src/components/Preview/Code.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import axios from "axios";
-import gIndexConfig from "config";
-import { useEffect, useState } from "react";
-
-import Markdown from "components/Markdown";
-
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-
-import { PreviewProps } from "types/api/files";
-
-import ErrorPreview from "./Error";
-
-export default function CodePreview(props: PreviewProps) {
- const [isLoading, setIsLoading] = useState(true);
- const [isError, setIsError] = useState(false);
- const [content, setContent] = useState("");
-
- useEffect(() => {
- axios
- .get(generatedDownloadLink(props.file, false))
- .then((res) => {
- const isObject = typeof res.data === "object";
- setContent(
- `\`\`\`${props.file.fileExtension}\n${isObject ? JSON.stringify(res.data, null, 2) : res.data}\`\`\``,
- );
- setIsLoading(false);
- })
- .catch((err) => {
- console.error(err);
- setIsError(true);
- });
- }, [props.file, props.file.fileExtension]);
-
- return (
-
- {isLoading ? (
-
-
-
-
-
-
-
Loading content...
-
-
- ) : (
- <>{isError ?
:
}>
- )}
-
- );
-}
diff --git a/src/components/Preview/Document.tsx b/src/components/Preview/Document.tsx
deleted file mode 100644
index 5345074..0000000
--- a/src/components/Preview/Document.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
-import gIndexConfig from "config";
-
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-
-import { PreviewProps } from "types/api/files";
-
-import ErrorPreview from "./Error";
-
-function LoadingRender() {
- return (
-
-
-
-
-
-
-
Loading content...
-
-
- );
-}
-function ErrorRender() {
- return ;
-}
-
-export default function DocumentPreview(props: PreviewProps) {
- return (
-
-
-
- );
-}
diff --git a/src/components/Preview/Error.tsx b/src/components/Preview/Error.tsx
deleted file mode 100644
index 5bd6203..0000000
--- a/src/components/Preview/Error.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-interface ErrorPreviewProps {
- error?: string;
-}
-export default function ErrorPreview(props: ErrorPreviewProps) {
- return (
-
-
Failed to load file preview
-
- Error has been occured while loading preview, it may be caused by{" "}
- invalid file type or{" "}
- network error .
-
- You can try again later or download the file to view.
-
-
- {props.error && (
- <>
- Error details
- {props.error}
- >
- )}
-
- );
-}
diff --git a/src/components/Preview/Image.tsx b/src/components/Preview/Image.tsx
deleted file mode 100644
index e7abfc4..0000000
--- a/src/components/Preview/Image.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import gIndexConfig from "config";
-import { useEffect, useState } from "react";
-
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-
-import { PreviewProps } from "types/api/files";
-
-import ErrorPreview from "./Error";
-
-export default function ImagePreview(props: PreviewProps) {
- const [imgSrc, setImgSrc] = useState("");
- const [isLoading, setIsLoading] = useState(true);
- const [isError, setIsError] = useState(false);
- const [errorMessage, setErrorMessage] = useState("");
-
- useEffect(() => {
- setIsError(false);
- setIsLoading(true);
-
- const timeout = setTimeout(() => {
- setIsError(true);
- setErrorMessage("Image took too long to load");
- return;
- }, 15000);
-
- fetch(generatedDownloadLink(props.file, false))
- .then((res) => {
- if (!res.ok) throw new Error(res.statusText);
- return res.blob();
- })
- .then((blob) => {
- const reader = new FileReader();
- reader.onload = () => {
- setImgSrc(reader.result as string);
- };
- reader.readAsDataURL(blob);
- })
- .catch((err) => {
- console.error(err);
- setIsError(true);
- setErrorMessage(err.message);
- })
- .finally(() => {
- setIsLoading(false);
- clearTimeout(timeout);
- });
-
- return () => clearTimeout(timeout);
- }, [props.file]);
-
- return (
- <>
- {isLoading ? (
-
-
-
-
-
-
-
Loading image...
-
-
- ) : (
- <>
- {isError ? (
-
- ) : (
-
- )}
- >
- )}
- >
- );
-}
diff --git a/src/components/Preview/Manga.tsx b/src/components/Preview/Manga.tsx
deleted file mode 100644
index 0a2357f..0000000
--- a/src/components/Preview/Manga.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import axios from "axios";
-import gIndexConfig from "config";
-import JSZip from "jszip";
-import { useCallback, useEffect, useState } from "react";
-
-import ButtonIcon from "components/ButtonIcon";
-
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-
-import { PreviewProps } from "types/api/files";
-
-import ErrorPreview from "./Error";
-
-export default function MangaPreview(props: PreviewProps) {
- const [maxPageIndex, setMaxPageIndex] = useState(4);
- const [isLoading, setIsLoading] = useState(true);
- const [isError, setIsError] = useState(false);
- const [viewPageIndex, setViewPageIndex] = useState(0);
- const [images, setImages] = useState([]);
- const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
-
- useEffect(() => {
- const mangaImages: string[] = [];
- axios
- .get(generatedDownloadLink(props.file, false, false, null, true), {
- responseType: "blob",
- })
- .then(async (res) => {
- const zipData = await JSZip.loadAsync(res.data);
- for (let i = 0; i <= maxPageIndex; i++) {
- const file = zipData.file(Object.keys(zipData.files)[i]);
- if (file) {
- const blob = await file.async("blob");
- const reader = new FileReader();
- reader.onload = () => {
- mangaImages.push(reader.result as string);
- };
- reader.readAsDataURL(blob);
- }
- }
- setImages(mangaImages);
- setMaxPageIndex(mangaImages.length);
- })
- .catch((err) => {
- console.error(err);
- setIsError(true);
- })
- .finally(() => {
- setIsLoading(false);
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.file]);
-
- const handleCurrentView = useCallback(
- (pageNumber: number) => {
- if (pageNumber < 0 || pageNumber > maxPageIndex) return;
- setViewPageIndex(pageNumber);
- },
- [maxPageIndex],
- );
-
- return (
-
-
- Preview only shows the first 5 pages. Please download the file to view the rest.
-
- {isLoading ? (
-
-
-
-
-
-
-
Loading content...
-
-
- ) : (
- <>
- {isError ? (
-
- ) : (
-
-
-
-
setViewSize(viewSize === "fit" ? "full" : "fit")}
- />
-
-
- handleCurrentView(viewPageIndex - 1)}
- disabled={viewPageIndex === 0}
- />
-
- {viewPageIndex + 1} / {maxPageIndex + 1}
-
- handleCurrentView(viewPageIndex + 1)}
- disabled={viewPageIndex === maxPageIndex}
- />
-
-
- )}
- >
- )}
-
- );
-}
diff --git a/src/components/Preview/Rich.tsx b/src/components/Preview/Rich.tsx
deleted file mode 100644
index e6f4ccb..0000000
--- a/src/components/Preview/Rich.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import axios from "axios";
-import gIndexConfig from "config";
-import { useEffect, useState } from "react";
-
-import Markdown from "components/Markdown";
-
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-
-import { PreviewProps } from "types/api/files";
-
-import ErrorPreview from "./Error";
-
-export default function RichPreview(props: PreviewProps) {
- const [isLoading, setIsLoading] = useState(true);
- const [isError, setIsError] = useState(false);
- const [content, setContent] = useState("");
-
- useEffect(() => {
- axios
- .get(generatedDownloadLink(props.file))
- .then((res) => {
- setContent(res.data);
- setIsLoading(false);
- })
- .catch((err) => {
- console.error(err);
- setIsError(true);
- });
- }, [props.file]);
-
- return (
-
- {isLoading ? (
-
-
-
-
-
-
-
Loading content...
-
-
- ) : (
- <>{isError ?
:
}>
- )}
-
- );
-}
diff --git a/src/components/Preview/Unknown.tsx b/src/components/Preview/Unknown.tsx
deleted file mode 100644
index 2878d84..0000000
--- a/src/components/Preview/Unknown.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-export default function UnknownPreview() {
- return (
-
-
File preview not available
-
- This file type is not supported for preview.
-
- You can download the file to view.
-
-
- );
-}
diff --git a/src/components/Preview/Video.tsx b/src/components/Preview/Video.tsx
deleted file mode 100644
index a854ccc..0000000
--- a/src/components/Preview/Video.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useState } from "react";
-import ReactPlayer from "react-player";
-
-import { generatedDownloadLink } from "utils/generateDownloadLink";
-
-import { PreviewProps } from "types/api/files";
-
-import ErrorPreview from "./Error";
-
-export default function VideoPreview(props: PreviewProps) {
- const [isError, setIsError] = useState(false);
-
- return (
-
- {isError ? (
-
- ) : (
-
(
- {children}
- )}
- style={{
- width: "100%",
- height: "100%",
- maxHeight: "60vh",
- }}
- onError={() => setIsError(true)}
- />
- )}
-
- );
-}
diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx
deleted file mode 100644
index b285931..0000000
--- a/src/components/Tooltip/index.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { ReactNode, useEffect, useRef, useState } from "react";
-import { Popper, PopperProps } from "react-popper";
-
-interface TooltipProps {
- children: ReactNode;
- content: ReactNode;
- placement?: PopperProps["placement"];
- delay?: number;
-}
-export default function Tooltip(props: TooltipProps) {
- const [refElement, setRefElement] = useState(null);
- const [showTooltip, setShowTooltip] = useState(false);
- const timeoutRef = useRef(null);
-
- useEffect(() => {
- function handleMouseEnter() {
- if (props.delay) {
- timeoutRef.current = window.setTimeout(() => {
- setShowTooltip(true);
- }, props.delay);
- } else {
- setShowTooltip(true);
- }
- }
-
- function handleMouseLeave() {
- if (props.delay) {
- window.clearTimeout(timeoutRef.current ?? undefined);
- }
- setShowTooltip(false);
- }
-
- function handleScroll() {
- setShowTooltip(false);
- }
-
- if (refElement) {
- refElement.addEventListener("mouseenter", handleMouseEnter);
- refElement.addEventListener("mouseleave", handleMouseLeave);
- refElement.addEventListener("click", handleMouseLeave);
- window.addEventListener("scroll", handleScroll);
- }
-
- return () => {
- if (refElement) {
- refElement.removeEventListener("mouseenter", handleMouseEnter);
- refElement.removeEventListener("mouseleave", handleMouseLeave);
- refElement.removeEventListener("click", handleMouseLeave);
- window.removeEventListener("scroll", handleScroll);
- }
- };
- }, [refElement, props.delay]);
-
- return (
- <>
- {props.children}
- {
- const { height, width, x, y } = state.rects.reference;
- const { innerHeight, innerWidth } = window;
- state.styles.popper = {
- ...state.styles.popper,
- visibility: height + y > innerHeight || width + x > innerWidth ? "hidden" : "visible",
- };
- },
- },
- ]}
- referenceElement={refElement as HTMLElement}
- strategy='absolute'
- >
- {({ ref, style, placement }) => (
-
- {props.content}
-
- )}
-
- >
- );
-}
diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..2d5ce4e
--- /dev/null
+++ b/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,123 @@
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+import * as React from "react";
+import { cn } from "~/utils";
+
+const Breadcrumb = React.forwardRef<
+ HTMLElement,
+ React.ComponentPropsWithoutRef<"nav"> & {
+ separator?: React.ReactNode;
+ }
+>(({ ...props }, ref) => (
+
+));
+Breadcrumb.displayName = "Breadcrumb";
+
+const BreadcrumbList = React.forwardRef<
+ HTMLOListElement,
+ React.ComponentPropsWithoutRef<"ol">
+>(({ className, ...props }, ref) => (
+
+));
+BreadcrumbList.displayName = "BreadcrumbList";
+
+const BreadcrumbItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+
+));
+BreadcrumbItem.displayName = "BreadcrumbItem";
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean;
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+});
+BreadcrumbLink.displayName = "BreadcrumbLink";
+
+const BreadcrumbPage = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentPropsWithoutRef<"span">
+>(({ className, ...props }, ref) => (
+
+));
+BreadcrumbPage.displayName = "BreadcrumbPage";
+
+const BreadcrumbSeparator = ({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) => (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+);
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
+
+const BreadcrumbEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More
+
+);
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..cb48899
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,57 @@
+import { Slot } from "@radix-ui/react-slot";
+import { type VariantProps, cva } from "class-variance-authority";
+import * as React from "react";
+import { cn } from "~/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-zinc-900 text-zinc-50 hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90",
+ destructive:
+ "bg-red-500 text-zinc-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90",
+ outline:
+ "border border-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
+ secondary:
+ "bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
+ ghost:
+ "hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
+ link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..33d1b5a
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,89 @@
+import * as React from "react";
+import { cn } from "~/utils";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
+};
diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx
new file mode 100644
index 0000000..fe9547b
--- /dev/null
+++ b/src/components/ui/carousel.tsx
@@ -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;
+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[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & 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) => {
+ 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 (
+
+
+ {children}
+
+
+ );
+ },
+);
+Carousel.displayName = "Carousel";
+
+const CarouselContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselContent.displayName = "CarouselContent";
+
+const CarouselItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselItem.displayName = "CarouselItem";
+
+const CarouselPrevious = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+
+ Previous slide
+
+ );
+});
+CarouselPrevious.displayName = "CarouselPrevious";
+
+const CarouselNext = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+
+ Next slide
+
+ );
+});
+CarouselNext.displayName = "CarouselNext";
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+};
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..ca79c71
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import * as React from "react";
+import { cn } from "~/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx
new file mode 100644
index 0000000..0e83233
--- /dev/null
+++ b/src/components/ui/drawer.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import * as React from "react";
+import { Drawer as DrawerPrimitive } from "vaul";
+import { cn } from "~/utils";
+
+const Drawer = ({
+ shouldScaleBackground = true,
+ ...props
+}: React.ComponentProps) => (
+
+);
+Drawer.displayName = "Drawer";
+
+const DrawerTrigger = DrawerPrimitive.Trigger;
+
+const DrawerPortal = DrawerPrimitive.Portal;
+
+const DrawerClose = DrawerPrimitive.Close;
+
+const DrawerOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
+
+const DrawerContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+));
+DrawerContent.displayName = "DrawerContent";
+
+const DrawerHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DrawerHeader.displayName = "DrawerHeader";
+
+const DrawerFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DrawerFooter.displayName = "DrawerFooter";
+
+const DrawerTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
+
+const DrawerDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+};
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..f9ab5af
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import * as React from "react";
+import { cn } from "~/utils";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..b75dfc7
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+import { cn } from "~/utils";
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..ca7cd73
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/src/components/ui/menubar.tsx b/src/components/ui/menubar.tsx
new file mode 100644
index 0000000..96aa1fd
--- /dev/null
+++ b/src/components/ui/menubar.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import * as MenubarPrimitive from "@radix-ui/react-menubar";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import * as React from "react";
+import { cn } from "~/utils";
+
+const MenubarMenu = MenubarPrimitive.Menu;
+
+const MenubarGroup = MenubarPrimitive.Group;
+
+const MenubarPortal = MenubarPrimitive.Portal;
+
+const MenubarSub = MenubarPrimitive.Sub;
+
+const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
+
+const Menubar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Menubar.displayName = MenubarPrimitive.Root.displayName;
+
+const MenubarTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
+
+const MenubarSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
+
+const MenubarSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
+
+const MenubarContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
+ ref,
+ ) => (
+
+
+
+ ),
+);
+MenubarContent.displayName = MenubarPrimitive.Content.displayName;
+
+const MenubarItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+MenubarItem.displayName = MenubarPrimitive.Item.displayName;
+
+const MenubarCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
+
+const MenubarRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
+
+const MenubarLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
+
+const MenubarSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
+
+const MenubarShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+MenubarShortcut.displayname = "MenubarShortcut";
+
+export {
+ Menubar,
+ MenubarMenu,
+ MenubarTrigger,
+ MenubarContent,
+ MenubarItem,
+ MenubarSeparator,
+ MenubarLabel,
+ MenubarCheckboxItem,
+ MenubarRadioGroup,
+ MenubarRadioItem,
+ MenubarPortal,
+ MenubarSubContent,
+ MenubarSubTrigger,
+ MenubarGroup,
+ MenubarSub,
+ MenubarShortcut,
+};
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..2f70c3e
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+import * as React from "react";
+import { cn } from "~/utils";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..4466ee5
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import * as React from "react";
+import { cn } from "~/utils";
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref,
+ ) => (
+