feature: Revamp and full rebuild

This commit is contained in:
mbaharip
2023-09-11 04:42:02 +07:00
parent d6f42b0c83
commit b36b1964d4
140 changed files with 7901 additions and 7615 deletions
-7
View File
@@ -1,7 +0,0 @@
# You can generate encryption key from setup page.
NEXT_PUBLIC_ENCRYPTION_KEY=""
# You can use any random string for JWT_KEY, it is recommended to use a strong key by combining characters, numbers and symbols.
JWT_KEY=""
# Domain name of your website. Used for fetching the og-image from public directory.
# Example: https://drive.example.com
NEXT_PUBLIC_DOMAIN=""
+5
View File
@@ -0,0 +1,5 @@
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"
+4 -7
View File
@@ -9,7 +9,6 @@
/coverage
# next.js
/.next/
/out/
# production
@@ -28,17 +27,15 @@ yarn-error.log*
.env
.env.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# personal docs
/docs
/.next
/.vscode
/src/pages/api/legacy/
.vercel
/**/*/_legacy/
/.*/
/src/_app
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="TypeScriptCompiler">
<option name="recompileOnChanges" value="true" />
</component>
</project>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-22
View File
@@ -1,22 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="743" name="TypeScript" />
</Languages>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ExceptionCaughtLocallyJS" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="tw" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
</profile>
</component>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/drive-manager.iml" filepath="$PROJECT_DIR$/.idea/drive-manager.iml" />
</modules>
</component>
</project>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" />
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+1 -1
View File
@@ -4,7 +4,7 @@ module.exports = {
require("prettier-plugin-tailwindcss"),
require("@trivago/prettier-plugin-sort-imports"),
],
printWidth: 60,
printWidth: 120,
tabWidth: 2,
useTabs: false,
semi: true,
+50 -43
View File
@@ -1,72 +1,79 @@
![next-gdrive-index](./public/og.png)
## Status
Basic features are working now, can be used for daily use.
You can check the demo [here](https://drive.mbaharip.com).
<p align='center'>
<a href='https://drive-demo.mbaharip.com'>Demo</a>
·
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/Deploying'>Deploying Guide</a>
·
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ'>FAQ</a>
</p>
## 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.
It's aim to simplify the process of sharing files using Google Drive, and also implements some features that I think is useful for sharing files.
This project are **HEAVILY INSPIRED** by [onedrive-vercel-index](https://github.com/spencerwooo/onedrive-vercel-index) by SpencerWooo.
### Why I made this?
I know there already some Google Drive indexer out there, but all of them are using **Cloudflare** worker.
I've tried to use it, but the speed isn't that great for me.
Currently I'm using SpencerWooo's onedrive index, and it's working perfectly for me from usage perspective and speed.
But, if I compare the price between these two services, Google Drive still cheaper than Onedrive.
when I compare the price between these two services, Google Drive still cheaper than Onedrive.
For 100GB plan, Google Drive is IDR 269.000 / ~$18 USD annually, and Onedrive is IDR 319.000 / ~$21 USD annually.
For free plan, Google Drive offering 15GB of storage, and Onedrive offering 5GB of storage.
I know there are a lot of people selling cheap edu account for Google Drive and Onedrive, but most of the time those account doesn't last long, especially Google Drive account. So I want to do it legit without buying cheap edu account or anything similar this time.
## Features
### Navigate through your Google Drive files and folders.
You can navigate through your Google Drive files and folders, just like you're browsing your local files.
You can also set the root folder, so you can share only specific folder in your Google Drive.
### Theme support.
Choose your side!
- **Private Index**, lock the whole index site with password.
- **Folder and file protection**, you can protect your folder and files with password.
- **Readme file**, you can add readme file for your folder, so you can add some description for your folder.
- **File preview**, you can preview some file types directly from the browser, without downloading it first.
- Image preview
- Video preview
- Audio preview
- Markdown preview
- PDF preview
- Document preview (docx, pptx, xlsx)
- Code preview
- Text preview
- Manga preview (cbz)
- **File search**, you can search for files and folders easily.
- **Direct download**, you can download files directly from index site without opening Google Drive.
- **Raw file link**, you can use the raw file link for hosting your web project assets or comments on forum.
### Site wide password protection.
You can lock the site so only those who know the password can access it.
NOTE: This only protect the site, not the files. The files are still accessible if you know the link.
## File Security
### Search for files and folders.
We implement native search for files and folders, so you can search for files and folders easily.
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.
### File preview for some file types.
You can preview some file types directly from the browser, without downloading it first.
Most files are Images, Videos, Audio, Markdown, and PDF. (I'm still adding more file types)
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.
### Direct download for files.
Download files directly from index site without opening Google Drive.
## Known Issue
### Raw file link for files.
You can use the raw file link for hosting your web project assets or comments on forum.
### File size limit
### Embed your readme file for folder.
You can embed your readme file for folder, so you can add some description for your folder.
Simply upload `.readme.md` on your folder, and it will be displayed on the folder page.
File size limit causing some files can't be previewed, and the download will be redirected to the raw file link.
### Override folder OpenGraph image.
You can override the folder OpenGraph image by uploading `.banner.(png/jpg/jpeg/webp/svg)` on your folder.
### Long Response Time
## Credits
- [onedrive-vercel-index](https://github.com/spencerwooo/onedrive-vercel-index) by SpencerWooo for the inspiration and also some file type helpers.
The flow of the project are:
---
- Validate the path is valid or not. (Only applied for `/...[path]`)
- Fetch the password, readme, and files from Google Drive.
- Show to the user.
## Known Issues
- ~~Fetching files from Google Drive API take too much time, it triggers 504 Error on Vercel.~~
- ~~Can't download big files, because of Vercel's 4.5MB limit for serverless function.~~ (Implement redirect to Google Drive download link instead of using Vercel serverless function for files bigger than 4MB)
- Need to set the folder to public, so it can be downloaded directly from Google Drive.
- The way to get files are changed because of this, now it's using the file name, and 8 first character of file id for hiding the id so no one can access it via Google Drive.
- All id and webContentLink are now encrypted using AES-128-CBC.
- Protected folder cause long response time, because it need to fetch the file / folder first > fetch each of the parents till root > check if it's inside protected folder > then checking the password.
- Download might show scan warning, because it's using Google Drive's direct download link.
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.
## TODO
Todo list are moved to [here](./TODO.md).
ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive.
### No support for Google Shared Drive
I don't have Google Shared Drive, so I can't test it and implement it.
### No support for Google Docs, Sheets, and Slides
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.
+70
View File
@@ -0,0 +1,70 @@
import { IGDriveFiles } from "types/api/files";
import { APIFilesResponse } from "types/api/response";
const mockFiles: APIFilesResponse = {
timestamp: Date.now(),
responseTime: 0,
folders: [],
files: [],
nextPageToken: "nextpageToken",
readmeExists: true,
bannerExists: true,
passwordExists: false,
};
const mockFilesProtected: APIFilesResponse = {
timestamp: Date.now(),
responseTime: 0,
folders: [],
files: [],
nextPageToken: "nextpageToken",
readmeExists: true,
bannerExists: true,
passwordExists: true,
};
for (let i = 0; i < 10; i++) {
const folder: IGDriveFiles = {
mimeType: "application/vnd.google-apps.folder",
encryptedId: `encryptedId${i + 1}`,
name: `folder${i + 1}`,
trashed: false,
modifiedTime: new Date().toISOString(),
};
const file: IGDriveFiles = {
mimeType: "application/vnd.google-apps.file",
encryptedId: `encryptedId${i + 1}`,
name: `file${i + 1}`,
trashed: false,
modifiedTime: new Date().toISOString(),
fileExtension: "txt",
encryptedWebContentLink: "encryptedWebContentLink",
size: Math.floor(Math.random() * 1000000000),
thumbnailLink: "/og.png",
imageMediaMetadata:
Math.random() > 0.5
? {
width: 1920,
height: 1080,
rotation: 0,
}
: null,
videoMediaMetadata:
Math.random() > 0.5
? {
width: 1920,
height: 1080,
durationMillis: 100000,
}
: null,
};
mockFiles.folders.push(folder);
mockFiles.files.push(file);
folder.name = `folder${i + 1}protected`;
file.name = `file${i + 1}protected`;
mockFilesProtected.folders.push(folder);
mockFilesProtected.files.push(file);
}
export { mockFiles, mockFilesProtected };
+32 -4
View File
@@ -1,6 +1,34 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = 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",
},
],
},
];
},
};
module.exports = withMDX(nextConfig);
+25 -28
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 5000",
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
@@ -11,57 +11,54 @@
"pretty": "prettier --write ."
},
"dependencies": {
"@react-three/drei": "^9.66.1",
"@react-three/fiber": "^8.13.0",
"@types/react": "18.0.35",
"@types/react-dom": "18.0.11",
"@upstash/redis": "^1.20.4",
"@vercel/og": "^0.5.4",
"autoprefixer": "10.4.14",
"@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",
"axios": "^1.3.5",
"cors": "^2.8.5",
"googleapis": "^118.0.0",
"jsonwebtoken": "^9.0.0",
"mime-types": "^2.1.35",
"next": "^13.4.4",
"next-seo": "^6.0.0",
"nextjs-progressbar": "^0.0.16",
"postcss": "8.4.22",
"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",
"react-icons": "4.8.1-snapshot.2",
"react-loading": "^2.0.3",
"react-is": "^18.2.0",
"react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-toastify": "^9.1.2",
"rehype-katex": "^6.0.2",
"rehype-prism-plus": "^1.5.1",
"rehype-raw": "^6.1.1",
"rehype-sanitize": "^5.0.1",
"remark-breaks": "^3.0.2",
"react-player": "^2.13.0",
"react-popper": "^2.3.0",
"react-toastify": "^9.1.3",
"rehype-katex": "^6.0.3",
"rehype-prism-plus": "^1.6.3",
"rehype-raw": "6.1.1",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"remark-slug": "^7.0.1",
"remark-toc": "^8.0.1",
"swr": "^2.1.3",
"tailwindcss": "3.3.1",
"three": "^0.152.0"
"remark-toc": "^8.0.1"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@tailwindcss/typography": "^0.5.9",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@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/three": "^0.150.2",
"cypress": "^12.10.0",
"autoprefixer": "10.4.14",
"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"
}
}
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 357.3 318.6" style="enable-background:new 0 0 357.3 318.6;" xml:space="preserve">
<style type="text/css">
.st0{clip-path:url(#SVGID_00000113337896632168277910000000023085085965044388_);}
.st1{fill:url(#SVGID_00000013887681291698298630000007337251733899413648_);}
.st2{fill:#FFFFFF;}
.st3{fill:#FAFAFA;}
</style>
<g>
<g>
<defs>
<polygon id="SVGID_1_" points="228.6,229.7 128.7,229.7 178.6,143.2 "/>
</defs>
<clipPath id="SVGID_00000171714398683706915590000003431004812763861921_">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<g style="clip-path:url(#SVGID_00000171714398683706915590000003431004812763861921_);">
<radialGradient id="SVGID_00000098178616607910525110000001051538810791995295_" cx="178.635" cy="161.295" r="161.753" gradientTransform="matrix(1 0 0 -1 0 320.6)" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#A2DDF8"/>
<stop offset="1" style="stop-color:#BC7DB4"/>
</radialGradient>
<path style="fill:url(#SVGID_00000098178616607910525110000001051538810791995295_);" d="M34.7,311.1c-9.8,0-18.6-5.1-23.5-13.6
s-4.9-18.6,0-27.1L155.1,21.1c4.9-8.5,13.7-13.6,23.5-13.6s18.6,5.1,23.5,13.6l143.9,249.3c4.9,8.5,4.9,18.6,0,27.1
s-13.7,13.6-23.5,13.6H34.7z"/>
<path class="st2" d="M178.6,15c6.6,0,13.2,3.3,17,9.8l102.1,176.8l0,0l37,64.1l4.9,8.4c7.6,13.1-1.9,29.5-17,29.5H86.9H68.7h-34
c-15.1,0-24.6-16.4-17-29.5L135.5,70l9.1-15.7l17-29.5C165.4,18.3,172,15,178.6,15L178.6,15z M178.6,0c-12.5,0-23.7,6.5-30,17.3
l-17,29.5l-9.1,15.7L4.7,266.6c-6.3,10.8-6.3,23.8,0,34.6c6.3,10.9,17.5,17.3,30,17.3h34h18.1h235.7c12.5,0,23.7-6.5,30-17.3
c6.3-10.8,6.3-23.8,0-34.6l-4.9-8.4l-37-64.1L208.6,17.3C202.4,6.5,191.2,0,178.6,0L178.6,0z"/>
</g>
</g>
<g>
<path class="st3" d="M339.6,274.1l-17-29.5H120.9l-34,58.9h235.7C337.7,303.6,347.1,287.2,339.6,274.1z"/>
<path class="st3" d="M245.5,229h68L195.6,24.8c-7.6-13.1-26.5-13.1-34,0l-17,29.5L245.5,229z"/>
<path class="st3" d="M169.6,128.9l-34-58.9L17.7,274.1c-7.6,13.1,1.9,29.5,17,29.5h34L169.6,128.9z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

@@ -1,95 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.min.css";
import {
LayoutContext,
TLayout,
} from "context/layoutContext";
import { TTheme, ThemeContext } from "context/themeContext";
type Props = {
children: React.ReactNode;
};
function ClientContextWrapper({ children }: Props) {
const [theme, setTheme] = useState<TTheme>("light");
const [layout, setLayout] = useState<TLayout>("grid");
useEffect(() => {
console.log("ContextWrapper useEffect");
if (typeof window === "undefined") return;
const lsTheme = localStorage.getItem("theme");
const lsLayout = localStorage.getItem("layout");
const lsSitePassword =
localStorage.getItem("sitePassword");
if (
lsTheme &&
(lsTheme === "dark" || lsTheme === "light")
) {
setTheme(lsTheme as TTheme);
}
if (
lsLayout &&
(lsLayout === "grid" || lsLayout === "list")
) {
setLayout(lsLayout as TLayout);
}
}, []);
useEffect(() => {
if (theme === "light") {
document.body.classList.remove("dark");
} else {
document.body.classList.add("dark");
}
}, [theme]);
return (
<>
<ThemeContext.Provider
value={{
theme,
setTheme: (theme) => {
if (typeof window !== "undefined") {
localStorage.setItem("theme", theme);
}
setTheme(theme);
},
}}
>
<LayoutContext.Provider
value={{
layout,
setLayout: (layout) => {
if (typeof window !== "undefined") {
localStorage.setItem("layout", layout);
}
setLayout(layout);
},
}}
>
<ToastContainer
position='bottom-center'
autoClose={2500}
limit={3}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss={false}
draggable
pauseOnHover={false}
theme={theme}
/>
{children}
</LayoutContext.Provider>
</ThemeContext.Provider>
</>
);
}
export default ClientContextWrapper;
-117
View File
@@ -1,117 +0,0 @@
"use client";
import Link from "next/link";
import { Fragment, useEffect, useState } from "react";
import { MdHome } from "react-icons/md";
import { FilePath } from "types/api/path";
import siteConfig from "config/site.config";
type Props = {
data?: FilePath[];
};
function Breadcrumb({ data = [] }: Props) {
const [breadcrumb, setBreadcrumb] = useState<FilePath[]>(
[],
);
const [isLimited, setIsLimited] =
useState<boolean>(false);
const [breadcrumbPath, setBreadcrumbPath] = useState<
string[]
>([]);
useEffect(() => {
if (data.length) {
const reverseBreadcrumbs = data;
const isLimitReached =
reverseBreadcrumbs.length >
siteConfig.files.breadcrumbDepth;
const limitBreadcrumbs = isLimitReached
? reverseBreadcrumbs.slice(
reverseBreadcrumbs.length -
siteConfig.files.breadcrumbDepth,
reverseBreadcrumbs.length,
)
: reverseBreadcrumbs;
setBreadcrumb(limitBreadcrumbs);
setIsLimited(isLimitReached);
const path: string[] = [];
reverseBreadcrumbs.forEach((item) => {
path.push(item.name);
});
setBreadcrumbPath(path);
}
}, [data]);
return (
<div className={"flex w-full items-center gap-1 py-1"}>
{/* Home */}
<Link
href={"/"}
className={"flex items-center gap-1"}
>
<MdHome />
<span className={"hidden tablet:block"}>Root</span>
</Link>
{isLimited && (
<Fragment>
<span>{siteConfig.files.breadcrumbLimiter}</span>
<span>...</span>
</Fragment>
)}
<div
className={
"line-clamp-1 flex w-auto flex-grow-0 items-center gap-1 overflow-hidden break-words break-all"
}
>
{breadcrumb.map(
(parent: FilePath, index: number) => {
const findCurrentPath =
breadcrumbPath.findIndex(
(item) => item === parent.name,
);
const url = breadcrumbPath
.slice(0, findCurrentPath + 1)
.join("/");
return (
<Fragment key={index}>
<span>
{siteConfig.files.breadcrumbLimiter}
</span>
<Link
href={
index === breadcrumb.length - 1
? "#"
: url
}
key={`breadcrumb-${index}`}
className={`${
index === breadcrumb.length - 1
? "cursor-default opacity-100"
: ""
} `}
>
<span
className={`${
index === breadcrumb.length - 1
? "line-clamp-1 w-auto flex-grow-0 overflow-hidden break-words font-bold"
: "whitespace-nowrap"
}`}
>
{parent.name}
</span>
</Link>
</Fragment>
);
},
)}
</div>
</div>
);
}
export default Breadcrumb;
-111
View File
@@ -1,111 +0,0 @@
"use client";
import {
useCallback,
useContext,
useEffect,
useState,
} from "react";
import {
LayoutContext,
TLayoutContext,
} from "context/layoutContext";
import { API_Response } from "types/api";
import { FilesResponse } from "types/api/files";
import apiConfig from "config/api.config";
import GridLayout from "./compGridLayout";
import ListLayout from "./compListLayout";
type Props = {
data: FilesResponse;
};
function FileLayout({ data }: Props) {
const { layout } =
useContext<TLayoutContext>(LayoutContext);
const [clientData, setClientData] =
useState<FilesResponse>(data);
const [nextPageToken, setNextPageToken] =
useState<string>(data.nextPageToken ?? "");
const [isNextPageLoading, setIsNextPageLoading] =
useState<boolean>(false);
const [totalItems, setTotalItems] = useState<number>(
data.files.length + data.folders.length || 0,
);
useEffect(() => {
const files = clientData.files.length ?? 0;
const folders = clientData.folders.length ?? 0;
setTotalItems(files + folders);
}, [clientData]);
const handleLoadMore = useCallback(async () => {
setIsNextPageLoading(true);
const { data: newData } = await fetch(
`${apiConfig.basePath}/api/files?pageToken=${nextPageToken}`,
).then(
(res) =>
res.json() as Promise<API_Response<FilesResponse>>,
);
setClientData((prev) => ({
...prev,
files: [...prev.files, ...(newData?.files ?? [])],
folders: [
...prev.folders,
...(newData?.folders ?? []),
],
}));
setNextPageToken(newData?.nextPageToken ?? "");
setIsNextPageLoading(false);
}, [nextPageToken]);
return (
<>
{layout === "grid" ? (
<>
<GridLayout data={clientData} />
</>
) : (
<ListLayout data={clientData} />
)}
{nextPageToken && (
<div
className={
"flex w-full flex-nowrap items-center justify-center"
}
>
<button
className={"link w-full whitespace-nowrap"}
onClick={handleLoadMore}
>
{isNextPageLoading
? "Loading data..."
: "Load more"}
</button>
</div>
)}
<div
className={
"flex w-full flex-nowrap items-center justify-between gap-2 opacity-75"
}
>
<div className={"divider-horizontal"} />
<span
className={
"w-fit whitespace-nowrap text-center text-xs"
}
>
Total {totalItems} items
</span>
<div className={"divider-horizontal"} />
</div>
</>
);
}
export default FileLayout;
-32
View File
@@ -1,32 +0,0 @@
import Link from "next/link";
import siteConfig from "config/site.config";
function Footer() {
return (
<footer
className={
"flex w-full items-center justify-center gap-2 bg-zinc-100 py-1 dark:bg-zinc-900"
}
>
<span className={"text-xs font-semibold"}>
{siteConfig.footer.renderYear &&
new Date().getFullYear()}{" "}
{siteConfig.footer.text} -{" "}
<Link
role={"url"}
href={
"https://www.github.com/mbaharip/next-gdrive-index"
}
target={"_blank"}
rel={"noreferrer noopener"}
>
next-gdrive-index
</Link>{" "}
</span>
</footer>
);
}
export default Footer;
-201
View File
@@ -1,201 +0,0 @@
"use client";
import { drive_v3 } from "googleapis";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MdFolder, MdPlayCircle } from "react-icons/md";
import formatDuration from "utils/fileHelper/formatDuration";
import getFileIcon from "utils/fileHelper/iconMap";
import getFileGroup from "utils/fileHelper/typeMap";
import { FilesResponse } from "types/api/files";
import siteConfig from "config/site.config";
type Props = {
data: FilesResponse;
};
function GridLayout({ data }: Props) {
const pathname = usePathname();
return (
<div className={"flex h-full w-full flex-col gap-8"}>
{data.folders.length === 0 &&
data.files.length === 0 && (
<div
className={
"w-full flex items-center justify-center py-2"
}
>
<span
className={"text-lg font-bold text-center"}
>
No files or folders found
</span>
</div>
)}
{data.folders.length > 0 && (
<div
className={
"grid grid-cols-1 gap-4 tablet:grid-cols-4"
}
>
{data.folders.map((folder) => (
<GridFolder
folder={folder}
key={folder.id}
path={
pathname === "/" ? "" : (pathname as string)
}
/>
))}
</div>
)}
{data.files.length > 0 && (
<div
className={
"grid grid-cols-1 gap-4 tablet:grid-cols-4"
}
>
{data.files.map((file) => (
<GridFile
file={file}
key={file.id}
path={
pathname === "/" ? "" : (pathname as string)
}
/>
))}
</div>
)}
</div>
);
}
function GridFolder({
folder,
path = "",
}: {
folder: drive_v3.Schema$File;
path?: string;
}) {
const folderURL = `${path}/${encodeURIComponent(
folder.name ?? "",
)}`;
return (
<Link
href={folderURL}
title={folder.name ?? ""}
>
<div className={"file h-fit py-2"}>
<MdFolder className={"flex-shrink-0 flex-grow-0"} />
<div className={"line-clamp-1 w-full break-all"}>
<span className={"flex-shrink-0 flex-grow-0"}>
{folder.name}
</span>
</div>
</div>
</Link>
);
}
function GridFile({
file,
path = "",
}: {
file: drive_v3.Schema$File;
path?: string;
}) {
const Icon = getFileIcon(
file.fileExtension ?? "",
file.mimeType ?? "",
);
const fileGroup = getFileGroup(
file.fileExtension ?? "",
file.mimeType ?? "",
);
const isThumbnailAllowed =
siteConfig.files.allowThumbnailFileType.includes(
fileGroup,
);
const fileURL = `${path}/${encodeURIComponent(
file.name ?? "",
)}`;
return (
<Link
href={fileURL}
title={file.name ?? ""}
>
<div
className={
"file group relative h-full flex-col items-center"
}
>
{file.thumbnailLink && isThumbnailAllowed ? (
<>
<img
src={file.thumbnailLink ?? ""}
alt={file.name ?? ""}
className={
"relative h-52 w-full overflow-hidden rounded bg-checkerboard object-cover dark:bg-checkerboard-dark"
}
/>
{file.videoMediaMetadata && (
<div
className={
"absolute grid h-52 w-full place-items-center"
}
>
<MdPlayCircle
className={
"global-duration h-16 w-16 opacity-75 transition-opacity group-hover:opacity-100"
}
/>
{file.videoMediaMetadata.durationMillis && (
<div
className={
"global-duration absolute bottom-0 right-2 rounded-tl-lg bg-zinc-900 px-4 py-0.5 text-center text-sm transition-colors group-hover:bg-zinc-200 group-active:bg-zinc-50 dark:group-hover:bg-zinc-800 dark:group-active:bg-zinc-950"
}
>
{formatDuration(
file.videoMediaMetadata
.durationMillis,
)}
</div>
)}
</div>
)}
</>
) : (
<div
className={
"grid h-52 w-full place-items-center overflow-hidden rounded"
}
>
<Icon className={"h-20 w-20"} />
</div>
)}
<div
className={
"relative my-auto flex w-full items-center gap-4"
}
>
<img
src={file.iconLink ?? ""}
alt={file.name ?? ""}
className={"h-4 w-4"}
/>
<div className={"line-clamp-2 w-full break-all"}>
<span className={"flex-shrink-0 flex-grow-0"}>
{file.name}
</span>
</div>
</div>
</div>
</Link>
);
}
export default GridLayout;
-378
View File
@@ -1,378 +0,0 @@
"use client";
import { drive_v3 } from "googleapis";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
MdContentCopy,
MdDownload,
MdFolder,
MdPlayCircle,
} from "react-icons/md";
import useCopyText from "hooks/useCopyText";
import formatDate from "utils/fileHelper/formatDate";
import formatSize from "utils/fileHelper/formatSize";
import getFileGroup from "utils/fileHelper/typeMap";
import { FilesResponse } from "types/api/files";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
import siteConfig from "config/site.config";
type Props = {
data: FilesResponse;
};
function ListLayout({ data }: Props) {
const pathname = usePathname();
return (
<div className={"flex h-full w-full flex-col gap-4"}>
<div className={"flex flex-col gap-2"}>
<div
className={
"grid grid-cols-4 p-2 tablet:grid-cols-12"
}
>
<span
role={"columnheader"}
className={
"col-span-3 font-bold tablet:col-span-7"
}
>
Name
</span>
<span
role={"columnheader"}
className={
"hidden text-center font-bold tablet:col-span-2 tablet:block"
}
>
Modified At
</span>
<span
role={"columnheader"}
className={
"hidden text-center font-bold tablet:col-span-1 tablet:block"
}
>
Size
</span>
<span
role={"columnheader"}
className={
"hidden text-center font-bold tablet:col-span-1 tablet:block"
}
>
Type
</span>
<span
role={"columnheader"}
className={
"col-span-1 text-center font-bold tablet:col-span-1"
}
>
Actions
</span>
</div>
<div className={"divider-horizontal"} />
<div className={"flex w-full flex-col"}>
{data.folders.length === 0 &&
data.files.length === 0 && (
<div
className={
"col-span-full flex items-center justify-center py-2"
}
>
<span
className={
"text-lg font-bold text-center"
}
>
No files or folders found
</span>
</div>
)}
{data.folders.length > 0 && (
<>
{data.folders.map((folder) => (
<ListFolder
folder={folder}
key={`list-${folder.id}`}
path={
pathname === "/"
? ""
: (pathname as string)
}
/>
))}
</>
)}
{data.files.length > 0 && (
<>
{data.files.map((file) => (
<ListFile
file={file}
key={`list-${file.id}`}
path={
pathname === "/"
? ""
: (pathname as string)
}
/>
))}
</>
)}
</div>
</div>
</div>
);
}
function ListFolder({
folder,
path = "",
}: {
folder: drive_v3.Schema$File;
path?: string;
}) {
const folderURL = `${path}/${encodeURIComponent(
folder.name ?? "",
)}`;
return (
<Link
href={folderURL}
title={folder.name ?? ""}
className={"w-full"}
>
<div
className={
"file-list grid w-full grid-cols-4 py-2 tablet:grid-cols-12"
}
>
<span
className={
"col-span-3 flex items-center gap-2 tablet:col-span-7"
}
>
<MdFolder
className={
"aspect-square h-8 w-8 flex-shrink-0 flex-grow-0 rounded object-contain p-1"
}
/>
<div className={"line-clamp-1 w-full break-all"}>
<span className={"flex-shrink-0 flex-grow-0"}>
{folder.name}
</span>
</div>
</span>
<span
className={
"hidden text-center tablet:col-span-2 tablet:block"
}
>
{formatDate(new Date(folder.modifiedTime ?? ""))}
</span>
<span
className={
"hidden text-center tablet:col-span-1 tablet:block"
}
>
{folder.size ? formatSize(folder.size) : "-"}
</span>
<span
className={
"hidden text-center tablet:col-span-1 tablet:block"
}
>
{Constant.type_folder}
</span>
<span
className={
"col-span-1 text-center tablet:col-span-1"
}
>
<Action path={folderURL} />
</span>
</div>
</Link>
);
}
function ListFile({
file,
path = "",
}: {
file: drive_v3.Schema$File;
path?: string;
}) {
const fileGroup = getFileGroup(
file.fileExtension ?? "",
file.mimeType ?? "",
);
const isThumbnailAllowed =
siteConfig.files.allowThumbnailFileType.includes(
fileGroup,
);
const fileURL = `${path}/${encodeURIComponent(
file.name ?? "",
)}`;
return (
<Link
href={fileURL}
title={file.name ?? ""}
className={"w-full"}
>
<div
className={
"file-list group grid w-full grid-cols-4 items-center justify-center py-2 tablet:grid-cols-12"
}
>
<span
className={
"col-span-3 flex items-center gap-2 tablet:col-span-7"
}
>
{file.thumbnailLink && isThumbnailAllowed ? (
<div className={"relative"}>
<img
src={file.thumbnailLink}
alt={file.name ?? ""}
className={
"relative aspect-square h-8 w-8 flex-shrink-0 flex-grow-0 rounded bg-checkerboard object-cover dark:bg-checkerboard-dark"
}
/>
{file.videoMediaMetadata && (
<div
className={
"absolute inset-0 flex items-center justify-center"
}
>
<MdPlayCircle
className={
"global-duration h-6 w-6 opacity-75 transition-opacity group-hover:opacity-100"
}
/>
</div>
)}
</div>
) : (
<img
src={file.iconLink ?? ""}
alt={file.name ?? ""}
className={
"aspect-square h-8 w-8 flex-shrink-0 flex-grow-0 rounded object-contain p-2"
}
/>
)}
<div
className={
"line-clamp-1 w-full flex-grow-0 break-all"
}
>
<span className={"flex-shrink-0 flex-grow-0"}>
{file.name}
</span>
</div>
</span>
<span
className={
"hidden text-center tablet:col-span-2 tablet:block"
}
>
{formatDate(new Date(file.modifiedTime ?? ""))}
</span>
<span
className={
"hidden text-center tablet:col-span-1 tablet:block"
}
>
{file.size ? formatSize(file.size) : "-"}
</span>
<span
className={`hidden text-center capitalize tablet:col-span-1 tablet:block`}
>
{Constant[`type_${fileGroup}`] ??
Constant.type_default}
</span>
<span
className={
"col-span-1 text-center tablet:col-span-1"
}
>
<Action
path={fileURL}
isDownloadable={true}
/>
</span>
</div>
</Link>
);
}
function Action({
path = "",
isDownloadable = false,
}: {
path?: string;
isDownloadable?: boolean;
}) {
const copyLink = useCopyText();
let url: string = apiConfig.basePath;
if (path) {
if (isDownloadable) {
url += "/download";
}
url += path;
}
return (
<div
className={
"flex w-full items-center justify-center gap-2"
}
>
<div
role={"button"}
className={
"global-duration grid aspect-square h-8 w-8 cursor-pointer place-items-center rounded-full p-1 transition-colors hover:bg-zinc-300 active:bg-zinc-400 dark:hover:bg-zinc-700 dark:active:bg-zinc-600"
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
copyLink(url);
}}
>
<MdContentCopy
title={"Copy raw link"}
className={
"h-6 w-6 opacity-75 transition-opacity group-hover:opacity-100"
}
/>
</div>
{isDownloadable && (
<Link
title={"Download"}
href={url}
target={"_blank"}
rel={"noopener noreferrer"}
onClick={(e) => {
e.stopPropagation();
}}
className={
"global-duration grid aspect-square h-8 w-8 cursor-pointer place-items-center rounded-full p-1 transition-colors hover:bg-zinc-300 active:bg-zinc-400 dark:hover:bg-zinc-700 dark:active:bg-zinc-600"
}
>
<MdDownload
className={
"h-6 w-6 opacity-75 transition-opacity group-hover:opacity-100"
}
/>
</Link>
)}
</div>
);
}
export default ListLayout;
-202
View File
@@ -1,202 +0,0 @@
"use client";
import Link from "next/link";
import { useContext, useState } from "react";
import {
MdClose,
MdDarkMode,
MdLightMode,
MdLogout,
MdMenu,
MdSearch,
} from "react-icons/md";
import {
TThemeContext,
ThemeContext,
} from "context/themeContext";
import { Constant } from "types/general/constant";
import siteConfig from "config/site.config";
function Navbar() {
const { theme, setTheme } =
useContext<TThemeContext>(ThemeContext);
const [isMenuOpen, setIsMenuOpen] =
useState<boolean>(false);
const clearPassword = () => {
document.cookie = `${Constant.cookiePassword}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
};
return (
<>
<nav
className={
"sticky top-0 z-[1000] flex w-full items-center justify-between gap-2 bg-zinc-100 px-2 py-1 dark:bg-zinc-900 tablet:gap-4"
}
>
<Link
href={"/"}
title={siteConfig.siteName}
className={"flex items-center gap-2"}
>
<img
src={"/logo.svg"}
alt={siteConfig.siteName}
className={`h-8 w-8 ${
theme === "dark" && "hue-rotate-180 invert"
}`}
loading={"lazy"}
/>
<span
className={
"hidden text-lg font-bold tablet:block"
}
>
{siteConfig.navbar.title || siteConfig.siteName}
</span>
</Link>
<div className={"flex items-center gap-4"}>
<div
className={
"hidden items-center gap-4 tablet:flex"
}
>
{siteConfig.navbar.links.map((link) => (
<Link
key={`nav-link-${link.name}`}
href={link.href}
rel={
link.newTab ? "noopener noreferrer" : ""
}
target={link.newTab ? "_blank" : "_parent"}
title={link.name}
className={
"flex items-center gap-1 whitespace-nowrap"
}
>
{link.icon && <link.icon />}
{link.name}
</Link>
))}
</div>
<div
id={"nav-search"}
title={"Search files"}
role={"button"}
className={
"interactive relative flex aspect-square h-6 w-6 items-center justify-center tablet:h-5 tablet:w-5"
}
>
<MdSearch
className={
"global-duration absolute h-full w-full"
}
/>
</div>
<div
id={"nav-theme"}
title={"Toggle Theme"}
role={"button"}
className={
"interactive relative flex aspect-square h-6 w-6 items-center justify-center tablet:h-5 tablet:w-5"
}
onClick={() =>
setTheme(theme === "dark" ? "light" : "dark")
}
>
<MdDarkMode
className={`global-duration absolute h-full w-full transition-all ${
theme === "dark"
? "pointer-events-none rotate-90 opacity-0"
: "pointer-events-auto rotate-0 opacity-100"
}`}
/>
<MdLightMode
className={`global-duration absolute h-full w-full transition-all ${
theme === "dark"
? "pointer-events-auto rotate-0 opacity-100"
: "pointer-events-none rotate-90 opacity-0"
}`}
/>
</div>
<div
id={"nav-logout"}
title={"Logout"}
role={"button"}
className={
"interactive relative flex aspect-square h-6 w-6 items-center justify-center tablet:h-5 tablet:w-5"
}
onClick={() => {
const confirm = window.confirm(
"Are you sure you want to clear all password?",
);
if (confirm) {
clearPassword();
window.location.reload();
}
}}
>
<MdLogout
className={
"global-duration absolute h-full w-full"
}
/>
</div>
<div
id={"nav-menu"}
title={"Menu"}
role={"button"}
className={
"interactive relative flex aspect-square h-6 w-6 items-center justify-center tablet:hidden"
}
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
<MdMenu
className={`global-duration absolute h-full w-full transition-all ${
isMenuOpen
? "pointer-events-none rotate-90 opacity-0"
: "pointer-events-auto rotate-0 opacity-100"
}`}
/>
<MdClose
className={`global-duration absolute h-full w-full transition-all ${
isMenuOpen
? "pointer-events-auto rotate-0 opacity-100"
: "pointer-events-none rotate-90 opacity-0"
}`}
/>
</div>
</div>
</nav>
<div
className={`global-duration fixed z-[999] flex h-dynamic w-full flex-col items-start justify-center gap-4 bg-zinc-100 px-8 text-xl transition-all dark:bg-zinc-900 tablet:hidden ${
isMenuOpen ? "top-0" : "-top-full"
}`}
>
{siteConfig.navbar.links.map((link) => (
<Link
key={`m-nav-link-${link.name}`}
href={link.href}
rel={link.newTab ? "noopener noreferrer" : ""}
target={link.newTab ? "_blank" : "_parent"}
title={link.name}
className={`global-duration relative flex items-center gap-1 whitespace-nowrap transition-opacity delay-75 ${
isMenuOpen ? "opacity-100" : "opacity-0"
}`}
>
{link.icon && <link.icon />}
{link.name}
</Link>
))}
</div>
</>
);
}
export default Navbar;
-96
View File
@@ -1,96 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import passwordHash from "utils/encryptionHelper/passwordHash";
import { Constant } from "types/general/constant";
function Password({
path,
redirect = "/",
}: {
path: string;
redirect?: string;
}) {
const router = useRouter();
const [password, setPassword] = useState<string>("");
const handlePasswordSubmit = (
e: React.FormEvent<HTMLFormElement>,
) => {
e.preventDefault();
const isPasswordExists =
document.cookie.split(Constant.cookiePassword)
.length > 1;
const expires = new Date();
expires.setFullYear(expires.getFullYear() + 1);
if (isPasswordExists) {
const passwordCookie = document.cookie
.split(Constant.cookiePassword)[1]
.split(";")[0]
.split("=")[1];
const parsedPassword = JSON.parse(passwordCookie);
parsedPassword[path] = passwordHash.encode(password);
document.cookie = `${
Constant.cookiePassword
}=${JSON.stringify(
parsedPassword,
)}; expires=${expires.toUTCString()}; path=/`;
} else {
const parsedPassword = {
[path]: passwordHash.encode(password),
};
document.cookie = `${
Constant.cookiePassword
}=${JSON.stringify(
parsedPassword,
)}; expires=${expires.toUTCString()}; path=/`;
}
router.push(redirect);
};
return (
<div
className={
"flex max-w-screen-lg flex-col items-center gap-2"
}
>
<h1 className={"font-semibold"}>
Password protected
</h1>
<p className={"text-center"}>
The file or folder you are trying to access is
password protected. Please enter the password to
continue.
</p>
<form
className={
"my-8 flex w-full max-w-md flex-col gap-2"
}
onSubmit={handlePasswordSubmit}
>
<input
type={"password"}
className={"input"}
placeholder={"Password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type={"submit"}
className={"interactive"}
>
Submit
</button>
</form>
</div>
);
}
export default Password;
@@ -1,43 +0,0 @@
"use client";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypePrism from "rehype-prism-plus";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkSlug from "remark-slug";
import remarkToc from "remark-toc";
import shortEncryption from "utils/encryptionHelper/shortEncryption";
import "styles/highlight.css";
import "styles/markdown.css";
type Props = {
data: string;
};
function CompReadmeRender({ data }: Props) {
return (
<div className={"markdown w-full"}>
<ReactMarkdown
remarkPlugins={[
remarkGfm,
remarkMath,
remarkSlug,
remarkToc,
]}
rehypePlugins={[
rehypeKatex,
rehypeRaw,
[rehypePrism, { ignoreMissing: true }],
]}
>
{shortEncryption.decrypt(data)}
</ReactMarkdown>
</div>
);
}
export default CompReadmeRender;
-116
View File
@@ -1,116 +0,0 @@
"use client";
import {
useContext,
useEffect,
useRef,
useState,
} from "react";
import {
MdArrowDropDown,
MdGridView,
MdList,
} from "react-icons/md";
import {
LayoutContext,
TLayoutContext,
} from "context/layoutContext";
function SwitchLayout() {
const { layout, setLayout } =
useContext<TLayoutContext>(LayoutContext);
const [isRenderGrid, setIsRenderGrid] =
useState<boolean>();
const [isDropdownOpen, setIsDropdownOpen] =
useState<boolean>(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setIsRenderGrid(layout === "grid");
}, [layout]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
}
document.addEventListener(
"mousedown",
handleClickOutside,
);
return () => {
document.removeEventListener(
"mousedown",
handleClickOutside,
);
};
}, [dropdownRef]);
return (
<div
className={
"relative flex w-fit flex-shrink-0 flex-grow-0 flex-col"
}
ref={dropdownRef}
>
<div
className={"layoutDropdown capitalize"}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<div className={"flex w-16 items-center gap-2"}>
{isRenderGrid && (
<MdGridView className={"h-4 w-4"} />
)}
{!isRenderGrid && (
<MdList className={"h-4 w-4"} />
)}
<span>{isRenderGrid ? "Grid" : "List"}</span>
</div>
<MdArrowDropDown />
</div>
<div
className={`layoutOption absolute left-0 right-0 top-0 z-10 flex ${
isDropdownOpen
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0"
} ${
isRenderGrid ? "flex-col" : "flex-col-reverse"
}`}
>
<div
data-active={isRenderGrid}
className={"layoutItem"}
onClick={() => {
setLayout("grid");
setIsRenderGrid(true);
setIsDropdownOpen(false);
}}
>
<MdGridView className={"h-4 w-4"} />
<span>Grid</span>
</div>
<div
data-active={!isRenderGrid}
className={"layoutItem"}
onClick={() => {
setLayout("list");
setIsRenderGrid(false);
setIsDropdownOpen(false);
}}
>
<MdList className={"h-4 w-4"} />
<span>List</span>
</div>
</div>
</div>
);
}
export default SwitchLayout;
@@ -1,82 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import createErrorPayload from "utils/apiHelper/createErrorPayload";
import gdrive from "utils/apiHelper/gdrive";
import shortEncryption from "utils/encryptionHelper/shortEncryption";
import ExtendedError from "utils/generalHelper/extendedError";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
export async function GET(
request: NextRequest,
{ params }: { params: { encryptedFileId: string } },
) {
const _start = Date.now();
const { encryptedFileId } = params;
try {
const getMetadata = gdrive.files.get({
fileId: shortEncryption.decrypt(encryptedFileId),
fields: "id, name, mimeType, webContentLink",
});
const getStream = gdrive.files.get(
{
fileId: shortEncryption.decrypt(encryptedFileId),
alt: "media",
},
{ responseType: "arraybuffer" },
);
const [metadata, stream] = await Promise.all([
getMetadata,
getStream,
]);
if (!metadata || metadata.data.trashed) {
throw new ExtendedError(
Constant.apiFileNotFound,
404,
"notFound",
Constant.reasonNotFound,
);
}
if (
Number(metadata.data.size) >
apiConfig.files.download.maxFileSize &&
apiConfig.files.download.maxFileSize > 0
) {
return NextResponse.redirect(
metadata.data.webContentLink as string,
{
status: 302,
},
);
}
const imgBuffer = (await stream.data) as ArrayBuffer;
return new NextResponse(imgBuffer, {
status: 200,
headers: {
"Content-Type":
metadata.data.mimeType ||
"application/octet-stream",
"Cache-Control": apiConfig.cacheControl,
"Content-Disposition": `inline; filename="${metadata.data.name}"`,
},
});
} catch (error: any) {
const payload = createErrorPayload(
error,
"GET /api/banner/:id",
_start,
);
return NextResponse.json(payload, {
status: payload.code,
});
}
}
@@ -1,223 +0,0 @@
import { drive_v3 } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
import createErrorPayload from "utils/apiHelper/createErrorPayload";
import gdrive from "utils/apiHelper/gdrive";
import getSearchParams from "utils/apiHelper/getSearchParams";
import shortEncryption from "utils/encryptionHelper/shortEncryption";
import ExtendedError from "utils/generalHelper/extendedError";
import { API_Response } from "types/api";
import {
FileResponse,
FilesResponse,
} from "types/api/files";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
export async function GET(
request: NextRequest,
{ params }: { params: { encryptedId: string } },
) {
const _start = Date.now();
try {
const { pageToken, banner, thumbnail } =
getSearchParams(request.url, [
"pageToken",
"banner",
"thumbnail",
]);
const id = shortEncryption.decrypt(params.encryptedId);
if (id === apiConfig.files.rootFolder) {
return NextResponse.redirect(
`${apiConfig.basePath}/api/files`,
{
status: 301,
},
);
}
const file = await gdrive.files.get({
fileId: id,
fields: apiConfig.files.field,
});
if (!file || file.data.trashed) {
throw new ExtendedError(
Constant.apiFileNotFound,
404,
"notFound",
Constant.reasonNotFound,
);
}
/**
* If fetched file isn't a folder, return File
*/
if (
file.data.mimeType !==
"application/vnd.google-apps.folder"
) {
if (thumbnail === "1") {
return NextResponse.redirect(
`${
apiConfig.basePath
}/api/thumbnail/${shortEncryption.encrypt(
id as string,
)}`,
{
status: 302,
},
);
}
const payload: API_Response<FileResponse> = {
success: true,
timestamp: new Date().toISOString(),
responseTime: Date.now() - _start,
data: {
...file.data,
id: shortEncryption.encrypt(
file.data.id as string,
),
webContentLink:
shortEncryption.encrypt(
file.data.webContentLink as string,
) || undefined,
},
};
return NextResponse.json(payload, {
status: 200,
headers: {
"Cache-Control": apiConfig.cacheControl,
},
});
}
const query = [
...apiConfig.files.query,
`'${id}' in parents`,
];
const fetchFolderContents = await gdrive.files.list({
q: `${query.join(" and ")}`,
fields: `files(${apiConfig.files.field}), nextPageToken`,
orderBy: apiConfig.files.orderBy,
pageSize: apiConfig.files.itemsPerPage,
pageToken: pageToken || undefined,
});
const readmeFile = fetchFolderContents.data.files?.find(
(file) =>
file.name === apiConfig.files.specialFile.readme,
);
let readmeContent: string | undefined;
const bannerFile = fetchFolderContents.data.files?.find(
(file) =>
file.name?.startsWith(
apiConfig.files.specialFile.banner,
) && file.mimeType?.startsWith("image/"),
);
if (readmeFile) {
const readme = await gdrive.files.get(
{
fileId: readmeFile.id as string,
alt: "media",
},
{ responseType: "text" },
);
readmeContent = readme.data as string;
}
if (banner === "1") {
if (!bannerFile) {
throw new ExtendedError(
Constant.apiFileNotFound,
404,
"notFound",
"The banner file is not found.",
);
}
return NextResponse.redirect(
`${
apiConfig.basePath
}/api/banner/${shortEncryption.encrypt(
bannerFile.id as string,
)}`,
{
status: 302,
},
);
}
const folderList =
(fetchFolderContents.data.files
?.filter(
(file) =>
file.mimeType ===
"application/vnd.google-apps.folder",
)
.map((file) => ({
...file,
id: shortEncryption.encrypt(file.id as string),
})) as drive_v3.Schema$File[]) || [];
const fileList =
(fetchFolderContents.data.files
?.filter(
(file) =>
!file.mimeType?.startsWith(
"application/vnd.google-apps",
) &&
!apiConfig.files.hiddenFiles.some(
(hiddenFile) =>
file.name?.startsWith(hiddenFile),
),
)
.map((file) => ({
...file,
id: shortEncryption.encrypt(file.id as string),
webContentLink:
shortEncryption.encrypt(
file.webContentLink as string,
) || undefined,
})) as drive_v3.Schema$File[]) || [];
const payload: API_Response<FilesResponse> = {
success: true,
timestamp: new Date().toISOString(),
responseTime: Date.now() - _start,
data: {
folders: folderList,
files: fileList,
isReadmeExists: !!readmeFile,
readmeContent: readmeContent
? shortEncryption.encrypt(readmeContent)
: undefined,
isBannerExists: !!bannerFile,
nextPageToken:
fetchFolderContents.data.nextPageToken ||
undefined,
},
};
return NextResponse.json(payload, {
status: 200,
headers: {
"Cache-Control": apiConfig.cacheControl,
},
});
} catch (error: any) {
const payload = createErrorPayload(
error,
"GET /api/files/:id",
_start,
);
return NextResponse.json(payload, {
status: payload.code || 500,
});
}
}
-148
View File
@@ -1,148 +0,0 @@
import { drive_v3 } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
import createErrorPayload from "utils/apiHelper/createErrorPayload";
import gdrive from "utils/apiHelper/gdrive";
import getSearchParams from "utils/apiHelper/getSearchParams";
import shortEncryption from "utils/encryptionHelper/shortEncryption";
import ExtendedError from "utils/generalHelper/extendedError";
import { API_Response } from "types/api";
import { FilesResponse } from "types/api/files";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
export async function GET(request: NextRequest) {
const _start = Date.now();
try {
const { pageToken, banner } = getSearchParams(
request.url,
["pageToken", "banner"],
);
const query: string[] = [
...apiConfig.files.query,
`'${apiConfig.files.rootFolder}' in parents`,
];
const fetchFolderContents = await gdrive.files.list({
q: `${query.join(" and ")}`,
fields: `files(${apiConfig.files.field}), nextPageToken`,
orderBy: apiConfig.files.orderBy,
pageSize: apiConfig.files.itemsPerPage,
pageToken: pageToken || undefined,
});
const readmeFile = fetchFolderContents.data.files?.find(
(file) =>
file.name === apiConfig.files.specialFile.readme,
);
let readmeContent: string | undefined;
const bannerFile = fetchFolderContents.data.files?.find(
(file) =>
file.name?.startsWith(
apiConfig.files.specialFile.banner,
) && file.mimeType?.startsWith("image/"),
);
if (readmeFile) {
const readme = await gdrive.files.get(
{
fileId: readmeFile.id as string,
alt: "media",
},
{ responseType: "text" },
);
readmeContent = readme.data as string;
}
if (banner === "1") {
if (!bannerFile) {
throw new ExtendedError(
Constant.apiFileNotFound,
404,
"notFound",
"The banner file is not found.",
);
}
return NextResponse.redirect(
`${
apiConfig.basePath
}/api/banner/${shortEncryption.encrypt(
bannerFile.id as string,
)}`,
{
status: 302,
},
);
}
const folderList =
(fetchFolderContents.data.files
?.filter(
(file) =>
file.mimeType ===
"application/vnd.google-apps.folder",
)
.map((file) => ({
...file,
id: shortEncryption.encrypt(file.id as string),
})) as drive_v3.Schema$File[]) || [];
const fileList =
(fetchFolderContents.data.files
?.filter(
(file) =>
!file.mimeType?.startsWith(
"application/vnd.google-apps",
) &&
!apiConfig.files.hiddenFiles.some(
(hiddenFile) =>
file.name?.startsWith(hiddenFile),
),
)
.map((file) => ({
...file,
id: shortEncryption.encrypt(file.id as string),
webContentLink:
shortEncryption.encrypt(
file.webContentLink as string,
) || undefined,
})) as drive_v3.Schema$File[]) || [];
const payload: API_Response<FilesResponse> = {
success: true,
timestamp: new Date().toISOString(),
responseTime: Date.now() - _start,
data: {
folders: folderList,
files: fileList,
isReadmeExists: !!readmeFile,
readmeContent: readmeContent
? shortEncryption.encrypt(readmeContent)
: undefined,
isBannerExists: !!bannerFile,
nextPageToken:
fetchFolderContents.data.nextPageToken ||
undefined,
},
};
return NextResponse.json(payload, {
status: 200,
headers: {
"Cache-Control": apiConfig.cacheControl,
},
});
} catch (error: any) {
const payload = createErrorPayload(
error,
"GET /api/files",
_start,
);
return NextResponse.json(payload, {
status: payload.code || 500,
});
}
}
-76
View File
@@ -1,76 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import createErrorPayload from "utils/apiHelper/createErrorPayload";
import getSearchParams from "utils/apiHelper/getSearchParams";
import shortEncryption from "utils/encryptionHelper/shortEncryption";
import ExtendedError from "utils/generalHelper/extendedError";
import { Constant } from "types/general/constant";
export function GET(request: NextRequest) {
const _start = Date.now();
try {
const { text } = getSearchParams(request.url, ["text"]);
if (!text) {
throw new ExtendedError(
Constant.apiBadRequest,
400,
"bad_request",
"Missing required parameter: text",
);
}
const decryptedText = shortEncryption.decrypt(text);
return NextResponse.json(
{ text: decryptedText },
{ status: 200 },
);
} catch (error: any) {
const payload = createErrorPayload(
error,
"GET /api/help/encrypt",
_start,
);
return NextResponse.json(payload, {
status: payload.code,
});
}
}
export function POST(request: NextRequest) {
const _start = Date.now();
try {
const { text } = getSearchParams(request.url, ["text"]);
if (!text) {
throw new ExtendedError(
Constant.apiBadRequest,
400,
"bad_request",
"Missing required parameter: text",
);
}
const encryptedText = shortEncryption.encrypt(text);
return NextResponse.json(
{ text: encryptedText },
{ status: 200 },
);
} catch (error: any) {
const payload = createErrorPayload(
error,
"GET /api/help/encrypt",
_start,
);
return NextResponse.json(payload, {
status: payload.code,
});
}
}
@@ -1,103 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import createErrorPayload from "utils/apiHelper/createErrorPayload";
import gdrive from "utils/apiHelper/gdrive";
import shortEncryption from "utils/encryptionHelper/shortEncryption";
import ExtendedError from "utils/generalHelper/extendedError";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
export async function GET(
request: NextRequest,
{ params }: { params: { encryptedFileId: string } },
) {
const _start = Date.now();
const { encryptedFileId } = params;
try {
const getMetadata = gdrive.files.get({
fileId: shortEncryption.decrypt(encryptedFileId),
fields:
"id, name, mimeType, webContentLink, thumbnailLink, size",
});
const getStream = gdrive.files.get(
{
fileId: shortEncryption.decrypt(encryptedFileId),
alt: "media",
},
{ responseType: "arraybuffer" },
);
const [metadata, stream] = await Promise.all([
getMetadata,
getStream,
]);
if (!metadata || metadata.data.trashed) {
throw new ExtendedError(
Constant.apiFileNotFound,
404,
"notFound",
Constant.reasonNotFound,
);
}
if (!metadata.data.thumbnailLink) {
const imgStream = await fetch(
`${apiConfig.basePath}/og.png`,
).then((res) => res.arrayBuffer());
return new NextResponse(imgStream, {
status: 200,
headers: {
"Content-Type":
metadata.data.mimeType ||
"application/octet-stream",
"Cache-Control": apiConfig.cacheControl,
"Content-Disposition": `inline; filename="${metadata.data.name}"`,
},
});
}
const isWithinMaxFileSize =
Number(metadata.data.size) <
apiConfig.files.download.maxFileSize &&
apiConfig.files.download.maxFileSize > 0;
if (
metadata.data.mimeType?.startsWith("image") &&
isWithinMaxFileSize
) {
const imgBuffer = (await stream.data) as ArrayBuffer;
return new NextResponse(imgBuffer, {
status: 200,
headers: {
"Cache-Control": apiConfig.cacheControl,
},
});
}
return NextResponse.redirect(
metadata.data.thumbnailLink as string,
{
status: 302,
headers: {
"Cache-Control": apiConfig.cacheControl,
},
},
);
} catch (error: any) {
const payload = createErrorPayload(
error,
"GET /api/thumbnail/:id",
_start,
);
return NextResponse.json(payload, {
status: payload.code,
});
}
}
@@ -1,277 +0,0 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import createErrorPayload from "utils/apiHelper/createErrorPayload";
import gdrive from "utils/apiHelper/gdrive";
import passwordHash from "utils/encryptionHelper/passwordHash";
import shortEncryption from "utils/encryptionHelper/shortEncryption";
import ExtendedError from "utils/generalHelper/extendedError";
import { API_Response } from "types/api";
import {
FilePath,
ValidateFilePathResponse,
} from "types/api/path";
import { RequestContext } from "types/general";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
import siteConfig from "config/site.config";
export async function GET(
request: NextRequest,
{ params }: RequestContext<"path", string[]>,
) {
const _start = Date.now();
const pathArray = params.path;
try {
const fetchRootId =
apiConfig.files.rootFolder !== "root"
? apiConfig.files.rootFolder
: gdrive.files.get({
fileId: "root",
fields: "id",
});
const fetchPathId = pathArray.map(async (path) => {
const query = [
"trashed = false",
"'me' in owners",
`name = '${path}'`,
];
const fetchFolderContents = await gdrive.files.list({
q: `${query.join(" and ")}`,
fields: "files(id, name, mimeType, parents)",
});
if (!fetchFolderContents.data.files?.length) {
throw new ExtendedError(
Constant.apiFileNotFound,
404,
"notFound",
`Path "${path}" is not found`,
);
}
return {
path,
data: fetchFolderContents.data.files.map(
(file) => ({
id: file.id,
parents: file.parents?.[0],
mimeType: file.mimeType,
}),
),
};
});
const [rootId, pathId] = await Promise.all([
fetchRootId,
Promise.all(fetchPathId),
]);
const selectedPath: string[] = [];
const mappedPath: FilePath[] = [];
// Check path validity
pathId.forEach((path, index) => {
let checkPath;
if (index === 0) {
checkPath = path.data.find(
(file) => file.parents === rootId,
);
} else {
checkPath = path.data.find(
(file) =>
file.parents === selectedPath[index - 1],
);
}
if (!checkPath) {
throw new ExtendedError(
Constant.apiFileNotFound,
404,
"notFound",
`Path "${path.path}" is not found`,
);
}
selectedPath.push(checkPath.id as string);
mappedPath.push({
name: path.path,
encryptedId: shortEncryption.encrypt(
checkPath.id as string,
),
mimeType: checkPath.mimeType as string,
});
});
// Check for protected folder
const fetchProtectedFolder = mappedPath.map(
async (path) => {
if (
path.mimeType !==
"application/vnd.google-apps.folder"
)
return;
const query = [
"trashed = false",
"'me' in owners",
`'${shortEncryption.decrypt(
path.encryptedId,
)}' in parents`,
`name = '${apiConfig.files.specialFile.password}'`,
];
const fetchFolderContents = await gdrive.files.list(
{
q: `${query.join(" and ")}`,
fields: "files(id, name, mimeType, parents)",
},
);
if (!fetchFolderContents.data.files?.length) return;
return {
path: path.name,
passwordId: fetchFolderContents.data.files[0].id,
};
},
);
let protectedFolder = await Promise.all(
fetchProtectedFolder,
);
protectedFolder = protectedFolder.filter(
(folder) => folder !== undefined,
);
const nearestProtectedFolder =
protectedFolder[protectedFolder.length - 1] ?? null;
const masterKey = request.headers.get(
Constant.cookieMaster,
);
const validMasterKey = passwordHash.verify(
masterKey || "",
apiConfig.masterKey,
);
if (
nearestProtectedFolder &&
nearestProtectedFolder.passwordId &&
!validMasterKey
) {
const fetchPassword = await gdrive.files.get(
{
fileId: nearestProtectedFolder.passwordId,
alt: "media",
},
{ responseType: "text" },
);
const userPassword = cookies().get(
Constant.cookiePassword,
)?.value;
if (!userPassword) {
throw new ExtendedError(
Constant.apiNotAuthorized,
401,
"unauthorized",
`You need to provide password to access "${nearestProtectedFolder.path}"`,
);
}
const parsedUserPassword = JSON.parse(userPassword);
const nearestFolderPassword =
parsedUserPassword[
decodeURIComponent(nearestProtectedFolder.path)
];
if (!nearestFolderPassword) {
throw new ExtendedError(
Constant.apiNotAuthorized,
401,
"unauthorized",
`You need to provide password to access "${nearestProtectedFolder.path}"`,
);
}
if (
!passwordHash.verify(
fetchPassword.data as string,
nearestFolderPassword,
)
) {
throw new ExtendedError(
Constant.apiNotAuthorized,
401,
"unauthorized",
`The password you provided is incorrect`,
);
}
}
if (
siteConfig.privateIndex &&
!nearestProtectedFolder &&
!validMasterKey
) {
const userPassword = cookies().get(
Constant.cookiePassword,
)?.value;
if (!userPassword) {
throw new ExtendedError(
Constant.apiNotAuthorized,
401,
"unauthorized",
`You need to provide password to access "root"`,
);
}
const parsedUserPassword = JSON.parse(userPassword);
const nearestFolderPassword =
parsedUserPassword["root"];
if (!nearestFolderPassword) {
throw new ExtendedError(
Constant.apiNotAuthorized,
401,
"unauthorized",
`You need to provide password to access "root"`,
);
}
if (
!passwordHash.compare(
siteConfig.indexPassword,
nearestFolderPassword,
)
) {
throw new ExtendedError(
Constant.apiNotAuthorized,
401,
"unauthorized",
`The password you provided is incorrect`,
);
}
}
const payload: API_Response<ValidateFilePathResponse> =
{
success: true,
timestamp: new Date().toISOString(),
responseTime: Date.now() - _start,
data: mappedPath,
};
return NextResponse.json(payload, {
status: 200,
});
} catch (error: any) {
const payload = createErrorPayload(
error,
`GET /api/verify/${pathArray.join("/")}`,
_start,
);
return NextResponse.json(payload, {
status: payload.code || 500,
});
}
}
-89
View File
@@ -1,89 +0,0 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import createErrorPayload from "utils/apiHelper/createErrorPayload";
import passwordHash from "utils/encryptionHelper/passwordHash";
import ExtendedError from "utils/generalHelper/extendedError";
import { API_Response } from "types/api";
import { ValidateFilePathResponse } from "types/api/path";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
import siteConfig from "config/site.config";
export async function GET(request: NextRequest) {
const _start = Date.now();
try {
const masterKey = request.headers.get(
Constant.cookieMaster,
);
const validMasterKey = passwordHash.verify(
masterKey || "",
apiConfig.masterKey,
);
if (siteConfig.privateIndex && !validMasterKey) {
const userPassword = cookies().get(
Constant.cookiePassword,
)?.value;
if (!userPassword) {
throw new ExtendedError(
Constant.apiNotAuthorized,
401,
"unauthorized",
`You need to provide password to access "root"`,
);
}
const parsedUserPassword = JSON.parse(userPassword);
const nearestFolderPassword =
parsedUserPassword["root"];
if (!nearestFolderPassword) {
throw new ExtendedError(
Constant.apiNotAuthorized,
401,
"unauthorized",
`You need to provide password to access "root"`,
);
}
if (
!passwordHash.compare(
siteConfig.indexPassword,
nearestFolderPassword,
)
) {
throw new ExtendedError(
Constant.apiNotAuthorized,
401,
"unauthorized",
`The password you provided is incorrect`,
);
}
}
const payload: API_Response<ValidateFilePathResponse> =
{
success: true,
timestamp: new Date().toISOString(),
responseTime: Date.now() - _start,
data: [],
};
return NextResponse.json(payload, {
status: 200,
});
} catch (error: any) {
const payload = createErrorPayload(
error,
"GET /api/validate",
_start,
);
return new Response(JSON.stringify(payload), {
status: payload.code,
});
}
}
@@ -1,3 +0,0 @@
function SetupPage() {}
export default SetupPage;
@@ -1,5 +0,0 @@
function SetupFirstStep() {
return <div className={""}></div>;
}
export default SetupFirstStep;
-33
View File
@@ -1,33 +0,0 @@
function RootLoading() {
return (
<div
className={
"mx-auto flex h-full w-full max-w-screen-xl flex-col gap-2"
}
>
<div
className={
"flex h-6 w-full items-center justify-between gap-2 tablet:gap-4"
}
>
<div
className={
"skeleton mr-auto h-full w-24 py-1 tablet:w-32"
}
/>
<div
className={
"skeleton h-full w-24 py-1 tablet:w-32"
}
/>
</div>
<div
className={
"skeleton h-full min-h-[50dvh] w-full rounded-lg px-4 py-2"
}
></div>
</div>
);
}
export default RootLoading;
-204
View File
@@ -1,204 +0,0 @@
import { Metadata, ResolvingMetadata } from "next";
import { cookies } from "next/headers";
import Breadcrumb from "components/compBreadcrumb";
import FileLayout from "components/compFileLayout";
import CompReadmeRender from "components/compReadmeRender";
import SwitchLayout from "components/compSwitchLayout";
import { handleError } from "utils/generalHelper/axiosFetch";
import { API_Error, API_Response } from "types/api";
import { FilesResponse } from "types/api/files";
import { ValidateFilePathResponse } from "types/api/path";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
import siteConfig from "config/site.config";
type Props = {
params: { path: string[] };
searchParams: {
[key: string]: string | string[] | undefined;
};
};
export async function generateMetadata(
{ params, searchParams }: Props,
parent?: ResolvingMetadata,
): Promise<Metadata> {
const pathValidation = await fetch(
`${apiConfig.basePath}/api/validate/${params.path.join(
"/",
)}`,
{
headers: {
[Constant.cookieMaster]: process.env
.MASTER_KEY as string,
},
},
).then(
(res) =>
res.json() as Promise<
API_Response<ValidateFilePathResponse>
>,
);
let pageName = siteConfig.siteName;
if (pathValidation.success) {
pageName = pathValidation.data?.[
pathValidation.data?.length - 1
].name as string;
}
if (
!pathValidation.success &&
(pathValidation as API_Error).code === 401
) {
pageName = "Protected Folder";
}
// optionally access and extend (rather than replace) parent metadata
const prevMeta = (await parent) as Metadata;
return {
...prevMeta,
title: `${pageName} | ${siteConfig.siteName}`,
openGraph: {
title: `${pageName} | ${siteConfig.siteName}`,
description: siteConfig.siteDescription,
url: `/${params.path.join("/")}`,
siteName: siteConfig.siteName,
images: [
{
alt: `${pageName} | ${siteConfig.siteName}`,
type: "image/png",
width: 1200,
height: 630,
url: `/opengraph/${params.path.join("/")}`,
},
],
},
twitter: {
title: `${pageName} | ${siteConfig.siteName}`,
description: siteConfig.siteDescription,
card: "summary_large_image",
site: siteConfig.siteName,
images: [
{
alt: `${pageName} | ${siteConfig.siteName}`,
type: "image/png",
width: 1200,
height: 630,
url: `/opengraph/${params.path.join("/")}`,
},
],
},
};
}
async function FilePage({ params }: Props) {
const passwordCookies =
cookies().get(Constant.cookiePassword)?.value || "";
const pathValidation = await fetch(
`${apiConfig.basePath}/api/validate/${params.path.join(
"/",
)}`,
{
credentials: "include",
headers: {
Cookie: `${Constant.cookiePassword}=${passwordCookies}`,
},
},
).then(
(res) =>
res.json() as Promise<
API_Response<ValidateFilePathResponse>
>,
);
if (!pathValidation.success) {
const errorData = pathValidation as API_Error;
const payload = handleError(errorData);
throw new Error(payload);
}
let isFolder = false;
if (
pathValidation.data?.[pathValidation.data?.length - 1]
.mimeType === "application/vnd.google-apps.folder"
) {
isFolder = true;
}
const lastId = pathValidation.data?.[
pathValidation.data?.length - 1
].encryptedId as string;
const filesData = await fetch(
`${apiConfig.basePath}/api/files/${lastId}`,
{
credentials: "include",
headers: {
Cookie: `${Constant.cookiePassword}=${passwordCookies}`,
},
},
).then(
(res) =>
res.json() as Promise<API_Response<FilesResponse>>,
);
if (!filesData.success) {
const errorData = filesData as API_Error;
const payload = handleError(errorData);
throw new Error(payload);
}
return (
<div
className={
"mx-auto flex h-full w-auto max-w-screen-xl flex-col gap-2"
}
>
<div
id={"content-head"}
className={
"flex w-full items-center justify-between gap-2 tablet:gap-4"
}
>
<Breadcrumb data={pathValidation.data} />
<SwitchLayout />
</div>
<div
id={"content-files"}
className={"card"}
>
{isFolder ? (
<FileLayout
data={
filesData.data
? (filesData.data as FilesResponse)
: { folders: [], files: [] }
}
/>
) : (
<span>File preview</span>
)}
</div>
{filesData.data?.isReadmeExists &&
filesData.data?.readmeContent && (
<div
id={"content-readme"}
className={"card"}
>
<CompReadmeRender
data={filesData.data.readmeContent}
/>
</div>
)}
</div>
);
}
export default FilePage;
@@ -0,0 +1,94 @@
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",
});
const _fileContent = gdrive.files.get(
{
fileId: decryptData(data.id),
alt: "media",
},
{ responseType: "stream" },
);
const [fileMeta, fileContent] = await Promise.all([_fileMeta, _fileContent]);
const fileSize = Number(fileMeta.data.size) ?? 0;
if (fileSize > gIndexConfig.apiConfig.maxFileSize && !disableLimit) {
return NextResponse.redirect(fileMeta.data.webContentLink!, {
status: 302,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
}
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
fileContent.data.on("data", (chunk) => {
chunks.push(chunk);
});
fileContent.data.on("end", () => {
resolve(Buffer.concat(chunks));
});
fileContent.data.on("error", (err) => {
reject(err);
});
});
return new NextResponse(fileBuffer, {
status: 200,
headers: {
"Content-Type": fileMeta.data.mimeType ?? "application/octet-stream",
"Content-Disposition": `${preview ? "inline;" : "attachment;"} filename="${encodeURIComponent(fileName)}"`,
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
@@ -0,0 +1,213 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import {
decryptData,
encryptData,
} from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import { checkPathPassword } from "utils/passwordHelper";
import {
APIValidateResponse,
ErrorResponse,
} from "types/api/response";
import { Constant } from "types/constant";
type FilePath = {
name: string;
encryptedId: string;
mimeType: string;
};
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } },
) {
const reqStart = Date.now();
const pathArray = params.path;
try {
const fetchRootId = gdrive.files.get({
fileId: gIndexConfig.apiConfig.rootFolder,
fields: "id",
});
const fetchPathId = pathArray.map(async (path) => {
const query = ["trashed = false", `name = '${path}'`];
const fetchFolderContents = await gdrive.files.list({
q: `${query.join(" and ")}`,
fields: "files(id, name, mimeType, parents)",
});
if (!fetchFolderContents.data.files?.length) {
throw new Error(`Path "${path}" is not found`);
}
return {
path,
data: fetchFolderContents.data.files.map(
(file) => ({
id: file.id,
parents: file.parents?.[0],
mimeType: file.mimeType,
}),
),
};
});
const [rootId, pathId] = await Promise.all([
fetchRootId,
Promise.all(fetchPathId),
]);
const selectedPath: string[] = [];
const mappedPath: FilePath[] = [];
// Check path validity
pathId.forEach((path, index) => {
let checkPath =
index === 0
? path.data.find(
(file) => file.parents === rootId.data.id,
)
: path.data.find(
(file) =>
file.parents === selectedPath[index - 1],
);
if (!checkPath)
throw new ExtendedError(
"Path not found",
404,
`Can't find path "${path.path}". Please check your path again.`,
);
selectedPath.push(checkPath.id as string);
mappedPath.push({
name: path.path,
encryptedId: encryptData(checkPath.id as string),
mimeType: checkPath.mimeType as string,
});
});
return NextResponse.json(mappedPath, {
status: 200,
});
// Check for protected folder
const fetchProtectedFolder = mappedPath.map(
async (path) => {
if (
path.mimeType !==
"application/vnd.google-apps.folder"
)
return;
const query = [
"trashed = false",
`'${decryptData(path.encryptedId)}' in parents`,
`name = '${gIndexConfig.apiConfig.specialFile.password}'`,
];
const fetchFolderContents = await gdrive.files.list(
{
q: `${query.join(" and ")}`,
fields: "files(id, name, mimeType, parents)",
},
);
if (!fetchFolderContents.data.files?.length) return;
return {
path: path.name,
passwordId: fetchFolderContents.data.files[0].id,
};
},
);
let protectedFolder = await Promise.all(
fetchProtectedFolder,
);
console.log(protectedFolder);
protectedFolder = protectedFolder.filter(
(item) => item !== undefined,
);
const nearestFolderPassword =
protectedFolder[protectedFolder.length - 1] ?? null;
console.log(
"After nearestFolderPassword",
nearestFolderPassword,
);
if (
nearestFolderPassword &&
nearestFolderPassword.passwordId
) {
const fetchPassword = await gdrive.files.get(
{
fileId: nearestFolderPassword.passwordId,
alt: "media",
},
{
responseType: "text",
},
);
const userPassword =
request.cookies.get(Constant.cookies_SitePassword)
?.value ?? null;
if (!userPassword) {
throw new ExtendedError(
"Password required",
401,
`You need to enter password to access this file / folder`,
);
}
const nearestPathIndex = mappedPath.findIndex(
(path) => path.name === nearestFolderPassword.path,
);
const currentPath =
"/" +
mappedPath
.slice(0, nearestPathIndex + 1)
.map((path) => path.name)
.join("/");
const validPassword = checkPathPassword(
currentPath,
userPassword,
fetchPassword.data as string,
);
if (!validPassword) {
throw new ExtendedError(
"Invalid password",
401,
`The password you entered is invalid`,
);
}
}
const payload: APIValidateResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
path: mappedPath.map((path) => path.name).join("/"),
valid: true,
};
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,
});
}
}
+155
View File
@@ -0,0 +1,155 @@
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 gdrive from "utils/gdriveInstance";
import { IGDriveFiles } from "types/api/files";
import { APIFileResponse, APIFilesResponse, ErrorResponse } from "types/api/response";
export async function GET(request: NextRequest, { params }: { params: { encryptedId: string } }) {
const reqStart = Date.now();
try {
const id = decryptData(params.encryptedId);
if (id === gIndexConfig.apiConfig.rootFolder)
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_VERCEL_URL}/api/files`, { status: 301 });
const file = await gdrive.files.get({
fileId: id,
fields: gIndexConfig.apiConfig.defaultField,
});
if (!file || file.data.trashed) throw new ExtendedError("File not found", 404, "Can't find requested file.");
if (file.data.mimeType !== "application/vnd.google-apps.folder") {
const res: APIFileResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
file: {
mimeType: file.data.mimeType as string,
fileExtension: file.data.fileExtension as string,
encryptedId: params.encryptedId,
name: file.data.name as string,
trashed: file.data.trashed as boolean,
modifiedTime: file.data.modifiedTime as string,
encryptedWebContentLink: encryptData(file.data.webContentLink as string),
size: Number(file.data.size ?? 0),
thumbnailLink: file.data.thumbnailLink as string,
imageMediaMetadata: file.data.imageMediaMetadata
? {
width: Number(file.data.imageMediaMetadata.width),
height: Number(file.data.imageMediaMetadata.height),
rotation: Number(file.data.imageMediaMetadata.rotation),
}
: undefined,
videoMediaMetadata: file.data.videoMediaMetadata
? {
width: Number(file.data.videoMediaMetadata.width),
height: Number(file.data.videoMediaMetadata.height),
durationMillis: Number(file.data.videoMediaMetadata.durationMillis),
}
: undefined,
},
};
return NextResponse.json(res, {
status: 200,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
}
const { pageToken } = getSearchParams(request.url, ["pageToken"]);
const filterName = gIndexConfig.apiConfig.hiddenFiles.map((item) => `name != '${item}'`).join(" and ");
const query: string[] = [...gIndexConfig.apiConfig.defaultQuery, `'${id}' in parents`, `${filterName}`];
const fetchFolderContents = await gdrive.files.list({
q: query.join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: gIndexConfig.apiConfig.defaultOrder,
pageSize: gIndexConfig.apiConfig.itemsPerPage,
pageToken: pageToken || undefined,
});
const folders: IGDriveFiles[] =
fetchFolderContents.data.files
?.filter((item) => item.mimeType === "application/vnd.google-apps.folder")
.map((item) => ({
mimeType: item.mimeType as string,
encryptedId: encryptData(item.id as string),
name: item.name as string,
trashed: item.trashed as boolean,
modifiedTime: item.modifiedTime as string,
})) ?? [];
const files: IGDriveFiles[] =
fetchFolderContents.data.files
?.filter((item) => item.mimeType !== "application/vnd.google-apps.folder")
.map((item) => ({
mimeType: item.mimeType as string,
fileExtension: item.fileExtension as string,
encryptedId: encryptData(item.id as string),
name: item.name as string,
trashed: item.trashed as boolean,
modifiedTime: item.modifiedTime as string,
encryptedWebContentLink: encryptData(item.webContentLink as string),
size: Number(item.size ?? 0),
thumbnailLink: item.thumbnailLink as string,
imageMediaMetadata: item.imageMediaMetadata
? {
width: Number(item.imageMediaMetadata.width ?? 0),
height: Number(item.imageMediaMetadata.height ?? 0),
rotation: Number(item.imageMediaMetadata.rotation ?? 0),
}
: undefined,
videoMediaMetadata: item.videoMediaMetadata
? {
width: Number(item.videoMediaMetadata.width ?? 0),
height: Number(item.videoMediaMetadata.height ?? 0),
durationMillis: Number(item.videoMediaMetadata.durationMillis ?? 0),
}
: undefined,
})) ?? [];
const readmeExists =
fetchFolderContents.data.files?.some((item) => item.name === gIndexConfig.apiConfig.specialFile.readme) ?? false;
const bannerExists =
fetchFolderContents.data.files?.some(
(item) =>
item.name?.startsWith(gIndexConfig.apiConfig.specialFile.banner) && item.mimeType?.startsWith("image/"),
) ?? false;
const passwordExists =
fetchFolderContents.data.files?.some((item) => item.name === gIndexConfig.apiConfig.specialFile.password) ??
false;
const res: APIFilesResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
folders,
files,
nextPageToken: fetchFolderContents.data.nextPageToken || undefined,
readmeExists,
bannerExists,
passwordExists,
};
return NextResponse.json(res, {
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,
});
}
}
+150
View File
@@ -0,0 +1,150 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import privateIndexProtection from "utils/apiHelper/privateIndexProtection";
import { encryptData } from "utils/encryptionHelper/hash";
import gdrive from "utils/gdriveInstance";
import { IGDriveFiles } from "types/api/files";
import {
APIFilesResponse,
ErrorResponse,
} from "types/api/response";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
await privateIndexProtection(request);
const { pageToken } = getSearchParams(request.url, [
"pageToken",
]);
const query: string[] = [
...gIndexConfig.apiConfig.defaultQuery,
`'${gIndexConfig.apiConfig.rootFolder}' in parents`,
];
const fetchFolderContents = await gdrive.files.list({
q: query.join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: gIndexConfig.apiConfig.defaultOrder,
pageSize: gIndexConfig.apiConfig.itemsPerPage,
pageToken: pageToken || undefined,
});
const folders: IGDriveFiles[] =
fetchFolderContents.data.files
?.filter(
(item) =>
item.mimeType ===
"application/vnd.google-apps.folder",
)
.map((item) => ({
mimeType: item.mimeType as string,
encryptedId: encryptData(item.id as string),
name: item.name as string,
trashed: item.trashed as boolean,
modifiedTime: item.modifiedTime as string,
})) ?? [];
const files: IGDriveFiles[] =
fetchFolderContents.data.files
?.filter(
(item) =>
item.mimeType !==
"application/vnd.google-apps.folder" &&
!gIndexConfig.apiConfig.hiddenFiles.includes(
item.name as string,
),
)
.map((item) => ({
mimeType: item.mimeType as string,
fileExtension: item.fileExtension as string,
encryptedId: encryptData(item.id as string),
name: item.name as string,
trashed: item.trashed as boolean,
modifiedTime: item.modifiedTime as string,
encryptedWebContentLink: encryptData(
item.webContentLink as string,
),
size: Number(item.size ?? 0),
thumbnailLink: item.thumbnailLink as string,
imageMediaMetadata: item.imageMediaMetadata
? {
width: Number(
item.imageMediaMetadata.width ?? 0,
),
height: Number(
item.imageMediaMetadata.height ?? 0,
),
rotation: Number(
item.imageMediaMetadata.rotation ?? 0,
),
}
: undefined,
videoMediaMetadata: item.videoMediaMetadata
? {
width: Number(
item.videoMediaMetadata.width ?? 0,
),
height: Number(
item.videoMediaMetadata.height ?? 0,
),
durationMillis: Number(
item.videoMediaMetadata.durationMillis ??
0,
),
}
: undefined,
})) ?? [];
const readmeExists =
fetchFolderContents.data.files?.some(
(item) =>
item.name ===
gIndexConfig.apiConfig.specialFile.readme,
) ?? false;
const bannerExists =
fetchFolderContents.data.files?.some(
(item) =>
item.name?.startsWith(
gIndexConfig.apiConfig.specialFile.banner,
) && item.mimeType?.startsWith("image/"),
) ?? false;
const passwordExists =
fetchFolderContents.data.files?.some(
(item) =>
item.name ===
gIndexConfig.apiConfig.specialFile.password,
) ?? false;
const res: APIFilesResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
folders,
files,
nextPageToken:
fetchFolderContents.data.nextPageToken || undefined,
readmeExists,
bannerExists,
passwordExists,
};
return NextResponse.json(res, {
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,
});
}
}
+190
View File
@@ -0,0 +1,190 @@
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 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,
});
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 gdrive.files.list({
q: query.join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: gIndexConfig.apiConfig.defaultOrder,
pageSize: gIndexConfig.apiConfig.itemsPerPage,
pageToken: pageToken ?? undefined,
});
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,
});
}
}
+129
View File
@@ -0,0 +1,129 @@
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 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 gdrive.files.list({
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",
},
{ 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,
});
}
}
+79
View File
@@ -0,0 +1,79 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import { decryptData } from "utils/encryptionHelper/hash";
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 gdrive.files.list({
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,
});
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import ExtendedError from "utils/extendedError";
import { addNewPassword } from "utils/passwordHelper";
import { ErrorResponse } from "types/api/response";
import { Constant } from "types/constant";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const { path, password } = getSearchParams(
request.url,
["path", "password"],
);
if (!path || !password)
throw new ExtendedError(
"Missing parameters",
400,
"Path or password is missing",
);
const cookies = request.cookies.get(
Constant.cookies_SitePassword,
);
if (!cookies)
throw new ExtendedError(
"No cookies found",
400,
"Site password not found",
);
const newPassword = addNewPassword(
path!,
password!,
cookies.value,
true,
);
return NextResponse.json(
{
newPassword: {
path,
password,
},
},
{
status: 200,
headers: {
"Set-Cookie": newPassword as string,
},
},
);
} 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,
});
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { decryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import { ErrorResponse } from "types/api/response";
import { Constant } from "types/constant";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const cookies = request.cookies.get(
Constant.cookies_SitePassword,
);
if (!cookies)
throw new ExtendedError(
"No cookies found",
400,
"Site password not found",
);
const rawToken = decryptData(cookies.value);
const token = JSON.parse(rawToken);
return NextResponse.json(
{
password: token,
},
{
status: 200,
},
);
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
@@ -0,0 +1,76 @@
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");
const path: string[] = [];
let lastId = id;
const fileContent = await gdrive.files.get({
fileId: lastId,
fields: "id, name, mimeType, parents",
});
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",
});
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("/"), process.env.NEXT_PUBLIC_VERCEL_URL);
return NextResponse.redirect(redirectURL.toString(), {
status: 302,
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,
});
}
}
+65
View File
@@ -0,0 +1,65 @@
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 gdrive from "utils/gdriveInstance";
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 gdrive.files.list({
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,
});
}
}
-140
View File
@@ -1,140 +0,0 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import createErrorPayload from "utils/apiHelper/createErrorPayload";
import gdrive from "utils/apiHelper/gdrive";
import shortEncryption from "utils/encryptionHelper/shortEncryption";
import ExtendedError from "utils/generalHelper/extendedError";
import { API_Response } from "types/api";
import { ValidateFilePathResponse } from "types/api/path";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } },
) {
const _start = Date.now();
const { path } = params;
try {
const passwordCookies =
cookies().get(Constant.cookiePassword)?.value || "";
const pathValidation = await fetch(
`${apiConfig.basePath}/api/validate/${path.join(
"/",
)}`,
{
headers: {
Cookie: `${Constant.cookiePassword}=${passwordCookies}`,
},
},
).then(
(res) =>
res.json() as Promise<
API_Response<ValidateFilePathResponse>
>,
);
let isFolder = false;
if (
pathValidation.data?.[pathValidation.data?.length - 1]
.mimeType === "application/vnd.google-apps.folder"
) {
isFolder = true;
}
const lastId = pathValidation.data?.[
pathValidation.data?.length - 1
].encryptedId as string;
if (isFolder) {
throw new ExtendedError(
Constant.apiBadRequest,
400,
"badRequest",
"Can't download folder.",
);
}
const getMetadata = gdrive.files.get({
fileId: shortEncryption.decrypt(lastId),
fields: "id, name, mimeType, webContentLink",
});
const getStream = gdrive.files.get(
{
fileId: shortEncryption.decrypt(lastId),
alt: "media",
},
{ responseType: "stream" },
);
const [metadata, stream] = await Promise.all([
getMetadata,
getStream,
]);
if (!metadata || metadata.data.trashed) {
const msg = metadata.data.trashed
? "File has been deleted"
: "File not found";
throw new ExtendedError(
Constant.apiFileNotFound,
404,
"notFound",
msg,
);
}
if (
Number(metadata.data.size) >
apiConfig.files.download.maxFileSize &&
apiConfig.files.download.maxFileSize > 0
) {
return NextResponse.redirect(
metadata.data.webContentLink as string,
{ status: 302 },
);
}
const arrayBuffer = await new Promise<ArrayBuffer>(
(resolve, reject) => {
const chunks: Buffer[] = [];
stream.data.on("data", (chunk) =>
chunks.push(chunk),
);
stream.data.on("end", () => {
const buffer = Buffer.concat(chunks);
resolve(
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
),
);
});
stream.data.on("error", reject);
},
);
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
"Content-Type":
metadata.data.mimeType ||
"application/octet-stream",
"Cache-Control": apiConfig.cacheControl,
"Content-Disposition": `attachment; filename="${metadata.data.name}"`,
},
});
} catch (error: any) {
const payload = createErrorPayload(
error,
`GET /download/${path.join("/")}`,
_start,
);
return NextResponse.json(payload, {
status: payload.code || 500,
});
}
}
-133
View File
@@ -1,133 +0,0 @@
"use client";
// Error components must be Client Components
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import CompPassword from "components/compPassword";
import ExtendedError from "utils/generalHelper/extendedError";
import { Constant } from "types/general/constant";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const [extendedError, setExtendedError] =
useState<ExtendedError>();
const [path, setPath] = useState<string>("root");
useEffect(() => {
if (error.message.includes("{")) {
const errorObj = JSON.parse(
error.message,
) as ExtendedError;
const extendError = new ExtendedError(
errorObj.extendedMessage ??
Constant.apiInternalError,
errorObj.code ?? 500,
errorObj.category ?? "internalServerError",
errorObj.reason ?? "Internal Server Error",
);
setExtendedError(extendError);
console.log(extendError.code);
const path =
extendError.reason?.split('"')[1]?.split('"')[0] ??
"root";
setPath(path);
} else {
const extendedError = new ExtendedError(
Constant.apiInternalError,
500,
"internalServerError",
error.message,
);
setExtendedError(extendedError);
}
}, [error]);
// useEffect(() => {
// if (extendedError?.code === 401) {
// router.push(
// `/password?redirect=${pathname}&path=${path}`,
// );
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [extendedError, path, pathname]);
return (
<div
className={
"relative flex h-full min-h-[80dvh] w-full flex-grow flex-col items-center justify-center gap-8"
}
>
{/* Image placeholder */}
<div
className={
"grid h-48 w-48 place-items-center rounded-lg bg-blue-300 font-semibold text-zinc-900"
}
>
Placeholder Image
</div>
{extendedError?.code === 401 ? (
<>
<CompPassword path={path} />
</>
) : (
<>
{/* Error message */}
<div
className={
"flex max-w-screen-lg flex-col items-center gap-2"
}
>
<h1 className={"font-semibold"}>
{extendedError?.code}
</h1>
<p className={"font-medium"}>
{extendedError?.message}
</p>
<div
className={
"flex flex-row gap-2 tablet:flex-row"
}
>
<a href={"/"}>
<button className={"link"}>
Go back to home
</button>
</a>
<button
onClick={() => {
reset();
}}
className={"interactive"}
>
Try again
</button>
</div>
</div>
{/* Error reason */}
<pre
className={
"absolute bottom-0 my-4 max-w-sm whitespace-pre-wrap rounded-lg border border-zinc-500/25 bg-zinc-100 px-4 py-2 text-xs dark:bg-zinc-900 md:max-w-screen-lg"
}
>
<b>{extendedError?.category}: </b>
{extendedError?.reason}
</pre>
</>
)}
</div>
);
}
-63
View File
@@ -1,63 +0,0 @@
import {
Exo_2,
JetBrains_Mono,
Source_Sans_Pro,
} from "next/font/google";
import ClientContextWrapper from "components/clientContextWrapper";
import Footer from "components/compFooter";
import Navbar from "components/compNavbar";
import "styles/globals.css";
const exo2 = Exo_2({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-exo2",
});
const sourceSansPro = Source_Sans_Pro({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-source-sans-pro",
});
const jetBrainsMono = JetBrains_Mono({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-jetbrains-mono",
});
type Props = {
children: React.ReactNode;
};
function RootLayout({ children }: Props) {
return (
<html lang='en'>
<body
className={`${exo2.variable} ${sourceSansPro.variable} ${jetBrainsMono.variable} font-body`}
>
<ClientContextWrapper>
<main
className={
"text-dark-900 flex h-full min-h-dynamic w-full flex-col bg-zinc-200 font-body dark:bg-zinc-800 dark:text-zinc-100"
}
>
<Navbar />
<div className={"flex-grow px-4 py-2"}>
{children}
</div>
<Footer />
</main>
</ClientContextWrapper>
</body>
</html>
);
}
export default RootLayout;
-38
View File
@@ -1,38 +0,0 @@
function RootLoading() {
return (
<div
className={
"mx-auto flex h-full w-full max-w-screen-xl flex-col gap-2"
}
>
<div
className={
"flex h-6 w-full items-center justify-between gap-2 tablet:gap-4"
}
>
<div
className={
"skeleton mr-auto h-full w-24 py-1 tablet:w-32"
}
/>
<div
className={
"skeleton h-full w-24 py-1 tablet:w-32"
}
/>
</div>
<div
className={
"skeleton h-full min-h-[30dvh] w-full rounded-lg px-4 py-2"
}
/>
<div
className={
"skeleton h-full min-h-[30dvh] w-full rounded-lg px-4 py-2"
}
/>
</div>
);
}
export default RootLoading;
-37
View File
@@ -1,37 +0,0 @@
import Link from "next/link";
function NotFound() {
return (
<div
className={
"relative flex h-full min-h-[80dvh] w-full flex-grow flex-col items-center justify-center gap-8"
}
>
{/* Image placeholder */}
<div
className={
"grid h-48 w-48 place-items-center rounded-lg bg-blue-300 font-semibold text-zinc-900"
}
>
Placeholder Image
</div>
<div
className={
"flex max-w-screen-lg flex-col items-center gap-2"
}
>
<h1 className={"font-semibold"}>404</h1>
<p className={"font-medium"}>
The file or folder you are trying to access does
not exist or has been deleted.
</p>
<Link href={"/"}>
<button>Go back to home</button>
</Link>
</div>
</div>
);
}
export default NotFound;
-160
View File
@@ -1,160 +0,0 @@
import { ImageResponse } from "next/server";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
import siteConfig from "config/site.config";
export const runtime = "edge";
export const alt = siteConfig.siteName;
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
export default async function Image() {
const fetchBannerData = fetch(
`${apiConfig.basePath}/api/files`,
{
headers: {
[Constant.cookieMaster]: process.env
.MASTER_KEY as string,
},
},
).then((res) => res.json());
const fetchFontBold = fetch(
`${apiConfig.basePath}/fonts/Exo2-Bold.ttf`,
).then((res) => res.arrayBuffer());
const fetchFontRegular = fetch(
`${apiConfig.basePath}/fonts/Exo2-Regular.ttf`,
).then((res) => res.arrayBuffer());
const [dataFetch, fontBold, fontRegular] =
await Promise.all([
fetchBannerData,
fetchFontBold,
fetchFontRegular,
]);
if (!dataFetch.data.isBannerExists) {
return new ImageResponse(
(
<img
src={`${apiConfig.basePath}/og.png`}
alt={alt}
className={"h-full w-full object-cover"}
/>
),
{
...size,
},
);
}
return new ImageResponse(
(
<div
tw='flex w-full h-full relative bg-zinc-900 text-zinc-50'
style={{
fontFamily: "Exo2",
}}
>
<div
tw='absolute flex w-full h-full opacity-25'
style={{
width: "120%",
height: "120%",
filter: "blur(5px)",
}}
>
<img
src={`${apiConfig.basePath}/api/files?banner=1`}
alt={""}
width={1250}
height={680}
style={{
objectFit: "cover",
}}
/>
</div>
<div tw='flex flex-col items-start max-w-2/5 w-full h-4/5 my-auto justify-start p-8'>
<div tw='flex items-center justify-center w-full'>
<img
src={`${apiConfig.basePath}/favicon.svg`}
alt={""}
tw='w-12 h-12 mb-4'
/>
</div>
<div
tw='font-bold text-5xl flex items-center justify-center w-full'
style={{
fontWeight: 700,
}}
>
{siteConfig.siteName}
</div>
<div
tw='text-3xl py-8'
style={{
letterSpacing: "0",
display: "flex",
whiteSpace: "pre-wrap",
width: "100%",
wordBreak: "break-word",
}}
>
{siteConfig.siteDescription}
</div>
</div>
<div tw='flex items-center justify-center rounded-l-lg overflow-hidden h-4/5 my-auto w-3/5 ml-auto'>
<img
src={`${apiConfig.basePath}/api/files?banner=1`}
alt={""}
tw='w-full shadow-xl rounded-lg'
width={1200}
height={630}
style={{
objectFit: "cover",
}}
/>
</div>
<div tw='absolute flex items-center bottom-4 w-full justify-center text-xl'>
<div>Powered by</div>
<img
src={`${process.env.NEXT_PUBLIC_DOMAIN}/logo.svg`}
alt={""}
tw='w-8 h-8 ml-2'
/>
<div
tw='font-bold'
style={{
fontWeight: 700,
}}
>
next-gdrive-index
</div>
</div>
</div>
),
{
...size,
fonts: [
{
name: "Exo2",
data: fontRegular,
weight: 400,
style: "normal",
},
{
name: "Exo2",
data: fontBold,
weight: 700,
style: "normal",
},
],
},
);
}
-228
View File
@@ -1,228 +0,0 @@
import {
ImageResponse,
NextRequest,
NextResponse,
} from "next/server";
import createErrorPayload from "utils/apiHelper/createErrorPayload";
import ExtendedError from "utils/generalHelper/extendedError";
import { API_Response } from "types/api";
import {
FileResponse,
FilesResponse,
} from "types/api/files";
import {
FilePath,
ValidateFilePathResponse,
} from "types/api/path";
import { RequestContext } from "types/general";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
import siteConfig from "config/site.config";
export const runtime = "edge";
export const alt = siteConfig.siteName;
export const size = {
width: 1200,
height: 630,
};
export async function GET(
request: NextRequest,
{ params }: RequestContext<"path", string[]>,
) {
const _start = Date.now();
const { path } = params;
try {
const pathValidation = await fetch(
`${apiConfig.basePath}/api/validate/${path.join(
"/",
)}`,
{
headers: {
[Constant.cookieMaster]: process.env
.MASTER_KEY as string,
},
},
).then(
(res) =>
res.json() as Promise<
API_Response<ValidateFilePathResponse>
>,
);
if (!pathValidation.success) {
throw new ExtendedError(
Constant.apiFileNotFound,
404,
"notFound",
"Can't find the file you're looking for.",
);
}
const targetFile = pathValidation.data?.[
pathValidation.data.length - 1
] as FilePath;
const fetchFile = fetch(
`${apiConfig.basePath}/api/files/${targetFile.encryptedId}`,
{
headers: {
[Constant.cookieMaster]: process.env
.MASTER_KEY as string,
},
},
).then(
(res) =>
res.json() as Promise<
API_Response<FilesResponse | FileResponse>
>,
);
const fetchFontBold = fetch(
`${apiConfig.basePath}/fonts/Exo2-Bold.ttf`,
).then((res) => res.arrayBuffer());
const fetchFontRegular = fetch(
`${apiConfig.basePath}/fonts/Exo2-Regular.ttf`,
).then((res) => res.arrayBuffer());
const [file, fontBold, fontRegular] = await Promise.all(
[fetchFile, fetchFontBold, fetchFontRegular],
);
if (
targetFile.mimeType ===
"application/vnd.google-apps.folder" &&
!(file.data as FilesResponse).isBannerExists
) {
return new ImageResponse(
(
<img
src={`${apiConfig.basePath}/og.png`}
alt={alt}
className={"h-full w-full object-cover"}
/>
),
{
...size,
},
);
}
return new ImageResponse(
(
<div
tw='flex w-full h-full relative bg-zinc-900 text-zinc-50'
style={{
fontFamily: "Exo2",
}}
>
<div
tw='absolute flex w-full h-full opacity-25'
style={{
width: "120%",
height: "120%",
filter: "blur(5px)",
}}
>
<img
src={`${apiConfig.basePath}/api/files/${targetFile.encryptedId}?banner=1&thumbnail=1`}
alt={""}
width={1250}
height={680}
style={{
objectFit: "cover",
}}
/>
</div>
<div tw='flex flex-col items-start max-w-2/5 w-full h-4/5 my-auto justify-start p-8'>
<div tw='flex items-center justify-center w-full'>
<img
src={`${apiConfig.basePath}/favicon.svg`}
alt={""}
tw='w-12 h-12 mb-4'
/>
</div>
<div
tw='font-bold text-5xl flex items-center justify-center w-full'
style={{
fontWeight: 700,
}}
>
{siteConfig.siteName}
</div>
<div
tw='text-3xl py-8'
style={{
letterSpacing: "0",
display: "flex",
whiteSpace: "pre-wrap",
width: "100%",
wordBreak: "break-word",
}}
>
{targetFile.name}
</div>
</div>
<div tw='flex items-center justify-center rounded-l-lg overflow-hidden h-4/5 my-auto w-3/5 ml-auto'>
<img
src={`${apiConfig.basePath}/api/files/${targetFile.encryptedId}?banner=1&thumbnail=1`}
alt={""}
tw='w-full shadow-xl rounded-lg'
width={1200}
height={630}
style={{
objectFit: "cover",
}}
/>
</div>
<div tw='absolute flex items-center bottom-4 w-full justify-center text-xl'>
<div>Powered by</div>
<img
src={`${process.env.NEXT_PUBLIC_DOMAIN}/logo.svg`}
alt={""}
tw='w-8 h-8 ml-2'
/>
<div
tw='font-bold'
style={{
fontWeight: 700,
}}
>
next-gdrive-index
</div>
</div>
</div>
),
{
...size,
fonts: [
{
name: "Exo2",
data: fontRegular,
weight: 400,
style: "normal",
},
{
name: "Exo2",
data: fontBold,
weight: 700,
style: "normal",
},
],
},
);
} catch (error: any) {
const payload = createErrorPayload(
error,
"GET /opengraph",
_start,
);
return NextResponse.json(payload, {
status: payload.code,
});
}
}
-144
View File
@@ -1,144 +0,0 @@
import { Metadata } from "next";
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import Breadcrumb from "components/compBreadcrumb";
import FileLayout from "components/compFileLayout";
import CompReadmeRender from "components/compReadmeRender";
import SwitchLayout from "components/compSwitchLayout";
import { handleError } from "utils/generalHelper/axiosFetch";
import { API_Error, API_Response } from "types/api";
import { FilesResponse } from "types/api/files";
import { ValidateFilePathResponse } from "types/api/path";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
import siteConfig from "config/site.config";
export const metadata: Metadata = {
metadataBase: new URL(apiConfig.basePath),
title: siteConfig.siteName,
description: siteConfig.siteDescription,
alternates: {
canonical: "/",
},
openGraph: {
title: siteConfig.siteName,
description: siteConfig.siteDescription,
url: `/`,
siteName: siteConfig.siteName,
},
twitter: {
title: siteConfig.siteName,
description: siteConfig.siteDescription,
card: "summary_large_image",
site: siteConfig.siteName,
},
};
async function RootPage() {
const passwordCookies =
cookies().get(Constant.cookiePassword)?.value ?? "";
const getPathValidation = fetch(
`${apiConfig.basePath}/api/validate`,
{
credentials: "include",
headers: {
Cookie: `${Constant.cookiePassword}=${passwordCookies}`,
},
},
).then(
(res) =>
res.json() as Promise<
API_Response<ValidateFilePathResponse>
>,
);
const getFilesData = fetch(
`${apiConfig.basePath}/api/files`,
{
credentials: "include",
headers: {
Cookie: `${Constant.cookiePassword}=${passwordCookies}`,
},
},
).then(
(res) =>
res.json() as Promise<API_Response<FilesResponse>>,
);
const [pathValidation, filesData] = await Promise.all([
getPathValidation,
getFilesData,
]);
if (!pathValidation.success) {
const errorData = pathValidation as API_Error;
if (errorData.code === 401) {
const protectedPath = errorData.reason
.split('"')[1]
.split('"')[0];
redirect(
`/password?redirect=/&path=${protectedPath}`,
);
}
if (errorData.code === 404) {
notFound();
}
const payload = handleError(errorData);
throw new Error(payload);
}
if (!filesData.success) {
const errorData = filesData as API_Error;
const payload = handleError(errorData);
throw new Error(payload);
}
return (
<div
className={
"mx-auto flex h-full w-auto max-w-screen-xl flex-col gap-2"
}
>
<div
id={"content-head"}
className={
"flex w-full items-center justify-between gap-2 tablet:gap-4"
}
>
<Breadcrumb />
<SwitchLayout />
</div>
<div
id={"content-files"}
className={"card"}
>
<FileLayout
data={
filesData.data
? filesData.data
: { folders: [], files: [] }
}
/>
</div>
{filesData.data?.isReadmeExists &&
filesData.data?.readmeContent && (
<div
id={"content-readme"}
className={"card"}
>
<CompReadmeRender
data={filesData.data.readmeContent}
/>
</div>
)}
</div>
);
}
export default RootPage;
-67
View File
@@ -1,67 +0,0 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import CompPassword from "components/compPassword";
import apiConfig from "config/api.config";
import siteConfig from "config/site.config";
type Props = {
searchParams: {
[key: string]: string | string[] | undefined;
};
};
export const metadata: Metadata = {
metadataBase: new URL(apiConfig.basePath),
title: siteConfig.siteName,
description: siteConfig.siteDescription,
alternates: {
canonical: "/",
},
openGraph: {
title: siteConfig.siteName,
description: siteConfig.siteDescription,
url: `/`,
siteName: siteConfig.siteName,
},
twitter: {
title: siteConfig.siteName,
description: siteConfig.siteDescription,
card: "summary_large_image",
site: siteConfig.siteName,
},
};
function Password({ searchParams }: Props) {
if (
searchParams["redirect"] === undefined ||
searchParams["path"] === undefined
) {
redirect("/");
}
return (
<div
className={
"relative flex h-full min-h-[80dvh] w-full flex-grow flex-col items-center justify-center gap-8"
}
>
{/* Image placeholder */}
<div
className={
"grid h-48 w-48 place-items-center rounded-lg bg-blue-300 font-semibold text-zinc-900"
}
>
Placeholder Image
</div>
<CompPassword
path={searchParams["path"] as string}
redirect={searchParams["redirect"] as string}
/>
</div>
);
}
export default Password;
-2
View File
@@ -1,2 +0,0 @@
User-agent: *
Disallow: /
-160
View File
@@ -1,160 +0,0 @@
import { ImageResponse } from "next/server";
import { Constant } from "types/general/constant";
import apiConfig from "config/api.config";
import siteConfig from "config/site.config";
export const runtime = "edge";
export const alt = siteConfig.siteName;
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
export default async function Image() {
const fetchBannerData = fetch(
`${apiConfig.basePath}/api/files`,
{
headers: {
[Constant.cookieMaster]: process.env
.MASTER_KEY as string,
},
},
).then((res) => res.json());
const fetchFontBold = fetch(
`${apiConfig.basePath}/fonts/Exo2-Bold.ttf`,
).then((res) => res.arrayBuffer());
const fetchFontRegular = fetch(
`${apiConfig.basePath}/fonts/Exo2-Regular.ttf`,
).then((res) => res.arrayBuffer());
const [dataFetch, fontBold, fontRegular] =
await Promise.all([
fetchBannerData,
fetchFontBold,
fetchFontRegular,
]);
if (!dataFetch.data.isBannerExists) {
return new ImageResponse(
(
<img
src={`${apiConfig.basePath}/og.png`}
alt={alt}
className={"h-full w-full object-cover"}
/>
),
{
...size,
},
);
}
return new ImageResponse(
(
<div
tw='flex w-full h-full relative bg-zinc-900 text-zinc-50'
style={{
fontFamily: "Exo2",
}}
>
<div
tw='absolute flex w-full h-full opacity-25'
style={{
width: "120%",
height: "120%",
filter: "blur(5px)",
}}
>
<img
src={`${apiConfig.basePath}/api/files?banner=1`}
alt={""}
width={1250}
height={680}
style={{
objectFit: "cover",
}}
/>
</div>
<div tw='flex flex-col items-start max-w-2/5 w-full h-4/5 my-auto justify-start p-8'>
<div tw='flex items-center justify-center w-full'>
<img
src={`${apiConfig.basePath}/favicon.svg`}
alt={""}
tw='w-12 h-12 mb-4'
/>
</div>
<div
tw='font-bold text-5xl flex items-center justify-center w-full'
style={{
fontWeight: 700,
}}
>
{siteConfig.siteName}
</div>
<div
tw='text-3xl py-8'
style={{
letterSpacing: "0",
display: "flex",
whiteSpace: "pre-wrap",
width: "100%",
wordBreak: "break-word",
}}
>
{siteConfig.siteDescription}
</div>
</div>
<div tw='flex items-center justify-center rounded-l-lg overflow-hidden h-4/5 my-auto w-3/5 ml-auto'>
<img
src={`${apiConfig.basePath}/api/files?banner=1`}
alt={""}
tw='w-full shadow-xl rounded-lg'
width={1200}
height={630}
style={{
objectFit: "cover",
}}
/>
</div>
<div tw='absolute flex items-center bottom-4 w-full justify-center text-xl'>
<div>Powered by</div>
<img
src={`${process.env.NEXT_PUBLIC_DOMAIN}/logo.svg`}
alt={""}
tw='w-8 h-8 ml-2'
/>
<div
tw='font-bold'
style={{
fontWeight: 700,
}}
>
next-gdrive-index
</div>
</div>
</div>
),
{
...size,
fonts: [
{
name: "Exo2",
data: fontRegular,
weight: 400,
style: "normal",
},
{
name: "Exo2",
data: fontBold,
weight: 700,
style: "normal",
},
],
},
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { twMerge } from "tailwind-merge";
interface AlertProps {
variant?: "success" | "error" | "warning" | "info";
icon?: string | IconifyIcon;
title?: string;
message: string;
className?: string;
}
export default function Alert(props: AlertProps) {
const {
variant = "info",
icon,
title,
message,
className,
} = props;
return (
<div
className={twMerge(
"w-full h-fit flex flex-col rounded-lg gap-1 px-4 py-2",
variant === "success" &&
"bg-success-100 text-success-main",
variant === "error" &&
"bg-danger-100 text-danger-main",
variant === "warning" &&
"bg-warning-100 text-warning-main",
variant === "info" && "bg-info-100 text-info-main",
className && className,
)}
>
{(icon || title) && (
<div className='flex gap-1 items-center'>
{icon && (
<Icon
icon={icon}
color='currentColor'
width={24}
height={24}
/>
)}
{title && (
<span className='font-medium text-lg'>
{title}
</span>
)}
</div>
)}
<p className='text-sm'>{message}</p>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { Icon, IconifyIcon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Fragment, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
interface BreadcrumbProps {
paths: Record<"label" | "path", string>[];
separator?: string | IconifyIcon;
maxPath?: number;
}
export default function Breadcrumb(props: BreadcrumbProps) {
const router = useRouter();
const {
paths,
separator = "ion:chevron-forward",
maxPath = 2,
} = props;
const [breadcrumbPaths, setBreadcrumbPaths] =
useState<Record<"label" | "path", string>[]>(paths);
const [isPathSliced, setIsPathSliced] =
useState<boolean>(false);
const [hiddenPaths, setHiddenPaths] = useState<
Record<"label" | "path", string>[]
>([]);
const [menuAnchor, setMenuAnchor] =
useState<HTMLElement | null>(null);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
useEffect(() => {
if (paths.length > maxPath) {
const newPaths = paths.slice(
paths.length - maxPath,
paths.length,
);
const hiddenPaths = paths.slice(
0,
paths.length - maxPath,
);
setBreadcrumbPaths(newPaths);
setIsPathSliced(true);
setHiddenPaths(hiddenPaths);
} else {
setBreadcrumbPaths(paths);
setIsPathSliced(false);
}
}, [paths, maxPath]);
return (
<>
<div className='flex items-center flex-wrap w-full gap-2'>
<Link href={"/"}>
<ButtonIcon
icon={"ion:home"}
variant='transparent'
className='flex-shrink-0 flex-grow-0 p-1'
/>
{/* <Icon
icon='ion:home'
color='currentColor'
width={20}
height={20}
className='flex-shrink-0 flex-grow-0'
/> */}
</Link>
{isPathSliced && (
<>
<Icon
icon={separator}
color='currentColor'
width={20}
height={20}
/>
<ButtonIcon
icon={"ion:ellipsis-horizontal"}
variant='transparent'
className='flex-shrink-0 flex-grow-0 p-1'
ref={setMenuAnchor}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
{/* <span className='mx-1'>...</span> */}
</>
)}
{breadcrumbPaths.map((path, index) => (
<Fragment key={`breadcrumb-${path}@${index}`}>
<Icon
icon={separator}
color='currentColor'
width={20}
height={20}
className='flex-shrink-0 flex-grow-0'
/>
<Link
href={path.path}
className={`${
path.path === router.asPath
? "text-primary-50 pointer-events-none cursor-default"
: "text-primary-300 hover:text-primary-100 cursor-pointer"
} transition-smooth max-w-md mx-1 w-fit ${
index === breadcrumbPaths.length - 1 &&
"line-clamp-1"
}`}
>
{decodeURIComponent(path.label)}
</Link>
</Fragment>
))}
</div>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-full max-w-sm bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen
? `opacity-100 pointer-events-auto`
: `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor
? menuAnchor?.getBoundingClientRect()
.bottom ?? 0
: "unset",
left: menuAnchor
? menuAnchor?.getBoundingClientRect().left ??
0
: "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{hiddenPaths.map((path, index) => (
<Link
href={path.path}
key={`hidden-path-${path}@${index}`}
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2 w-full'
title={decodeURIComponent(path.label)}
>
{decodeURIComponent(path.label)}
</Link>
))}
</div>
</div>
</ClickAway>
</>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
fullWidth?: boolean;
variant?:
| "primary"
| "accent"
| "success"
| "danger"
| "warning"
| "info"
| "transparent";
size?: "small" | "medium" | "large";
pill?: boolean;
square?: boolean;
rounded?: "none" | "small" | "medium" | "large";
startIcon?: string | IconifyIcon;
endIcon?: string | IconifyIcon;
children: ReactNode;
loading?: boolean;
className?: string;
}
export default function Button(props: ButtonProps) {
const {
fullWidth = false,
variant = "primary",
size = "medium",
pill = false,
square = false,
rounded = "none",
loading = false,
startIcon,
endIcon,
children,
className,
...rest
} = props;
return (
<button
className={twMerge(
"outline-none border-none capitalize transition-smooth",
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
"rounded-lg w-fit flex items-center justify-center flex-grow",
fullWidth && "w-full",
rounded === "none" && "rounded-none",
rounded === "small" && "rounded-sm",
rounded === "medium" && "rounded-md",
rounded === "large" && "rounded-lg",
pill && "rounded-full",
size === "small" && "px-2 py-1 text-sm gap-1",
size === "medium" && "px-4 py-2 text-base gap-2",
size === "large" && "px-8 py-4 text-lg gap-4",
square && "aspect-square",
variant === "primary" &&
"bg-primary-600 text-primary-50 hover:bg-primary-500 active:bg-primary-700",
variant === "accent" &&
"bg-accent-600 text-accent-50 hover:bg-accent-500 active:bg-accent-700",
variant === "success" &&
"bg-success-600 text-success-50 hover:bg-success-500 active:bg-success-700",
variant === "danger" &&
"bg-danger-600 text-danger-50 hover:bg-danger-500 active:bg-danger-700",
variant === "warning" &&
"bg-warning-600 text-warning-50 hover:bg-warning-500 active:bg-warning-700",
variant === "info" &&
"bg-info-600 text-info-50 hover:bg-info-500 active:bg-info-700",
variant === "transparent" &&
"bg-transparent text-primary-50 hover:bg-primary-500 active:bg-primary-700",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-primary-700 disabled:hover:bg-primary-700 disabled:active:bg-primary-700 disabled:text-primary-50",
className && className,
)}
disabled={loading ?? props.disabled}
{...rest}
>
{props.loading ? (
<Icon
icon={"mdi:loading"}
color='white'
className={twMerge(
"w-5 h-5 animate-spin",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
) : (
<>
{startIcon && (
<Icon
icon={startIcon}
color='white'
className={twMerge(
"w-5 h-5",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
)}
{children}
{endIcon && (
<Icon
icon={endIcon}
color='white'
className={twMerge(
"w-5 h-5",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
)}
</>
)}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonGroupProps {
children: ReactNode;
fullWidth?: boolean;
rounded?: "none" | "small" | "medium" | "large";
className?: string;
}
export default function ButtonGroup(
props: ButtonGroupProps,
) {
const {
children,
fullWidth = false,
rounded = "large",
className,
} = props;
return (
<div
className={twMerge(
"flex flex-row items-center justify-center overflow-hidden",
fullWidth && "w-full",
rounded === "none" && "rounded-none",
rounded === "small" && "rounded-sm",
rounded === "medium" && "rounded-md",
rounded === "large" && "rounded-lg",
className && className,
)}
>
{children}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { ForwardRefRenderFunction, forwardRef } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonIconProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: string | IconifyIcon;
size?: "small" | "medium" | "large";
variant?: "primary" | "accent" | "success" | "danger" | "warning" | "info" | "transparent";
className?: string;
}
const ButtonIcon: ForwardRefRenderFunction<HTMLButtonElement, ButtonIconProps> = (props, ref) => {
const { icon, size = "medium", variant = "primary", className, ...rest } = props;
let iconSize = 24;
switch (size) {
case "small":
iconSize = 16;
break;
case "medium":
iconSize = 24;
break;
case "large":
iconSize = 32;
}
return (
<button
ref={ref}
className={twMerge(
"outline-none border-none capitalize transition-smooth aspect-square",
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
"rounded-full flex items-center justify-center w-fit h-fit p-0",
size === "small" && "w-6 h-6",
size === "medium" && "w-8 h-8",
size === "large" && "w-12 h-12",
variant === "primary" && "bg-primary-600/25 text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
variant === "accent" && "bg-accent-600/25 text-accent-50 hover:bg-accent-500/50 active:bg-accent-700",
variant === "success" && "bg-success-600/25 text-success-50 hover:bg-success-500/50 active:bg-success-700",
variant === "danger" && "bg-danger-600/25 text-danger-50 hover:bg-danger-500/50 active:bg-danger-700",
variant === "warning" && "bg-warning-600/25 text-warning-50 hover:bg-warning-500/50 active:bg-warning-700",
variant === "info" && "bg-info-600/25 text-info-50 hover:bg-info-500/50 active:bg-info-700",
variant === "transparent" && "bg-transparent text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
props.disabled && "opacity-50 cursor-not-allowed",
className && className,
)}
{...rest}
>
<Icon
icon={icon}
color='white'
width={iconSize}
height={iconSize}
/>
</button>
);
};
export default forwardRef(ButtonIcon);
+38
View File
@@ -0,0 +1,38 @@
import { ReactNode, useEffect } from "react";
import { twMerge } from "tailwind-merge";
interface ClickAwayProps extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
open: boolean;
onClickAway: (e: any) => void;
className?: string;
}
export default function ClickAway(props: ClickAwayProps) {
const { children, open, onClickAway, className, ...rest } = props;
useEffect(() => {
const body = document.querySelector("body");
const isScrollbarRendered = document.documentElement.clientHeight < document.documentElement.scrollHeight;
const isMobile = window.matchMedia("(max-width: 768px)").matches;
if (open && isScrollbarRendered) {
body?.style.setProperty("overflow", "hidden");
if (!isMobile) body?.style.setProperty("padding-right", "4px");
} else {
body?.style.removeProperty("overflow");
if (!isMobile) body?.style.removeProperty("padding-right");
}
}, [open]);
return (
<div
className={twMerge(
"w-full h-full bg-primary-950/50 backdrop-blur-none transition-smooth",
className && className,
)}
onClick={onClickAway}
{...rest}
>
{children}
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { stripExtension } from "utils/filesHelper";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { getPreviewIcon } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
interface ListItemProps {
file: IGDriveFiles;
isProtected: boolean;
}
export default function ListItem(props: ListItemProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
const [fileInfo, setFileInfo] = useState<string[]>([]);
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
const [filePath, setFilePath] = useState<string>("");
useEffect(() => {
const currentPath = router.asPath.slice(1);
const filePath = props.file.name;
const path = [currentPath, encodeURIComponent(filePath)].join("/");
// If start with /, remove it
if (path.startsWith("/")) {
setFilePath(path.slice(1));
} else {
setFilePath(path);
}
}, [router.asPath, props.file.name]);
useEffect(() => {
const info = [];
const extInfo = [];
props.file.modifiedTime &&
info.push(
new Date(props.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
);
props.file.size && info.push(bytesToReadable(props.file.size));
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
setExtensionInfo(extInfo);
setFileInfo(info);
}, [props.file]);
const handleCopyFileLink = () => {
try {
const url = `${window.location.origin}/${filePath}`;
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
try {
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
window.open(
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
"_blank",
);
setMenuOpen(false);
};
return (
<>
<Link
title={props.file.name}
href={filePath}
className='w-full p-1 flex relative items-stretch gap-2 tablet:gap-4 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
<div className='flex flex-col items-stretch gap-2 w-full relative'>
{props.file.thumbnailLink &&
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
<img
src={props.file.thumbnailLink}
alt={props.file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48'
loading='lazy'
/>
) : (
<Icon
icon={
props.file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48 p-8 tablet:p-16'
/>
)}
<div className='flex flex-col p-1'>
<span className='line-clamp-1 break-all whitespace-pre-wrap w-full text-center my-1'>
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
</span>{" "}
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
</div>
</div>
</div>
<div className='absolute top-1 right-1 z-10'>
<Tooltip content='More'>
<ButtonIcon
className='bg-primary-950/50'
ref={setMenuAnchor}
icon='ion:ellipsis-vertical'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
</Tooltip>
</div>
</Link>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor ? menuAnchor?.getBoundingClientRect().bottom ?? 0 : "unset",
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 150 : "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{props.file.mimeType.includes("folder") ? (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy folder link</span>
</div>
</>
) : (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy file link</span>
</div>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy download link</span>
</div>
<div className='w-full h-px bg-primary-500' />
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleDownload(props.file, props.isProtected)}
>
<Icon
icon='ion:download'
color='currentColor'
width={18}
height={18}
/>
<span>Download</span>
</div>
</>
)}
</div>
</div>
</ClickAway>
</>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { stripExtension } from "utils/filesHelper";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { getPreviewIcon } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
interface ListItemProps {
file: IGDriveFiles;
isProtected: boolean;
}
export default function ListItem(props: ListItemProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
const [fileInfo, setFileInfo] = useState<string[]>([]);
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
const [filePath, setFilePath] = useState<string>("");
useEffect(() => {
const currentPath = router.asPath.slice(1);
const filePath = props.file.name;
const path = [currentPath, encodeURIComponent(filePath)].join("/");
// If start with /, remove it
if (path.startsWith("/")) {
setFilePath(path.slice(1));
} else {
setFilePath(path);
}
}, [router.asPath, props.file.name]);
useEffect(() => {
const info = [];
const extInfo = [];
props.file.modifiedTime &&
info.push(
new Date(props.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
);
props.file.size && info.push(bytesToReadable(props.file.size));
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
setExtensionInfo(extInfo);
setFileInfo(info);
}, [props.file]);
const handleCopyFileLink = () => {
try {
const url = `${window.location.origin}/${filePath}`;
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
try {
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
window.open(
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
"_blank",
);
setMenuOpen(false);
};
return (
<>
<Link
title={props.file.name}
href={filePath}
className='w-full p-1 pr-2 flex items-center gap-4 tablet:gap-8 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
<div className='flex items-center gap-4 w-full flex-grow-0 flex-shrink'>
{props.file.thumbnailLink &&
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
<img
src={props.file.thumbnailLink}
alt={props.file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
loading='lazy'
/>
) : (
<Icon
icon={
props.file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
/>
)}
<div className='flex flex-col'>
<span className='line-clamp-1 break-all whitespace-pre-wrap'>
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
</span>{" "}
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
</div>
</div>
</div>
<div className='relative z-10'>
<Tooltip content='More'>
<ButtonIcon
ref={setMenuAnchor}
icon='ion:ellipsis-vertical'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
</Tooltip>
</div>
</Link>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor ? (menuAnchor?.getBoundingClientRect().bottom ?? 0) - 36 : "unset",
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 200 : "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{props.file.mimeType.includes("folder") ? (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy folder link</span>
</div>
</>
) : (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy file link</span>
</div>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy download link</span>
</div>
<div className='w-full h-px bg-primary-500' />
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleDownload(props.file, props.isProtected)}
>
<Icon
icon='ion:download'
color='currentColor'
width={18}
height={18}
/>
<span>Download</span>
</div>
</>
)}
</div>
</div>
</ClickAway>
</>
);
}
+34
View File
@@ -0,0 +1,34 @@
import GridItem from "components/File/GridItem";
import { IGDriveFiles } from "types/api/files";
interface GridViewProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
}
export default function ViewGrid(props: GridViewProps) {
return (
<div className='w-full gap-4 grid grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4'>
{props.data.folders.map((folder) => (
<GridItem
key={folder.encryptedId}
file={folder}
isProtected={props.isProtected}
/>
))}
{props.data.files.map((file) => (
<GridItem
key={file.encryptedId}
file={file}
isProtected={props.isProtected}
/>
))}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import ListItem from "components/File/ListItem";
import { IGDriveFiles } from "types/api/files";
interface ViewListProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
}
export default function ViewList(props: ViewListProps) {
return (
<div className='w-full flex flex-col gap-2 rounded-lg'>
{props.data.folders.map((folder) => (
<ListItem
key={folder.encryptedId}
file={folder}
isProtected={props.isProtected}
/>
))}
{props.data.files.map((file) => (
<ListItem
key={file.encryptedId}
file={file}
isProtected={props.isProtected}
/>
))}
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import axios from "axios";
import gIndexConfig from "config";
import { useRouter } from "next/router";
import { poppins } from "pages/_app";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import Breadcrumb from "components/Breadcrumb";
import Button from "components/Button";
import Markdown from "components/Markdown";
import { addNewPassword } from "utils/passwordHelper";
import { IGDriveFiles } from "types/api/files";
import { APIGetFileResponse } from "types/api/response";
import { Constant } from "types/constant";
import Header from "../Header";
import PasswordLayout from "../Password";
import ViewGrid from "./Grid";
import ViewList from "./List";
const Loading = () => (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading view...</span>
</div>
</div>
);
interface ExplorerLayoutProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
readmeFile: string | null;
isProtected: boolean;
isFileProtected?: boolean;
encryptedId?: string;
}
export default function ExplorerLayout(props: ExplorerLayoutProps) {
const router = useRouter();
const [data, setData] = useState<ExplorerLayoutProps["data"]>(props.data);
const [view, setView] = useState<"grid" | "list">(() => {
const viewType = localStorage.getItem(Constant.storage_ViewType);
if (!viewType) {
localStorage.setItem(Constant.storage_ViewType, "list");
return "list";
}
if (viewType === "grid") {
return "grid";
} else {
return "list";
}
});
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
const [isButtonLoading, setIsButtonLoading] = useState<boolean>(false);
const [ViewComponent, setViewComponent] = useState<ReactNode>(
<ViewList
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
useEffect(() => {
new Promise(async (resolve) => {
if (view === "list") {
setViewComponent(
<ViewList
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
} else if (view === "grid") {
setViewComponent(
<ViewGrid
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
}
await new Promise((resolve) => setTimeout(resolve, 50));
resolve(true);
}).then(() => {
setIsViewLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, data]);
useEffect(() => {
setShowPasswordLayout(props.isProtected);
}, [props.isProtected]);
useEffect(() => {
const paths = router.asPath.split("/").filter((path) => path !== "");
const newPaths: Record<"label" | "path", string>[] = [];
paths.forEach((path, index) => {
newPaths.push({
label: path,
path: `/${paths.slice(0, index + 1).join("/")}`,
});
});
setBreadcrumbPaths(newPaths);
}, [router]);
const handleChangeView = useCallback(
(view: "grid" | "list") => {
setIsViewLoading(true);
setView(view);
localStorage.setItem(Constant.storage_ViewType, view);
},
[setView],
);
const handleLoadNextPage = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsButtonLoading(true);
if (!data.pageToken) {
setIsButtonLoading(false);
return;
}
const newData = await axios.get<APIGetFileResponse>("/api/getData", {
params: {
encryptedId: props.encryptedId,
pageToken: data.pageToken,
},
});
setData((prev) => ({
...prev,
files: [...prev.files, ...newData.data.data.files],
folders: [...prev.folders, ...newData.data.data.folders],
pageToken: newData.data.data.pageToken,
}));
setIsButtonLoading(false);
},
[data, props.encryptedId],
);
return (
<div className='w-full flex h-full flex-col gap-4 relative'>
{/* Top */}
<Header
viewSelector={{ view, setView: handleChangeView }}
showPasswordLayout={showPasswordLayout}
/>
<div className='w-full h-px bg-primary-500' />
{/* Header */}
{!showPasswordLayout && (
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
{/* Breadcrumb */}
<Breadcrumb paths={breadcrumbPaths} />
</div>
)}
{/* Content */}
{showPasswordLayout ? (
<PasswordLayout
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 1] ?? "/")}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<div
className={twMerge(
"w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4",
!props.readmeFile && "grid-cols-1 tablet:grid-cols-12 desktop:grid-cols-12",
)}
>
<div
className={twMerge(
"col-span-full desktop:col-span-8 overflow-auto",
!props.readmeFile && "desktop:col-span-full",
)}
>
{isViewLoading ? (
<Loading />
) : (
<div className='w-full flex flex-col gap-4'>
{data.files.length || data.folders.length ? (
<>{ViewComponent}</>
) : (
<div className='w-full min-h-[5rem] grid place-items-center'>
Can&apos;t find any files or folders in this directory
</div>
)}
{data.pageToken && (
<Button
variant='accent'
fullWidth
rounded='large'
className='mt-4'
loading={isButtonLoading}
onClick={handleLoadNextPage}
>
Load more...
</Button>
)}
</div>
)}
</div>
{props.readmeFile && (
<div className='col-span-full desktop:col-span-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 rounded-lg sticky top-4 overflow-auto ${poppins.className}`}
>
<Markdown content={props.readmeFile} />
</div>
</div>
)}
</div>
)}
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
import { Icon } from "@iconify/react";
import axios from "axios";
import gIndexConfig from "config";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import Button from "components/Button";
import ButtonGroup from "components/ButtonGroup";
import ButtonIcon from "components/ButtonIcon";
import Modal from "components/Modal";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { getPreviewIcon } from "utils/previewHelper";
import { IGDriveFiles } from "types/api/files";
import { APISearchResponse } from "types/api/response";
interface HeaderProps {
viewSelector?: {
view: "grid" | "list";
setView: (view: "grid" | "list") => void;
};
showPasswordLayout?: boolean;
}
export default function Header(props: HeaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [searchModalOpen, setSearchModalOpen] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>("");
const [axiosCancelToken, setAxiosCancelToken] = useState<ReturnType<typeof axios.CancelToken.source> | null>(null); // eslint-disable-line
const [searchLoading, setSearchLoading] = useState<boolean>(false);
const [searchResults, setSearchResults] = useState<(IGDriveFiles & { redirect: string })[]>([]);
useEffect(() => {
if (!searchQuery) {
setSearchLoading(false);
return;
}
setSearchLoading(true);
if (axiosCancelToken) {
axiosCancelToken.cancel();
}
const timeout = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 500);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
useEffect(() => {
if (!searchModalOpen) {
setSearchResults([]);
setSearchQuery("");
setSearchLoading(false);
}
}, [searchModalOpen]);
useEffect(() => {
if (!debouncedSearchQuery) {
setSearchLoading(false);
setSearchResults([]);
return;
}
const source = axios.CancelToken.source();
setAxiosCancelToken(source);
axios
.get<APISearchResponse>("/api/search", {
params: {
query: debouncedSearchQuery,
},
cancelToken: source.token,
})
.then((res) => {
setSearchResults(res.data.files);
})
.catch((err) => {
if (axios.isCancel(err)) {
return;
} else {
console.error(err);
throw new Error(err);
}
})
.finally(() => {
setSearchLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchQuery]);
return (
<>
<div className='flex items-center justify-between gap-4'>
<h1 className='text-2xl font-medium'>{gIndexConfig.siteConfig.siteName}</h1>
{!props.showPasswordLayout && (
<div className='flex items-center gap-4'>
{props.viewSelector && (
<ButtonGroup className='border border-primary-700 tablet:flex hidden'>
<Button
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
startIcon={"ion:list"}
onClick={() => props.viewSelector?.setView("list")}
>
List view
</Button>
<Button
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
startIcon={"ion:grid"}
onClick={() => props.viewSelector?.setView("grid")}
>
Grid view
</Button>
</ButtonGroup>
)}
<Tooltip content='Search file'>
<ButtonIcon
icon='ion:search'
variant='transparent'
onClick={() => {
setSearchModalOpen(true);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}}
/>
</Tooltip>
</div>
)}
</div>
{!props.showPasswordLayout && props.viewSelector && (
<div className='flex w-full items-center tablet:hidden'>
<ButtonGroup className='border border-primary-700 w-full'>
<Button
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
size='small'
startIcon={"ion:list"}
onClick={() => props.viewSelector?.setView("list")}
>
List view
</Button>
<Button
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
size='small'
startIcon={"ion:grid"}
onClick={() => props.viewSelector?.setView("grid")}
>
Grid view
</Button>
</ButtonGroup>
</div>
)}
<Modal
key={"search-modal"}
open={searchModalOpen}
onClose={() => setSearchModalOpen(false)}
title='Search'
>
<div className='w-full flex flex-col gap-2'>
<input
ref={inputRef}
type='text'
placeholder='Input search query'
className='w-full px-4 py-2 bg-primary-900 outline-none border border-primary-500 rounded-lg'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchLoading ? (
<div className='w-full py-8 flex items-center justify-center gap-2 animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Searching file...</span>
</div>
) : (
<div className='flex flex-col gap-2 max-h-96 overflow-auto'>
{searchResults.length === 0 ? (
<div className='w-full py-8 flex items-center justify-center gap-2'>
<span>No results found</span>
</div>
) : (
<>
{searchResults.map((file) => (
<Link
href={file.redirect}
key={file.encryptedId}
className='flex items-center gap-4 w-full flex-grow-0 flex-shrink bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
{file.thumbnailLink &&
(file.mimeType.startsWith("video") || file.mimeType.startsWith("image")) ? (
<img
src={file.thumbnailLink}
alt={file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
loading='lazy'
/>
) : (
<Icon
icon={
file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(file.fileExtension!, file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
/>
)}
<div className='flex flex-col'>
<span className='font-medium'>{file.name}</span>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>
{[
file.mimeType && file.mimeType.includes("folder") && "Folder",
file.fileExtension && file.fileExtension.toUpperCase(),
]
.filter((item) => item)
.join(" ・ ")}
</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>
{[
new Date(file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
file.size ? bytesToReadable(file.size) : undefined,
]
.filter((item) => item)
.join(" ・ ")}
</span>
</div>
</div>
</Link>
))}
</>
)}
</div>
)}
</div>
</Modal>
</>
);
}
+188
View File
@@ -0,0 +1,188 @@
import gIndexConfig from "config";
import { NextSeo, NextSeoProps } from "next-seo";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import {
addNewPassword,
checkPathPassword,
} from "utils/passwordHelper";
import { Constant } from "types/constant";
import PasswordLayout from "../Password";
interface LoaderLayoutProps
extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
seo?: NextSeoProps;
}
export default function LoaderLayout(
props: LoaderLayoutProps,
) {
const router = useRouter();
const { children, seo, className, ...rest } = props;
const sitePassword =
process.env.NEXT_PUBLIC_SITE_PASSWORD;
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isPasswordUnlocked, setIsPasswordUnlocked] =
useState<boolean>(
gIndexConfig.siteConfig.privateIndex ? false : true,
);
const handlePrivateIndexCheck = () => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
if (cookie) {
const password = cookie.split("=")[1];
if (!password) return setIsPasswordUnlocked(false);
if (!sitePassword)
throw new Error("Can't find site password");
const valid = checkPathPassword(
"site-wide",
password,
sitePassword as string,
);
if (valid) return setIsPasswordUnlocked(true);
}
};
useEffect(() => {
setIsLoading(true);
new Promise((resolve) =>
resolve(handlePrivateIndexCheck()),
).then(() => setIsLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const routeChangeStart = () => setIsLoading(true);
const routeChangeComplete = () => {
handlePrivateIndexCheck();
setIsLoading(false);
};
router.events.on("routeChangeStart", routeChangeStart);
router.events.on(
"routeChangeComplete",
routeChangeComplete,
);
router.events.on(
"routeChangeError",
routeChangeComplete,
);
return () => {
router.events.off(
"routeChangeStart",
routeChangeStart,
);
router.events.off(
"routeChangeComplete",
routeChangeComplete,
);
router.events.off(
"routeChangeError",
routeChangeComplete,
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.events]);
return (
<>
<NextSeo {...seo} />
{isLoading ? (
<div className='w-full h-auto flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Validating path...</span>
</div>
</div>
) : (
<div
className={`w-full h-auto flex flex-col p-4 tablet:px-8 desktop:px-16 ${className}`}
{...rest}
key={router.asPath}
>
{gIndexConfig.siteConfig.privateIndex &&
!isPasswordUnlocked ? (
<PasswordLayout
path='Private Mode Password'
onSubmitted={(password) => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
addNewPassword(
"site-wide",
password,
cookie?.split("=")[1] ?? undefined,
);
setIsLoading(true);
const validPassword = () => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
if (cookie) {
const password = cookie.split("=")[1];
if (!password) return false;
if (!sitePassword)
throw new Error(
"Can't find site password",
);
const valid = checkPathPassword(
"site-wide",
password,
sitePassword as string,
);
return valid;
}
};
new Promise((resolve) =>
resolve(validPassword()),
)
.then((data) => {
if (data) {
setIsPasswordUnlocked(true);
} else {
setIsPasswordUnlocked(false);
}
})
.finally(() => setIsLoading(false));
}}
/>
) : (
<>{children}</>
)}
</div>
)}
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { Icon } from "@iconify/react";
import { useRouter } from "next/router";
import { useState } from "react";
import Button from "components/Button";
import ButtonIcon from "components/ButtonIcon";
interface PasswordLayoutProps {
onSubmitted: (password: string) => void;
path: string;
}
export default function PasswordLayout(
props: PasswordLayoutProps,
) {
const router = useRouter();
const [showPassword, setShowPassword] =
useState<boolean>(false);
const [password, setPassword] = useState<string>("");
const [pending, setPending] = useState<boolean>(false);
return (
<div className='col-span-full text-center min-h-[50vh] flex-grow flex flex-col items-center justify-center'>
<figure>
<img
src='/images/password.png'
alt="You don't have access"
className='w-64 h-64 tablet:w-96 tablet:h-96'
/>
</figure>
<div className='w-full flex items-center justify-center flex-col my-8'>
<span className='text-lg font-medium'>
The folder / file you are trying to access is
protected with a password.
</span>
<pre className='max-w-sm w-full tablet:max-w-2xl'>
<code className='text-sm'>{props.path}</code>
</pre>
<span>Please enter the password to continue:</span>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
props.onSubmitted(password);
setPending(true);
}}
className='w-full max-w-lg flex items-center border border-primary-500 rounded-lg'
>
<div className='w-full flex items-center px-2 bg-primary-900 rounded-l-lg overflow-hidden'>
<input
type={showPassword ? "text" : "password"}
autoComplete='off'
className='w-full px-2 py-2 bg-primary-900 outline-none'
placeholder='Enter password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<ButtonIcon
type='button'
variant='transparent'
icon={showPassword ? "ion:eye-off" : "ion:eye"}
size='small'
className='p-1'
onClick={() => setShowPassword(!showPassword)}
/>
</div>
<Button
variant='accent'
type='submit'
className='rounded-r-lg w-24'
disabled={pending}
>
{pending ? (
<Icon
icon='mdi:loading'
className='animate-spin'
width={24}
height={24}
color='currentColor'
/>
) : (
"Submit"
)}
</Button>
</form>
</div>
);
}
+319
View File
@@ -0,0 +1,319 @@
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 PasswordLayout from "../Password";
const Loading = () => (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading view...</span>
</div>
</div>
);
interface PreviewLayoutProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
isFileProtected: boolean;
encryptedId?: string;
}
export default function PreviewLayout(props: PreviewLayoutProps) {
const router = useRouter();
const [data, setData] = useState<PreviewLayoutProps["data"]>(props.data);
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
const [PreviewComponent, setPreviewComponent] = useState<ReactNode>();
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
const [downloadToken] = useState<string | null>(() => {
if (gIndexConfig.apiConfig.allowDownloadProtectedFile) return null;
if (props.isFileProtected) {
return createDownloadToken();
} else {
return null;
}
});
const [expiredIn, setExpiredIn] = useState<string>("");
const fileMetadata = useMemo<
{
key: string;
label: string;
value: string | number | null;
}[]
>(() => {
const metadata = [];
if (data.file) {
if (data.file.name)
metadata.push({
key: "name",
label: "Name",
value: data.file.name,
});
if (data.file.mimeType)
metadata.push({
key: "mimeType",
label: "Type",
value: data.file.mimeType,
});
if (data.file.modifiedTime)
metadata.push({
key: "modifiedTime",
label: "Last Modified",
value: new Date(data.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
});
if (data.file.size)
metadata.push({
key: "size",
label: "Size",
value: bytesToReadable(data.file.size),
});
if (data.file.imageMediaMetadata && data.file.imageMediaMetadata.width && data.file.imageMediaMetadata.height)
metadata.push({
key: "imageMediaMetadata",
label: "Dimension",
value: `${data.file.imageMediaMetadata.width}x${data.file.imageMediaMetadata.height}`,
});
if (data.file.videoMediaMetadata)
metadata.push(
{
key: "videoMediaMetadata",
label: "Dimension",
value: `${data.file.videoMediaMetadata.width}x${data.file.videoMediaMetadata.height}`,
},
{
key: "videoMediaMetadata",
label: "Duration",
value: durationToReadable(data.file.videoMediaMetadata.durationMillis),
},
);
}
return metadata;
}, [data.file]);
useEffect(() => {
if (!downloadToken) return;
const interval = setInterval(() => {
const expiredAt = Number(decryptData(downloadToken));
const date = new Date(expiredAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
const hours = Math.floor(diff / 1000 / 60 / 60);
const minutes = Math.floor(diff / 1000 / 60) % 60;
const seconds = Math.floor(diff / 1000) % 60;
setExpiredIn(`${hours}:${minutes}:${seconds}`);
}, 1000);
return () => clearInterval(interval);
}, [downloadToken]);
useEffect(() => {
new Promise(async (resolve) => {
const Preview = getPreviewComponent(props.data.file?.fileExtension ?? "unknown", props.data.file?.mimeType ?? "");
setPreviewComponent(<Preview file={props.data.file!} />);
await new Promise((resolve) => setTimeout(resolve, 50));
resolve(true);
}).then(() => {
setIsViewLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
setShowPasswordLayout(props.isProtected);
}, [props.isProtected]);
useEffect(() => {
const paths = router.asPath.split("/").filter((path) => path !== "");
const newPaths: Record<"label" | "path", string>[] = [];
paths.forEach((path, index) => {
newPaths.push({
label: path,
path: `/${paths.slice(0, index + 1).join("/")}`,
});
});
setBreadcrumbPaths(newPaths);
}, [router]);
return (
<div className='w-full flex h-full flex-col gap-4 relative'>
{/* Top */}
<div className='flex items-center justify-between gap-4'>
<h1 className='text-2xl font-medium'>{gIndexConfig.siteConfig.siteName}</h1>
{!showPasswordLayout && (
<div className='flex items-center gap-4'>
{/* Disable until figure out how to search from root */}
{/* <Tooltip content='Search file'>
<ButtonIcon
icon='ion:search'
variant='transparent'
/>
</Tooltip> */}
</div>
)}
</div>
<div className='w-full h-px bg-primary-500' />
{/* Header */}
{!showPasswordLayout && (
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
{/* Breadcrumb */}
<Breadcrumb paths={breadcrumbPaths} />
</div>
)}
{/* Content */}
{showPasswordLayout ? (
<PasswordLayout
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 2] ?? "/")}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<div className={twMerge("w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4")}>
<div className={twMerge("col-span-full desktop:col-span-8 overflow-auto")}>
{isViewLoading ? (
<Loading />
) : (
<div className='w-full flex flex-col gap-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 p-4 mb-4 rounded-lg overflow-auto ${poppins.className}`}
>
{PreviewComponent}
</div>
</div>
)}
</div>
<div className='col-span-full desktop:col-span-4'>
<div className='sticky top-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 mb-4 rounded-lg overflow-auto ${poppins.className}`}
>
<div className='flex items-center gap-2'>
<Icon
icon={"ion:information-circle"}
className='w-6 h-6'
/>
<h2 className='text-2xl font-medium'>File info</h2>
</div>
<div className='w-full h-px bg-primary-500' />
<div className='flex flex-col gap-2 w-full'>
{fileMetadata.map((item) => (
<div
className='grid grid-cols-3'
key={`${item.key}-${item.label.toLowerCase()}`}
>
<span className='col-span-1 font-bold'>{item.label}</span>
<p className='col-span-2 break-all whitespace-pre-line'>{item.value ?? "-"}</p>
</div>
))}
</div>
</div>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 my-4 rounded-lg overflow-auto ${poppins.className}`}
>
<Link
href={generatedDownloadLink(data.file!, props.isFileProtected, false, downloadToken)}
target='_blank'
rel='noopener noreferrer'
>
<Button
fullWidth
variant='accent'
startIcon={"ion:download"}
rounded='large'
>
Download
</Button>
</Link>
<Button
fullWidth
variant='primary'
startIcon={"ion:copy"}
rounded='large'
onClick={() => {
try {
const url = generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken);
navigator.clipboard.writeText(url);
toast.success("URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy URL to clipboard.");
console.error(error.message);
}
}}
>
Copy URL
</Button>
<div className='w-full px-2 bg-primary-950 outline-none rounded-lg border border-primary-500 select-all'>
<div className='overflow-x-scroll py-2 whitespace-nowrap'>
{generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken)}
</div>
</div>
{!gIndexConfig.apiConfig.allowDownloadProtectedFile && props.isFileProtected && (
<span className='text-center mt-4 text-sm'>
This file is inside protected folder.
<br />
Download link will be expired in <b>{expiredIn}</b>.
</span>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { jetbrainsMono } from "pages/_app";
import { useRef, useState } from "react";
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";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkSlug from "remark-slug";
import remarkToc from "remark-toc";
import Button from "components/Button";
import "styles/highlight.css";
import "styles/markdown.css";
interface PreProps extends React.HTMLAttributes<HTMLPreElement> {}
function PreComponent(props: PreProps) {
const codeRef = useRef<HTMLPreElement | null>(null);
const [showCopy, setShowCopy] = useState<boolean>(false);
const [isCopied, setIsCopied] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const [copyTimeout, setCopyTimeout] = useState<NodeJS.Timeout>();
const handleCopy = () => {
if (!codeRef.current) return;
if (copyTimeout) clearTimeout(copyTimeout);
try {
navigator.clipboard.writeText(codeRef.current.textContent as string);
setIsError(false);
setIsCopied(true);
setCopyTimeout(
setTimeout(() => {
setIsCopied(false);
}, 2000),
);
toast.success("Code copied to clipboard");
} catch (error: any) {
setIsCopied(false);
setIsError(true);
setCopyTimeout(
setTimeout(() => {
setIsError(false);
}, 2000),
);
toast.error("Failed to copy code to clipboard");
}
};
return (
<div
className={`w-full h-full relative`}
onMouseEnter={() => {
setShowCopy(true);
}}
onMouseLeave={() => {
setShowCopy(false);
setIsCopied(false);
setIsError(false);
}}
>
<div className={`absolute top-0 right-0 p-2 ${showCopy ? "opacity-100" : "opacity-0"} transition-smooth`}>
<Button
size='small'
rounded='medium'
variant={isError ? "danger" : isCopied ? "success" : "primary"}
startIcon={isCopied ? "ion:checkmark" : isError ? "ion:close" : "ion:copy"}
disabled={isCopied || isError}
onClick={() => {
handleCopy();
}}
>
{isCopied ? "Copied!" : isError ? "Failed to copy" : "Copy"}
</Button>
</div>
<pre
ref={codeRef}
{...props}
className='overflow-x-auto'
>
{props.children}
</pre>
</div>
);
}
interface MarkdownProps {
content: string;
}
export default function Markdown(props: MarkdownProps) {
return (
<div className='markdown w-full'>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
rehypePlugins={[rehypeKatex, rehypeRaw, [rehypePrism, { ignoreMissing: true }]]}
components={{
pre: PreComponent,
code: ({ node, inline, className, children, ...props }) => (
<code
className={`${className} ${jetbrainsMono.className}`}
{...props}
>
{children}
</code>
),
}}
>
{props.content}
</ReactMarkdown>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { ReactNode, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
interface ModalProps {
children: ReactNode;
open: boolean;
onClose: () => void;
title: string;
footer?: ReactNode;
keepMounted?: boolean;
}
export default function Modal(props: ModalProps) {
let DELAY = 10;
const [showModal, setShowModal] = useState<boolean>(props.open);
const [keepMounted, setKeepMounted] = useState<boolean>(false);
const [shouldRender, setShouldRender] = useState<boolean>(false);
const [shouldUnmount, setShouldUnmount] = useState<boolean>(true);
useEffect(() => {
const handleClose = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.onClose();
}
};
window.addEventListener("keydown", handleClose);
return () => {
window.removeEventListener("keydown", handleClose);
};
}, [props]);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (showModal && !shouldRender) {
timeoutId = setTimeout(() => {
setShouldRender(true);
}, DELAY);
} else if (!showModal && shouldRender) {
timeoutId = setTimeout(() => {
setShouldRender(false);
}, DELAY);
}
return () => {
clearTimeout(timeoutId);
};
}, [showModal, shouldRender, DELAY]);
useEffect(() => {
setShowModal(props.open);
if (props.open) setShouldUnmount(false);
}, [props.open]);
useEffect(() => {
if (showModal && props.keepMounted) setKeepMounted(true);
}, [showModal, props.keepMounted]);
const handleClose = () => {
setShowModal(false);
if (!keepMounted) setKeepMounted(false);
props.onClose();
};
// useEffect(() => {
// if (!showModal && !keepMounted && shouldRender) {
// const timeout = setTimeout(() => {
// setShouldUnmount(true);
// }, 250);
// return () => {
// clearTimeout(timeout);
// };
// } else {
// setShouldUnmount(false);
// }
// }, [showModal, keepMounted, shouldRender, DELAY]);
if (shouldUnmount) return null;
return (
<ClickAway
open={showModal}
onClickAway={handleClose}
className={`fixed grid place-items-center top-0 left-0 z-[60] w-full h-full transition-smooth ${
shouldRender ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
onTransitionEnd={() => {
if (!showModal && !keepMounted) {
setShouldUnmount(true);
} else {
setShouldUnmount(false);
}
}}
>
<div
className='w-full px-4 max-w-screen-lg'
onClick={(e) => {
e.stopPropagation();
}}
>
<div className='bg-primary-900 text-primary-50 rounded-lg py-2 px-4 flex flex-col gap-2 w-full'>
{/* Header */}
<div className='flex flex-row items-center justify-between py-2'>
<span className='text-xl font-medium w-full line-clamp-1'>{props.title}</span>
<ButtonIcon
icon={"ion:close"}
onClick={handleClose}
/>
</div>
<div className='w-full h-px bg-primary-500' />
{/* Body */}
<div className='py-2'>{props.children}</div>
{/* Footer */}
{props.footer && <div className='py-2'>{props.footer}</div>}
</div>
</div>
</ClickAway>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useState } from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function AudioPreview(props: PreviewProps) {
const [isError, setIsError] = useState<boolean>(false);
return (
<div className='w-full h-full'>
{isError ? (
<ErrorPreview />
) : (
<AudioPlayer
src={generatedDownloadLink(props.file, false)}
showJumpControls={false}
showFilledVolume
style={{
borderRadius: "0.375rem",
}}
onError={() => setIsError(true)}
/>
)}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import axios from "axios";
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import Markdown from "components/Markdown";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function CodePreview(props: PreviewProps) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
useEffect(() => {
axios
.get(generatedDownloadLink(props.file, false))
.then((res) => {
const isObject = typeof res.data === "object";
setContent(
`\`\`\`${props.file.fileExtension}\n${isObject ? JSON.stringify(res.data, null, 2) : res.data}\`\`\``,
);
setIsLoading(false);
})
.catch((err) => {
console.error(err);
setIsError(true);
});
}, [props.file, props.file.fileExtension]);
return (
<div className='w-full h-full'>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
)}
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
import gIndexConfig from "config";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
function LoadingRender() {
return (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
);
}
function ErrorRender() {
return <ErrorPreview />;
}
export default function DocumentPreview(props: PreviewProps) {
return (
<div className='w-full h-full'>
<DocViewer
documents={[
{
uri: generatedDownloadLink(props.file, false, true),
},
]}
pluginRenderers={DocViewerRenderers}
config={{
header: {
disableHeader: true,
},
loadingRenderer: {
overrideComponent: LoadingRender,
},
noRenderer: {
overrideComponent: ErrorRender,
},
}}
className='min-h-[70vh] max-h-[70vh] w-full h-full rounded-md'
theme={{
disableThemeScrollbar: true,
}}
/>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
interface ErrorPreviewProps {
error?: string;
}
export default function ErrorPreview(props: ErrorPreviewProps) {
return (
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
<h2 className='text-2xl font-bold'>Failed to load file preview</h2>
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
Error has been occured while loading preview, it may be caused by{" "}
<span className='font-medium underline'>invalid file type</span> or{" "}
<span className='font-medium underline'>network error</span>.
<br />
You can try again later or download the file to view.
</span>
{props.error && (
<>
<span className='font-medium underline'>Error details</span>
<span className='text-sm tablet:text-base text-primary-300 my-2'>{props.error}</span>
</>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function ImagePreview(props: PreviewProps) {
const [imgSrc, setImgSrc] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
setIsError(false);
setIsLoading(true);
const timeout = setTimeout(() => {
setIsError(true);
setErrorMessage("Image took too long to load");
return;
}, 15000);
fetch(generatedDownloadLink(props.file, false))
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.blob();
})
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
setImgSrc(reader.result as string);
};
reader.readAsDataURL(blob);
})
.catch((err) => {
console.error(err);
setIsError(true);
setErrorMessage(err.message);
})
.finally(() => {
setIsLoading(false);
clearTimeout(timeout);
});
return () => clearTimeout(timeout);
}, [props.file]);
return (
<>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading image...</span>
</div>
</div>
) : (
<>
{isError ? (
<ErrorPreview />
) : (
<img
src={imgSrc}
alt={props.file.name}
className='w-full h-full rounded-md'
/>
)}
</>
)}
</>
);
}
+122
View File
@@ -0,0 +1,122 @@
import axios from "axios";
import gIndexConfig from "config";
import JSZip from "jszip";
import { useCallback, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function MangaPreview(props: PreviewProps) {
const [maxPageIndex, setMaxPageIndex] = useState<number>(4);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [viewPageIndex, setViewPageIndex] = useState<number>(0);
const [images, setImages] = useState<string[]>([]);
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
useEffect(() => {
const mangaImages: string[] = [];
axios
.get(generatedDownloadLink(props.file, false, false, null, true), {
responseType: "blob",
})
.then(async (res) => {
const zipData = await JSZip.loadAsync(res.data);
for (let i = 0; i <= maxPageIndex; i++) {
const file = zipData.file(Object.keys(zipData.files)[i]);
if (file) {
const blob = await file.async("blob");
const reader = new FileReader();
reader.onload = () => {
mangaImages.push(reader.result as string);
};
reader.readAsDataURL(blob);
}
}
setImages(mangaImages);
})
.catch((err) => {
console.error(err);
setIsError(true);
})
.finally(() => {
setIsLoading(false);
});
}, [props.file, maxPageIndex]);
const handleCurrentView = useCallback(
(pageNumber: number) => {
if (pageNumber < 0 || pageNumber > maxPageIndex) return;
setViewPageIndex(pageNumber);
},
[maxPageIndex],
);
return (
<div className='w-full h-full'>
<blockquote className='w-full h-auto flex items-center my-2 bg-primary-800 border-l-4 border-l-accent-500 pl-3 pr-2 py-2 text-sm tablet:text-base rounded-md'>
Preview only shows the first 5 pages. Please download the file to view the rest.
</blockquote>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>
{isError ? (
<ErrorPreview />
) : (
<div className='flex flex-col'>
<div className='w-full h-full relative'>
<img
src={images[viewPageIndex]}
alt={`${props.file.name} - page ${viewPageIndex}`}
className={`w-full h-full object-contain bg-primary-950 rounded-md ${
viewSize === "fit" ? "max-h-[70vh]" : ""
}`}
/>
<ButtonIcon
icon={viewSize === "fit" ? "ion:expand" : "ion:contract"}
className='absolute bottom-2 right-2'
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
/>
</div>
<div className='flex flex-row gap-2 items-center justify-center my-2'>
<ButtonIcon
variant='transparent'
icon={"ion:chevron-back"}
onClick={() => handleCurrentView(viewPageIndex - 1)}
disabled={viewPageIndex === 0}
/>
<span>
{viewPageIndex + 1} / {maxPageIndex + 1}
</span>
<ButtonIcon
variant='transparent'
icon={"ion:chevron-forward"}
onClick={() => handleCurrentView(viewPageIndex + 1)}
disabled={viewPageIndex === maxPageIndex}
/>
</div>
</div>
)}
</>
)}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import axios from "axios";
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import Markdown from "components/Markdown";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function RichPreview(props: PreviewProps) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
useEffect(() => {
axios
.get(generatedDownloadLink(props.file))
.then((res) => {
setContent(res.data);
setIsLoading(false);
})
.catch((err) => {
console.error(err);
setIsError(true);
});
}, [props.file]);
return (
<div className='w-full h-full'>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
)}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export default function UnknownPreview() {
return (
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
<h2 className='text-2xl font-bold'>File preview not available</h2>
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
This file type is not supported for preview.
<br />
You can download the file to view.
</span>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useState } from "react";
import ReactPlayer from "react-player";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function VideoPreview(props: PreviewProps) {
const [isError, setIsError] = useState<boolean>(false);
return (
<div className='w-full h-full'>
{isError ? (
<ErrorPreview />
) : (
<ReactPlayer
key={props.file.encryptedId}
url={generatedDownloadLink(props.file, false)}
controls
pip
wrapper={({ children }) => (
<div className='w-full h-[60vh] bg-primary-950 rounded-md overflow-hidden'>{children}</div>
)}
style={{
width: "100%",
height: "100%",
maxHeight: "60vh",
}}
onError={() => setIsError(true)}
/>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { Popper, PopperProps } from "react-popper";
interface TooltipProps {
children: ReactNode;
content: ReactNode;
placement?: PopperProps<unknown>["placement"];
delay?: number;
}
export default function Tooltip(props: TooltipProps) {
const [refElement, setRefElement] = useState<HTMLElement | null>(null);
const [showTooltip, setShowTooltip] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
function handleMouseEnter() {
if (props.delay) {
timeoutRef.current = window.setTimeout(() => {
setShowTooltip(true);
}, props.delay);
} else {
setShowTooltip(true);
}
}
function handleMouseLeave() {
if (props.delay) {
window.clearTimeout(timeoutRef.current ?? undefined);
}
setShowTooltip(false);
}
function handleScroll() {
setShowTooltip(false);
}
if (refElement) {
refElement.addEventListener("mouseenter", handleMouseEnter);
refElement.addEventListener("mouseleave", handleMouseLeave);
refElement.addEventListener("click", handleMouseLeave);
window.addEventListener("scroll", handleScroll);
}
return () => {
if (refElement) {
refElement.removeEventListener("mouseenter", handleMouseEnter);
refElement.removeEventListener("mouseleave", handleMouseLeave);
refElement.removeEventListener("click", handleMouseLeave);
window.removeEventListener("scroll", handleScroll);
}
};
}, [refElement, props.delay]);
return (
<>
<div ref={setRefElement}>{props.children}</div>
<Popper
placement={props.placement ?? "top"}
modifiers={[
{
name: "offset",
options: {
offset: [0, 10],
},
},
{
name: "hide",
enabled: true,
phase: "main",
requires: ["computeStyles"],
fn: ({ state }) => {
const { height, width, x, y } = state.rects.reference;
const { innerHeight, innerWidth } = window;
state.styles.popper = {
...state.styles.popper,
visibility: height + y > innerHeight || width + x > innerWidth ? "hidden" : "visible",
};
},
},
]}
referenceElement={refElement as HTMLElement}
strategy='absolute'
>
{({ ref, style, placement }) => (
<div
ref={ref}
style={style}
data-placement={placement}
className={`bg-primary-800 max-w-sm text-primary-50 text-sm font-medium px-2 py-1 rounded-md shadow-md transition-smooth ${
showTooltip ? `opacity-100 pointer-events-auto` : "opacity-0 pointer-events-none"
}`}
>
{props.content}
</div>
)}
</Popper>
</>
);
}
-74
View File
@@ -1,74 +0,0 @@
module.exports = {
// Client ID are safe to expose
client_id:
"126409166174-l0f9hdblsrmhkt9jeue9m8o93skfs1sr.apps.googleusercontent.com",
/**
* Change this value to match your platform.
*/
basePath:
process.env.NODE_ENV === "production"
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: "http://localhost:5000",
/**
* Hashed key used for getting protected file for metadata
*/
masterKey:
"640e3e38dd31aec254f214ba38541a82ddb615e73ce9c6129f33ef549b154ab9",
files: {
query: ["trashed = false"],
field:
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, iconLink, trashed",
orderBy: "folder, name asc, modifiedTime desc",
/**
* Special file names that will be used for certain purposes
* These files will be ignored when searching for files
* and will be hidden from the file list
*/
specialFile: {
password: ".password",
readme: ".readme.md",
/**
* Banner are used for generating custom open graph image for folder
* By default, all folder will use the og.png inside public folder
*/
banner: ".banner",
},
hiddenFiles: [".password", ".readme.md", ".banner"],
itemsPerPage: 50,
searchResult: 10,
/**
* Starting point of the drive
* U̶s̶e̶ ̶'̶r̶o̶o̶t̶'̶ ̶t̶o̶ ̶u̶s̶e̶ ̶M̶y̶ ̶D̶r̶i̶v̶e̶ ̶a̶s̶ ̶s̶t̶a̶r̶t̶i̶n̶g̶ ̶p̶o̶i̶n̶t̶
* O̶r̶ ̶u̶s̶e̶ ̶f̶o̶l̶d̶e̶r̶ ̶i̶d̶ ̶t̶o̶ ̶u̶s̶e̶ ̶a̶ ̶s̶p̶e̶c̶i̶f̶i̶c̶ ̶f̶o̶l̶d̶e̶r̶ ̶a̶s̶ ̶s̶t̶a̶r̶t̶i̶n̶g̶ ̶p̶o̶i̶n̶t̶
* Since service account can't access 'root' folder, we need to use folder id
* Why using service account? Since refresh token always expired, I need to use service account to make sure the app always work
*/
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j",
download: {
/**
* Allow user to download protected file without password
* If this set to false, the download link will have temporary token to download the files
* If this set to true, the download link will be permanent
* Default: false
*/
allowProtectedFile: false,
temporaryTokenDuration: 60 * 60, // 1 hour
/**
* If you're using Vercel, the max response size is 4.5MB
* https://vercel.com/docs/platform/limits#serverless-function-payload-size-limit
* If you're using another platform that has different limit,
* change this value to match your platform.
* Set to 0 to disable limit
*/
maxFileSize: 4 * 1024 * 1024,
},
},
/**
* https://web.dev/uses-long-cache-ttl/
*/
cacheControl: "s-maxage=60, stale-while-revalidate",
};
+203
View File
@@ -0,0 +1,203 @@
import colors from "tailwindcss/colors";
const config: gIndexConfig = {
version: "1.0.0",
/**
* Base path of the app, used for generating links
* If you're not using Vercel, you need to change this to your domain name
*
* 2023/09/10: This is not used anymore, edit via env variable instead
*
* @default process.env.NEXT_PUBLIC_VERCEL_URL
*/
basePath:
process.env.NODE_ENV === "development" ? "http://localhost:3000" : `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`,
/**
* Hashed key for fetching protected files / folders from the server
* This key will bypass the file / folder password
*
* 2023/09/10: This is not used anymore
*/
masterKey: "masterkey",
/**
* How long the cache will be stored in the browser
* Used for all pages and api routes
* Default is 5 minutes (300/60 = 5min)
*
* @default "max-age=300, s-maxage=300, stale-while-revalidate, public"
*/
cacheControl: "max-age=300, s-maxage=300, stale-while-revalidate, public",
apiConfig: {
/**
* Starting point of the drive.
* Will be used for '/' route.
*
* Since service account can't access 'root' folder
* You need to create a new folder and share it with the service account
* Then, copy the folder id and paste it here
*/
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j",
defaultQuery: ["trashed = false", "(not mimeType contains 'google-apps' or mimeType contains 'folder')"],
defaultField:
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed",
defaultOrder: "folder, name asc, modifiedTime desc",
itemsPerPage: 50,
searchResult: 5,
/**
* Special file name that will be used for certain purposes
* These files will be ignored when searching for files
* and will be hidden from the files list by default
*/
specialFile: {
password: ".password",
readme: ".readme.md",
/**
* Banner will be used for opengraph image for folder
* By default, all folder will use default og image
*/
banner: ".banner",
},
hiddenFiles: [".password", ".readme.md", ".banner"],
/**
* Allow user to download protected file without password.
* If this set to false, download link will have temporary token attached to it
* If this set to true, user can download the file without password as long as they have the link
*
* Default: false
*/
allowDownloadProtectedFile: false,
/**
* Duration in hours.
*
* Default: 6 hours
*/
temporaryTokenDuration: 6,
/**
* Maximum file size that can be downloaded via api routes
* If it's larger than this, it will be redirected to the file url
*
* If you're using Vercel, they have a limit of ~4 - ~4.5MB response size
* ref: https://vercel.com/docs/platform/limits#serverless-function-payload-size-limit
* If you're using another platform, you can match the limit with your platform
* Or you can set this to 0 to disable the limit
*
* Default: 4MB
*/
maxFileSize: 4 * 1024 * 1024,
},
siteConfig: {
/**
* This value will be used for metadata
*/
siteName: "next-gdrive-index",
siteDescription: "A simple file browser for Google Drive",
siteIcon: "/flaticon.svg",
favIcon: "/favicon.svg",
twitterHandle: "@mbaharip_",
/**
* Tailwind color name.
* Ref: https://tailwindcss.com/docs/customizing-colors
*/
defaultAccentColor: "teal",
/**
* Site wide password protection
* If this is set, all files and folders will be protected by this password
*
* The site password are set from Environment Variable (NEXT_GDRIVE_INDEX_PASSWORD)
* It's because I don't want to store sensitive data in the code
*/
privateIndex: false,
/**
* Example item:
* {
* icon: string, // icon name from iconify (https://icon-sets.iconify.design/)
* name: string,
* href: string,
* external?: boolean
* }
*/
navbarItems: [
{
icon: "ion:document-text",
name: "Documentation",
href: "https://github.com/mbahArip/next-gdrive-index/wiki",
external: true,
},
{
icon: "ion:logo-github",
name: "Github",
href: "https://www.github.com/mbaharip",
external: true,
},
{
icon: "ion:logo-paypal",
name: "Buy me a coffee",
href: "https://www.paypal.me/mbaharip",
external: true,
},
{
icon: "ion:mail",
name: "Contact",
href: "mailto:support@mbaharip.com",
},
],
},
};
export default config;
interface gIndexConfig {
version: string;
basePath: string;
masterKey: string;
cacheControl: string;
apiConfig: {
rootFolder: string;
defaultQuery: string[];
defaultField: string;
defaultOrder: string;
itemsPerPage: number;
searchResult: number;
specialFile: {
password: string;
readme: string;
banner: string;
};
hiddenFiles: string[];
allowDownloadProtectedFile: boolean;
temporaryTokenDuration: number;
maxFileSize: number;
};
siteConfig: {
siteName: string;
siteDescription: string;
siteIcon: string;
favIcon: string;
twitterHandle: string;
defaultAccentColor: keyof typeof colors;
privateIndex: boolean;
navbarItems: {
icon: string;
name: string;
href: string;
external?: boolean;
}[];
};
}
-82
View File
@@ -1,82 +0,0 @@
import {
BsEnvelopeAt,
BsGithub,
BsPaypal,
} from "react-icons/bs";
const config = {
/* Site MetaData */
siteName: "mbahArip Stash",
siteDescription:
"Personal Stash of mbahArip, a place to store my files.",
/* General */
/**
* Site wide password protection
*/
privateIndex: false,
indexPassword:
"640e3e38dd31aec254f214ba38541a82ddb615e73ce9c6129f33ef549b154ab9",
navbar: {
/**
* Title beside the logo
* If not set, will be using `siteName` instead
*/
title: "",
links: [
{
icon: BsGithub,
name: "GitHub",
href: "https://www.github.com/mbaharip",
newTab: true,
},
{
icon: BsPaypal,
name: "Donate",
href: "https://www.paypal.me/mbaharip",
newTab: true,
},
{
icon: BsEnvelopeAt,
name: "Email",
href: "mailto:support@mbaharip.com",
},
{
name: "Main Website",
href: "https://www.mbaharip.com",
newTab: true,
},
],
},
files: {
/**
* Limit breadcrumb to specific depth
* 0 = Unlimited
* 1 = Only show current file
* 2 = Show current file and its parent
* 3 = Show current file, its parent and grandparent
* and so on.
* Default: 2
*/
breadcrumbDepth: 2,
breadcrumbLimiter: "/",
/**
* Limit thumbnail to specific file group
* Refer to typeGroup on /src/utils/fileHelper/fileGroup.ts:1
* Will use google drive thumbnailLink
* If thumbnailLink not available, will use Icon instead
*/
allowThumbnailFileType: ["image", "video", "pdf"],
},
footer: {
/**
* Footer text will be rendered as
* {year} {footerText} - next-gdrive-index ❤️
*/
text: "mbahArip Stash",
renderYear: true,
},
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export function middleware(request: NextRequest) {
return NextResponse.next();
}
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link";
import Button from "components/Button";
import LoaderLayout from "components/Layout/Loader";
export default function NotFoundPage() {
return (
<LoaderLayout
seo={{
title: "Not Found",
}}
>
<div className='w-full flex h-full flex-col gap-4 relative items-center justify-center'>
<figure>
<img
src='/images/404.png'
alt='Page not found'
className='w-64 h-64 tablet:w-96 tablet:h-96 object-contain'
/>
</figure>
<div className='w-full flex items-center justify-center flex-col my-8'>
<span className='text-lg font-medium'>
The page you are trying to access is not found.
</span>
<span>
Please check the URL or go back to the homepage.
</span>
<div className='flex items-center justify-between flex-col tablet:flex-row gap-2 tablet:gap-4 my-4 tablet:my-12'>
<Link href='/'>
<Button
variant='accent'
rounded='large'
className='w-full tablet:w-auto'
>
Go back to homepage
</Button>
</Link>
</div>
</div>
</div>
</LoaderLayout>
);
}
+254
View File
@@ -0,0 +1,254 @@
import axios from "axios";
import gIndexConfig from "config";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import ExplorerLayout from "components/Layout/Explorer";
import LoaderLayout from "components/Layout/Loader";
import PasswordLayout from "components/Layout/Password";
import PreviewLayout from "components/Layout/Preview";
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
import gdrive from "utils/gdriveInstance";
import { addNewPassword, checkPathPassword } from "utils/passwordHelper";
import { IGDriveFiles } from "types/api/files";
import { APIGetFileResponse, APIGetPasswordResponse, APIGetReadmeResponse } from "types/api/response";
import { Constant } from "types/constant";
interface FilePathPageProps {
mappedEncryptedPath: Record<"name" | "id" | "mimeType", string>[];
}
interface StateDataProps {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
}
export default function FilePathPage(props: FilePathPageProps) {
const router = useRouter();
const [data, setData] = useState<StateDataProps>({
file: null,
files: [],
folders: [],
pageToken: null,
});
const [readmeFile, setReadmeFile] = useState<string | null>(null);
const [isProtected, setIsProtected] = useState<boolean>(false);
const [nearestProtected, setNearestProtected] = useState<string>("");
const [isFileProtected, setIsFileProtected] = useState<boolean>(false);
const [isFile, setIsFile] = useState<boolean>(false);
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
useEffect(() => {
setIsLoadingData(true);
const lastPathMimeType = props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].mimeType;
let isLastPathFile = false;
if (lastPathMimeType === "application/vnd.google-apps.folder") {
isLastPathFile = false;
setIsFile(false);
} else {
isLastPathFile = true;
setIsFile(true);
}
const _getPassword = axios.get<APIGetPasswordResponse>("/api/getPassword", {
params: {
path: encryptData(JSON.stringify(props.mappedEncryptedPath)),
},
});
const _getData = axios.get<APIGetFileResponse>("/api/getData", {
params: {
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
isFile: isLastPathFile ? "1" : undefined,
},
});
const _getReadme = axios.get<APIGetReadmeResponse>("/api/getReadme", {
params: {
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
},
});
Promise.all([_getPassword, _getData, _getReadme])
.then(async ([passwordData, fileData, readmeData]) => {
// Check password
if (passwordData.data.data.length) {
let isPathProtected = true;
const savedPasswordCookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
const savedPasswordValue = savedPasswordCookie?.split("=")[1] ?? undefined;
if (savedPasswordValue) {
// Check only nearest path
const nearestPassword = passwordData.data.data[passwordData.data.data.length - 1];
setNearestProtected(nearestPassword.relativePath);
const isPasswordValid = checkPathPassword(
nearestPassword.relativePath,
savedPasswordValue,
decryptData(nearestPassword.password),
);
if (isPasswordValid) {
isPathProtected = false;
}
}
setIsProtected(isPathProtected);
setIsFileProtected(true);
}
// Assign files
setData(fileData.data.data);
// Fetch readme
setReadmeFile(readmeData.data.data);
})
.catch((err) => {
console.error(err);
throw new Error(err);
})
.finally(() => {
setIsLoadingData(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.mappedEncryptedPath]);
return (
<LoaderLayout
seo={{
title: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].name,
}}
>
{isLoadingData ? (
<div className='w-full h-full flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Fetching {isFile ? "file info" : "folder contents"}...</span>
</div>
</div>
) : (
<>
{isProtected ? (
<PasswordLayout
path={nearestProtected}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(nearestProtected), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<>
{isFile ? (
<PreviewLayout
key={router.asPath}
data={data}
isProtected={false}
isFileProtected={isFileProtected}
encryptedId={props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id}
/>
) : (
<ExplorerLayout
key={router.asPath}
data={data}
readmeFile={readmeFile}
isProtected={false}
isFileProtected={isFileProtected}
encryptedId={props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id}
/>
)}
</>
)}
</>
)}
</LoaderLayout>
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { path } = context.params as { path: string[] };
const fetchRootId = gdrive.files.get({
fileId: gIndexConfig.apiConfig.rootFolder,
fields: "id",
});
const fetchPathId = path.map(async (path) => {
const query = ["trashed = false", `name = '${path}'`];
const fetchFolderContents = await gdrive.files.list({
q: `${query.join(" and ")}`,
fields: "files(id, name, mimeType, parents)",
});
if (!fetchFolderContents.data.files?.length) {
return {
path,
data: [],
};
}
return {
path,
data: fetchFolderContents.data.files.map((file) => ({
id: file.id,
parents: file.parents?.[0],
mimeType: file.mimeType,
})),
};
});
const [rootId, pathId] = await Promise.all([fetchRootId, Promise.all(fetchPathId)]);
if (pathId.some((path) => !path.data.length)) {
return {
notFound: true,
};
}
const selectedPath: string[] = [];
const mappedPath: Record<"name" | "id" | "mimeType", string>[] = [];
const rejectedPath: string[] = [];
// Check path validity
pathId.forEach((path, index) => {
let checkPath =
index === 0
? path.data.find((file) => file.parents === rootId.data.id)
: path.data.find((file) => file.parents === selectedPath[index - 1]);
if (!checkPath) {
rejectedPath.push(path.path);
return;
}
selectedPath.push(checkPath.id as string);
mappedPath.push({
name: path.path,
id: checkPath.id as string,
mimeType: checkPath.mimeType as string,
});
});
if (rejectedPath.length) {
return {
notFound: true,
};
}
return {
props: {
mappedEncryptedPath: mappedPath.map((path) => ({
name: path.name,
id: encryptData(path.id),
mimeType: path.mimeType,
})),
},
};
}
+339
View File
@@ -0,0 +1,339 @@
import { Icon } from "@iconify/react";
import gIndexConfig from "config";
import { DefaultSeo } from "next-seo";
import { AppProps } from "next/app";
import { JetBrains_Mono, Kanit, Poppins } from "next/font/google";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { twMerge } from "tailwind-merge";
import twColor from "tailwindcss/colors";
import Button from "components/Button";
import ButtonGroup from "components/ButtonGroup";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Modal from "components/Modal";
import Tooltip from "components/Tooltip";
import hexToRgb from "utils/hexToRGB";
import { removeAllPassword } from "utils/passwordHelper";
import "styles/globals.css";
const kanit = Kanit({
display: "auto",
weight: ["300", "400", "500", "600", "700", "800", "900"],
subsets: ["latin-ext", "latin"],
preload: true,
});
export const poppins = Poppins({
display: "auto",
weight: ["300", "400", "500", "600", "700", "800", "900"],
subsets: ["latin-ext", "latin"],
preload: true,
});
export const jetbrainsMono = JetBrains_Mono({
display: "auto",
weight: ["300", "400", "500", "600", "700", "800"],
subsets: ["latin-ext", "latin"],
style: ["normal", "italic"],
preload: true,
});
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter();
const [accentColor] = useState<keyof typeof twColor>(
gIndexConfig.siteConfig.defaultAccentColor as keyof typeof twColor,
);
const [showToTopButton, setShowToTopButton] = useState<boolean>(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState<boolean>(false);
const [clearPasswordModalOpen, setClearPasswordModalOpen] = useState<boolean>(false);
useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 150) {
setShowToTopButton(true);
} else {
setShowToTopButton(false);
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const handleClearPassword = () => {
removeAllPassword();
setClearPasswordModalOpen(false);
router.reload();
};
useEffect(() => {
const colorObject = twColor[accentColor];
Object.keys(colorObject).forEach((key) => {
document.documentElement.style.setProperty(
`--accent-${key}`,
hexToRgb(colorObject[key as keyof typeof colorObject]),
);
});
}, [accentColor]);
return (
<div
id='next-gdrive-index'
className={`${kanit.className} flex flex-col tablet:flex-row w-full`}
>
<DefaultSeo
defaultTitle={gIndexConfig.siteConfig.siteName}
titleTemplate={`%s | ${gIndexConfig.siteConfig.siteName}`}
description={gIndexConfig.siteConfig.siteDescription}
canonical={process.env.NEXT_PUBLIC_VERCEL_URL}
openGraph={{
type: "website",
title: gIndexConfig.siteConfig.siteName,
description: gIndexConfig.siteConfig.siteDescription,
url: process.env.NEXT_PUBLIC_VERCEL_URL,
siteName: gIndexConfig.siteConfig.siteName,
images: [
{
url: `/og.png`,
width: 1200,
height: 630,
},
],
}}
twitter={{
cardType: "summary_large_image",
handle: gIndexConfig.siteConfig.twitterHandle,
}}
additionalMetaTags={[
{
name: "twitter:title",
content: gIndexConfig.siteConfig.siteName,
},
{
name: "twitter:description",
content: gIndexConfig.siteConfig.siteDescription,
},
{
name: "twitter:image",
content: `/og.png`,
},
]}
/>
<nav className='w-full fixed top-0 z-50 tablet:w-20 h-12 tablet:min-h-screen bg-primary-950 text-primary-50 flex flex-row tablet:flex-col items-center justify-between'>
<div className='w-full tablet:w-20 h-12 tablet:min-h-screen px-4 tablet:py-4 bg-primary-950 relative z-50 flex flex-row tablet:flex-col items-center justify-between'>
<Link
href='/'
onClick={() => setIsMobileMenuOpen(false)}
>
<Tooltip
content={"Home"}
placement={"right"}
>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-8 tablet:w-10 h-8 tablet:h-10'
loading='eager'
/>
</Tooltip>
</Link>
<div className='hidden tablet:flex flex-col gap-4'>
{gIndexConfig.siteConfig.navbarItems.map((link) => (
<Link
href={link.href}
key={`navbar-${link.name}`}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className={`flex flex-col items-center justify-center opacity-80 hover:opacity-100 transition-smooth ${
router.pathname === link.href ? "opacity-100 cursor-default" : "cursor-pointer"
}`}
>
<Tooltip
content={link.name}
placement='right'
>
<ButtonIcon
icon={link.icon as string}
variant='transparent'
/>
</Tooltip>
</Link>
))}
<div className='w-full h-px bg-primary-500' />
<Tooltip
content='Clear password'
placement='right'
>
<ButtonIcon
icon={"ion:log-out"}
variant='transparent'
className='opacity-80 hover:opacity-100 transition-smooth'
onClick={() => setClearPasswordModalOpen(true)}
/>
</Tooltip>
</div>
<div className='tablet:hidden h-full flex items-center'>
<Button
variant='transparent'
pill
square
onClick={() => setIsMobileMenuOpen((prev) => !prev)}
>
<Icon
icon='ion:menu'
width={24}
height={24}
color='white'
className={`transition-smooth absolute ${
isMobileMenuOpen
? "opacity-0 rotate-90 pointer-events-none"
: "opacity-100 rotate-0 pointer-events-auto"
}`}
/>
<Icon
icon='ion:close'
width={24}
height={24}
color='white'
className={`transition-smooth absolute ${
isMobileMenuOpen
? "opacity-100 rotate-0 pointer-events-auto"
: "opacity-0 rotate-90 pointer-events-none"
}`}
/>
</Button>
</div>
</div>
<ClickAway
open={isMobileMenuOpen}
onClickAway={() => setIsMobileMenuOpen(false)}
className={`transition-smooth w-full h-screen block tablet:hidden absolute top-0 z-40 ${
isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div className={`transition-smooth ${isMobileMenuOpen ? "translate-y-0" : "-translate-y-full"}`}>
<div className='h-fit flex px-4 w-full flex-col gap-4 py-16 pb-4 bg-primary-950 drop-shadow-md z-40'>
{gIndexConfig.siteConfig.navbarItems.map((link) => (
<Link
href={link.href}
key={`navbar-${link.name}`}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className={`flex items-center justify-start gap-4 w-full opacity-80 hover:opacity-100 transition-smooth ${
router.pathname === link.href ? "opacity-100 cursor-default" : "cursor-pointer"
}`}
onClick={(e) => {
// If href === current page, prevent default
if (router.pathname === link.href) {
e.preventDefault();
}
e.stopPropagation();
setIsMobileMenuOpen(false);
}}
>
<Icon
icon={link.icon as string}
width={24}
height={24}
color='white'
/>
<span>{link.name}</span>
</Link>
))}
<Button
variant='danger'
fullWidth
onClick={() => setClearPasswordModalOpen(true)}
rounded='large'
startIcon={"ion:log-out"}
className='mt-4'
>
Clear password
</Button>
</div>
</div>
</ClickAway>
</nav>
<main
className={`w-full min-h-screen bg-primary-950 text-primary-50 flex flex-col tablet:pl-20 pl-0 pt-12 tablet:pt-0`}
>
<div className='flex-grow w-full h-full flex'>
<Component {...pageProps} />
</div>
<footer className='w-full h-8 text-center text-sm py-8 flex items-center justify-center'>
<span>
Powered by{" "}
<Link
href={"https://www.github.com/mbaharip/next-gdrive-index"}
target='_blank'
rel='noopener noreferrer'
className='underline underline-offset-8 hover:underline-offset-1 decoration-wavy decoration-transparent hover:decoration-accent-400 text-accent-400 transition-smooth'
>
next-gdrive-index
</Link>
</span>
</footer>
</main>
<ToastContainer
autoClose={1500}
limit={3}
position='bottom-center'
theme='dark'
/>
{/* To the top */}
<ButtonIcon
icon={"ion:chevron-up"}
variant='accent'
size='large'
className={twMerge(
"fixed right-4 z-50",
showToTopButton ? "opacity-100 pointer-events-auto bottom-4" : "opacity-0 pointer-events-none bottom-0",
)}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
/>
{/* Clear password */}
<Modal
key={`clear-password-modal`}
open={clearPasswordModalOpen}
onClose={() => setClearPasswordModalOpen(false)}
title='Clear password'
>
<div className='flex flex-col gap-4'>
<div className='flex flex-col'>
<span className='text-lg'>Are you sure you want to clear your password?</span>
<span className='text-sm text-primary-400'>
You will need to re-enter your password to access protected folders / files.
</span>
</div>
<ButtonGroup>
<Button
variant='primary'
startIcon={"ion:close"}
onClick={() => setClearPasswordModalOpen(false)}
>
Cancel
</Button>
<Button
variant='danger'
startIcon={"ion:log-out"}
onClick={handleClearPassword}
>
Clear password
</Button>
</ButtonGroup>
</div>
</Modal>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import gIndexConfig from "config";
import { Head, Html, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang='en'>
<Head>
<link
rel='icon'
href={gIndexConfig.siteConfig.favIcon}
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
+87
View File
@@ -0,0 +1,87 @@
import axios from "axios";
import gIndexConfig from "config";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import ExplorerLayout from "components/Layout/Explorer";
import LoaderLayout from "components/Layout/Loader";
import { IGDriveFiles } from "types/api/files";
import {
APIGetFileResponse,
APIGetReadmeResponse,
} from "types/api/response";
interface StateDataProps {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
}
export default function RootPage() {
const router = useRouter();
const [data, setData] = useState<StateDataProps>({
file: null,
files: [],
folders: [],
pageToken: null,
});
const [readmeFile, setReadmeFile] = useState<
string | null
>(null);
const [isLoadingData, setIsLoadingData] =
useState<boolean>(true);
useEffect(() => {
setIsLoadingData(true);
const _getData =
axios.get<APIGetFileResponse>("/api/getData");
const _getReadme = axios.get<APIGetReadmeResponse>(
"/api/getReadme",
);
Promise.all([_getData, _getReadme])
.then(([fileData, readmeData]) => {
// Assign files
setData(fileData.data.data);
// Fetch readme
setReadmeFile(readmeData.data.data);
})
.catch((err) => {
console.error(err);
throw new Error(err);
})
.finally(() => {
setIsLoadingData(false);
});
}, []);
return (
<LoaderLayout>
{isLoadingData ? (
<div className='w-full h-full flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Fetching folder contents...</span>
</div>
</div>
) : (
<ExplorerLayout
key={router.asPath}
data={data}
readmeFile={readmeFile}
// If you want the root to be protected, use Private index instead
isProtected={false}
/>
)}
</LoaderLayout>
);
}
+40 -235
View File
@@ -3,249 +3,54 @@
@tailwind utilities;
:root {
--animation-duration: 300ms;
--animation-timing: cubic-bezier(0.4, 0, 0.2, 1);
--animation-duration: 300ms;
--animation-timing: cubic-bezier(0.4, 0, 0.2, 1);
--accent-50: 240 249 255;
--accent-100: 224 242 254;
--accent-200: 186 230 253;
--accent-300: 125 211 252;
--accent-400: 56 189 248;
--accent-500: 14 165 233;
--accent-600: 2 132 199;
--accent-700: 3 105 161;
--accent-800: 7 89 133;
--accent-900: 12 74 110;
--accent-950: 8 47 73;
}
html, body {
margin: 0;
padding: 0;
box-sizing: border-box;
html,
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
* {
@apply outline outline-red-500/0 !important;
}
/* Base */
@layer base {
html {
@apply text-sm tablet:text-base;
}
* {
@apply outline-none;
@apply focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500;
}
* {
@apply focus:outline focus:outline-blue-400 focus:rounded;
@apply dark:focus:outline-blue-600;
}
html,
body {
@apply bg-primary-950;
}
/* Scrollbar */
::-webkit-scrollbar {
@apply w-1.5;
}
::-webkit-scrollbar {
@apply h-1 w-1;
}
html ::-webkit-scrollbar-track {
@apply bg-zinc-100 rounded;
}
::-webkit-scrollbar-track {
@apply bg-primary-950;
}
html ::-webkit-scrollbar-thumb {
@apply bg-zinc-400 rounded;
}
::-webkit-scrollbar-thumb {
@apply bg-primary-600 hover:bg-primary-500;
}
html ::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-500;
}
html.dark ::-webkit-scrollbar-track {
@apply bg-zinc-500;
}
html.dark ::-webkit-scrollbar-thumb {
@apply bg-zinc-900;
}
html.dark ::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-800;
}
/* Text */
a, .interactive {
@apply opacity-90;
@apply hover:opacity-100;
@apply transition-opacity duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
a[role='url'] {
@apply opacity-100 text-blue-600;
@apply hover:text-blue-500;
@apply active:text-blue-700;
@apply focus:underline;
@apply dark:text-blue-500;
@apply dark:hover:text-blue-400;
@apply dark:active:text-blue-600;
@apply transition-colors duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
button {
@apply bg-blue-600 text-zinc-100 px-4 py-2 opacity-100;
@apply rounded-xl focus:rounded-xl active:rounded-xl;
@apply hover:bg-blue-500 hover:text-zinc-100;
@apply active:bg-blue-700 active:text-zinc-100;
@apply focus:outline-none;
@apply dark:bg-blue-500 dark:text-zinc-100;
@apply dark:hover:bg-blue-400 dark:hover:text-zinc-100;
@apply dark:active:bg-blue-600 dark:active:text-zinc-100;
@apply transition-colors duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
button.ghost {
@apply bg-transparent border border-blue-600 px-4 py-2 rounded-xl opacity-100;
@apply hover:bg-blue-100;
@apply active:bg-blue-200;
@apply focus:outline-none;
@apply dark:border-blue-500;
@apply dark:hover:bg-blue-800;
@apply dark:active:bg-blue-900;
@apply transition-colors duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
button.link {
@apply bg-transparent px-4 py-2 rounded-xl opacity-100;
@apply hover:bg-blue-100;
@apply active:bg-blue-200;
@apply focus:outline-none;
@apply dark:hover:bg-blue-800;
@apply dark:active:bg-blue-900;
@apply transition-colors duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
span {
@apply text-base leading-normal;
}
h1, h2, h3 {
@apply font-bold;
}
h4, h5, h6 {
@apply font-semibold;
}
h1 {
@apply text-4xl tablet:text-5xl;
}
h2 {
@apply text-3xl tablet:text-4xl;
}
h3 {
@apply text-2xl tablet:text-3xl;
}
h4 {
@apply text-xl tablet:text-2xl;
}
h5 {
@apply text-lg tablet:text-xl;
}
h6 {
@apply text-base tablet:text-lg;
}
input {
@apply bg-zinc-100 px-4 py-2;
@apply rounded-lg focus:rounded-lg;
@apply border;
@apply border-zinc-400 hover:border-blue-300 active:border-blue-400;
@apply dark:bg-zinc-900;
@apply dark:border-zinc-600 dark:hover:border-blue-400 dark:active:border-blue-500;
@apply transition-colors duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
input::-ms-reveal,
input::-ms-clear {
@apply hidden;
}
}
@layer components {
div.layoutDropdown {
@apply rounded-lg pr-2 pl-4 py-1 cursor-pointer;
@apply flex items-center justify-between gap-8;
@apply text-sm tablet:text-base;
@apply border;
@apply border-zinc-400 hover:border-blue-300 active:border-blue-400;
@apply bg-zinc-100 hover:bg-zinc-200 active:bg-zinc-50;
@apply dark:border-zinc-600 dark:hover:border-blue-400 dark:active:border-blue-500;
@apply dark:bg-zinc-900 dark:hover:bg-zinc-800 dark:active:bg-zinc-950;
@apply disabled:border-zinc-300 dark:disabled:border-zinc-700;
@apply disabled:bg-zinc-400 dark:disabled:bg-zinc-800;
@apply disabled:text-zinc-500;
@apply transition duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
div.layoutOption {
@apply rounded-lg overflow-hidden;
@apply justify-center;
@apply text-sm tablet:text-base;
@apply border;
@apply border-blue-400;
@apply bg-zinc-200;
@apply dark:border-blue-500;
@apply dark:bg-zinc-800;
@apply transition duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
div.layoutItem {
@apply flex items-center justify-start gap-2 pr-2 pl-4 py-1 h-full w-full cursor-pointer;
@apply bg-zinc-100 hover:bg-blue-300/25 focus:bg-zinc-50;
@apply dark:bg-zinc-900 dark:hover:bg-blue-400/25 dark:focus:bg-zinc-950;
}
div.layoutItem[data-active="true"] {
@apply bg-blue-300 cursor-default;
@apply dark:bg-blue-400;
}
div.card {
@apply rounded-lg p-4;
@apply flex flex-col items-center justify-between gap-4;
@apply text-sm tablet:text-base;
@apply border;
@apply border-zinc-400;
@apply bg-zinc-100;
@apply dark:border-zinc-600;
@apply dark:bg-zinc-900;
}
div.file {
@apply rounded-lg p-2 cursor-pointer w-full;
@apply flex items-center gap-2;
@apply text-sm tablet:text-base;
@apply border;
@apply border-zinc-400 hover:border-blue-300 active:border-blue-400;
@apply bg-zinc-100 hover:bg-zinc-200 active:bg-zinc-50;
@apply dark:border-zinc-600 dark:hover:border-blue-400 dark:active:border-blue-500;
@apply dark:bg-zinc-900 dark:hover:bg-zinc-800 dark:active:bg-zinc-950;
@apply transition duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
div.file-list {
@apply rounded-lg p-2 cursor-pointer w-full;
@apply gap-2;
@apply text-sm tablet:text-base;
@apply bg-zinc-100 hover:bg-zinc-200 active:bg-zinc-50;
@apply dark:bg-zinc-900 dark:hover:bg-zinc-800 dark:active:bg-zinc-950;
@apply transition duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
}
/* Utility */
@layer utilities {
.global-duration {
@apply duration-[var(--animation-duration)] ease-[var(--animation-timing)];
}
.divider-horizontal {
@apply w-full h-px rounded-lg;
@apply bg-zinc-400;
@apply dark:border-zinc-600;
}
.divider-vertical {
@apply h-full w-px rounded-lg;
@apply bg-zinc-400;
@apply dark:border-zinc-600;
}
.skeleton {
@apply rounded-lg bg-zinc-400 dark:bg-zinc-700 animate-pulse;
}
}

Some files were not shown because too many files have changed in this diff Show More