Merge pull request #2 from mbahArip/feat/app-dir

Feat/app dir
This commit is contained in:
Arief Rachmawan
2023-09-11 05:06:05 +07:00
committed by GitHub
148 changed files with 8069 additions and 7566 deletions
-7
View File
@@ -1,7 +0,0 @@
# You can generate encryption key from setup page.
NEXT_PUBLIC_ENCRYPTION_KEY=""
# You can use any random string for JWT_KEY, it is recommended to use a strong key by combining characters, numbers and symbols.
JWT_KEY=""
# Domain name of your website. Used for fetching the og-image from public directory.
# Example: https://drive.example.com
NEXT_PUBLIC_DOMAIN=""
+5
View File
@@ -0,0 +1,5 @@
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
NEXT_PUBLIC_ENCRYPTION_KEY="Rj3wRGB9nUElQ7OA"
NEXT_PUBLIC_SITE_PASSWORD="mbaharip"
NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
+5 -6
View File
@@ -9,7 +9,6 @@
/coverage
# next.js
/.next/
/out/
# production
@@ -28,15 +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/
/**/*/_legacy/
/.*/
/src/_app
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-22
View File
@@ -1,22 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="743" name="TypeScript" />
</Languages>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ExceptionCaughtLocallyJS" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="tw" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
</profile>
</component>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/drive-manager.iml" filepath="$PROJECT_DIR$/.idea/drive-manager.iml" />
</modules>
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myRunOnSave" value="true" />
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+15 -4
View File
@@ -1,9 +1,10 @@
"use strict";
const tailwindConfig = require("prettier-plugin-tailwindcss");
module.exports = {
plugins: [tailwindConfig],
printWidth: 80,
plugins: [
require("prettier-plugin-tailwindcss"),
require("@trivago/prettier-plugin-sort-imports"),
],
printWidth: 120,
tabWidth: 2,
useTabs: false,
semi: true,
@@ -19,4 +20,14 @@ module.exports = {
endOfLine: "lf",
embeddedLanguageFormatting: "auto",
singleAttributePerLine: true,
importOrder: [
"<THIRD_PARTY_MODULES>",
"^components/(.*)$",
"^(utils|hooks|context)/(.*)$",
"^types/(.*)$",
"^(config|styles)/(.*)$",
"^[./]",
],
importOrderSeparation: true,
importOrderSortSpecifiers: true,
};
+50 -43
View File
@@ -1,74 +1,81 @@
![next-gdrive-index](./public/og.png)
## Status
Basic features are working now, can be used for daily use.
You can check the demo [here](https://drive.mbaharip.com).
<p align='center'>
<a href='https://drive-demo.mbaharip.com'>Demo</a>
·
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/Deploying'>Deploying Guide</a>
·
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ'>FAQ</a>
</p>
**Development will be paused for a few weeks, I will resume the project as soon as possible.**
## 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.
+38 -106
View File
@@ -1,109 +1,41 @@
# Todo
Detailed what I need to do for this project.
This is just a reminder for me, so I don't forget what I need to do.
- Redo how to handle error
Probably also include some ideas that I want to implement in the future.
# /
- ~~Override opengraph using banner image~~
- ~~Render readme file~~
- ~~Copy folder url~~
- ~~Copy file direct url~~
- ~~Download file url~~
- ~~Index password~~
## Frontend
### General
- ~~Site wide password protection~~
- Migrate to app directory (?)
# /:path
- ~~Override opengraph using banner image / thumbnail image~~
- ~~Render readme file~~
- ~~Copy folder url~~
- ~~Copy file direct url~~
- ~~Download file url~~
- ~~Password protected~~
- Issues: Return 500 error if it's wrong password
- File Preview
- 3D > 3DPreview
- Audio > AudioPreview
- Archive > UnknownPreview
- RichText > MarkdownPreview
- OfficeWord > OfficePreview
- OfficeExcel > OfficePreview
- OfficePowerPoint > OfficePreview
- PDF > PDFPreview
- Database > UnknownPreview
- Image > ImagePreview
- Code > CodePreview
- Text > TextPreview
- Video > VideoPreview
- Font > UnknownPreview
- Default > UnknownPreview
- Binary > UnknownPreview
### Navbar `8/8 Completed`
- ~~Search for files~~
- ~~Open / Close search modal~~
- ~~Search for files or folder~~
- ~~Configurable search limit~~
- ~~Close search modal when click outside of modal~~
- ~~Theme~~
- ~~Switch theme~~
- ~~Store theme preference in local storage~~
- ~~Navbar menu~~
### Layout Container `10/10 Completed`
This part contains only breadcrumb and view selection.
- ~~Breadcrumb~~
- ~~Fetch breadcrumb~~
- ~~Navigate through breadcrumb~~
- ~~Limit breadcrumb~~
- ~~Add `...` if breadcrumb is too long~~
- ~~Add ellipsis if current file name is too long~~
- ~~Configurable breadcrumb limit~~
- ~~Layout view~~
- ~~Grid view~~
- ~~List view~~
- ~~Store view preference in local storage~~
- ~~Close view selection when click outside~~
### Home `/` | `4/5 Completed`
- ~~Fetch files~~
- ~~Fetch readme if exist~~
- ~~Fetch banner if exist~~
- ~~Pagination~~
- Protected folder
### Folder `/folder/:id` | `5/6 Completed`
- ~~Check if folder exist~~
- ~~Fetch files~~
- ~~Fetch readme if exist~~
- ~~Fetch banner if exist~~
- ~~Pagination~~
- Protected folder
### File `/file/:id` | ``
## Backend
### General
Since Google Drive direct download need the file to be public, I implement the `partial file id` to keep people from directly accessing the Google Drive itself, and also encrypt the `id` and `webContentLink` from Google Drive API to ensure that no one can access the file directly from Google Drive.
- ~~Change `fileId` to `{file name}:{partial file id}`~~
- ~~Encrypt `id` and `webContentLink`~~
- ~~Making sure response time is fast enough. Aim for max 1.5s.~~
- ~~Fetch root folder `600~900ms`~~
- ~~Fetch file or folder `700~1400ms`~~
- ~~Fetch breadcrumb `900~1500ms`~~
- ~~Fetch readme `1100~1200ms`~~
- ~~Fetch readme from specific folder `1200~1500ms`~~
- ~~Fetch banner `500-800ms`~~
- ~~Fetch banner from specific folder `500~800ms`~~
- Protect folder
### Fetch files `/api/files` | `16/17 Completed`
- ~~Fetch file~~
- ~~Fetch folder~~
- ~~Paginated~~
- ~~Fetch info regarding readme file~~
- ~~Fetch info regarding banner file~~
- ~~Fetch info regarding password file~~ (Not used yet)
- Download file
- ~~Redirect to Google Drive download link instead of using Vercel serverless function for files bigger than 4MB~~
- ~~Only allow for files~~
- Files inside protected folder
- ~~Get thumbnail for files~~
- ~~Get banner inside folder~~
- ~~Only if banner file is inside the folder~~
- ~~Redirect to Google Drive download link instead of using Vercel serverless function for files bigger than 4MB~~
- ~~Check if banner file is image~~
- ~~Generate breadcrumb~~
- ~~On folder~~
- ~~On file~~
- ~~Hide root folder~~
- ~~Limiting breadcrumb length~~
### Get readme data `/api/readme` | `2/2 Completed`
This API route doesn't need authorization, because it's only used for getting readme data.
- ~~Get root folder readme~~
- ~~Get specific folder readme~~
### Get banner file id `/api/banner` | `2/2 Completed`
This API route doesn't need authorization, because it's only used for getting banner file id for Opengraph Image.
- ~~Get root folder banner~~
- ~~Get specific folder banner~~
### Search for files `/api/search` | `2/2 Completed`
- ~~Search for files and folder~~
- ~~Limit search result~~
# /download/:path
- ~~Download file based on path~~
- ~~Validate path~~
- Protected file
- Token based for protected file
+70
View File
@@ -0,0 +1,70 @@
import { IGDriveFiles } from "types/api/files";
import { APIFilesResponse } from "types/api/response";
const mockFiles: APIFilesResponse = {
timestamp: Date.now(),
responseTime: 0,
folders: [],
files: [],
nextPageToken: "nextpageToken",
readmeExists: true,
bannerExists: true,
passwordExists: false,
};
const mockFilesProtected: APIFilesResponse = {
timestamp: Date.now(),
responseTime: 0,
folders: [],
files: [],
nextPageToken: "nextpageToken",
readmeExists: true,
bannerExists: true,
passwordExists: true,
};
for (let i = 0; i < 10; i++) {
const folder: IGDriveFiles = {
mimeType: "application/vnd.google-apps.folder",
encryptedId: `encryptedId${i + 1}`,
name: `folder${i + 1}`,
trashed: false,
modifiedTime: new Date().toISOString(),
};
const file: IGDriveFiles = {
mimeType: "application/vnd.google-apps.file",
encryptedId: `encryptedId${i + 1}`,
name: `file${i + 1}`,
trashed: false,
modifiedTime: new Date().toISOString(),
fileExtension: "txt",
encryptedWebContentLink: "encryptedWebContentLink",
size: Math.floor(Math.random() * 1000000000),
thumbnailLink: "/og.png",
imageMediaMetadata:
Math.random() > 0.5
? {
width: 1920,
height: 1080,
rotation: 0,
}
: null,
videoMediaMetadata:
Math.random() > 0.5
? {
width: 1920,
height: 1080,
durationMillis: 100000,
}
: null,
};
mockFiles.folders.push(folder);
mockFiles.files.push(file);
folder.name = `folder${i + 1}protected`;
file.name = `file${i + 1}protected`;
mockFilesProtected.folders.push(folder);
mockFilesProtected.files.push(file);
}
export { mockFiles, mockFilesProtected };
+32 -4
View File
@@ -1,6 +1,34 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
const withMDX = require("@next/mdx")({
extension: /\.mdx?$/,
options: {
// If you use remark-gfm, you'll need to use next.config.mjs
// as the package is ESM only
// https://github.com/remarkjs/remark-gfm#install
remarkPlugins: [],
rehypePlugins: [],
// If you use `MDXProvider`, uncomment the following line.
providerImportSource: "@mdx-js/react",
},
});
const nextConfig = {
pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
reactStrictMode: true,
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Cache-Control",
value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
},
],
},
];
},
};
module.exports = withMDX(nextConfig);
+32 -32
View File
@@ -3,62 +3,62 @@
"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",
"cypress": "cypress open"
"cypress": "cypress open",
"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.3.0",
"next-seo": "^6.0.0",
"nextjs-progressbar": "^0.0.16",
"postcss": "8.4.22",
"react": "18.2.0",
"react-dom": "18.2.0",
"jszip": "^3.10.1",
"next": "^13.4.13",
"next-seo": "^6.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-h5-audio-player": "^3.8.6",
"react-icons": "^4.8.0",
"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.3.0",
"eslint-config-next": "^13.4.3",
"postcss": "8.4.22",
"prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.7",
"tailwind-merge": "^1.14.0",
"tailwindcss": "3.3.1",
"typescript": "5.0.4"
}
}
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 357.3 318.6" style="enable-background:new 0 0 357.3 318.6;" xml:space="preserve">
<style type="text/css">
.st0{clip-path:url(#SVGID_00000113337896632168277910000000023085085965044388_);}
.st1{fill:url(#SVGID_00000013887681291698298630000007337251733899413648_);}
.st2{fill:#FFFFFF;}
.st3{fill:#FAFAFA;}
</style>
<g>
<g>
<defs>
<polygon id="SVGID_1_" points="228.6,229.7 128.7,229.7 178.6,143.2 "/>
</defs>
<clipPath id="SVGID_00000171714398683706915590000003431004812763861921_">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<g style="clip-path:url(#SVGID_00000171714398683706915590000003431004812763861921_);">
<radialGradient id="SVGID_00000098178616607910525110000001051538810791995295_" cx="178.635" cy="161.295" r="161.753" gradientTransform="matrix(1 0 0 -1 0 320.6)" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#A2DDF8"/>
<stop offset="1" style="stop-color:#BC7DB4"/>
</radialGradient>
<path style="fill:url(#SVGID_00000098178616607910525110000001051538810791995295_);" d="M34.7,311.1c-9.8,0-18.6-5.1-23.5-13.6
s-4.9-18.6,0-27.1L155.1,21.1c4.9-8.5,13.7-13.6,23.5-13.6s18.6,5.1,23.5,13.6l143.9,249.3c4.9,8.5,4.9,18.6,0,27.1
s-13.7,13.6-23.5,13.6H34.7z"/>
<path class="st2" d="M178.6,15c6.6,0,13.2,3.3,17,9.8l102.1,176.8l0,0l37,64.1l4.9,8.4c7.6,13.1-1.9,29.5-17,29.5H86.9H68.7h-34
c-15.1,0-24.6-16.4-17-29.5L135.5,70l9.1-15.7l17-29.5C165.4,18.3,172,15,178.6,15L178.6,15z M178.6,0c-12.5,0-23.7,6.5-30,17.3
l-17,29.5l-9.1,15.7L4.7,266.6c-6.3,10.8-6.3,23.8,0,34.6c6.3,10.9,17.5,17.3,30,17.3h34h18.1h235.7c12.5,0,23.7-6.5,30-17.3
c6.3-10.8,6.3-23.8,0-34.6l-4.9-8.4l-37-64.1L208.6,17.3C202.4,6.5,191.2,0,178.6,0L178.6,0z"/>
</g>
</g>
<g>
<path class="st3" d="M339.6,274.1l-17-29.5H120.9l-34,58.9h235.7C337.7,303.6,347.1,287.2,339.6,274.1z"/>
<path class="st3" d="M245.5,229h68L195.6,24.8c-7.6-13.1-26.5-13.1-34,0l-17,29.5L245.5,229z"/>
<path class="st3" d="M169.6,128.9l-34-58.9L17.7,274.1c-7.6,13.1,1.9,29.5,17,29.5h34L169.6,128.9z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

-26
View File
@@ -1,26 +0,0 @@
To finishing the setup, you need to do the following steps:
## API Config
The API Config file are located at `src/config/api.ts`. You need to fill in the following information:
```js
module.exports = {
client_id: "lorem",
client_secret: "ipsum",
refresh_token: "dolor",
}
```
## Environment Config
The environment variables are differ for each hosting service. If you are using Vercel, you can add the environment variables on the project settings page.
```dotenv
ENCRYPTION_KEY="Encryption"
```
**NOTE:** Make sure to redeploy the site after adding the environment variables.
## Customizing the Site
The site can be customized by editing the `src/config/site.ts` file. You can change the site title, description, and other information.
All the explanation also included in the file.
And that's it, you're done with the setup. You can now start using the site.
-47
View File
@@ -1,47 +0,0 @@
# Google Cloud Setup
Table of Contents:
- [Making a Google Cloud Project and enabling Google Drive API](#making-a-google-cloud-project-and-enabling-google-drive-api)
- [Creating Oauth Consent Screen](#creating-oauth-consent-screen)
- [Creating Oauth Client ID](#creating-oauth-client-id)
- [Getting Refresh Token](#getting-refresh-token)
## Making a Google Cloud Project and enabling Google Drive API
1. Open [Google Cloud Console](https://console.cloud.google.com/).
2. Click on the project selector dropdown in the top left corner and select or create a new project.
![google-cloud-1.png](../images/setup/google-cloud-1.png)
3. Activate the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com) for your project.
![google-cloud-3.png](../images/setup/google-cloud-3.png)
4. After activating the API, you will be redirected to the API overview page. For creating Oauth Client ID, you need to create Oauth consent first, you can do this by clicking on the "Oauth Consent Screen" menu.
## Creating Oauth Consent Screen
1. On the Oauth consent screen page, select "External" as the user type and click on the "Create" button.
![google-cloud-5.png](../images/setup/google-cloud-5.png)
2. On the next page, fill in the required information and click on the "Save" button.
(In my case I'm only filling `App name`, `User support email`, and `Developer contact information`.)
3. On the **Scopes** Page, select the following scopes and click on the "Save" button, then click on the "Continue" button.
![google-cloud-6.png](../images/setup/google-cloud-6.png)
4. On the **Test Users** Page, click on the "Add Users" button and add your email address or any other email address that you want to use. Then click on the "Save and Continue" button.
5. And you're done with the Oauth consent screen. Now you can create the Oauth Client ID by clicking on the "Credentials" menu.
**NOTE: We don't need to publish the app, since we're only using it for our own purpose.**
## Creating Oauth Client ID
1. On the **Credentials** page, click on the "Create Credentials" button and select "Oauth Client ID".
![google-cloud-4.png](../images/setup/google-cloud-4.png)
2. On the next page, select "Web Application" as the application type, and fill in the required information.
3. In the **Authorized redirect URIs** section, add the following redirect URI: `https://developers.google.com/oauthplayground`, then click on the "Create" button.
4. After creating the Oauth Client ID, you will be redirected to the **Credentials** page. Then copy the Client ID, and Client Secret. (You will need them later on the bottom of this page, and to fetch refresh token.)
## Getting Refresh Token
1. Open [Google OAuth Playground](https://developers.google.com/oauthplayground/).
2. Click on the gear icon on the upper right corner and check the box next to "Use your own OAuth credentials" and paste the Client ID and Client Secret that you copied earlier.
![google-cloud-7.png](../images/setup/google-cloud-7.png)
3. Select the following scopes and click on the "Authorize APIs" button.
![google-cloud-8.png](../images/setup/google-cloud-8.png)
4. Google will ask you to log in to your Google account, you need to log in using the account that specified on Test User (Creating Oauth Consent Screen - step 4).
5. It will show you a warning that the app is not verified, simply click on "Continue" button, and it will redirect you back to the Playground.
![google-cloud-9.png](../images/setup/google-cloud-9.png)
6. Click on the "Exchange authorization code for tokens" button, and it will show you the refresh token. Copy the refresh token and paste it on the bottom of this page.
![google-cloud-10.png](../images/setup/google-cloud-10.png)
7. You can close the Playground tab now.
8. You're done with the Google Cloud Setup.
View File
@@ -0,0 +1,94 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import { decryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import { isDownloadTokenValid } from "utils/tokenHelper";
import { ErrorResponse } from "types/api/response";
export async function GET(
request: NextRequest,
{
params,
}: {
params: {
encryptedId: string;
fileName: string;
};
},
) {
const reqStart = Date.now();
const { encryptedId, fileName } = params;
try {
const data = JSON.parse(decryptData(encryptedId)) as {
id: string;
isProtected: boolean;
};
const { token, preview, disableLimit } = getSearchParams(request.url, ["token", "preview", "disableLimit"]);
if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && !token)
throw new ExtendedError("Missing download token", 403, "You are not authorized to download this file");
if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && token && !isDownloadTokenValid(token))
throw new ExtendedError("Invalid download token", 403, "You are not authorized to download this file");
const _fileMeta = gdrive.files.get({
fileId: decryptData(data.id),
fields: "id, name, size, mimeType, webContentLink",
});
const _fileContent = gdrive.files.get(
{
fileId: decryptData(data.id),
alt: "media",
},
{ responseType: "stream" },
);
const [fileMeta, fileContent] = await Promise.all([_fileMeta, _fileContent]);
const fileSize = Number(fileMeta.data.size) ?? 0;
if (fileSize > gIndexConfig.apiConfig.maxFileSize && !disableLimit) {
return NextResponse.redirect(fileMeta.data.webContentLink!, {
status: 302,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
}
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
fileContent.data.on("data", (chunk) => {
chunks.push(chunk);
});
fileContent.data.on("end", () => {
resolve(Buffer.concat(chunks));
});
fileContent.data.on("error", (err) => {
reject(err);
});
});
return new NextResponse(fileBuffer, {
status: 200,
headers: {
"Content-Type": fileMeta.data.mimeType ?? "application/octet-stream",
"Content-Disposition": `${preview ? "inline;" : "attachment;"} filename="${encodeURIComponent(fileName)}"`,
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
@@ -0,0 +1,213 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import {
decryptData,
encryptData,
} from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import { checkPathPassword } from "utils/passwordHelper";
import {
APIValidateResponse,
ErrorResponse,
} from "types/api/response";
import { Constant } from "types/constant";
type FilePath = {
name: string;
encryptedId: string;
mimeType: string;
};
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } },
) {
const reqStart = Date.now();
const pathArray = params.path;
try {
const fetchRootId = gdrive.files.get({
fileId: gIndexConfig.apiConfig.rootFolder,
fields: "id",
});
const fetchPathId = pathArray.map(async (path) => {
const query = ["trashed = false", `name = '${path}'`];
const fetchFolderContents = await gdrive.files.list({
q: `${query.join(" and ")}`,
fields: "files(id, name, mimeType, parents)",
});
if (!fetchFolderContents.data.files?.length) {
throw new Error(`Path "${path}" is not found`);
}
return {
path,
data: fetchFolderContents.data.files.map(
(file) => ({
id: file.id,
parents: file.parents?.[0],
mimeType: file.mimeType,
}),
),
};
});
const [rootId, pathId] = await Promise.all([
fetchRootId,
Promise.all(fetchPathId),
]);
const selectedPath: string[] = [];
const mappedPath: FilePath[] = [];
// Check path validity
pathId.forEach((path, index) => {
let checkPath =
index === 0
? path.data.find(
(file) => file.parents === rootId.data.id,
)
: path.data.find(
(file) =>
file.parents === selectedPath[index - 1],
);
if (!checkPath)
throw new ExtendedError(
"Path not found",
404,
`Can't find path "${path.path}". Please check your path again.`,
);
selectedPath.push(checkPath.id as string);
mappedPath.push({
name: path.path,
encryptedId: encryptData(checkPath.id as string),
mimeType: checkPath.mimeType as string,
});
});
return NextResponse.json(mappedPath, {
status: 200,
});
// Check for protected folder
const fetchProtectedFolder = mappedPath.map(
async (path) => {
if (
path.mimeType !==
"application/vnd.google-apps.folder"
)
return;
const query = [
"trashed = false",
`'${decryptData(path.encryptedId)}' in parents`,
`name = '${gIndexConfig.apiConfig.specialFile.password}'`,
];
const fetchFolderContents = await gdrive.files.list(
{
q: `${query.join(" and ")}`,
fields: "files(id, name, mimeType, parents)",
},
);
if (!fetchFolderContents.data.files?.length) return;
return {
path: path.name,
passwordId: fetchFolderContents.data.files[0].id,
};
},
);
let protectedFolder = await Promise.all(
fetchProtectedFolder,
);
console.log(protectedFolder);
protectedFolder = protectedFolder.filter(
(item) => item !== undefined,
);
const nearestFolderPassword =
protectedFolder[protectedFolder.length - 1] ?? null;
console.log(
"After nearestFolderPassword",
nearestFolderPassword,
);
if (
nearestFolderPassword &&
nearestFolderPassword.passwordId
) {
const fetchPassword = await gdrive.files.get(
{
fileId: nearestFolderPassword.passwordId,
alt: "media",
},
{
responseType: "text",
},
);
const userPassword =
request.cookies.get(Constant.cookies_SitePassword)
?.value ?? null;
if (!userPassword) {
throw new ExtendedError(
"Password required",
401,
`You need to enter password to access this file / folder`,
);
}
const nearestPathIndex = mappedPath.findIndex(
(path) => path.name === nearestFolderPassword.path,
);
const currentPath =
"/" +
mappedPath
.slice(0, nearestPathIndex + 1)
.map((path) => path.name)
.join("/");
const validPassword = checkPathPassword(
currentPath,
userPassword,
fetchPassword.data as string,
);
if (!validPassword) {
throw new ExtendedError(
"Invalid password",
401,
`The password you entered is invalid`,
);
}
}
const payload: APIValidateResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
path: mappedPath.map((path) => path.name).join("/"),
valid: true,
};
return NextResponse.json(payload, {
status: 200,
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+155
View File
@@ -0,0 +1,155 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import { IGDriveFiles } from "types/api/files";
import { APIFileResponse, APIFilesResponse, ErrorResponse } from "types/api/response";
export async function GET(request: NextRequest, { params }: { params: { encryptedId: string } }) {
const reqStart = Date.now();
try {
const id = decryptData(params.encryptedId);
if (id === gIndexConfig.apiConfig.rootFolder)
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_VERCEL_URL}/api/files`, { status: 301 });
const file = await gdrive.files.get({
fileId: id,
fields: gIndexConfig.apiConfig.defaultField,
});
if (!file || file.data.trashed) throw new ExtendedError("File not found", 404, "Can't find requested file.");
if (file.data.mimeType !== "application/vnd.google-apps.folder") {
const res: APIFileResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
file: {
mimeType: file.data.mimeType as string,
fileExtension: file.data.fileExtension as string,
encryptedId: params.encryptedId,
name: file.data.name as string,
trashed: file.data.trashed as boolean,
modifiedTime: file.data.modifiedTime as string,
encryptedWebContentLink: encryptData(file.data.webContentLink as string),
size: Number(file.data.size ?? 0),
thumbnailLink: file.data.thumbnailLink as string,
imageMediaMetadata: file.data.imageMediaMetadata
? {
width: Number(file.data.imageMediaMetadata.width),
height: Number(file.data.imageMediaMetadata.height),
rotation: Number(file.data.imageMediaMetadata.rotation),
}
: undefined,
videoMediaMetadata: file.data.videoMediaMetadata
? {
width: Number(file.data.videoMediaMetadata.width),
height: Number(file.data.videoMediaMetadata.height),
durationMillis: Number(file.data.videoMediaMetadata.durationMillis),
}
: undefined,
},
};
return NextResponse.json(res, {
status: 200,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
}
const { pageToken } = getSearchParams(request.url, ["pageToken"]);
const filterName = gIndexConfig.apiConfig.hiddenFiles.map((item) => `name != '${item}'`).join(" and ");
const query: string[] = [...gIndexConfig.apiConfig.defaultQuery, `'${id}' in parents`, `${filterName}`];
const fetchFolderContents = await gdrive.files.list({
q: query.join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: gIndexConfig.apiConfig.defaultOrder,
pageSize: gIndexConfig.apiConfig.itemsPerPage,
pageToken: pageToken || undefined,
});
const folders: IGDriveFiles[] =
fetchFolderContents.data.files
?.filter((item) => item.mimeType === "application/vnd.google-apps.folder")
.map((item) => ({
mimeType: item.mimeType as string,
encryptedId: encryptData(item.id as string),
name: item.name as string,
trashed: item.trashed as boolean,
modifiedTime: item.modifiedTime as string,
})) ?? [];
const files: IGDriveFiles[] =
fetchFolderContents.data.files
?.filter((item) => item.mimeType !== "application/vnd.google-apps.folder")
.map((item) => ({
mimeType: item.mimeType as string,
fileExtension: item.fileExtension as string,
encryptedId: encryptData(item.id as string),
name: item.name as string,
trashed: item.trashed as boolean,
modifiedTime: item.modifiedTime as string,
encryptedWebContentLink: encryptData(item.webContentLink as string),
size: Number(item.size ?? 0),
thumbnailLink: item.thumbnailLink as string,
imageMediaMetadata: item.imageMediaMetadata
? {
width: Number(item.imageMediaMetadata.width ?? 0),
height: Number(item.imageMediaMetadata.height ?? 0),
rotation: Number(item.imageMediaMetadata.rotation ?? 0),
}
: undefined,
videoMediaMetadata: item.videoMediaMetadata
? {
width: Number(item.videoMediaMetadata.width ?? 0),
height: Number(item.videoMediaMetadata.height ?? 0),
durationMillis: Number(item.videoMediaMetadata.durationMillis ?? 0),
}
: undefined,
})) ?? [];
const readmeExists =
fetchFolderContents.data.files?.some((item) => item.name === gIndexConfig.apiConfig.specialFile.readme) ?? false;
const bannerExists =
fetchFolderContents.data.files?.some(
(item) =>
item.name?.startsWith(gIndexConfig.apiConfig.specialFile.banner) && item.mimeType?.startsWith("image/"),
) ?? false;
const passwordExists =
fetchFolderContents.data.files?.some((item) => item.name === gIndexConfig.apiConfig.specialFile.password) ??
false;
const res: APIFilesResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
folders,
files,
nextPageToken: fetchFolderContents.data.nextPageToken || undefined,
readmeExists,
bannerExists,
passwordExists,
};
return NextResponse.json(res, {
status: 200,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+150
View File
@@ -0,0 +1,150 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import privateIndexProtection from "utils/apiHelper/privateIndexProtection";
import { encryptData } from "utils/encryptionHelper/hash";
import gdrive from "utils/gdriveInstance";
import { IGDriveFiles } from "types/api/files";
import {
APIFilesResponse,
ErrorResponse,
} from "types/api/response";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
await privateIndexProtection(request);
const { pageToken } = getSearchParams(request.url, [
"pageToken",
]);
const query: string[] = [
...gIndexConfig.apiConfig.defaultQuery,
`'${gIndexConfig.apiConfig.rootFolder}' in parents`,
];
const fetchFolderContents = await gdrive.files.list({
q: query.join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: gIndexConfig.apiConfig.defaultOrder,
pageSize: gIndexConfig.apiConfig.itemsPerPage,
pageToken: pageToken || undefined,
});
const folders: IGDriveFiles[] =
fetchFolderContents.data.files
?.filter(
(item) =>
item.mimeType ===
"application/vnd.google-apps.folder",
)
.map((item) => ({
mimeType: item.mimeType as string,
encryptedId: encryptData(item.id as string),
name: item.name as string,
trashed: item.trashed as boolean,
modifiedTime: item.modifiedTime as string,
})) ?? [];
const files: IGDriveFiles[] =
fetchFolderContents.data.files
?.filter(
(item) =>
item.mimeType !==
"application/vnd.google-apps.folder" &&
!gIndexConfig.apiConfig.hiddenFiles.includes(
item.name as string,
),
)
.map((item) => ({
mimeType: item.mimeType as string,
fileExtension: item.fileExtension as string,
encryptedId: encryptData(item.id as string),
name: item.name as string,
trashed: item.trashed as boolean,
modifiedTime: item.modifiedTime as string,
encryptedWebContentLink: encryptData(
item.webContentLink as string,
),
size: Number(item.size ?? 0),
thumbnailLink: item.thumbnailLink as string,
imageMediaMetadata: item.imageMediaMetadata
? {
width: Number(
item.imageMediaMetadata.width ?? 0,
),
height: Number(
item.imageMediaMetadata.height ?? 0,
),
rotation: Number(
item.imageMediaMetadata.rotation ?? 0,
),
}
: undefined,
videoMediaMetadata: item.videoMediaMetadata
? {
width: Number(
item.videoMediaMetadata.width ?? 0,
),
height: Number(
item.videoMediaMetadata.height ?? 0,
),
durationMillis: Number(
item.videoMediaMetadata.durationMillis ??
0,
),
}
: undefined,
})) ?? [];
const readmeExists =
fetchFolderContents.data.files?.some(
(item) =>
item.name ===
gIndexConfig.apiConfig.specialFile.readme,
) ?? false;
const bannerExists =
fetchFolderContents.data.files?.some(
(item) =>
item.name?.startsWith(
gIndexConfig.apiConfig.specialFile.banner,
) && item.mimeType?.startsWith("image/"),
) ?? false;
const passwordExists =
fetchFolderContents.data.files?.some(
(item) =>
item.name ===
gIndexConfig.apiConfig.specialFile.password,
) ?? false;
const res: APIFilesResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
folders,
files,
nextPageToken:
fetchFolderContents.data.nextPageToken || undefined,
readmeExists,
bannerExists,
passwordExists,
};
return NextResponse.json(res, {
status: 200,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+190
View File
@@ -0,0 +1,190 @@
import gIndexConfig from "config";
import { drive_v3 } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import {
decryptData,
encryptData,
} from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import { IGDriveFiles } from "types/api/files";
import {
APIGetFileResponse,
ErrorResponse,
} from "types/api/response";
function isHiddenFile(name: string) {
return gIndexConfig.apiConfig.hiddenFiles.find((item) =>
name.startsWith(item),
)
? true
: false;
}
function generateFileObject(
data: drive_v3.Schema$File,
): IGDriveFiles {
return {
mimeType: data.mimeType as string,
fileExtension: (data.fileExtension as string) ?? null,
encryptedId: encryptData(data.id as string),
name: data.name as string,
trashed: (data.trashed as boolean) ?? false,
modifiedTime: new Date(
data.modifiedTime as string,
).toLocaleDateString(),
encryptedWebContentLink: encryptData(
data.webContentLink as string,
),
size: Number(data.size ?? 0),
thumbnailLink: (data.thumbnailLink as string) ?? null,
imageMediaMetadata: data.imageMediaMetadata
? {
width: Number(data.imageMediaMetadata.width ?? 0),
height: Number(
data.imageMediaMetadata.height ?? 0,
),
rotation: Number(
data.imageMediaMetadata.rotation ?? 0,
),
}
: null,
videoMediaMetadata: data.videoMediaMetadata
? {
width: Number(data.videoMediaMetadata.width ?? 0),
height: Number(
data.videoMediaMetadata.height ?? 0,
),
durationMillis: Number(
data.videoMediaMetadata.durationMillis ?? 0,
),
}
: null,
};
}
function generateFolderObject(
data: drive_v3.Schema$File,
): IGDriveFiles {
return {
mimeType: data.mimeType as string,
encryptedId: encryptData(data.id as string),
name: data.name as string,
trashed: (data.trashed as boolean) ?? false,
modifiedTime: new Date(
data.modifiedTime as string,
).toLocaleDateString(),
};
}
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const { encryptedId, isFile, pageToken } =
getSearchParams(request.url, [
"encryptedId",
"isFile",
"pageToken",
]);
let data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
} = {
file: null,
files: [],
folders: [],
pageToken: null,
};
if (isFile && encryptedId) {
const fileContent = await gdrive.files.get({
fileId: decryptData(encryptedId),
fields: gIndexConfig.apiConfig.defaultField,
});
if (isHiddenFile(fileContent.data.name as string))
throw new ExtendedError(
"File not found",
404,
"File you are looking for is not found",
);
const payload: IGDriveFiles = generateFileObject(
fileContent.data,
);
data.file = payload;
} else {
const filterName = gIndexConfig.apiConfig.hiddenFiles
.map((item) => `name != '${item}'`)
.join(" and ");
const query: string[] = [
...gIndexConfig.apiConfig.defaultQuery,
`'${
encryptedId
? decryptData(encryptedId)
: gIndexConfig.apiConfig.rootFolder
}' in parents`,
`${filterName}`,
];
const folderContent = await gdrive.files.list({
q: query.join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: gIndexConfig.apiConfig.defaultOrder,
pageSize: gIndexConfig.apiConfig.itemsPerPage,
pageToken: pageToken ?? undefined,
});
const listFolders: IGDriveFiles[] =
folderContent.data.files
?.filter(
(item) =>
item.mimeType ===
"application/vnd.google-apps.folder",
)
.map((item) => generateFolderObject(item)) ?? [];
const listFiles: IGDriveFiles[] =
folderContent.data.files
?.filter(
(item) =>
item.mimeType !==
"application/vnd.google-apps.folder",
)
.map((item) => generateFileObject(item)) ?? [];
data = {
...data,
files: listFiles,
folders: listFolders,
pageToken: folderContent.data.nextPageToken ?? null,
};
}
const payload: APIGetFileResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
data,
};
return NextResponse.json(payload, {
status: 200,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+129
View File
@@ -0,0 +1,129 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import {
decryptData,
encryptData,
} from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import {
APIGetPasswordResponse,
ErrorResponse,
} from "types/api/response";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const { path } = getSearchParams(request.url, ["path"]);
if (!path)
throw new ExtendedError(
"Path is required",
400,
"Can't find path in query, please check your query",
);
const mappedPath: Record<
"name" | "id" | "mimeType",
string
>[] = JSON.parse(decryptData(path));
const passwordParents = mappedPath.map(
(path) => `'${decryptData(path.id)}' in parents`,
);
const query: string[] = [
"trashed = false",
`name = '${
gIndexConfig.apiConfig.specialFile.password
}' and (${passwordParents.join(" or ")})`,
];
const passwordData = await gdrive.files.list({
q: query.join(" and "),
fields: "files(id, name, parents)",
});
const protectedPaths = mappedPath.map((path) => {
const isPasswordExist = passwordData.data.files?.find(
(file) =>
file.parents?.[0] === decryptData(path.id),
);
if (isPasswordExist)
return {
name: path.name,
passwordId: isPasswordExist.id,
};
});
const _passwordContent: Promise<{
path: string;
password: string;
}>[] = [];
protectedPaths.forEach((path) => {
if (!path) return;
_passwordContent.push(
gdrive.files
.get(
{
fileId: path.passwordId as string,
alt: "media",
},
{ responseType: "text" },
)
.then((res) => ({
path: path.name as string,
password: res.data as string,
})),
);
});
const passwordContent = await Promise.all(
_passwordContent,
);
let prevPath = [""];
let password: Record<
"relativePath" | "password",
string
>[] = [];
mappedPath.forEach((path) => {
prevPath.push(path.name);
const isPasswordExist = passwordContent.find(
(password) => password.path === path.name,
);
if (isPasswordExist) {
password.push({
relativePath: prevPath.join("/"),
password: encryptData(isPasswordExist.password),
});
}
});
const payload: APIGetPasswordResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
data: password,
};
return NextResponse.json(payload, {
status: 200,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+79
View File
@@ -0,0 +1,79 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import { decryptData } from "utils/encryptionHelper/hash";
import gdrive from "utils/gdriveInstance";
import {
APIGetReadmeResponse,
ErrorResponse,
} from "types/api/response";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const { encryptedId } = getSearchParams(request.url, [
"encryptedId",
]);
const query: string[] = [
...gIndexConfig.apiConfig.defaultQuery,
`'${
encryptedId
? decryptData(encryptedId)
: gIndexConfig.apiConfig.rootFolder
}' in parents`,
];
const folderContent = await gdrive.files.list({
q: query.join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: gIndexConfig.apiConfig.defaultOrder,
pageSize: gIndexConfig.apiConfig.itemsPerPage,
pageToken: undefined,
});
const isReadmeExist = folderContent.data.files?.find(
(file) =>
file.name ===
gIndexConfig.apiConfig.specialFile.readme,
);
let data: string | null = null;
if (isReadmeExist) {
const fileContent = await gdrive.files.get(
{
fileId: isReadmeExist.id as string,
alt: "media",
},
{ responseType: "text" },
);
data = fileContent.data as string;
}
const payload: APIGetReadmeResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
data,
};
return NextResponse.json(payload, {
status: 200,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import ExtendedError from "utils/extendedError";
import { addNewPassword } from "utils/passwordHelper";
import { ErrorResponse } from "types/api/response";
import { Constant } from "types/constant";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const { path, password } = getSearchParams(
request.url,
["path", "password"],
);
if (!path || !password)
throw new ExtendedError(
"Missing parameters",
400,
"Path or password is missing",
);
const cookies = request.cookies.get(
Constant.cookies_SitePassword,
);
if (!cookies)
throw new ExtendedError(
"No cookies found",
400,
"Site password not found",
);
const newPassword = addNewPassword(
path!,
password!,
cookies.value,
true,
);
return NextResponse.json(
{
newPassword: {
path,
password,
},
},
{
status: 200,
headers: {
"Set-Cookie": newPassword as string,
},
},
);
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { decryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import { ErrorResponse } from "types/api/response";
import { Constant } from "types/constant";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const cookies = request.cookies.get(
Constant.cookies_SitePassword,
);
if (!cookies)
throw new ExtendedError(
"No cookies found",
400,
"Site password not found",
);
const rawToken = decryptData(cookies.value);
const token = JSON.parse(rawToken);
return NextResponse.json(
{
password: token,
},
{
status: 200,
},
);
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
@@ -0,0 +1,76 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import { decryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import { ErrorResponse } from "types/api/response";
export async function GET(request: NextRequest, { params }: { params: { encryptedId: string } }) {
const reqStart = Date.now();
const { encryptedId } = params;
try {
const id = decryptData(encryptedId);
if (!id) throw new ExtendedError("Invalid encryptedId", 400, "EncryptedId provided is invalid");
const path: string[] = [];
let lastId = id;
const fileContent = await gdrive.files.get({
fileId: lastId,
fields: "id, name, mimeType, parents",
});
if (fileContent) {
path.push(fileContent.data.name as string);
if (fileContent.data.parents) {
lastId = fileContent.data.parents[0] as string;
while (lastId) {
const folderContent = await gdrive.files.get({
fileId: lastId,
fields: "id, name, mimeType, parents",
});
if (folderContent.data.id === gIndexConfig.apiConfig.rootFolder) {
lastId = "";
break;
}
if (folderContent) {
path.push(folderContent.data.name as string);
if (folderContent.data.parents) {
if (folderContent.data.parents[0] === gIndexConfig.apiConfig.rootFolder) {
lastId = "";
break;
} else {
lastId = folderContent.data.parents[0] as string;
}
} else {
lastId = "";
}
} else {
lastId = "";
}
}
}
}
const redirectURL = new URL(path.reverse().join("/"), process.env.NEXT_PUBLIC_VERCEL_URL);
return NextResponse.redirect(redirectURL.toString(), {
status: 302,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+65
View File
@@ -0,0 +1,65 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import { encryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import { IGDriveFiles } from "types/api/files";
import { APISearchResponse, ErrorResponse } from "types/api/response";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const { query } = getSearchParams(request.url, ["query"]);
if (!query) throw new ExtendedError("Missing query", 400, "Search query is required");
const searchFiles = await gdrive.files.list({
q: [...gIndexConfig.apiConfig.defaultQuery, `name contains '${query}'`].join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: "name_natural desc",
pageSize: gIndexConfig.apiConfig.searchResult,
pageToken: undefined,
});
const files: (IGDriveFiles & { redirect: string })[] =
searchFiles.data.files?.map((data) => ({
mimeType: data.mimeType ?? "Unknown",
encryptedId: encryptData(data.id as string),
name: data.name as string,
trashed: data.trashed ?? false,
modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(),
fileExtension: (data.fileExtension as string) ?? null,
encryptedWebContentLink: undefined,
size: Number(data.size) ?? 0,
thumbnailLink: (data.thumbnailLink as string) ?? null,
imageMediaMetadata: null,
videoMediaMetadata: null,
redirect: `/api/search/redirect/${encryptData(data.id as string)}`,
})) ?? [];
const payload: APISearchResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
files: files,
};
return NextResponse.json(payload, {
status: 200,
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
-13
View File
@@ -1,13 +0,0 @@
type Props = {
message?: string;
};
export default function EmptyFeedback({ message }: Props) {
return (
<div className={"flex flex-col"}>
<div className={"mx-auto flex items-center justify-center gap-4"}>
<span>{message || "Folder is empty"}</span>
</div>
</div>
);
}
-37
View File
@@ -1,37 +0,0 @@
import { MdWarning } from "react-icons/md";
type Props = {
message?: string;
};
export default function ErrorFeedback({ message }: Props) {
const errorMessage: string = `An error occurred while processing your request.
Please check the following details and try again:`;
return (
<div className={"flex w-full flex-col"}>
<div
className={
"mx-auto mb-2 flex items-center justify-center gap-4 max-tablet:flex-col"
}
>
<MdWarning className={"h-8 w-8 text-red-500 dark:text-red-400"} />
<p className={"text-start"}>{errorMessage}</p>
</div>
<div className={"divider-horizontal mx-auto max-w-screen-md"} />
<div
className={
"mx-auto mt-2 flex w-full flex-col items-start justify-center"
}
>
<span>Error details:</span>
<pre className={"w-full py-2"}>
<code className={"whitespace-pre-wrap"}>
{message || "Internal server error"}
</code>
</pre>
</div>
</div>
);
}
-21
View File
@@ -1,21 +0,0 @@
import ReactLoading from "react-loading";
type Props = {
message?: string;
};
export default function LoadingFeedback({ message }: Props) {
return (
<div className={"flex flex-col"}>
<div className={"mx-auto flex items-center justify-center gap-4"}>
<ReactLoading
type='spin'
width={20}
height={20}
className={"loading"}
/>
<span>{message || "Loading..."}</span>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { twMerge } from "tailwind-merge";
interface AlertProps {
variant?: "success" | "error" | "warning" | "info";
icon?: string | IconifyIcon;
title?: string;
message: string;
className?: string;
}
export default function Alert(props: AlertProps) {
const {
variant = "info",
icon,
title,
message,
className,
} = props;
return (
<div
className={twMerge(
"w-full h-fit flex flex-col rounded-lg gap-1 px-4 py-2",
variant === "success" &&
"bg-success-100 text-success-main",
variant === "error" &&
"bg-danger-100 text-danger-main",
variant === "warning" &&
"bg-warning-100 text-warning-main",
variant === "info" && "bg-info-100 text-info-main",
className && className,
)}
>
{(icon || title) && (
<div className='flex gap-1 items-center'>
{icon && (
<Icon
icon={icon}
color='currentColor'
width={24}
height={24}
/>
)}
{title && (
<span className='font-medium text-lg'>
{title}
</span>
)}
</div>
)}
<p className='text-sm'>{message}</p>
</div>
);
}
+153 -52
View File
@@ -1,64 +1,165 @@
import { BreadCrumbsResponse, TFileParent } from "types/googleapis";
import { Icon, IconifyIcon } from "@iconify/react";
import Link from "next/link";
import { Fragment } from "react";
import { MdHome } from "react-icons/md";
import siteConfig from "config/site.config";
import { useRouter } from "next/router";
import { Fragment, useEffect, useState } from "react";
type Props = {
data: BreadCrumbsResponse | undefined;
isLoading: boolean;
};
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
export default function Breadcrumb({ data, isLoading }: Props) {
const reverseBreadcrumbs = data?.breadcrumbs?.slice().reverse();
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 (
<>
{isLoading ? (
<div className='flex h-[1.5rem] w-32 animate-pulse items-center gap-2 rounded bg-zinc-300 dark:bg-zinc-600' />
) : (
<div className={"flex items-center gap-1"}>
{/* Home */}
<Link
href={"/"}
className={"flex items-center gap-1"}
>
<MdHome />
<span>Root</span>
</Link>
{data?.isLimitReached && (
<Fragment>
<span>{siteConfig.breadcrumb.limiter}</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"
}
>
{reverseBreadcrumbs?.map((parent: TFileParent, index: number) => (
<Fragment key={index}>
<span>{siteConfig.breadcrumb.limiter}</span>
<Link
href={`/folder/${parent.id}`}
key={index}
>
<span
className={`${
index === reverseBreadcrumbs.length - 1
? "line-clamp-1 w-auto flex-grow-0 overflow-hidden break-words font-bold"
: "whitespace-nowrap"
}`}
>
{parent.name}
</span>
</Link>
</Fragment>
<div className='flex items-center flex-wrap w-full gap-2'>
<Link href={"/"}>
<ButtonIcon
icon={"ion:home"}
variant='transparent'
className='flex-shrink-0 flex-grow-0 p-1'
/>
{/* <Icon
icon='ion:home'
color='currentColor'
width={20}
height={20}
className='flex-shrink-0 flex-grow-0'
/> */}
</Link>
{isPathSliced && (
<>
<Icon
icon={separator}
color='currentColor'
width={20}
height={20}
/>
<ButtonIcon
icon={"ion:ellipsis-horizontal"}
variant='transparent'
className='flex-shrink-0 flex-grow-0 p-1'
ref={setMenuAnchor}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
{/* <span className='mx-1'>...</span> */}
</>
)}
{breadcrumbPaths.map((path, index) => (
<Fragment key={`breadcrumb-${path}@${index}`}>
<Icon
icon={separator}
color='currentColor'
width={20}
height={20}
className='flex-shrink-0 flex-grow-0'
/>
<Link
href={path.path}
className={`${
path.path === router.asPath
? "text-primary-50 pointer-events-none cursor-default"
: "text-primary-300 hover:text-primary-100 cursor-pointer"
} transition-smooth max-w-md mx-1 w-fit ${
index === breadcrumbPaths.length - 1 &&
"line-clamp-1"
}`}
>
{decodeURIComponent(path.label)}
</Link>
</Fragment>
))}
</div>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-full max-w-sm bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen
? `opacity-100 pointer-events-auto`
: `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor
? menuAnchor?.getBoundingClientRect()
.bottom ?? 0
: "unset",
left: menuAnchor
? menuAnchor?.getBoundingClientRect().left ??
0
: "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{hiddenPaths.map((path, index) => (
<Link
href={path.path}
key={`hidden-path-${path}@${index}`}
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2 w-full'
title={decodeURIComponent(path.label)}
>
{decodeURIComponent(path.label)}
</Link>
))}
</div>
</div>
)}
</ClickAway>
</>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
fullWidth?: boolean;
variant?:
| "primary"
| "accent"
| "success"
| "danger"
| "warning"
| "info"
| "transparent";
size?: "small" | "medium" | "large";
pill?: boolean;
square?: boolean;
rounded?: "none" | "small" | "medium" | "large";
startIcon?: string | IconifyIcon;
endIcon?: string | IconifyIcon;
children: ReactNode;
loading?: boolean;
className?: string;
}
export default function Button(props: ButtonProps) {
const {
fullWidth = false,
variant = "primary",
size = "medium",
pill = false,
square = false,
rounded = "none",
loading = false,
startIcon,
endIcon,
children,
className,
...rest
} = props;
return (
<button
className={twMerge(
"outline-none border-none capitalize transition-smooth",
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
"rounded-lg w-fit flex items-center justify-center flex-grow",
fullWidth && "w-full",
rounded === "none" && "rounded-none",
rounded === "small" && "rounded-sm",
rounded === "medium" && "rounded-md",
rounded === "large" && "rounded-lg",
pill && "rounded-full",
size === "small" && "px-2 py-1 text-sm gap-1",
size === "medium" && "px-4 py-2 text-base gap-2",
size === "large" && "px-8 py-4 text-lg gap-4",
square && "aspect-square",
variant === "primary" &&
"bg-primary-600 text-primary-50 hover:bg-primary-500 active:bg-primary-700",
variant === "accent" &&
"bg-accent-600 text-accent-50 hover:bg-accent-500 active:bg-accent-700",
variant === "success" &&
"bg-success-600 text-success-50 hover:bg-success-500 active:bg-success-700",
variant === "danger" &&
"bg-danger-600 text-danger-50 hover:bg-danger-500 active:bg-danger-700",
variant === "warning" &&
"bg-warning-600 text-warning-50 hover:bg-warning-500 active:bg-warning-700",
variant === "info" &&
"bg-info-600 text-info-50 hover:bg-info-500 active:bg-info-700",
variant === "transparent" &&
"bg-transparent text-primary-50 hover:bg-primary-500 active:bg-primary-700",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-primary-700 disabled:hover:bg-primary-700 disabled:active:bg-primary-700 disabled:text-primary-50",
className && className,
)}
disabled={loading ?? props.disabled}
{...rest}
>
{props.loading ? (
<Icon
icon={"mdi:loading"}
color='white'
className={twMerge(
"w-5 h-5 animate-spin",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
) : (
<>
{startIcon && (
<Icon
icon={startIcon}
color='white'
className={twMerge(
"w-5 h-5",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
)}
{children}
{endIcon && (
<Icon
icon={endIcon}
color='white'
className={twMerge(
"w-5 h-5",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
)}
</>
)}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonGroupProps {
children: ReactNode;
fullWidth?: boolean;
rounded?: "none" | "small" | "medium" | "large";
className?: string;
}
export default function ButtonGroup(
props: ButtonGroupProps,
) {
const {
children,
fullWidth = false,
rounded = "large",
className,
} = props;
return (
<div
className={twMerge(
"flex flex-row items-center justify-center overflow-hidden",
fullWidth && "w-full",
rounded === "none" && "rounded-none",
rounded === "small" && "rounded-sm",
rounded === "medium" && "rounded-md",
rounded === "large" && "rounded-lg",
className && className,
)}
>
{children}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { ForwardRefRenderFunction, forwardRef } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonIconProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: string | IconifyIcon;
size?: "small" | "medium" | "large";
variant?: "primary" | "accent" | "success" | "danger" | "warning" | "info" | "transparent";
className?: string;
}
const ButtonIcon: ForwardRefRenderFunction<HTMLButtonElement, ButtonIconProps> = (props, ref) => {
const { icon, size = "medium", variant = "primary", className, ...rest } = props;
let iconSize = 24;
switch (size) {
case "small":
iconSize = 16;
break;
case "medium":
iconSize = 24;
break;
case "large":
iconSize = 32;
}
return (
<button
ref={ref}
className={twMerge(
"outline-none border-none capitalize transition-smooth aspect-square",
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
"rounded-full flex items-center justify-center w-fit h-fit p-0",
size === "small" && "w-6 h-6",
size === "medium" && "w-8 h-8",
size === "large" && "w-12 h-12",
variant === "primary" && "bg-primary-600/25 text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
variant === "accent" && "bg-accent-600/25 text-accent-50 hover:bg-accent-500/50 active:bg-accent-700",
variant === "success" && "bg-success-600/25 text-success-50 hover:bg-success-500/50 active:bg-success-700",
variant === "danger" && "bg-danger-600/25 text-danger-50 hover:bg-danger-500/50 active:bg-danger-700",
variant === "warning" && "bg-warning-600/25 text-warning-50 hover:bg-warning-500/50 active:bg-warning-700",
variant === "info" && "bg-info-600/25 text-info-50 hover:bg-info-500/50 active:bg-info-700",
variant === "transparent" && "bg-transparent text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
props.disabled && "opacity-50 cursor-not-allowed",
className && className,
)}
{...rest}
>
<Icon
icon={icon}
color='white'
width={iconSize}
height={iconSize}
/>
</button>
);
};
export default forwardRef(ButtonIcon);
+38
View File
@@ -0,0 +1,38 @@
import { ReactNode, useEffect } from "react";
import { twMerge } from "tailwind-merge";
interface ClickAwayProps extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
open: boolean;
onClickAway: (e: any) => void;
className?: string;
}
export default function ClickAway(props: ClickAwayProps) {
const { children, open, onClickAway, className, ...rest } = props;
useEffect(() => {
const body = document.querySelector("body");
const isScrollbarRendered = document.documentElement.clientHeight < document.documentElement.scrollHeight;
const isMobile = window.matchMedia("(max-width: 768px)").matches;
if (open && isScrollbarRendered) {
body?.style.setProperty("overflow", "hidden");
if (!isMobile) body?.style.setProperty("padding-right", "4px");
} else {
body?.style.removeProperty("overflow");
if (!isMobile) body?.style.removeProperty("padding-right");
}
}, [open]);
return (
<div
className={twMerge(
"w-full h-full bg-primary-950/50 backdrop-blur-none transition-smooth",
className && className,
)}
onClick={onClickAway}
{...rest}
>
{children}
</div>
);
}
-104
View File
@@ -1,104 +0,0 @@
import { formatDuration } from "utils/formatHelper";
import { drive_v3 } from "googleapis";
import Link from "next/link";
import { MdPlayCircleFilled } from "react-icons/md";
import { getFileIcon, getFileType } from "utils/mimeTypesHelper";
import { BsFolderFill } from "react-icons/bs";
import { createFileId } from "utils/driveHelper";
import siteConfig from "config/site.config";
type Props = {
data: drive_v3.Schema$File;
};
export default function GridFile({ data }: Props) {
const isFolder = data.mimeType === "application/vnd.google-apps.folder";
// const Icon = getMimeIcon(data.mimeType as string);
const Icon = isFolder
? BsFolderFill
: getFileIcon(data.fileExtension as string, data.mimeType as string);
const fileType = getFileType(
data.fileExtension as string,
data.mimeType as string,
);
const allowThumbnail =
siteConfig.files.allowThumbnailFileType.includes(fileType);
return (
<Link
href={
isFolder
? `/folder/${createFileId(data, true)}`
: `/file/${createFileId(data, true)}`
}
key={data.id}
className={"file"}
title={data.name as string}
>
{/* Thumbnail */}
<div
className={`relative mx-auto grid h-40 w-full place-items-center overflow-hidden rounded-lg tablet:h-32 tablet:rounded-xl ${
!allowThumbnail && "border border-zinc-300 dark:border-zinc-700"
}`}
>
{data.thumbnailLink && allowThumbnail ? (
<div
className={"group relative h-full max-h-40 w-full tablet:max-h-32"}
>
<img
src={data.thumbnailLink}
alt={data.name as string}
className={"h-full w-full bg-checkerboard-dark object-cover"}
/>
{data.videoMediaMetadata && (
<div
className={
"absolute top-0 z-10 grid h-full w-full place-items-center"
}
>
<MdPlayCircleFilled className='h-16 w-16 text-white/50 transition-colors duration-300 group-hover:text-white/80' />
<span
className={
"absolute bottom-0 right-0 rounded bg-black/50 px-2 py-0.5 drop-shadow tablet:text-xs"
}
>
{formatDuration(data.videoMediaMetadata.durationMillis || 0)}
</span>
</div>
)}
</div>
) : (
<Icon className='h-16 w-16' />
)}
</div>
{/* File name */}
<div
className={
"flex-grow-1 mx-auto my-2 flex h-auto w-full flex-shrink-0 items-center gap-2"
}
>
{siteConfig.files.showFileNameIcon && (
<>
{data.iconLink ? (
<img
src={data.iconLink}
alt={"icon"}
className='h-4 w-4 flex-shrink-0'
/>
) : (
<Icon className='h-4 w-4 flex-shrink-0' />
)}
</>
)}
<span
className={
"line-clamp-2 h-full w-full whitespace-pre-wrap break-words text-center"
}
>
{data.name}
</span>
</div>
</Link>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { stripExtension } from "utils/filesHelper";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { getPreviewIcon } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
interface ListItemProps {
file: IGDriveFiles;
isProtected: boolean;
}
export default function ListItem(props: ListItemProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
const [fileInfo, setFileInfo] = useState<string[]>([]);
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
const [filePath, setFilePath] = useState<string>("");
useEffect(() => {
const currentPath = router.asPath.slice(1);
const filePath = props.file.name;
const path = [currentPath, encodeURIComponent(filePath)].join("/");
// If start with /, remove it
if (path.startsWith("/")) {
setFilePath(path.slice(1));
} else {
setFilePath(path);
}
}, [router.asPath, props.file.name]);
useEffect(() => {
const info = [];
const extInfo = [];
props.file.modifiedTime &&
info.push(
new Date(props.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
);
props.file.size && info.push(bytesToReadable(props.file.size));
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
setExtensionInfo(extInfo);
setFileInfo(info);
}, [props.file]);
const handleCopyFileLink = () => {
try {
const url = `${window.location.origin}/${filePath}`;
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
try {
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
window.open(
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
"_blank",
);
setMenuOpen(false);
};
return (
<>
<Link
title={props.file.name}
href={filePath}
className='w-full p-1 flex relative items-stretch gap-2 tablet:gap-4 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
<div className='flex flex-col items-stretch gap-2 w-full relative'>
{props.file.thumbnailLink &&
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
<img
src={props.file.thumbnailLink}
alt={props.file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48'
loading='lazy'
/>
) : (
<Icon
icon={
props.file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48 p-8 tablet:p-16'
/>
)}
<div className='flex flex-col p-1'>
<span className='line-clamp-1 break-all whitespace-pre-wrap w-full text-center my-1'>
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
</span>{" "}
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
</div>
</div>
</div>
<div className='absolute top-1 right-1 z-10'>
<Tooltip content='More'>
<ButtonIcon
className='bg-primary-950/50'
ref={setMenuAnchor}
icon='ion:ellipsis-vertical'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
</Tooltip>
</div>
</Link>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor ? menuAnchor?.getBoundingClientRect().bottom ?? 0 : "unset",
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 150 : "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{props.file.mimeType.includes("folder") ? (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy folder link</span>
</div>
</>
) : (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy file link</span>
</div>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy download link</span>
</div>
<div className='w-full h-px bg-primary-500' />
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleDownload(props.file, props.isProtected)}
>
<Icon
icon='ion:download'
color='currentColor'
width={18}
height={18}
/>
<span>Download</span>
</div>
</>
)}
</div>
</div>
</ClickAway>
</>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { drive_v3 } from "googleapis";
import Link from "next/link";
import { formatBytes, formatDate } from "utils/formatHelper";
import { MdContentCopy, MdDownload } from "react-icons/md";
import { getFileIcon } from "utils/mimeTypesHelper";
import { BsFolderFill } from "react-icons/bs";
import siteConfig from "config/site.config";
import { createFileId } from "utils/driveHelper";
import useCopyText from "hooks/useCopyText";
type Props = {
data: drive_v3.Schema$File;
};
export default function ListFile({ data }: Props) {
const isFolder = data.mimeType === "application/vnd.google-apps.folder";
const Icon = isFolder
? BsFolderFill
: getFileIcon(data.fileExtension as string, data.mimeType as string);
const copyText = useCopyText();
if (!data) return null;
return (
<Link
href={
isFolder
? `/folder/${createFileId(data, true)}`
: `/file/${createFileId(data, true)}`
}
key={data.id}
className={
"col-span-full grid grid-cols-1 gap-2 rounded-lg px-4 py-2 hover:bg-zinc-300 dark:hover:bg-zinc-700 tablet:grid-cols-8"
}
>
<div className={"flex w-full items-center gap-4 tablet:col-span-4"}>
{siteConfig.files.listUseFileIcon ? (
<img
src={data.iconLink as string}
alt={"icon"}
className={"h-4 w-4 flex-shrink-0 flex-grow-0"}
/>
) : (
<Icon className={"h-4 w-4 flex-shrink-0 flex-grow-0"} />
)}
<div className={"line-clamp-1 flex w-full flex-col"}>
<span
className={
"line-clamp-1 w-auto flex-grow-0 overflow-hidden break-words"
}
>
{data.name}
</span>
<div className={"flex tablet:hidden"}>
<span className={"text-xs"}>
{formatDate(new Date(data.modifiedTime as string))}
</span>
{siteConfig.files.listMobileShowFileSize && (
<span className={"text-xs"}>
{isFolder ? "" : `${formatBytes(data.size as string)}`}
</span>
)}
</div>
</div>
</div>
<div className={"hidden tablet:col-span-2 tablet:block"}>
{formatDate(new Date(data.modifiedTime as string))}
</div>
<div className={"hidden tablet:block"}>
{isFolder ? "" : formatBytes(data.size as string)}
</div>
<div className={"hidden gap-2 tablet:flex"}>
<button
className={"p-2"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
copyText(
isFolder
? `${process.env.NEXT_PUBLIC_DOMAIN}/folder/${createFileId(
data,
true,
)}`
: `${process.env.NEXT_PUBLIC_DOMAIN}/file/${createFileId(
data,
true,
)}`,
);
}}
>
<MdContentCopy />
</button>
{!isFolder && (
<Link
href={`/api/files/${createFileId(data, true)}?download=1`}
target={"_blank"}
rel={"noopener noreferrer"}
onClick={(e) => {
e.stopPropagation();
}}
>
<button className={"p-2"}>
<MdDownload />
</button>
</Link>
)}
</div>
</Link>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { stripExtension } from "utils/filesHelper";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { getPreviewIcon } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
interface ListItemProps {
file: IGDriveFiles;
isProtected: boolean;
}
export default function ListItem(props: ListItemProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
const [fileInfo, setFileInfo] = useState<string[]>([]);
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
const [filePath, setFilePath] = useState<string>("");
useEffect(() => {
const currentPath = router.asPath.slice(1);
const filePath = props.file.name;
const path = [currentPath, encodeURIComponent(filePath)].join("/");
// If start with /, remove it
if (path.startsWith("/")) {
setFilePath(path.slice(1));
} else {
setFilePath(path);
}
}, [router.asPath, props.file.name]);
useEffect(() => {
const info = [];
const extInfo = [];
props.file.modifiedTime &&
info.push(
new Date(props.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
);
props.file.size && info.push(bytesToReadable(props.file.size));
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
setExtensionInfo(extInfo);
setFileInfo(info);
}, [props.file]);
const handleCopyFileLink = () => {
try {
const url = `${window.location.origin}/${filePath}`;
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
try {
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
window.open(
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
"_blank",
);
setMenuOpen(false);
};
return (
<>
<Link
title={props.file.name}
href={filePath}
className='w-full p-1 pr-2 flex items-center gap-4 tablet:gap-8 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
<div className='flex items-center gap-4 w-full flex-grow-0 flex-shrink'>
{props.file.thumbnailLink &&
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
<img
src={props.file.thumbnailLink}
alt={props.file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
loading='lazy'
/>
) : (
<Icon
icon={
props.file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
/>
)}
<div className='flex flex-col'>
<span className='line-clamp-1 break-all whitespace-pre-wrap'>
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
</span>{" "}
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
</div>
</div>
</div>
<div className='relative z-10'>
<Tooltip content='More'>
<ButtonIcon
ref={setMenuAnchor}
icon='ion:ellipsis-vertical'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
</Tooltip>
</div>
</Link>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor ? (menuAnchor?.getBoundingClientRect().bottom ?? 0) - 36 : "unset",
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 200 : "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{props.file.mimeType.includes("folder") ? (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy folder link</span>
</div>
</>
) : (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy file link</span>
</div>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy download link</span>
</div>
<div className='w-full h-px bg-primary-500' />
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleDownload(props.file, props.isProtected)}
>
<Icon
icon='ion:download'
color='currentColor'
width={18}
height={18}
/>
<span>Download</span>
</div>
</>
)}
</div>
</div>
</ClickAway>
</>
);
}
@@ -1,61 +0,0 @@
import { TFile } from "types/googleapis";
import { drive_v3 } from "googleapis";
import { useEffect, useState } from "react";
import config from "config/site.config";
import H5AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import SWRLayout from "components/layout/SWRLayout";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function AudioPreview({ data }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string>("");
const [audioSrc, setAudioSrc] = useState<string>("");
useEffect(() => {
const timeout = setTimeout(() => {
setErrorMessage("Audio took too long to load");
setIsLoading(false);
}, config.preview.timeout);
const audio = new Audio();
audio.src = `/media/${data.id}/${data.name}`;
audio.onloadeddata = () => {
setAudioSrc(audio.src);
setIsLoading(false);
clearTimeout(timeout);
};
audio.onerror = (error: any) => {
setErrorMessage(error.message);
setIsLoading(false);
clearTimeout(timeout);
};
return () => {
clearTimeout(timeout);
};
}, [data.id, data.name]);
return (
<div className='flex w-full items-center justify-center'>
<SWRLayout
data={data}
error={errorMessage}
isLoading={isLoading}
useCard={false}
>
<div className={"preview-audio w-full"}>
<H5AudioPlayer
src={audioSrc}
autoPlay={false}
showFilledVolume={true}
customAdditionalControls={[]}
/>
</div>
</SWRLayout>
</div>
);
}
@@ -1,48 +0,0 @@
import { drive_v3 } from "googleapis";
import { useEffect, useState } from "react";
import useSWR from "swr";
import MarkdownRender from "components/utility/MarkdownRender";
import { getCodeLanguage } from "utils/mimeTypesHelper";
import { createFileId } from "utils/driveHelper";
import SWRLayout from "components/layout/SWRLayout";
type Props = {
data: drive_v3.Schema$File;
};
export default function CodePreview({ data }: Props) {
const [codeContent, setCodeContent] = useState<string>("");
const fileId = createFileId(data);
const {
data: swrData,
isLoading,
error,
} = useSWR(`/api/files/${fileId}?download=1`);
useEffect(() => {
if (swrData) {
const isObject = typeof swrData === "object";
setCodeContent(
`\`\`\`${getCodeLanguage(data.fileExtension as string)}\n${
isObject ? JSON.stringify(swrData, null, 2) : swrData
}\`\`\``,
);
}
}, [swrData, data.fileExtension]);
return (
<div className='flex w-full items-center justify-center'>
<SWRLayout
data={codeContent}
error={error}
isLoading={isLoading}
useCard={false}
>
<div className={"w-full"}>
<MarkdownRender content={codeContent} />
</div>
</SWRLayout>
</div>
);
}
@@ -1,17 +0,0 @@
import { TFile } from "types/googleapis";
import { drive_v3 } from "googleapis";
import ErrorFeedback from "components/APIFeedback/Error";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function DefaultPreview({ data }: Props) {
return (
<div className='flex h-full flex-col items-center justify-center'>
<ErrorFeedback
message={`.${data.fileExtension} file is currently not supported. Please download the file to view it.`}
/>
</div>
);
}
@@ -1,57 +0,0 @@
import { drive_v3 } from "googleapis";
import { useEffect, useState } from "react";
import config from "config/site.config";
import { reverseString } from "utils/hashHelper";
import SWRLayout from "components/layout/SWRLayout";
import { createFileId } from "utils/driveHelper";
type Props = {
data: drive_v3.Schema$File;
hash?: string;
};
export default function ImagePreview({ data, hash }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string>("");
const [imageSrc, setImageSrc] = useState<string>("");
useEffect(() => {
const timeout = setTimeout(() => {
setErrorMessage("Image took too long to load");
setIsLoading(false);
}, config.preview.timeout);
const image = new Image();
image.src = `/api/files/${createFileId(data)}?download=1`;
image.onload = () => {
setImageSrc(image.src);
setIsLoading(false);
clearTimeout(timeout);
};
image.onerror = (error: any) => {
setErrorMessage(error.message);
setIsLoading(false);
clearTimeout(timeout);
};
return () => {
clearTimeout(timeout);
};
}, [data]);
return (
<div className='flex w-full items-center justify-center'>
<SWRLayout
data={data}
error={errorMessage}
isLoading={isLoading}
useCard={false}
>
<img
src={imageSrc}
alt={(data.name as string) || reverseString(hash as string)}
className='max-h-full max-w-full rounded-lg'
/>
</SWRLayout>
</div>
);
}
@@ -1,33 +0,0 @@
import { drive_v3 } from "googleapis";
import useSWR from "swr";
import MarkdownRender from "components/utility/MarkdownRender";
import { createFileId } from "utils/driveHelper";
import SWRLayout from "components/layout/SWRLayout";
type Props = {
data: drive_v3.Schema$File;
};
export default function MarkdownPreview({ data }: Props) {
const fileId = createFileId(data);
const {
data: swrData,
error,
isLoading,
} = useSWR(`/api/files/${fileId}?download=1`);
return (
<div className='flex w-full items-center justify-center'>
<SWRLayout
data={swrData}
error={error}
isLoading={isLoading}
useCard={false}
>
<div className={"w-full"}>
<MarkdownRender content={swrData as string} />
</div>
</SWRLayout>
</div>
);
}
@@ -1,95 +0,0 @@
import { TFile } from "types/googleapis";
import { drive_v3 } from "googleapis";
import { Canvas, useFrame, useLoader } from "@react-three/fiber";
import { get3DLoader } from "utils/mimeTypesHelper";
import { ReactNode, Suspense, useRef } from "react";
import { BufferGeometry } from "three";
import ErrorFeedback from "components/APIFeedback/Error";
import { OrbitControls, Stage } from "@react-three/drei";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function ModelPreview({ data }: Props) {
const getLoader = get3DLoader(data.fileExtension as string);
if (!getLoader) {
return (
<div className='flex w-full items-center justify-center'>
<ErrorFeedback message={"Failed to load model"} />
</div>
);
}
// eslint-disable-next-line react-hooks/rules-of-hooks
const loader = useLoader(
getLoader,
`${process.env.NEXT_PUBLIC_DOMAIN}/api/files/${data.id}/download`,
);
return (
<div className='flex w-full items-center justify-center'>
<div className={"aspect-video h-full w-full overflow-hidden rounded-lg"}>
<Canvas
shadows
camera={{ position: [4, 0, -12], fov: 35 }}
>
<Suspense fallback={null}>
<Scene>
<Model model={loader as BufferGeometry} />
</Scene>
</Suspense>
</Canvas>
</div>
</div>
);
}
type SceneProps = {
children: ReactNode | JSX.Element;
};
function Scene({ children }: SceneProps): JSX.Element {
const controlsRef = useRef<any>();
useFrame(() => {
if (controlsRef.current) {
controlsRef.current.update();
}
});
return (
<>
<Stage
intensity={1.5}
environment='city'
shadows={{
type: "accumulative",
color: "#b78135",
colorBlend: 2,
opacity: 2,
}}
adjustCamera={0.9}
>
{children}
</Stage>
<OrbitControls makeDefault />
</>
);
}
function Model({ model }: { model: BufferGeometry }): JSX.Element {
return (
<mesh
geometry={model}
castShadow={true}
receiveShadow={true}
>
<meshStandardMaterial
color={"#b78135"}
roughness={0.5}
metalness={0.5}
/>
</mesh>
);
}
@@ -1,14 +0,0 @@
import { TFile } from "types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function OfficePreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>
Office Preview
</div>
);
}
@@ -1,62 +0,0 @@
import { drive_v3 } from "googleapis";
import { useState } from "react";
import config from "config/site.config";
import { createFileId } from "utils/driveHelper";
import SWRLayout from "components/layout/SWRLayout";
type Props = {
data: drive_v3.Schema$File;
};
export default function PDFPreview({ data }: Props) {
const fileId = createFileId(data);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string>("");
const fileURL = `${process.env.NEXT_PUBLIC_DOMAIN}/api/files/${fileId}?download=1`;
let providerURL;
switch (config.preview.pdfProvider) {
case "google":
providerURL = `https://drive.google.com/viewerng/viewer?embedded=true&url=${fileURL}`;
break;
case "mozilla":
providerURL = `https://mozilla.github.io/pdf.js/web/viewer.html?file=${fileURL}`;
break;
default:
providerURL = `https://drive.google.com/viewerng/viewer?embedded=true&url=${fileURL}`;
break;
}
return (
<div className='flex w-full items-center justify-center'>
<SWRLayout
data={data}
error={errorMessage}
isLoading={isLoading}
useCard={false}
>
<></>
</SWRLayout>
<div
className={`${
isLoading ? "hidden" : "block"
} mx-auto h-full w-full overflow-hidden rounded-lg`}
>
<iframe
width={"100%"}
height={"100%"}
className={"h-[50vh] tablet:h-[75vh]"}
src={providerURL}
title={data.name as string}
onLoad={() => {
setIsLoading(false);
}}
onError={(error: any) => {
setErrorMessage(error.message);
setIsLoading(false);
}}
/>
</div>
</div>
);
}
@@ -1,33 +0,0 @@
import { drive_v3 } from "googleapis";
import useSWR from "swr";
import MarkdownRender from "components/utility/MarkdownRender";
import { createFileId } from "utils/driveHelper";
import SWRLayout from "components/layout/SWRLayout";
type Props = {
data: drive_v3.Schema$File;
};
export default function TextPreview({ data }: Props) {
const fileId = createFileId(data);
const {
data: swrData,
error,
isLoading,
} = useSWR(`/api/files/${fileId}?download=1`);
return (
<div className='flex w-full items-center justify-center'>
<SWRLayout
data={swrData}
error={error}
isLoading={isLoading}
useCard={false}
>
<div className={"w-full"}>
<MarkdownRender content={swrData as string} />
</div>
</SWRLayout>
</div>
);
}
@@ -1,24 +0,0 @@
import { drive_v3 } from "googleapis";
import ReactPlayer from "react-player";
import { createFileId } from "utils/driveHelper";
type Props = {
data: drive_v3.Schema$File;
};
export default function VideoPreview({ data }: Props) {
const fileId = createFileId(data);
return (
<div className='flex w-full items-center justify-center'>
<div className={`aspect-video h-full max-h-[70vh] w-full`}>
<ReactPlayer
url={`/api/files/${fileId}?download=1`}
controls={true}
width='100%'
height='100%'
/>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import GridItem from "components/File/GridItem";
import { IGDriveFiles } from "types/api/files";
interface GridViewProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
}
export default function ViewGrid(props: GridViewProps) {
return (
<div className='w-full gap-4 grid grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4'>
{props.data.folders.map((folder) => (
<GridItem
key={folder.encryptedId}
file={folder}
isProtected={props.isProtected}
/>
))}
{props.data.files.map((file) => (
<GridItem
key={file.encryptedId}
file={file}
isProtected={props.isProtected}
/>
))}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import ListItem from "components/File/ListItem";
import { IGDriveFiles } from "types/api/files";
interface ViewListProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
}
export default function ViewList(props: ViewListProps) {
return (
<div className='w-full flex flex-col gap-2 rounded-lg'>
{props.data.folders.map((folder) => (
<ListItem
key={folder.encryptedId}
file={folder}
isProtected={props.isProtected}
/>
))}
{props.data.files.map((file) => (
<ListItem
key={file.encryptedId}
file={file}
isProtected={props.isProtected}
/>
))}
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import axios from "axios";
import gIndexConfig from "config";
import { useRouter } from "next/router";
import { poppins } from "pages/_app";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import Breadcrumb from "components/Breadcrumb";
import Button from "components/Button";
import Markdown from "components/Markdown";
import { addNewPassword } from "utils/passwordHelper";
import { IGDriveFiles } from "types/api/files";
import { APIGetFileResponse } from "types/api/response";
import { Constant } from "types/constant";
import Header from "../Header";
import PasswordLayout from "../Password";
import ViewGrid from "./Grid";
import ViewList from "./List";
const Loading = () => (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading view...</span>
</div>
</div>
);
interface ExplorerLayoutProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
readmeFile: string | null;
isProtected: boolean;
isFileProtected?: boolean;
encryptedId?: string;
}
export default function ExplorerLayout(props: ExplorerLayoutProps) {
const router = useRouter();
const [data, setData] = useState<ExplorerLayoutProps["data"]>(props.data);
const [view, setView] = useState<"grid" | "list">(() => {
const viewType = localStorage.getItem(Constant.storage_ViewType);
if (!viewType) {
localStorage.setItem(Constant.storage_ViewType, "list");
return "list";
}
if (viewType === "grid") {
return "grid";
} else {
return "list";
}
});
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
const [isButtonLoading, setIsButtonLoading] = useState<boolean>(false);
const [ViewComponent, setViewComponent] = useState<ReactNode>(
<ViewList
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
useEffect(() => {
new Promise(async (resolve) => {
if (view === "list") {
setViewComponent(
<ViewList
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
} else if (view === "grid") {
setViewComponent(
<ViewGrid
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
}
await new Promise((resolve) => setTimeout(resolve, 50));
resolve(true);
}).then(() => {
setIsViewLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, data]);
useEffect(() => {
setShowPasswordLayout(props.isProtected);
}, [props.isProtected]);
useEffect(() => {
const paths = router.asPath.split("/").filter((path) => path !== "");
const newPaths: Record<"label" | "path", string>[] = [];
paths.forEach((path, index) => {
newPaths.push({
label: path,
path: `/${paths.slice(0, index + 1).join("/")}`,
});
});
setBreadcrumbPaths(newPaths);
}, [router]);
const handleChangeView = useCallback(
(view: "grid" | "list") => {
setIsViewLoading(true);
setView(view);
localStorage.setItem(Constant.storage_ViewType, view);
},
[setView],
);
const handleLoadNextPage = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsButtonLoading(true);
if (!data.pageToken) {
setIsButtonLoading(false);
return;
}
const newData = await axios.get<APIGetFileResponse>("/api/getData", {
params: {
encryptedId: props.encryptedId,
pageToken: data.pageToken,
},
});
setData((prev) => ({
...prev,
files: [...prev.files, ...newData.data.data.files],
folders: [...prev.folders, ...newData.data.data.folders],
pageToken: newData.data.data.pageToken,
}));
setIsButtonLoading(false);
},
[data, props.encryptedId],
);
return (
<div className='w-full flex h-full flex-col gap-4 relative'>
{/* Top */}
<Header
viewSelector={{ view, setView: handleChangeView }}
showPasswordLayout={showPasswordLayout}
/>
<div className='w-full h-px bg-primary-500' />
{/* Header */}
{!showPasswordLayout && (
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
{/* Breadcrumb */}
<Breadcrumb paths={breadcrumbPaths} />
</div>
)}
{/* Content */}
{showPasswordLayout ? (
<PasswordLayout
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 1] ?? "/")}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<div
className={twMerge(
"w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4",
!props.readmeFile && "grid-cols-1 tablet:grid-cols-12 desktop:grid-cols-12",
)}
>
<div
className={twMerge(
"col-span-full desktop:col-span-8 overflow-auto",
!props.readmeFile && "desktop:col-span-full",
)}
>
{isViewLoading ? (
<Loading />
) : (
<div className='w-full flex flex-col gap-4'>
{data.files.length || data.folders.length ? (
<>{ViewComponent}</>
) : (
<div className='w-full min-h-[5rem] grid place-items-center'>
Can&apos;t find any files or folders in this directory
</div>
)}
{data.pageToken && (
<Button
variant='accent'
fullWidth
rounded='large'
className='mt-4'
loading={isButtonLoading}
onClick={handleLoadNextPage}
>
Load more...
</Button>
)}
</div>
)}
</div>
{props.readmeFile && (
<div className='col-span-full desktop:col-span-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 rounded-lg sticky top-4 overflow-auto ${poppins.className}`}
>
<Markdown content={props.readmeFile} />
</div>
</div>
)}
</div>
)}
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
import { Icon } from "@iconify/react";
import axios from "axios";
import gIndexConfig from "config";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import Button from "components/Button";
import ButtonGroup from "components/ButtonGroup";
import ButtonIcon from "components/ButtonIcon";
import Modal from "components/Modal";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { getPreviewIcon } from "utils/previewHelper";
import { IGDriveFiles } from "types/api/files";
import { APISearchResponse } from "types/api/response";
interface HeaderProps {
viewSelector?: {
view: "grid" | "list";
setView: (view: "grid" | "list") => void;
};
showPasswordLayout?: boolean;
}
export default function Header(props: HeaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [searchModalOpen, setSearchModalOpen] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>("");
const [axiosCancelToken, setAxiosCancelToken] = useState<ReturnType<typeof axios.CancelToken.source> | null>(null); // eslint-disable-line
const [searchLoading, setSearchLoading] = useState<boolean>(false);
const [searchResults, setSearchResults] = useState<(IGDriveFiles & { redirect: string })[]>([]);
useEffect(() => {
if (!searchQuery) {
setSearchLoading(false);
return;
}
setSearchLoading(true);
if (axiosCancelToken) {
axiosCancelToken.cancel();
}
const timeout = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 500);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
useEffect(() => {
if (!searchModalOpen) {
setSearchResults([]);
setSearchQuery("");
setSearchLoading(false);
}
}, [searchModalOpen]);
useEffect(() => {
if (!debouncedSearchQuery) {
setSearchLoading(false);
setSearchResults([]);
return;
}
const source = axios.CancelToken.source();
setAxiosCancelToken(source);
axios
.get<APISearchResponse>("/api/search", {
params: {
query: debouncedSearchQuery,
},
cancelToken: source.token,
})
.then((res) => {
setSearchResults(res.data.files);
})
.catch((err) => {
if (axios.isCancel(err)) {
return;
} else {
console.error(err);
throw new Error(err);
}
})
.finally(() => {
setSearchLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchQuery]);
return (
<>
<div className='flex items-center justify-between gap-4'>
<h1 className='text-2xl font-medium'>{gIndexConfig.siteConfig.siteName}</h1>
{!props.showPasswordLayout && (
<div className='flex items-center gap-4'>
{props.viewSelector && (
<ButtonGroup className='border border-primary-700 tablet:flex hidden'>
<Button
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
startIcon={"ion:list"}
onClick={() => props.viewSelector?.setView("list")}
>
List view
</Button>
<Button
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
startIcon={"ion:grid"}
onClick={() => props.viewSelector?.setView("grid")}
>
Grid view
</Button>
</ButtonGroup>
)}
<Tooltip content='Search file'>
<ButtonIcon
icon='ion:search'
variant='transparent'
onClick={() => {
setSearchModalOpen(true);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}}
/>
</Tooltip>
</div>
)}
</div>
{!props.showPasswordLayout && props.viewSelector && (
<div className='flex w-full items-center tablet:hidden'>
<ButtonGroup className='border border-primary-700 w-full'>
<Button
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
size='small'
startIcon={"ion:list"}
onClick={() => props.viewSelector?.setView("list")}
>
List view
</Button>
<Button
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
size='small'
startIcon={"ion:grid"}
onClick={() => props.viewSelector?.setView("grid")}
>
Grid view
</Button>
</ButtonGroup>
</div>
)}
<Modal
key={"search-modal"}
open={searchModalOpen}
onClose={() => setSearchModalOpen(false)}
title='Search'
>
<div className='w-full flex flex-col gap-2'>
<input
ref={inputRef}
type='text'
placeholder='Input search query'
className='w-full px-4 py-2 bg-primary-900 outline-none border border-primary-500 rounded-lg'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchLoading ? (
<div className='w-full py-8 flex items-center justify-center gap-2 animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Searching file...</span>
</div>
) : (
<div className='flex flex-col gap-2 max-h-96 overflow-auto'>
{searchResults.length === 0 ? (
<div className='w-full py-8 flex items-center justify-center gap-2'>
<span>No results found</span>
</div>
) : (
<>
{searchResults.map((file) => (
<Link
href={file.redirect}
key={file.encryptedId}
className='flex items-center gap-4 w-full flex-grow-0 flex-shrink bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
{file.thumbnailLink &&
(file.mimeType.startsWith("video") || file.mimeType.startsWith("image")) ? (
<img
src={file.thumbnailLink}
alt={file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
loading='lazy'
/>
) : (
<Icon
icon={
file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(file.fileExtension!, file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
/>
)}
<div className='flex flex-col'>
<span className='font-medium'>{file.name}</span>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>
{[
file.mimeType && file.mimeType.includes("folder") && "Folder",
file.fileExtension && file.fileExtension.toUpperCase(),
]
.filter((item) => item)
.join(" ・ ")}
</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>
{[
new Date(file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
file.size ? bytesToReadable(file.size) : undefined,
]
.filter((item) => item)
.join(" ・ ")}
</span>
</div>
</div>
</Link>
))}
</>
)}
</div>
)}
</div>
</Modal>
</>
);
}
+188
View File
@@ -0,0 +1,188 @@
import gIndexConfig from "config";
import { NextSeo, NextSeoProps } from "next-seo";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import {
addNewPassword,
checkPathPassword,
} from "utils/passwordHelper";
import { Constant } from "types/constant";
import PasswordLayout from "../Password";
interface LoaderLayoutProps
extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
seo?: NextSeoProps;
}
export default function LoaderLayout(
props: LoaderLayoutProps,
) {
const router = useRouter();
const { children, seo, className, ...rest } = props;
const sitePassword =
process.env.NEXT_PUBLIC_SITE_PASSWORD;
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isPasswordUnlocked, setIsPasswordUnlocked] =
useState<boolean>(
gIndexConfig.siteConfig.privateIndex ? false : true,
);
const handlePrivateIndexCheck = () => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
if (cookie) {
const password = cookie.split("=")[1];
if (!password) return setIsPasswordUnlocked(false);
if (!sitePassword)
throw new Error("Can't find site password");
const valid = checkPathPassword(
"site-wide",
password,
sitePassword as string,
);
if (valid) return setIsPasswordUnlocked(true);
}
};
useEffect(() => {
setIsLoading(true);
new Promise((resolve) =>
resolve(handlePrivateIndexCheck()),
).then(() => setIsLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const routeChangeStart = () => setIsLoading(true);
const routeChangeComplete = () => {
handlePrivateIndexCheck();
setIsLoading(false);
};
router.events.on("routeChangeStart", routeChangeStart);
router.events.on(
"routeChangeComplete",
routeChangeComplete,
);
router.events.on(
"routeChangeError",
routeChangeComplete,
);
return () => {
router.events.off(
"routeChangeStart",
routeChangeStart,
);
router.events.off(
"routeChangeComplete",
routeChangeComplete,
);
router.events.off(
"routeChangeError",
routeChangeComplete,
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.events]);
return (
<>
<NextSeo {...seo} />
{isLoading ? (
<div className='w-full h-auto flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Validating path...</span>
</div>
</div>
) : (
<div
className={`w-full h-auto flex flex-col p-4 tablet:px-8 desktop:px-16 ${className}`}
{...rest}
key={router.asPath}
>
{gIndexConfig.siteConfig.privateIndex &&
!isPasswordUnlocked ? (
<PasswordLayout
path='Private Mode Password'
onSubmitted={(password) => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
addNewPassword(
"site-wide",
password,
cookie?.split("=")[1] ?? undefined,
);
setIsLoading(true);
const validPassword = () => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
if (cookie) {
const password = cookie.split("=")[1];
if (!password) return false;
if (!sitePassword)
throw new Error(
"Can't find site password",
);
const valid = checkPathPassword(
"site-wide",
password,
sitePassword as string,
);
return valid;
}
};
new Promise((resolve) =>
resolve(validPassword()),
)
.then((data) => {
if (data) {
setIsPasswordUnlocked(true);
} else {
setIsPasswordUnlocked(false);
}
})
.finally(() => setIsLoading(false));
}}
/>
) : (
<>{children}</>
)}
</div>
)}
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { Icon } from "@iconify/react";
import { useRouter } from "next/router";
import { useState } from "react";
import Button from "components/Button";
import ButtonIcon from "components/ButtonIcon";
interface PasswordLayoutProps {
onSubmitted: (password: string) => void;
path: string;
}
export default function PasswordLayout(
props: PasswordLayoutProps,
) {
const router = useRouter();
const [showPassword, setShowPassword] =
useState<boolean>(false);
const [password, setPassword] = useState<string>("");
const [pending, setPending] = useState<boolean>(false);
return (
<div className='col-span-full text-center min-h-[50vh] flex-grow flex flex-col items-center justify-center'>
<figure>
<img
src='/images/password.png'
alt="You don't have access"
className='w-64 h-64 tablet:w-96 tablet:h-96'
/>
</figure>
<div className='w-full flex items-center justify-center flex-col my-8'>
<span className='text-lg font-medium'>
The folder / file you are trying to access is
protected with a password.
</span>
<pre className='max-w-sm w-full tablet:max-w-2xl'>
<code className='text-sm'>{props.path}</code>
</pre>
<span>Please enter the password to continue:</span>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
props.onSubmitted(password);
setPending(true);
}}
className='w-full max-w-lg flex items-center border border-primary-500 rounded-lg'
>
<div className='w-full flex items-center px-2 bg-primary-900 rounded-l-lg overflow-hidden'>
<input
type={showPassword ? "text" : "password"}
autoComplete='off'
className='w-full px-2 py-2 bg-primary-900 outline-none'
placeholder='Enter password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<ButtonIcon
type='button'
variant='transparent'
icon={showPassword ? "ion:eye-off" : "ion:eye"}
size='small'
className='p-1'
onClick={() => setShowPassword(!showPassword)}
/>
</div>
<Button
variant='accent'
type='submit'
className='rounded-r-lg w-24'
disabled={pending}
>
{pending ? (
<Icon
icon='mdi:loading'
className='animate-spin'
width={24}
height={24}
color='currentColor'
/>
) : (
"Submit"
)}
</Button>
</form>
</div>
);
}
+319
View File
@@ -0,0 +1,319 @@
import { Icon } from "@iconify/react";
import gIndexConfig from "config";
import Link from "next/link";
import { useRouter } from "next/router";
import { poppins } from "pages/_app";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify";
import { twMerge } from "tailwind-merge";
import Breadcrumb from "components/Breadcrumb";
import Button from "components/Button";
import bytesToReadable from "utils/bytesFormat";
import { durationToReadable } from "utils/durationFormat";
import { decryptData } from "utils/encryptionHelper/hash";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { addNewPassword } from "utils/passwordHelper";
import { getPreviewComponent } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
import { Constant } from "types/constant";
import PasswordLayout from "../Password";
const Loading = () => (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading view...</span>
</div>
</div>
);
interface PreviewLayoutProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
isFileProtected: boolean;
encryptedId?: string;
}
export default function PreviewLayout(props: PreviewLayoutProps) {
const router = useRouter();
const [data, setData] = useState<PreviewLayoutProps["data"]>(props.data);
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
const [PreviewComponent, setPreviewComponent] = useState<ReactNode>();
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
const [downloadToken] = useState<string | null>(() => {
if (gIndexConfig.apiConfig.allowDownloadProtectedFile) return null;
if (props.isFileProtected) {
return createDownloadToken();
} else {
return null;
}
});
const [expiredIn, setExpiredIn] = useState<string>("");
const fileMetadata = useMemo<
{
key: string;
label: string;
value: string | number | null;
}[]
>(() => {
const metadata = [];
if (data.file) {
if (data.file.name)
metadata.push({
key: "name",
label: "Name",
value: data.file.name,
});
if (data.file.mimeType)
metadata.push({
key: "mimeType",
label: "Type",
value: data.file.mimeType,
});
if (data.file.modifiedTime)
metadata.push({
key: "modifiedTime",
label: "Last Modified",
value: new Date(data.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
});
if (data.file.size)
metadata.push({
key: "size",
label: "Size",
value: bytesToReadable(data.file.size),
});
if (data.file.imageMediaMetadata && data.file.imageMediaMetadata.width && data.file.imageMediaMetadata.height)
metadata.push({
key: "imageMediaMetadata",
label: "Dimension",
value: `${data.file.imageMediaMetadata.width}x${data.file.imageMediaMetadata.height}`,
});
if (data.file.videoMediaMetadata)
metadata.push(
{
key: "videoMediaMetadata",
label: "Dimension",
value: `${data.file.videoMediaMetadata.width}x${data.file.videoMediaMetadata.height}`,
},
{
key: "videoMediaMetadata",
label: "Duration",
value: durationToReadable(data.file.videoMediaMetadata.durationMillis),
},
);
}
return metadata;
}, [data.file]);
useEffect(() => {
if (!downloadToken) return;
const interval = setInterval(() => {
const expiredAt = Number(decryptData(downloadToken));
const date = new Date(expiredAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
const hours = Math.floor(diff / 1000 / 60 / 60);
const minutes = Math.floor(diff / 1000 / 60) % 60;
const seconds = Math.floor(diff / 1000) % 60;
setExpiredIn(`${hours}:${minutes}:${seconds}`);
}, 1000);
return () => clearInterval(interval);
}, [downloadToken]);
useEffect(() => {
new Promise(async (resolve) => {
const Preview = getPreviewComponent(props.data.file?.fileExtension ?? "unknown", props.data.file?.mimeType ?? "");
setPreviewComponent(<Preview file={props.data.file!} />);
await new Promise((resolve) => setTimeout(resolve, 50));
resolve(true);
}).then(() => {
setIsViewLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
setShowPasswordLayout(props.isProtected);
}, [props.isProtected]);
useEffect(() => {
const paths = router.asPath.split("/").filter((path) => path !== "");
const newPaths: Record<"label" | "path", string>[] = [];
paths.forEach((path, index) => {
newPaths.push({
label: path,
path: `/${paths.slice(0, index + 1).join("/")}`,
});
});
setBreadcrumbPaths(newPaths);
}, [router]);
return (
<div className='w-full flex h-full flex-col gap-4 relative'>
{/* Top */}
<div className='flex items-center justify-between gap-4'>
<h1 className='text-2xl font-medium'>{gIndexConfig.siteConfig.siteName}</h1>
{!showPasswordLayout && (
<div className='flex items-center gap-4'>
{/* Disable until figure out how to search from root */}
{/* <Tooltip content='Search file'>
<ButtonIcon
icon='ion:search'
variant='transparent'
/>
</Tooltip> */}
</div>
)}
</div>
<div className='w-full h-px bg-primary-500' />
{/* Header */}
{!showPasswordLayout && (
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
{/* Breadcrumb */}
<Breadcrumb paths={breadcrumbPaths} />
</div>
)}
{/* Content */}
{showPasswordLayout ? (
<PasswordLayout
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 2] ?? "/")}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<div className={twMerge("w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4")}>
<div className={twMerge("col-span-full desktop:col-span-8 overflow-auto")}>
{isViewLoading ? (
<Loading />
) : (
<div className='w-full flex flex-col gap-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 p-4 mb-4 rounded-lg overflow-auto ${poppins.className}`}
>
{PreviewComponent}
</div>
</div>
)}
</div>
<div className='col-span-full desktop:col-span-4'>
<div className='sticky top-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 mb-4 rounded-lg overflow-auto ${poppins.className}`}
>
<div className='flex items-center gap-2'>
<Icon
icon={"ion:information-circle"}
className='w-6 h-6'
/>
<h2 className='text-2xl font-medium'>File info</h2>
</div>
<div className='w-full h-px bg-primary-500' />
<div className='flex flex-col gap-2 w-full'>
{fileMetadata.map((item) => (
<div
className='grid grid-cols-3'
key={`${item.key}-${item.label.toLowerCase()}`}
>
<span className='col-span-1 font-bold'>{item.label}</span>
<p className='col-span-2 break-all whitespace-pre-line'>{item.value ?? "-"}</p>
</div>
))}
</div>
</div>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 my-4 rounded-lg overflow-auto ${poppins.className}`}
>
<Link
href={generatedDownloadLink(data.file!, props.isFileProtected, false, downloadToken)}
target='_blank'
rel='noopener noreferrer'
>
<Button
fullWidth
variant='accent'
startIcon={"ion:download"}
rounded='large'
>
Download
</Button>
</Link>
<Button
fullWidth
variant='primary'
startIcon={"ion:copy"}
rounded='large'
onClick={() => {
try {
const url = generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken);
navigator.clipboard.writeText(url);
toast.success("URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy URL to clipboard.");
console.error(error.message);
}
}}
>
Copy URL
</Button>
<div className='w-full px-2 bg-primary-950 outline-none rounded-lg border border-primary-500 select-all'>
<div className='overflow-x-scroll py-2 whitespace-nowrap'>
{generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken)}
</div>
</div>
{!gIndexConfig.apiConfig.allowDownloadProtectedFile && props.isFileProtected && (
<span className='text-center mt-4 text-sm'>
This file is inside protected folder.
<br />
Download link will be expired in <b>{expiredIn}</b>.
</span>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { jetbrainsMono } from "pages/_app";
import { useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "react-toastify";
import rehypeKatex from "rehype-katex";
import rehypePrism from "rehype-prism-plus";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkSlug from "remark-slug";
import remarkToc from "remark-toc";
import Button from "components/Button";
import "styles/highlight.css";
import "styles/markdown.css";
interface PreProps extends React.HTMLAttributes<HTMLPreElement> {}
function PreComponent(props: PreProps) {
const codeRef = useRef<HTMLPreElement | null>(null);
const [showCopy, setShowCopy] = useState<boolean>(false);
const [isCopied, setIsCopied] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const [copyTimeout, setCopyTimeout] = useState<NodeJS.Timeout>();
const handleCopy = () => {
if (!codeRef.current) return;
if (copyTimeout) clearTimeout(copyTimeout);
try {
navigator.clipboard.writeText(codeRef.current.textContent as string);
setIsError(false);
setIsCopied(true);
setCopyTimeout(
setTimeout(() => {
setIsCopied(false);
}, 2000),
);
toast.success("Code copied to clipboard");
} catch (error: any) {
setIsCopied(false);
setIsError(true);
setCopyTimeout(
setTimeout(() => {
setIsError(false);
}, 2000),
);
toast.error("Failed to copy code to clipboard");
}
};
return (
<div
className={`w-full h-full relative`}
onMouseEnter={() => {
setShowCopy(true);
}}
onMouseLeave={() => {
setShowCopy(false);
setIsCopied(false);
setIsError(false);
}}
>
<div className={`absolute top-0 right-0 p-2 ${showCopy ? "opacity-100" : "opacity-0"} transition-smooth`}>
<Button
size='small'
rounded='medium'
variant={isError ? "danger" : isCopied ? "success" : "primary"}
startIcon={isCopied ? "ion:checkmark" : isError ? "ion:close" : "ion:copy"}
disabled={isCopied || isError}
onClick={() => {
handleCopy();
}}
>
{isCopied ? "Copied!" : isError ? "Failed to copy" : "Copy"}
</Button>
</div>
<pre
ref={codeRef}
{...props}
className='overflow-x-auto'
>
{props.children}
</pre>
</div>
);
}
interface MarkdownProps {
content: string;
}
export default function Markdown(props: MarkdownProps) {
return (
<div className='markdown w-full'>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
rehypePlugins={[rehypeKatex, rehypeRaw, [rehypePrism, { ignoreMissing: true }]]}
components={{
pre: PreComponent,
code: ({ node, inline, className, children, ...props }) => (
<code
className={`${className} ${jetbrainsMono.className}`}
{...props}
>
{children}
</code>
),
}}
>
{props.content}
</ReactMarkdown>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { ReactNode, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
interface ModalProps {
children: ReactNode;
open: boolean;
onClose: () => void;
title: string;
footer?: ReactNode;
keepMounted?: boolean;
}
export default function Modal(props: ModalProps) {
let DELAY = 10;
const [showModal, setShowModal] = useState<boolean>(props.open);
const [keepMounted, setKeepMounted] = useState<boolean>(false);
const [shouldRender, setShouldRender] = useState<boolean>(false);
const [shouldUnmount, setShouldUnmount] = useState<boolean>(true);
useEffect(() => {
const handleClose = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.onClose();
}
};
window.addEventListener("keydown", handleClose);
return () => {
window.removeEventListener("keydown", handleClose);
};
}, [props]);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (showModal && !shouldRender) {
timeoutId = setTimeout(() => {
setShouldRender(true);
}, DELAY);
} else if (!showModal && shouldRender) {
timeoutId = setTimeout(() => {
setShouldRender(false);
}, DELAY);
}
return () => {
clearTimeout(timeoutId);
};
}, [showModal, shouldRender, DELAY]);
useEffect(() => {
setShowModal(props.open);
if (props.open) setShouldUnmount(false);
}, [props.open]);
useEffect(() => {
if (showModal && props.keepMounted) setKeepMounted(true);
}, [showModal, props.keepMounted]);
const handleClose = () => {
setShowModal(false);
if (!keepMounted) setKeepMounted(false);
props.onClose();
};
// useEffect(() => {
// if (!showModal && !keepMounted && shouldRender) {
// const timeout = setTimeout(() => {
// setShouldUnmount(true);
// }, 250);
// return () => {
// clearTimeout(timeout);
// };
// } else {
// setShouldUnmount(false);
// }
// }, [showModal, keepMounted, shouldRender, DELAY]);
if (shouldUnmount) return null;
return (
<ClickAway
open={showModal}
onClickAway={handleClose}
className={`fixed grid place-items-center top-0 left-0 z-[60] w-full h-full transition-smooth ${
shouldRender ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
onTransitionEnd={() => {
if (!showModal && !keepMounted) {
setShouldUnmount(true);
} else {
setShouldUnmount(false);
}
}}
>
<div
className='w-full px-4 max-w-screen-lg'
onClick={(e) => {
e.stopPropagation();
}}
>
<div className='bg-primary-900 text-primary-50 rounded-lg py-2 px-4 flex flex-col gap-2 w-full'>
{/* Header */}
<div className='flex flex-row items-center justify-between py-2'>
<span className='text-xl font-medium w-full line-clamp-1'>{props.title}</span>
<ButtonIcon
icon={"ion:close"}
onClick={handleClose}
/>
</div>
<div className='w-full h-px bg-primary-500' />
{/* Body */}
<div className='py-2'>{props.children}</div>
{/* Footer */}
{props.footer && <div className='py-2'>{props.footer}</div>}
</div>
</div>
</ClickAway>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useState } from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function AudioPreview(props: PreviewProps) {
const [isError, setIsError] = useState<boolean>(false);
return (
<div className='w-full h-full'>
{isError ? (
<ErrorPreview />
) : (
<AudioPlayer
src={generatedDownloadLink(props.file, false)}
showJumpControls={false}
showFilledVolume
style={{
borderRadius: "0.375rem",
}}
onError={() => setIsError(true)}
/>
)}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import axios from "axios";
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import Markdown from "components/Markdown";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function CodePreview(props: PreviewProps) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
useEffect(() => {
axios
.get(generatedDownloadLink(props.file, false))
.then((res) => {
const isObject = typeof res.data === "object";
setContent(
`\`\`\`${props.file.fileExtension}\n${isObject ? JSON.stringify(res.data, null, 2) : res.data}\`\`\``,
);
setIsLoading(false);
})
.catch((err) => {
console.error(err);
setIsError(true);
});
}, [props.file, props.file.fileExtension]);
return (
<div className='w-full h-full'>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
)}
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
import gIndexConfig from "config";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
function LoadingRender() {
return (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
);
}
function ErrorRender() {
return <ErrorPreview />;
}
export default function DocumentPreview(props: PreviewProps) {
return (
<div className='w-full h-full'>
<DocViewer
documents={[
{
uri: generatedDownloadLink(props.file, false, true),
},
]}
pluginRenderers={DocViewerRenderers}
config={{
header: {
disableHeader: true,
},
loadingRenderer: {
overrideComponent: LoadingRender,
},
noRenderer: {
overrideComponent: ErrorRender,
},
}}
className='min-h-[70vh] max-h-[70vh] w-full h-full rounded-md'
theme={{
disableThemeScrollbar: true,
}}
/>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
interface ErrorPreviewProps {
error?: string;
}
export default function ErrorPreview(props: ErrorPreviewProps) {
return (
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
<h2 className='text-2xl font-bold'>Failed to load file preview</h2>
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
Error has been occured while loading preview, it may be caused by{" "}
<span className='font-medium underline'>invalid file type</span> or{" "}
<span className='font-medium underline'>network error</span>.
<br />
You can try again later or download the file to view.
</span>
{props.error && (
<>
<span className='font-medium underline'>Error details</span>
<span className='text-sm tablet:text-base text-primary-300 my-2'>{props.error}</span>
</>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function ImagePreview(props: PreviewProps) {
const [imgSrc, setImgSrc] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
setIsError(false);
setIsLoading(true);
const timeout = setTimeout(() => {
setIsError(true);
setErrorMessage("Image took too long to load");
return;
}, 15000);
fetch(generatedDownloadLink(props.file, false))
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.blob();
})
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
setImgSrc(reader.result as string);
};
reader.readAsDataURL(blob);
})
.catch((err) => {
console.error(err);
setIsError(true);
setErrorMessage(err.message);
})
.finally(() => {
setIsLoading(false);
clearTimeout(timeout);
});
return () => clearTimeout(timeout);
}, [props.file]);
return (
<>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading image...</span>
</div>
</div>
) : (
<>
{isError ? (
<ErrorPreview />
) : (
<img
src={imgSrc}
alt={props.file.name}
className='w-full h-full rounded-md'
/>
)}
</>
)}
</>
);
}
+122
View File
@@ -0,0 +1,122 @@
import axios from "axios";
import gIndexConfig from "config";
import JSZip from "jszip";
import { useCallback, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function MangaPreview(props: PreviewProps) {
const [maxPageIndex, setMaxPageIndex] = useState<number>(4);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [viewPageIndex, setViewPageIndex] = useState<number>(0);
const [images, setImages] = useState<string[]>([]);
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
useEffect(() => {
const mangaImages: string[] = [];
axios
.get(generatedDownloadLink(props.file, false, false, null, true), {
responseType: "blob",
})
.then(async (res) => {
const zipData = await JSZip.loadAsync(res.data);
for (let i = 0; i <= maxPageIndex; i++) {
const file = zipData.file(Object.keys(zipData.files)[i]);
if (file) {
const blob = await file.async("blob");
const reader = new FileReader();
reader.onload = () => {
mangaImages.push(reader.result as string);
};
reader.readAsDataURL(blob);
}
}
setImages(mangaImages);
})
.catch((err) => {
console.error(err);
setIsError(true);
})
.finally(() => {
setIsLoading(false);
});
}, [props.file, maxPageIndex]);
const handleCurrentView = useCallback(
(pageNumber: number) => {
if (pageNumber < 0 || pageNumber > maxPageIndex) return;
setViewPageIndex(pageNumber);
},
[maxPageIndex],
);
return (
<div className='w-full h-full'>
<blockquote className='w-full h-auto flex items-center my-2 bg-primary-800 border-l-4 border-l-accent-500 pl-3 pr-2 py-2 text-sm tablet:text-base rounded-md'>
Preview only shows the first 5 pages. Please download the file to view the rest.
</blockquote>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>
{isError ? (
<ErrorPreview />
) : (
<div className='flex flex-col'>
<div className='w-full h-full relative'>
<img
src={images[viewPageIndex]}
alt={`${props.file.name} - page ${viewPageIndex}`}
className={`w-full h-full object-contain bg-primary-950 rounded-md ${
viewSize === "fit" ? "max-h-[70vh]" : ""
}`}
/>
<ButtonIcon
icon={viewSize === "fit" ? "ion:expand" : "ion:contract"}
className='absolute bottom-2 right-2'
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
/>
</div>
<div className='flex flex-row gap-2 items-center justify-center my-2'>
<ButtonIcon
variant='transparent'
icon={"ion:chevron-back"}
onClick={() => handleCurrentView(viewPageIndex - 1)}
disabled={viewPageIndex === 0}
/>
<span>
{viewPageIndex + 1} / {maxPageIndex + 1}
</span>
<ButtonIcon
variant='transparent'
icon={"ion:chevron-forward"}
onClick={() => handleCurrentView(viewPageIndex + 1)}
disabled={viewPageIndex === maxPageIndex}
/>
</div>
</div>
)}
</>
)}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import axios from "axios";
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import Markdown from "components/Markdown";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function RichPreview(props: PreviewProps) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
useEffect(() => {
axios
.get(generatedDownloadLink(props.file))
.then((res) => {
setContent(res.data);
setIsLoading(false);
})
.catch((err) => {
console.error(err);
setIsError(true);
});
}, [props.file]);
return (
<div className='w-full h-full'>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
)}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export default function UnknownPreview() {
return (
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
<h2 className='text-2xl font-bold'>File preview not available</h2>
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
This file type is not supported for preview.
<br />
You can download the file to view.
</span>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useState } from "react";
import ReactPlayer from "react-player";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function VideoPreview(props: PreviewProps) {
const [isError, setIsError] = useState<boolean>(false);
return (
<div className='w-full h-full'>
{isError ? (
<ErrorPreview />
) : (
<ReactPlayer
key={props.file.encryptedId}
url={generatedDownloadLink(props.file, false)}
controls
pip
wrapper={({ children }) => (
<div className='w-full h-[60vh] bg-primary-950 rounded-md overflow-hidden'>{children}</div>
)}
style={{
width: "100%",
height: "100%",
maxHeight: "60vh",
}}
onError={() => setIsError(true)}
/>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { Popper, PopperProps } from "react-popper";
interface TooltipProps {
children: ReactNode;
content: ReactNode;
placement?: PopperProps<unknown>["placement"];
delay?: number;
}
export default function Tooltip(props: TooltipProps) {
const [refElement, setRefElement] = useState<HTMLElement | null>(null);
const [showTooltip, setShowTooltip] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
function handleMouseEnter() {
if (props.delay) {
timeoutRef.current = window.setTimeout(() => {
setShowTooltip(true);
}, props.delay);
} else {
setShowTooltip(true);
}
}
function handleMouseLeave() {
if (props.delay) {
window.clearTimeout(timeoutRef.current ?? undefined);
}
setShowTooltip(false);
}
function handleScroll() {
setShowTooltip(false);
}
if (refElement) {
refElement.addEventListener("mouseenter", handleMouseEnter);
refElement.addEventListener("mouseleave", handleMouseLeave);
refElement.addEventListener("click", handleMouseLeave);
window.addEventListener("scroll", handleScroll);
}
return () => {
if (refElement) {
refElement.removeEventListener("mouseenter", handleMouseEnter);
refElement.removeEventListener("mouseleave", handleMouseLeave);
refElement.removeEventListener("click", handleMouseLeave);
window.removeEventListener("scroll", handleScroll);
}
};
}, [refElement, props.delay]);
return (
<>
<div ref={setRefElement}>{props.children}</div>
<Popper
placement={props.placement ?? "top"}
modifiers={[
{
name: "offset",
options: {
offset: [0, 10],
},
},
{
name: "hide",
enabled: true,
phase: "main",
requires: ["computeStyles"],
fn: ({ state }) => {
const { height, width, x, y } = state.rects.reference;
const { innerHeight, innerWidth } = window;
state.styles.popper = {
...state.styles.popper,
visibility: height + y > innerHeight || width + x > innerWidth ? "hidden" : "visible",
};
},
},
]}
referenceElement={refElement as HTMLElement}
strategy='absolute'
>
{({ ref, style, placement }) => (
<div
ref={ref}
style={style}
data-placement={placement}
className={`bg-primary-800 max-w-sm text-primary-50 text-sm font-medium px-2 py-1 rounded-md shadow-md transition-smooth ${
showTooltip ? `opacity-100 pointer-events-auto` : "opacity-0 pointer-events-none"
}`}
>
{props.content}
</div>
)}
</Popper>
</>
);
}
@@ -1,37 +0,0 @@
import { ReactNode } from "react";
import Breadcrumb from "components/Breadcrumb";
import SwitchLayout from "components/utility/SwitchLayout";
import useSWR from "swr";
import fetcher from "utils/swrFetch";
import { BreadCrumbsResponse } from "types/googleapis";
type Props = {
children: ReactNode;
fileId: string;
renderSwitchLayout?: boolean;
};
export default function DefaultLayout({
children,
fileId,
renderSwitchLayout = true,
}: Props) {
const { data, isLoading } = useSWR<BreadCrumbsResponse>(
`/api/files/${fileId}/getPath`,
fetcher,
);
return (
<div className={"mx-auto flex max-w-screen-xl flex-col gap-2"}>
<div className={"flex w-full items-center justify-between gap-2"}>
<Breadcrumb
data={fileId === "root" ? undefined : data}
isLoading={isLoading}
/>
{renderSwitchLayout && <SwitchLayout />}
</div>
{children}
</div>
);
}
@@ -1,97 +0,0 @@
import { FilesResponse } from "types/googleapis";
import GridFile from "components/File/Grid";
import { drive_v3 } from "googleapis";
import EmptyFeedback from "components/APIFeedback/Empty";
type Props = {
data: FilesResponse | undefined;
pagination: {
swrData: FilesResponse[] | undefined;
size: number;
setSize: (size: number) => Promise<FilesResponse[] | undefined>;
isLoadingMore: boolean | undefined;
isReachingEnd: boolean | undefined;
};
};
export default function GridLayout({ data, pagination }: Props) {
const { swrData, isLoadingMore, isReachingEnd, size, setSize } = pagination;
return (
<>
{data?.folders.length === 0 && data?.files.length === 0 && (
<EmptyFeedback />
)}
{data!.folders.length > 0 && (
<>
<div className='flex w-full items-center justify-between rounded-lg px-4'>
<span className='font-bold'>Folders</span>
</div>
<div
id={"grid-folders"}
className='my-4 grid grid-cols-2 gap-4 tablet:grid-cols-5 desktop:grid-cols-7'
>
{data?.folders.length === 0 ? (
<div className='col-span-full mb-4 flex w-full items-center justify-center'>
<span className='text-gray-500'>No folders</span>
</div>
) : (
<>
{data?.folders?.map((folder) => (
<GridFile
data={folder as drive_v3.Schema$File}
key={folder.id}
/>
))}
</>
)}
</div>
</>
)}
{data!.folders.length > 0 && data!.files.length > 0 && (
<div className='divider-horizontal' />
)}
{data!.files.length > 0 && (
<>
<div className='flex w-full items-center justify-between rounded-lg px-4'>
<span className='font-bold'>Files</span>
</div>
<div
id={"grid-files"}
className='my-4 grid grid-cols-2 gap-4 tablet:grid-cols-5 desktop:grid-cols-7'
>
{data?.files.length === 0 ? (
<div className='col-span-full mb-4 flex w-full items-center justify-center'>
<span className='text-gray-500'>No files</span>
</div>
) : (
<>
{data?.files.map((file, idx) => (
<GridFile
data={file as drive_v3.Schema$File}
key={`File-idx${idx}`}
/>
))}
</>
)}
</div>
</>
)}
{swrData?.[0].nextPageToken && !isReachingEnd && (
<button
disabled={isLoadingMore}
onClick={() => {
setSize(size + 1).then((r) => r);
}}
className='w-full rounded-lg'
>
{isLoadingMore ? "Loading..." : "Load more file"}
</button>
)}
</>
);
}
@@ -1,71 +0,0 @@
import { FilesResponse } from "types/googleapis";
import ListFile from "components/File/List";
import { drive_v3 } from "googleapis";
import EmptyFeedback from "components/APIFeedback/Empty";
type Props = {
data: FilesResponse | undefined;
pagination: {
swrData: FilesResponse[] | undefined;
size: number;
setSize: (size: number) => Promise<FilesResponse[] | undefined>;
isLoadingMore: boolean | undefined;
isReachingEnd: boolean | undefined;
};
};
export default function ListLayout({ data, pagination }: Props) {
const { swrData, isLoadingMore, isReachingEnd, size, setSize } = pagination;
if (!data) return null;
return (
<>
{/* Head */}
<div
className={"grid grid-cols-1 gap-2 px-4 font-bold tablet:grid-cols-8"}
>
<div className={"tablet:col-span-4"}>Name</div>
<div className={"hidden tablet:col-span-2 tablet:block"}>Modified</div>
<div className={"hidden tablet:block"}>Size</div>
<div className={"hidden tablet:block"}>Action</div>
</div>
<div className={"divider-horizontal"} />
{/* Items */}
<div className={"grid grid-cols-1 gap-2 tablet:grid-cols-8"}>
{data?.folders.length === 0 && data?.files.length === 0 && (
<div className={"col-span-full"}>
<EmptyFeedback />
</div>
)}
{data.folders.map((folder) => (
<ListFile
data={folder as drive_v3.Schema$File}
key={folder.id}
/>
))}
{data!.files.map((file) => (
<ListFile
data={file as drive_v3.Schema$File}
key={file.id}
/>
))}
</div>
{swrData?.[0].nextPageToken && !isReachingEnd && (
<button
disabled={isLoadingMore}
onClick={() => {
setSize(size + 1).then((r) => r);
}}
className='w-full rounded-lg'
>
{isLoadingMore ? "Loading..." : "Load more file"}
</button>
)}
</>
);
}
-22
View File
@@ -1,22 +0,0 @@
import siteConfig from "config/site.config";
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<div className='flex w-full items-center justify-center gap-2 py-1'>
<span className='text-xs font-semibold'>
{currentYear} {siteConfig.footerText} - Powered by{" "}
<a
className={"link"}
href={"https://www.github.com/mbaharip/next-gdrive-index"}
target={"blank"}
rel={"noreferrer noopener"}
>
next-gdrive-index
</a>{" "}
</span>
</div>
);
}
-353
View File
@@ -1,353 +0,0 @@
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import siteConfig from "config/site.config";
import Link from "next/link";
import Image from "next/image";
import {
MdClose,
MdDarkMode,
MdLightMode,
MdLogout,
MdMenu,
MdSearch,
} from "react-icons/md";
import Modal from "components/utility/Modal";
import { formatBytes, formatDate } from "utils/formatHelper";
import { ErrorResponse, SearchResponse } from "types/googleapis";
import useSWR from "swr";
import { drive_v3 } from "googleapis";
import LoadingFeedback from "components/APIFeedback/Loading";
import ErrorFeedback from "components/APIFeedback/Error";
import EmptyFeedback from "components/APIFeedback/Empty";
import { BsFolderFill } from "react-icons/bs";
import { getFileIcon } from "utils/mimeTypesHelper";
import { ThemeContext, TThemeContext } from "context/themeContext";
import { createFileId } from "utils/driveHelper";
type Props = {
isUnlocked: boolean;
setIsUnlocked: (isUnlocked: boolean) => void;
};
export default function Navbar({ isUnlocked, setIsUnlocked }: Props) {
const { theme, setTheme } = useContext<TThemeContext>(ThemeContext);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isLogoutOpen, setIsLogoutOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<drive_v3.Schema$File[]>(); // [
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
const timerId = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 500);
return () => {
clearTimeout(timerId);
};
}, [searchQuery]);
const { data, error, isLoading } = useSWR<SearchResponse, ErrorResponse>(
`/api/search?q=${debouncedSearchQuery}`,
);
useEffect(() => {
if (!isLoading && data) {
setSearchResults(data.files as drive_v3.Schema$File[]);
}
}, [data, isLoading]);
const handleCloseSearch = useCallback(() => {
setIsSearching(false);
const timeout = setTimeout(() => {
setSearchQuery("");
setSearchResults([]);
}, 150);
return () => {
clearTimeout(timeout);
};
}, []);
const handleCloseLogout = useCallback(() => {
setIsLogoutOpen(false);
}, []);
return (
<>
{/* Navbar */}
<div className='navbar sticky top-0 z-[1000] flex w-full items-center justify-start gap-2 bg-inherit bg-opacity-90 px-2 py-2 tablet:gap-4'>
{/* Web Logo */}
<Link
href='/'
className='flex items-center gap-2'
>
<Image
src={siteConfig.siteIcon}
alt={siteConfig.siteName}
width={32}
height={32}
className='aspect-square h-6 w-6'
/>
<span className='hidden text-lg font-bold tablet:block'>
{siteConfig.siteName}
</span>
</Link>
{/* Gap */}
<div className='flex-grow'></div>
{/* Search */}
{(siteConfig.privateIndex && isUnlocked) || !siteConfig.privateIndex ? (
<div
id={"search-modal-toggle"}
className='interactive flex items-center gap-2'
role={"button"}
title={"Search"}
>
<div
className='relative flex aspect-square h-6 w-6 cursor-pointer items-center justify-center text-inherit'
onClick={() => {
setIsSearching(true);
searchInputRef.current?.focus();
}}
>
<MdSearch className='h-full w-full' />
</div>
</div>
) : (
<></>
)}
{/* Dark mode */}
<div
id={"btn-theme-toggle"}
className='interactive relative flex aspect-square h-6 w-6 cursor-pointer items-center justify-center text-inherit'
onClick={() => {
setTheme(theme === "dark" ? "light" : "dark");
}}
role={"button"}
title={"Toggle theme"}
>
<MdDarkMode
className={`absolute left-0 h-full w-full transition-all duration-75 ${
theme === "dark"
? "pointer-events-none rotate-90 opacity-0"
: "pointer-events-auto rotate-0 opacity-100"
}`}
/>
<MdLightMode
className={`absolute left-0 h-full w-full transition-all duration-75 ${
theme === "dark"
? "pointer-events-auto rotate-0 opacity-100"
: "pointer-events-none rotate-90 opacity-0"
}`}
/>
</div>
{/* Menu Tablet+ */}
<div className='hidden gap-4 tablet:flex'>
{siteConfig.navbarLinks.map((link, linkIdx) => (
<Link
key={`navbar-link-${linkIdx}`}
href={link.href}
rel={link.newTab ? "noopener noreferrer" : ""}
target={link.newTab ? "_blank" : "_self"}
className='flex items-center gap-2'
>
{link.icon && <link.icon />}
{link.name}
</Link>
))}
</div>
{/* Remove password / logout */}
{siteConfig.privateIndex && isUnlocked && (
<div
id={"btn-logout"}
className={"interactive flex items-center gap-2"}
role={"button"}
title={"Logout"}
onClick={() => setIsLogoutOpen(true)}
>
<MdLogout className={"h-full w-full"} />
<span className={"max-tablet:hidden"}>Logout</span>
</div>
)}
{/* Menu mobile */}
<div
className='interactive relative flex aspect-square h-6 w-6 items-center justify-center text-inherit tablet:hidden'
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
<MdMenu
className={`absolute left-0 h-full w-full ${
isMenuOpen
? "pointer-events-none rotate-90 opacity-0"
: "pointer-events-auto rotate-0 opacity-100"
}`}
/>
<MdClose
className={`absolute left-0 h-full w-full ${
isMenuOpen
? "pointer-events-auto rotate-0 opacity-100"
: "pointer-events-none rotate-90 opacity-0"
}`}
/>
</div>
</div>
{/* MobileMenu */}
<div
className={`flex h-full w-full flex-col tablet:hidden ${
isMenuOpen ? "right-0" : "-right-full"
} navbar fixed z-[999] bg-zinc-100 py-16 transition-all dark:bg-zinc-900`}
>
{siteConfig.navbarLinks.map((link, linkIdx) => (
<Link
key={`mobile-link-${linkIdx}`}
href={link.href}
rel={link.newTab ? "noopener noreferrer" : ""}
target={link.newTab ? "_blank" : "_self"}
className='flex w-full items-center justify-center gap-2 px-4 py-2 text-center text-lg'
>
{link.icon && <link.icon />}
{link.name}
</Link>
))}
</div>
{/* Search Modal */}
<Modal
title={
<span className='flex items-center gap-2 text-lg'>Search files</span>
}
isOpen={isSearching}
isCentered={false}
onClose={handleCloseSearch}
>
{/* Input */}
<div className='relative flex w-full flex-grow items-center'>
<MdSearch className='absolute right-4' />
<input
ref={searchInputRef}
type='text'
className='w-full flex-grow py-2 pr-8'
placeholder='Type folder or file name...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className='divider-horizontal' />
{/* Result */}
<div className='flex max-h-96 w-full flex-col overflow-auto'>
{isLoading && <LoadingFeedback message={"Searching..."} />}
{!isLoading && error && (
<ErrorFeedback message={error?.errors.message} />
)}
{!isLoading && searchResults?.length === 0 && (
<EmptyFeedback
message={
debouncedSearchQuery
? "No results found"
: "Type something to search"
}
/>
)}
{!isLoading && searchResults && (
<>
{searchResults.map((item) => {
const isFolder =
item.mimeType === "application/vnd.google-apps.folder";
const Icon = isFolder
? BsFolderFill
: getFileIcon(
item.fileExtension as string,
item.mimeType as string,
);
return (
<Link
href={
isFolder
? `/folder/${createFileId(item, true)}`
: `/file/${createFileId(item, true)}`
}
key={item.id}
className='rounded-lg py-1 hover:bg-zinc-300 dark:hover:bg-zinc-600 tablet:px-2 tablet:py-2'
onClick={handleCloseSearch}
>
<div className='flex items-center gap-4'>
<div className='flex aspect-square h-10 w-10 flex-shrink-0 flex-grow-0 items-center justify-center overflow-hidden rounded-lg tablet:h-12 tablet:w-12'>
{item.thumbnailLink ? (
<img
src={item.thumbnailLink}
alt={item.name as string}
className='h-full w-full object-cover'
/>
) : (
<Icon className='h-full w-full' />
)}
</div>
<div className='line-clamp-1 flex w-full flex-shrink flex-grow flex-col'>
<div className={"flex items-center gap-2"}>
<Icon className='h-4 w-4' />
<span className='text-sm font-medium'>
{item.name}
</span>
</div>
<span className='text-xs text-gray-500'>
{formatDate(new Date(item.modifiedTime as string))}
{!isFolder
? `${formatBytes(item.size as string)}`
: ""}
</span>
</div>
</div>
</Link>
);
})}
</>
)}
</div>
</Modal>
{/* Logout Modal */}
<Modal
title={<span className={"text-lg"}>Logout</span>}
isOpen={isLogoutOpen}
isCentered={true}
onClose={handleCloseLogout}
>
<div className={"flex flex-col gap-4"}>
<span>Are you sure you want to logout?</span>
<span>
It will remove all your data from this device and you will need to
login again.
</span>
<div className={"flex gap-4"}>
<button
onClick={handleCloseLogout}
className={"secondary flex-grow"}
>
Cancel
</button>
<button
onClick={() => {
if (typeof window !== "undefined") {
window.localStorage.removeItem("sitePassword");
setIsLogoutOpen(false);
setIsUnlocked(false);
}
}}
className={"primary flex-grow"}
>
Logout
</button>
</div>
</div>
</Modal>
</>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { useState } from "react";
import useLocalStorage from "hooks/useLocalStorage";
import { IoMdEye, IoMdEyeOff } from "react-icons/io";
import { MdLock } from "react-icons/md";
import { hashToken } from "utils/hashHelper";
type Props = {
folderId: string;
inputCallback: (data: { [p: string]: string }) => void;
};
export default function Password({ folderId, inputCallback }: Props) {
const [password, setPassword] = useState<string>("");
const [showPassword, setShowPassword] = useState<boolean>(false);
const [passwordStorage] = useLocalStorage<{
[key: string]: string;
}>("passwordStorage", {});
const handleSubmit = () => {
inputCallback({
...passwordStorage,
[folderId]: hashToken(password),
});
// setPasswordStorage({
// ...passwordStorage,
// [folderId]: hashToken(password),
// });
//
// callback();
};
return (
<div className={"card"}>
<div className='flex w-full items-center justify-between rounded-lg px-4'>
<span className='font-bold'>Protected with password</span>
</div>
<div className={"divider-horizontal"} />
<MdLock className={"mx-auto my-4 h-24 w-24 tablet:h-32 tablet:w-32"} />
<p className={"mx-auto max-w-screen-md px-4 text-center"}>
The folder or file you are trying to access is protected with a
password.
<br />
Please enter the password to continue.
</p>
<div
className={
"mx-auto flex w-full max-w-screen-md flex-col gap-2 px-4 py-4"
}
>
<div className={"flex items-center gap-2 max-tablet:flex-col"}>
<div className={"relative flex w-full items-center"}>
<input
type={showPassword ? "text" : "password"}
className={"w-full pr-4"}
value={password}
onChange={(e) => {
setPassword(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSubmit();
}
}}
placeholder={"Enter your password here..."}
/>
<div
className={"relative right-8 flex cursor-pointer items-center"}
onClick={() => {
setShowPassword(!showPassword);
}}
>
<IoMdEye
className={`absolute h-6 w-6 ${
showPassword
? "pointer-events-none scale-y-0 opacity-0"
: "pointer-events-auto scale-y-100 opacity-100"
} transition`}
/>
<IoMdEyeOff
className={`absolute h-6 w-6 ${
!showPassword
? "pointer-events-none scale-y-0 opacity-0"
: "pointer-events-auto scale-y-100 opacity-100"
} transition`}
/>
</div>
</div>
<button
className={"primary w-full whitespace-nowrap tablet:w-fit"}
onClick={handleSubmit}
>
{/*{isLoading ? (*/}
{/* <ReactLoading*/}
{/* type='spin'*/}
{/* width={16}*/}
{/* height={16}*/}
{/* className={"loading"}*/}
{/* />*/}
{/*) : (*/}
Submit
{/*)}*/}
</button>
</div>
</div>
</div>
);
}
-38
View File
@@ -1,38 +0,0 @@
import LoadingFeedback from "components/APIFeedback/Loading";
import MarkdownRender from "components/utility/MarkdownRender";
import { useEffect, useState } from "react";
type Props = {
isReadmeExist: boolean;
isReadmeLoading: boolean;
readmeData: string | undefined;
};
export default function Readme({
isReadmeExist,
isReadmeLoading,
readmeData,
}: Props) {
const [isMounted, setIsMounted] = useState<boolean>(false);
useEffect(() => {
if (!isReadmeLoading && isReadmeExist && readmeData) {
setIsMounted(true);
}
}, [isReadmeExist, isReadmeLoading, readmeData]);
return (
<>
{isReadmeLoading && (
<div className={"card"}>
<LoadingFeedback />
</div>
)}
{!isReadmeLoading && readmeData && isMounted && (
<div className={"card"}>
<MarkdownRender content={readmeData} />
</div>
)}
</>
);
}
-43
View File
@@ -1,43 +0,0 @@
import React, { useEffect, useState } from "react";
import LoadingFeedback from "components/APIFeedback/Loading";
import ErrorFeedback from "components/APIFeedback/Error";
type Props = {
data: unknown;
error: unknown;
isLoading: boolean;
useCard?: boolean;
children: React.ReactNode;
};
export default function SWRLayout({
data,
error,
isLoading,
useCard = true,
children,
}: Props) {
const [isMounted, setIsMounted] = useState<boolean>(false);
useEffect(() => {
if (!isLoading && !error && data) {
setIsMounted(true);
}
}, [data, error, isLoading]);
return (
<>
{isLoading && (
<div className={useCard ? "card" : ""}>
<LoadingFeedback />
</div>
)}
{!isLoading && error && (
<div className={useCard ? "card" : ""}>
<ErrorFeedback />
</div>
)}
{!isLoading && isMounted && <>{children}</>}
</>
);
}
-74
View File
@@ -1,74 +0,0 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkSlug from "remark-slug";
import remarkToc from "remark-toc";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypePrism from "rehype-prism-plus";
import { SpecialComponents } from "react-markdown/lib/ast-to-react";
import { NormalComponents } from "react-markdown/lib/complex-types";
import config from "config/site.config";
type Props = {
content: string;
};
const customComponents: Partial<
Omit<NormalComponents, keyof SpecialComponents> & SpecialComponents
> = {
img({ alt, src, ...props }: any) {
return (
<div className='my-4 flex flex-col gap-0'>
<img
src={src}
alt={alt}
className='mx-auto w-full max-w-screen-tablet rounded-lg drop-shadow'
{...props}
/>
{config.readme.renderImageAlt && (
<span className='my-0.5 text-center text-sm italic tablet:text-base'>
{alt} |{" "}
<a
className={"link"}
href={src}
target='_blank'
rel='noopener noreferrer'
>
Open in new tab
</a>
</span>
)}
</div>
);
},
a({ href, ...props }: any) {
return (
<a
className={"link"}
href={href}
target='_blank'
rel='noopener noreferrer'
{...props}
/>
);
},
};
export default function MarkdownRender({ content }: Props) {
return (
<div className='markdown'>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
rehypePlugins={[
rehypeKatex,
rehypeRaw,
[rehypePrism, { ignoreMissing: true }],
]}
components={customComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
-52
View File
@@ -1,52 +0,0 @@
import { ReactNode } from "react";
import { MdClose } from "react-icons/md";
type Props = {
title: string | ReactNode;
isOpen: boolean;
isCentered: boolean;
onClose: () => void;
children: ReactNode;
};
export default function Modal({
title,
isOpen,
isCentered = true,
onClose,
children,
}: Props) {
return (
<div
className={`fixed left-0 top-0 z-[1001] flex h-full w-full justify-center bg-zinc-500/75 text-inherit transition-all duration-300 dark:bg-zinc-900/75 ${
isOpen
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0"
} ${isCentered ? "items-center" : "items-start py-24"}`}
onClick={() => {
onClose();
}}
>
<div
className={`relative flex w-full min-w-[320px] max-w-sm flex-col gap-4 rounded-lg bg-zinc-100 p-4 drop-shadow-lg dark:bg-zinc-700 tablet:max-w-screen-tablet ${
isOpen ? "top-0" : "-top-12"
} transition-all duration-300`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div className='flex items-center justify-between'>
<span>{title}</span>
<span
className='interactive cursor-pointer'
onClick={onClose}
>
<MdClose />
</span>
</div>
<div className='flex flex-col items-center gap-2'>{children}</div>
</div>
</div>
);
}
-80
View File
@@ -1,80 +0,0 @@
import { MdArrowDropDown, MdGridView, MdList } from "react-icons/md";
import { useContext, useEffect, useRef, useState } from "react";
import { LayoutContext, TLayoutContext } from "context/layoutContext";
export default 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>
);
}
-47
View File
@@ -1,47 +0,0 @@
module.exports = {
// Client ID are safe to expose
client_id:
"126409166174-l2unckghm8d5m1gp3deu0uaps5son64f.apps.googleusercontent.com",
// Client secret and refresh token are encrypted
// Using encryption key
// https://drive.mbaharip.com/gudora-setup
client_secret:
"54f0505d11a8fe04d22ec642cdde4728:a6f911c70b0305a51160a2133a9bd2ada3c120567857002311f17bf29cc69e07d7f1a9ca20fd119d684191aee6e3866a",
refresh_token:
"209b4d2ff44bf877ce0636ddc81cdc9a:4a40e172ba5d6c1ee8daf50389841bd2c9d1153163196da0dab63c07b6f305bedafaa209a9e6adbb8d2377c74ba98cf818b6e73b8f57098686ed0b89bec93b0186047ccaab8804310a2ab4e46069d1cac8c322f0f1e206808ac62c18639610be07559eebad904905ba185cae8ba9fbe9",
files: {
// How many files to show per page
itemsPerPage: 25,
// Max number of files to show in search result
searchResult: 5,
// Starting point of the drive
// Use 'root' to use My Drive as starting point
// Or use folder id to use a specific folder as starting point
// TODO: Change when final
// rootFolder: "root",
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j", // Test folder
// Limit breadcrumb to specific depth
// 0 = Unlimited
// 1 = Only show current folder
// 2 = Show current folder and its parent
// 3 = Show current folder and its parent and grandparent
// and so on.
// Warning: More parent = More API calls = Slower loading time
// There are no workaround for this yet, since Google Drive API v3 return only 1 parent
// Default: 2
breadcrumbDepth: 2,
},
// Cache control header
// https://web.dev/uses-long-cache-ttl/
// Default: 1 minute
cache: "s-maxage=60, stale-while-revalidate",
// Max response body size
// 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.
maxResponseSize: 4 * 1024 * 1024,
};
+203
View File
@@ -0,0 +1,203 @@
import colors from "tailwindcss/colors";
const config: gIndexConfig = {
version: "1.0.0",
/**
* Base path of the app, used for generating links
* If you're not using Vercel, you need to change this to your domain name
*
* 2023/09/10: This is not used anymore, edit via env variable instead
*
* @default process.env.NEXT_PUBLIC_VERCEL_URL
*/
basePath:
process.env.NODE_ENV === "development" ? "http://localhost:3000" : `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`,
/**
* Hashed key for fetching protected files / folders from the server
* This key will bypass the file / folder password
*
* 2023/09/10: This is not used anymore
*/
masterKey: "masterkey",
/**
* How long the cache will be stored in the browser
* Used for all pages and api routes
* Default is 5 minutes (300/60 = 5min)
*
* @default "max-age=300, s-maxage=300, stale-while-revalidate, public"
*/
cacheControl: "max-age=300, s-maxage=300, stale-while-revalidate, public",
apiConfig: {
/**
* Starting point of the drive.
* Will be used for '/' route.
*
* Since service account can't access 'root' folder
* You need to create a new folder and share it with the service account
* Then, copy the folder id and paste it here
*/
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j",
defaultQuery: ["trashed = false", "(not mimeType contains 'google-apps' or mimeType contains 'folder')"],
defaultField:
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed",
defaultOrder: "folder, name asc, modifiedTime desc",
itemsPerPage: 50,
searchResult: 5,
/**
* Special file name that will be used for certain purposes
* These files will be ignored when searching for files
* and will be hidden from the files list by default
*/
specialFile: {
password: ".password",
readme: ".readme.md",
/**
* Banner will be used for opengraph image for folder
* By default, all folder will use default og image
*/
banner: ".banner",
},
hiddenFiles: [".password", ".readme.md", ".banner"],
/**
* Allow user to download protected file without password.
* If this set to false, download link will have temporary token attached to it
* If this set to true, user can download the file without password as long as they have the link
*
* Default: false
*/
allowDownloadProtectedFile: false,
/**
* Duration in hours.
*
* Default: 6 hours
*/
temporaryTokenDuration: 6,
/**
* Maximum file size that can be downloaded via api routes
* If it's larger than this, it will be redirected to the file url
*
* If you're using Vercel, they have a limit of ~4 - ~4.5MB response size
* ref: https://vercel.com/docs/platform/limits#serverless-function-payload-size-limit
* If you're using another platform, you can match the limit with your platform
* Or you can set this to 0 to disable the limit
*
* Default: 4MB
*/
maxFileSize: 4 * 1024 * 1024,
},
siteConfig: {
/**
* This value will be used for metadata
*/
siteName: "next-gdrive-index",
siteDescription: "A simple file browser for Google Drive",
siteIcon: "/flaticon.svg",
favIcon: "/favicon.svg",
twitterHandle: "@mbaharip_",
/**
* Tailwind color name.
* Ref: https://tailwindcss.com/docs/customizing-colors
*/
defaultAccentColor: "teal",
/**
* Site wide password protection
* If this is set, all files and folders will be protected by this password
*
* The site password are set from Environment Variable (NEXT_GDRIVE_INDEX_PASSWORD)
* It's because I don't want to store sensitive data in the code
*/
privateIndex: false,
/**
* Example item:
* {
* icon: string, // icon name from iconify (https://icon-sets.iconify.design/)
* name: string,
* href: string,
* external?: boolean
* }
*/
navbarItems: [
{
icon: "ion:document-text",
name: "Documentation",
href: "https://github.com/mbahArip/next-gdrive-index/wiki",
external: true,
},
{
icon: "ion:logo-github",
name: "Github",
href: "https://www.github.com/mbaharip",
external: true,
},
{
icon: "ion:logo-paypal",
name: "Buy me a coffee",
href: "https://www.paypal.me/mbaharip",
external: true,
},
{
icon: "ion:mail",
name: "Contact",
href: "mailto:support@mbaharip.com",
},
],
},
};
export default config;
interface gIndexConfig {
version: string;
basePath: string;
masterKey: string;
cacheControl: string;
apiConfig: {
rootFolder: string;
defaultQuery: string[];
defaultField: string;
defaultOrder: string;
itemsPerPage: number;
searchResult: number;
specialFile: {
password: string;
readme: string;
banner: string;
};
hiddenFiles: string[];
allowDownloadProtectedFile: boolean;
temporaryTokenDuration: number;
maxFileSize: number;
};
siteConfig: {
siteName: string;
siteDescription: string;
siteIcon: string;
favIcon: string;
twitterHandle: string;
defaultAccentColor: keyof typeof colors;
privateIndex: boolean;
navbarItems: {
icon: string;
name: string;
href: string;
external?: boolean;
}[];
};
}
-94
View File
@@ -1,94 +0,0 @@
import { BsDiscord, BsGithub, BsPaypal } from "react-icons/bs";
const config = {
/* Site MetaData */
// The name of the site
siteName: "mbahArip Stash",
// The description of the site
// Used in meta tags and document head
siteDescription: "Personal Stash of mbahArip, a place to store my files.",
// Fav icon of the site
// Also used as the logo of the site on the navbar
siteIcon: "/favicon.svg",
// Add site page password protection
// If this is set, the site will be protected with the password
// NOTE: The files are still accessible via direct link, but the site is not.
privateIndex: false,
indexPassword:
"640e3e38dd31aec254f214ba38541a82ddb615e73ce9c6129f33ef549b154ab9",
// Navbar menu links
navbarLinks: [
{
icon: BsDiscord,
name: "Discord",
href: "https://discord.gg/xxxxxx",
newTab: true,
},
{
icon: BsGithub,
name: "GitHub",
href: "https://www.github.com/mbaharip",
newTab: true,
},
{
icon: BsPaypal,
name: "Donate",
href: "https://www.paypal.me/mbaharip",
newTab: true,
},
{
name: "Main Website",
href: "https://www.mbaharip.com",
newTab: true,
},
],
// Footer text
// It will be rendered as
// {year} {footerText} - Powered by next-gdrive-index ❤️
footerText: "mbahArip Stash",
files: {
// Show file icon from Google Drive before the file name
showFileNameIcon: true,
// Use Google Drive file icon instead of our file icon on list view
listUseFileIcon: true,
// Show file size on mobile list view
listMobileShowFileSize: false,
// Refer type to this file:
// /src/utils/mimeTypesHelper.ts:40
allowThumbnailFileType: ["image", "video", "pdf"],
},
/* Experimental: Render Banner */
banner: {
render: true,
},
breadcrumb: {
limiter: "/",
},
/* Config for readme file render */
readme: {
// If this set
// rootFolder: "root", // Rootto true, it will render the readme file on the page.
render: true,
// Position of the readme file on the page.
// It can be "start" or "end".
position: "end",
// Render image description based on the image alt text.
renderImageAlt: true,
},
/* Config for file preview */
preview: {
// Timeout duration for preview request
// If the request takes longer than this, it will be canceled.
// And the thumbnail or the file icon will be shown instead.
// The value is in milliseconds.
timeout: 30000,
// The URL of the preview service
// Available services:
// google / mozilla
// NOTE: the Google service sometimes doesn't work.
pdfProvider: "mozilla",
},
};
export default config;
+5 -1
View File
@@ -1,4 +1,8 @@
import React, { createContext, useState, useEffect } from "react";
import React, {
createContext,
useEffect,
useState,
} from "react";
export type TLayout = "grid" | "list";
+7 -1
View File
@@ -1,4 +1,10 @@
import React, { createContext, useState, useEffect } from "react";
"use client";
import React, {
createContext,
useEffect,
useState,
} from "react";
export type TTheme = "dark" | "light";
+3 -1
View File
@@ -3,7 +3,9 @@ import { toast } from "react-toastify";
export default function useCopyText() {
return (text: string) => {
if (!navigator.clipboard) {
toast.error("Your browser does not support copying to clipboard.");
toast.error(
"Your browser does not support copying to clipboard.",
);
return;
}
if (!text) {
+26 -7
View File
@@ -2,7 +2,10 @@ import { useEffect, useState } from "react";
type SetValue<T> = (value: T | ((val: T) => T)) => void;
export default function useLocalStorage<T>(key: string, initialValue: T) {
export default function useLocalStorage<T>(
key: string,
initialValue: T,
) {
// First check if there is a value in localStorage
function readLocalStorage() {
if (typeof window === "undefined") return initialValue;
@@ -11,20 +14,30 @@ export default function useLocalStorage<T>(key: string, initialValue: T) {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
console.warn(
`Error reading localStorage key “${key}”:`,
error,
);
return initialValue;
}
}
const [storedValue, setStoredValue] = useState<T>(readLocalStorage);
const [storedValue, setStoredValue] = useState<T>(
readLocalStorage,
);
// Create dispatch function to set or remove localStorage
const setValue: SetValue<T> = (value) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
value instanceof Function
? value(storedValue)
: value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
window.localStorage.setItem(
key,
JSON.stringify(valueToStore),
);
} catch (error) {
console.error("Error setting localStorage:", error);
}
@@ -41,7 +54,10 @@ export default function useLocalStorage<T>(key: string, initialValue: T) {
const newValue = JSON.parse(event.newValue);
setStoredValue(newValue);
} catch (error) {
console.error("Error setting localStorage:", error);
console.error(
"Error setting localStorage:",
error,
);
}
}
};
@@ -49,7 +65,10 @@ export default function useLocalStorage<T>(key: string, initialValue: T) {
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener(
"storage",
handleStorageChange,
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
+6
View File
@@ -0,0 +1,6 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export function middleware(request: NextRequest) {
return NextResponse.next();
}
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link";
import Button from "components/Button";
import LoaderLayout from "components/Layout/Loader";
export default function NotFoundPage() {
return (
<LoaderLayout
seo={{
title: "Not Found",
}}
>
<div className='w-full flex h-full flex-col gap-4 relative items-center justify-center'>
<figure>
<img
src='/images/404.png'
alt='Page not found'
className='w-64 h-64 tablet:w-96 tablet:h-96 object-contain'
/>
</figure>
<div className='w-full flex items-center justify-center flex-col my-8'>
<span className='text-lg font-medium'>
The page you are trying to access is not found.
</span>
<span>
Please check the URL or go back to the homepage.
</span>
<div className='flex items-center justify-between flex-col tablet:flex-row gap-2 tablet:gap-4 my-4 tablet:my-12'>
<Link href='/'>
<Button
variant='accent'
rounded='large'
className='w-full tablet:w-auto'
>
Go back to homepage
</Button>
</Link>
</div>
</div>
</div>
</LoaderLayout>
);
}
+254
View File
@@ -0,0 +1,254 @@
import axios from "axios";
import gIndexConfig from "config";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import ExplorerLayout from "components/Layout/Explorer";
import LoaderLayout from "components/Layout/Loader";
import PasswordLayout from "components/Layout/Password";
import PreviewLayout from "components/Layout/Preview";
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
import gdrive from "utils/gdriveInstance";
import { addNewPassword, checkPathPassword } from "utils/passwordHelper";
import { IGDriveFiles } from "types/api/files";
import { APIGetFileResponse, APIGetPasswordResponse, APIGetReadmeResponse } from "types/api/response";
import { Constant } from "types/constant";
interface FilePathPageProps {
mappedEncryptedPath: Record<"name" | "id" | "mimeType", string>[];
}
interface StateDataProps {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
}
export default function FilePathPage(props: FilePathPageProps) {
const router = useRouter();
const [data, setData] = useState<StateDataProps>({
file: null,
files: [],
folders: [],
pageToken: null,
});
const [readmeFile, setReadmeFile] = useState<string | null>(null);
const [isProtected, setIsProtected] = useState<boolean>(false);
const [nearestProtected, setNearestProtected] = useState<string>("");
const [isFileProtected, setIsFileProtected] = useState<boolean>(false);
const [isFile, setIsFile] = useState<boolean>(false);
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
useEffect(() => {
setIsLoadingData(true);
const lastPathMimeType = props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].mimeType;
let isLastPathFile = false;
if (lastPathMimeType === "application/vnd.google-apps.folder") {
isLastPathFile = false;
setIsFile(false);
} else {
isLastPathFile = true;
setIsFile(true);
}
const _getPassword = axios.get<APIGetPasswordResponse>("/api/getPassword", {
params: {
path: encryptData(JSON.stringify(props.mappedEncryptedPath)),
},
});
const _getData = axios.get<APIGetFileResponse>("/api/getData", {
params: {
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
isFile: isLastPathFile ? "1" : undefined,
},
});
const _getReadme = axios.get<APIGetReadmeResponse>("/api/getReadme", {
params: {
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
},
});
Promise.all([_getPassword, _getData, _getReadme])
.then(async ([passwordData, fileData, readmeData]) => {
// Check password
if (passwordData.data.data.length) {
let isPathProtected = true;
const savedPasswordCookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
const savedPasswordValue = savedPasswordCookie?.split("=")[1] ?? undefined;
if (savedPasswordValue) {
// Check only nearest path
const nearestPassword = passwordData.data.data[passwordData.data.data.length - 1];
setNearestProtected(nearestPassword.relativePath);
const isPasswordValid = checkPathPassword(
nearestPassword.relativePath,
savedPasswordValue,
decryptData(nearestPassword.password),
);
if (isPasswordValid) {
isPathProtected = false;
}
}
setIsProtected(isPathProtected);
setIsFileProtected(true);
}
// Assign files
setData(fileData.data.data);
// Fetch readme
setReadmeFile(readmeData.data.data);
})
.catch((err) => {
console.error(err);
throw new Error(err);
})
.finally(() => {
setIsLoadingData(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.mappedEncryptedPath]);
return (
<LoaderLayout
seo={{
title: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].name,
}}
>
{isLoadingData ? (
<div className='w-full h-full flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Fetching {isFile ? "file info" : "folder contents"}...</span>
</div>
</div>
) : (
<>
{isProtected ? (
<PasswordLayout
path={nearestProtected}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(nearestProtected), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<>
{isFile ? (
<PreviewLayout
key={router.asPath}
data={data}
isProtected={false}
isFileProtected={isFileProtected}
encryptedId={props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id}
/>
) : (
<ExplorerLayout
key={router.asPath}
data={data}
readmeFile={readmeFile}
isProtected={false}
isFileProtected={isFileProtected}
encryptedId={props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id}
/>
)}
</>
)}
</>
)}
</LoaderLayout>
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { path } = context.params as { path: string[] };
const fetchRootId = gdrive.files.get({
fileId: gIndexConfig.apiConfig.rootFolder,
fields: "id",
});
const fetchPathId = path.map(async (path) => {
const query = ["trashed = false", `name = '${path}'`];
const fetchFolderContents = await gdrive.files.list({
q: `${query.join(" and ")}`,
fields: "files(id, name, mimeType, parents)",
});
if (!fetchFolderContents.data.files?.length) {
return {
path,
data: [],
};
}
return {
path,
data: fetchFolderContents.data.files.map((file) => ({
id: file.id,
parents: file.parents?.[0],
mimeType: file.mimeType,
})),
};
});
const [rootId, pathId] = await Promise.all([fetchRootId, Promise.all(fetchPathId)]);
if (pathId.some((path) => !path.data.length)) {
return {
notFound: true,
};
}
const selectedPath: string[] = [];
const mappedPath: Record<"name" | "id" | "mimeType", string>[] = [];
const rejectedPath: string[] = [];
// Check path validity
pathId.forEach((path, index) => {
let checkPath =
index === 0
? path.data.find((file) => file.parents === rootId.data.id)
: path.data.find((file) => file.parents === selectedPath[index - 1]);
if (!checkPath) {
rejectedPath.push(path.path);
return;
}
selectedPath.push(checkPath.id as string);
mappedPath.push({
name: path.path,
id: checkPath.id as string,
mimeType: checkPath.mimeType as string,
});
});
if (rejectedPath.length) {
return {
notFound: true,
};
}
return {
props: {
mappedEncryptedPath: mappedPath.map((path) => ({
name: path.name,
id: encryptData(path.id),
mimeType: path.mimeType,
})),
},
};
}
+309 -216
View File
@@ -1,246 +1,339 @@
import type { AppProps } from "next/app";
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 Navbar from "components/layout/Navbar";
import Footer from "components/layout/Footer";
import NextNProgress from "nextjs-progressbar";
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";
import "styles/markdown.css";
import "styles/highlight.css";
import "react-toastify/dist/ReactToastify.min.css";
import config from "config/site.config";
import { Exo_2, JetBrains_Mono, Source_Sans_Pro } from "next/font/google";
import { toast, ToastContainer } from "react-toastify";
import { DefaultSeo, DefaultSeoProps } from "next-seo";
import { SWRConfig } from "swr";
import { IconContext } from "react-icons";
import { LayoutContext } from "context/layoutContext";
import { ThemeContext } from "context/themeContext";
import fetcher from "utils/swrFetch";
import siteConfig from "config/site.config";
import { hashToken, verifyHash } from "utils/hashHelper";
const exo2 = Exo_2({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
const kanit = Kanit({
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-exo2",
weight: ["300", "400", "500", "600", "700", "800", "900"],
subsets: ["latin-ext", "latin"],
preload: true,
});
const sourceSansPro = Source_Sans_Pro({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
export const poppins = Poppins({
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-source-sans-pro",
weight: ["300", "400", "500", "600", "700", "800", "900"],
subsets: ["latin-ext", "latin"],
preload: true,
});
const jetBrainsMono = JetBrains_Mono({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
export const jetbrainsMono = JetBrains_Mono({
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-jetbrains-mono",
weight: ["300", "400", "500", "600", "700", "800"],
subsets: ["latin-ext", "latin"],
style: ["normal", "italic"],
preload: true,
});
export default function App({ Component, pageProps }: AppProps) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const [layoutStyle, setLayoutStyle] = useState<"grid" | "list">("grid");
const router = useRouter();
const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
const [passwordInput, setPasswordInput] = useState<string>("");
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(() => {
if (typeof window !== "undefined") {
const theme = localStorage.getItem("theme");
const layout = localStorage.getItem("layout");
const adminPassword = localStorage.getItem("sitePassword");
if (siteConfig.privateIndex) {
if (adminPassword === siteConfig.indexPassword) {
setIsUnlocked(true);
} else {
setIsUnlocked(false);
}
}
if (layout) {
setLayoutStyle(layout as "grid" | "list");
const handleScroll = () => {
if (window.scrollY > 150) {
setShowToTopButton(true);
} else {
setLayoutStyle("grid");
setShowToTopButton(false);
}
if (theme) {
setTheme(theme as "light" | "dark");
} else {
setTheme("light");
}
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => {
if (theme === "light") {
document.body.classList.remove("dark");
} else {
document.body.classList.add("dark");
}
}, [theme]);
const SEOConfig: DefaultSeoProps = {
titleTemplate: `%s | ${config.siteName}`,
defaultTitle: config.siteName,
description: config.siteDescription,
dangerouslySetAllPagesToNoFollow: true,
dangerouslySetAllPagesToNoIndex: true,
openGraph: {
type: "website",
title: config.siteName,
description: config.siteDescription,
url: process.env.NEXT_PUBLIC_DOMAIN,
images: [
{
url: `${process.env.NEXT_PUBLIC_DOMAIN}/api/og`,
width: 1200,
height: 630,
alt: config.siteName,
},
],
siteName: config.siteName,
},
twitter: {
handle: "@mbaharip_",
site: "@mbaharip_",
cardType: "summary_large_image",
},
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 (
<main
className={`${exo2.variable} ${sourceSansPro.variable} ${jetBrainsMono.variable} font-body`}
<div
id='next-gdrive-index'
className={`${kanit.className} flex flex-col tablet:flex-row w-full`}
>
<SWRConfig
value={{
fetcher: fetcher,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenOffline: false,
errorRetryCount: 3,
}}
>
<DefaultSeo {...SEOConfig} />
<ThemeContext.Provider
value={{
theme,
setTheme: (theme) => {
if (typeof window !== "undefined") {
localStorage.setItem("theme", theme);
}
setTheme(theme);
<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,
},
}}
>
<IconContext.Provider
value={{
size: "18px",
className: "react-icons",
}}
],
}}
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)}
>
<ToastContainer
position='bottom-center'
autoClose={2500}
limit={3}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss={false}
draggable
pauseOnHover={false}
theme={theme}
/>
<Navbar
isUnlocked={isUnlocked}
setIsUnlocked={setIsUnlocked}
/>
<NextNProgress options={{ showSpinner: false }} />
<LayoutContext.Provider
value={{
layout: layoutStyle,
setLayout: (layout) => {
if (typeof window !== "undefined") {
localStorage.setItem("layout", layout);
}
setLayoutStyle(layout);
},
}}
<Tooltip
content={"Home"}
placement={"right"}
>
<div className='h-full w-full flex-grow items-center justify-center bg-zinc-200 px-4 py-4 dark:bg-zinc-800'>
{siteConfig.privateIndex && !isUnlocked ? (
<div
className={
"card fillCard mx-auto flex max-w-screen-xl flex-col"
<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();
}
>
<div className='flex w-full items-center justify-between rounded-lg'>
<span className='font-bold'>Preview</span>
</div>
<div className={"divider-horizontal"} />
<div
className={
"flex h-full flex-grow flex-col items-center justify-center"
}
>
<p className={"my-2 text-center"}>
You have reached a private site that requires authorized
access. <br />
If you have a valid password, please enter it below to
proceed. Otherwise, please leave this site immediately.
Thank you for your cooperation.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
const verify = verifyHash(
passwordInput,
siteConfig.indexPassword,
);
if (verify) {
toast("Welcome back!");
setIsUnlocked(true);
localStorage.setItem(
"sitePassword",
hashToken(passwordInput),
);
setPasswordInput("");
} else {
toast.error("Wrong password");
setPasswordInput("");
}
}}
className={"mx-auto my-2 flex max-w-md gap-2"}
>
<input
className={`w-full`}
type={"password"}
placeholder={"Site password"}
value={passwordInput}
onChange={(e) => {
setPasswordInput(e.target.value);
}}
/>
<button type={"submit"}>Submit</button>
</form>
</div>
</div>
) : (
<Component {...pageProps} />
)}
</div>
</LayoutContext.Provider>
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>
<Footer />
</IconContext.Provider>
</ThemeContext.Provider>
</SWRConfig>
</main>
<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>
);
}
+7 -1
View File
@@ -1,9 +1,15 @@
import gIndexConfig from "config";
import { Head, Html, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang='en'>
<Head />
<Head>
<link
rel='icon'
href={gIndexConfig.siteConfig.favIcon}
/>
</Head>
<body>
<Main />
<NextScript />
-67
View File
@@ -1,67 +0,0 @@
import initMiddleware from "utils/apiMiddleware";
import { NextApiRequest, NextApiResponse } from "next";
import { BannerResponse, ErrorResponse } from "types/googleapis";
import { ExtendedError } from "utils/driveHelper";
import driveClient from "utils/driveClient";
import { urlEncrypt } from "utils/encryptionHelper";
export default initMiddleware(async function handler(
request: NextApiRequest,
response: NextApiResponse<BannerResponse | ErrorResponse>,
) {
const _start = Date.now();
try {
const { folderId } = request.query;
const [name, partialId] = (folderId as string).split(":");
if (!name || !partialId || partialId.length !== 8) {
throw new ExtendedError(
"Can't resolve name and id provided.",
400,
"invalidId",
);
}
const payload: BannerResponse = {
success: true,
timestamp: new Date().toISOString(),
responseTime: Date.now() - _start,
};
const findBanner = await driveClient.files.list({
q: `name contains '.banner' and trashed = false and 'me' in owners`,
fields: "files(id, name, mimeType, parents)",
});
const banner = findBanner.data.files?.find(
(file) =>
file.parents?.[0].startsWith(partialId) &&
file.name?.startsWith(".banner"),
);
if (!banner) {
payload.success = false;
return response.status(200).json(payload);
}
payload.banner = {
id: urlEncrypt(banner.id as string),
name: banner.name as string,
};
return response.status(200).json(payload);
} catch (error: any) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
responseTime: Date.now() - _start,
code: error.code || 500,
errors: {
message: error.errors?.[0].message || error.message || "Unknown error",
reason: error.errors?.[0].reason || error.cause || "internalError",
},
};
return response.status(payload.code || 500).json(payload);
}
});
-53
View File
@@ -1,53 +0,0 @@
import { BannerResponse, ErrorResponse } from "types/googleapis";
import { NextApiRequest, NextApiResponse } from "next";
import initMiddleware from "utils/apiMiddleware";
import apiConfig from "config/api.config";
import driveClient from "utils/driveClient";
import { urlEncrypt } from "utils/encryptionHelper";
export default initMiddleware(async function handler(
request: NextApiRequest,
response: NextApiResponse<BannerResponse | ErrorResponse>,
) {
const _start = Date.now();
try {
const payload: BannerResponse = {
success: true,
timestamp: new Date().toISOString(),
responseTime: Date.now() - _start,
};
const findBanner = await driveClient.files.list({
q: `name contains '.banner' and trashed = false and 'me' in owners and parents = '${apiConfig.files.rootFolder}'`,
fields: "files(id, name, mimeType, parents)",
});
const banner = findBanner.data.files?.find((file) =>
file.name?.startsWith(".banner"),
);
if (!banner) {
payload.success = false;
return response.status(200).json(payload);
}
payload.banner = {
id: urlEncrypt(banner.id as string),
name: banner.name as string,
};
return response.status(200).json(payload);
} catch (error: any) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
responseTime: Date.now() - _start,
code: error.code || 500,
errors: {
message: error.errors?.[0].message || error.message || "Unknown error",
reason: error.errors?.[0].reason || error.cause || "internalError",
},
};
return response.status(payload.code || 500).json(payload);
}
});

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