mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
✨feature: Revamp and full rebuild
This commit is contained in:
@@ -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=""
|
||||
@@ -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
@@ -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
|
||||
Generated
-5
@@ -1,5 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="TypeScriptCompiler">
|
||||
<option name="recompileOnChanges" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-7
@@ -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>
|
||||
Generated
-12
@@ -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
@@ -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>
|
||||
Generated
-8
@@ -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>
|
||||
Generated
-7
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -1,72 +1,79 @@
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user