mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 20:01:36 +00:00
@@ -1,7 +0,0 @@
|
||||
# You can generate encryption key from setup page.
|
||||
NEXT_PUBLIC_ENCRYPTION_KEY=""
|
||||
# You can use any random string for JWT_KEY, it is recommended to use a strong key by combining characters, numbers and symbols.
|
||||
JWT_KEY=""
|
||||
# Domain name of your website. Used for fetching the og-image from public directory.
|
||||
# Example: https://drive.example.com
|
||||
NEXT_PUBLIC_DOMAIN=""
|
||||
@@ -0,0 +1,5 @@
|
||||
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
|
||||
|
||||
NEXT_PUBLIC_ENCRYPTION_KEY="Rj3wRGB9nUElQ7OA"
|
||||
NEXT_PUBLIC_SITE_PASSWORD="mbaharip"
|
||||
NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
|
||||
+5
-6
@@ -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
|
||||
Generated
-5
@@ -1,5 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
Generated
-7
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DiscordProjectSettings">
|
||||
<option name="show" value="PROJECT_FILES" />
|
||||
<option name="description" value="" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<Languages>
|
||||
<language minSize="743" name="TypeScript" />
|
||||
</Languages>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ExceptionCaughtLocallyJS" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="myValues">
|
||||
<value>
|
||||
<list size="1">
|
||||
<item index="0" class="java.lang.String" itemvalue="tw" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
<option name="myCustomValuesEnabled" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/drive-manager.iml" filepath="$PROJECT_DIR$/.idea/drive-manager.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,74 +1,81 @@
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,70 @@
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APIFilesResponse } from "types/api/response";
|
||||
|
||||
const mockFiles: APIFilesResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: 0,
|
||||
folders: [],
|
||||
files: [],
|
||||
nextPageToken: "nextpageToken",
|
||||
readmeExists: true,
|
||||
bannerExists: true,
|
||||
passwordExists: false,
|
||||
};
|
||||
const mockFilesProtected: APIFilesResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: 0,
|
||||
folders: [],
|
||||
files: [],
|
||||
nextPageToken: "nextpageToken",
|
||||
readmeExists: true,
|
||||
bannerExists: true,
|
||||
passwordExists: true,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const folder: IGDriveFiles = {
|
||||
mimeType: "application/vnd.google-apps.folder",
|
||||
encryptedId: `encryptedId${i + 1}`,
|
||||
name: `folder${i + 1}`,
|
||||
trashed: false,
|
||||
modifiedTime: new Date().toISOString(),
|
||||
};
|
||||
const file: IGDriveFiles = {
|
||||
mimeType: "application/vnd.google-apps.file",
|
||||
encryptedId: `encryptedId${i + 1}`,
|
||||
name: `file${i + 1}`,
|
||||
trashed: false,
|
||||
modifiedTime: new Date().toISOString(),
|
||||
fileExtension: "txt",
|
||||
encryptedWebContentLink: "encryptedWebContentLink",
|
||||
size: Math.floor(Math.random() * 1000000000),
|
||||
thumbnailLink: "/og.png",
|
||||
imageMediaMetadata:
|
||||
Math.random() > 0.5
|
||||
? {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rotation: 0,
|
||||
}
|
||||
: null,
|
||||
videoMediaMetadata:
|
||||
Math.random() > 0.5
|
||||
? {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
durationMillis: 100000,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
mockFiles.folders.push(folder);
|
||||
mockFiles.files.push(file);
|
||||
|
||||
folder.name = `folder${i + 1}protected`;
|
||||
file.name = `file${i + 1}protected`;
|
||||
mockFilesProtected.folders.push(folder);
|
||||
mockFilesProtected.files.push(file);
|
||||
}
|
||||
|
||||
export { mockFiles, mockFilesProtected };
|
||||
+32
-4
@@ -1,6 +1,34 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
const withMDX = require("@next/mdx")({
|
||||
extension: /\.mdx?$/,
|
||||
options: {
|
||||
// If you use remark-gfm, you'll need to use next.config.mjs
|
||||
// as the package is ESM only
|
||||
// https://github.com/remarkjs/remark-gfm#install
|
||||
remarkPlugins: [],
|
||||
rehypePlugins: [],
|
||||
// If you use `MDXProvider`, uncomment the following line.
|
||||
providerImportSource: "@mdx-js/react",
|
||||
},
|
||||
});
|
||||
|
||||
const nextConfig = {
|
||||
pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
|
||||
reactStrictMode: true,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = withMDX(nextConfig);
|
||||
|
||||
+32
-32
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 357.3 318.6" style="enable-background:new 0 0 357.3 318.6;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{clip-path:url(#SVGID_00000113337896632168277910000000023085085965044388_);}
|
||||
.st1{fill:url(#SVGID_00000013887681291698298630000007337251733899413648_);}
|
||||
.st2{fill:#FFFFFF;}
|
||||
.st3{fill:#FAFAFA;}
|
||||
</style>
|
||||
<g>
|
||||
<g>
|
||||
<defs>
|
||||
<polygon id="SVGID_1_" points="228.6,229.7 128.7,229.7 178.6,143.2 "/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_00000171714398683706915590000003431004812763861921_">
|
||||
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
<g style="clip-path:url(#SVGID_00000171714398683706915590000003431004812763861921_);">
|
||||
|
||||
<radialGradient id="SVGID_00000098178616607910525110000001051538810791995295_" cx="178.635" cy="161.295" r="161.753" gradientTransform="matrix(1 0 0 -1 0 320.6)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" style="stop-color:#A2DDF8"/>
|
||||
<stop offset="1" style="stop-color:#BC7DB4"/>
|
||||
</radialGradient>
|
||||
<path style="fill:url(#SVGID_00000098178616607910525110000001051538810791995295_);" d="M34.7,311.1c-9.8,0-18.6-5.1-23.5-13.6
|
||||
s-4.9-18.6,0-27.1L155.1,21.1c4.9-8.5,13.7-13.6,23.5-13.6s18.6,5.1,23.5,13.6l143.9,249.3c4.9,8.5,4.9,18.6,0,27.1
|
||||
s-13.7,13.6-23.5,13.6H34.7z"/>
|
||||
<path class="st2" d="M178.6,15c6.6,0,13.2,3.3,17,9.8l102.1,176.8l0,0l37,64.1l4.9,8.4c7.6,13.1-1.9,29.5-17,29.5H86.9H68.7h-34
|
||||
c-15.1,0-24.6-16.4-17-29.5L135.5,70l9.1-15.7l17-29.5C165.4,18.3,172,15,178.6,15L178.6,15z M178.6,0c-12.5,0-23.7,6.5-30,17.3
|
||||
l-17,29.5l-9.1,15.7L4.7,266.6c-6.3,10.8-6.3,23.8,0,34.6c6.3,10.9,17.5,17.3,30,17.3h34h18.1h235.7c12.5,0,23.7-6.5,30-17.3
|
||||
c6.3-10.8,6.3-23.8,0-34.6l-4.9-8.4l-37-64.1L208.6,17.3C202.4,6.5,191.2,0,178.6,0L178.6,0z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st3" d="M339.6,274.1l-17-29.5H120.9l-34,58.9h235.7C337.7,303.6,347.1,287.2,339.6,274.1z"/>
|
||||
<path class="st3" d="M245.5,229h68L195.6,24.8c-7.6-13.1-26.5-13.1-34,0l-17,29.5L245.5,229z"/>
|
||||
<path class="st3" d="M169.6,128.9l-34-58.9L17.7,274.1c-7.6,13.1,1.9,29.5,17,29.5h34L169.6,128.9z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -1,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.
|
||||
@@ -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.
|
||||

|
||||
3. Activate the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com) for your project.
|
||||

|
||||
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.
|
||||

|
||||
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.
|
||||

|
||||
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".
|
||||

|
||||
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.
|
||||

|
||||
3. Select the following scopes and click on the "Authorize APIs" button.
|
||||

|
||||
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.
|
||||

|
||||
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.
|
||||

|
||||
7. You can close the Playground tab now.
|
||||
8. You're done with the Google Cloud Setup.
|
||||
@@ -0,0 +1,94 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import { decryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
import { isDownloadTokenValid } from "utils/tokenHelper";
|
||||
|
||||
import { ErrorResponse } from "types/api/response";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{
|
||||
params,
|
||||
}: {
|
||||
params: {
|
||||
encryptedId: string;
|
||||
fileName: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const reqStart = Date.now();
|
||||
const { encryptedId, fileName } = params;
|
||||
try {
|
||||
const data = JSON.parse(decryptData(encryptedId)) as {
|
||||
id: string;
|
||||
isProtected: boolean;
|
||||
};
|
||||
const { token, preview, disableLimit } = getSearchParams(request.url, ["token", "preview", "disableLimit"]);
|
||||
|
||||
if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && !token)
|
||||
throw new ExtendedError("Missing download token", 403, "You are not authorized to download this file");
|
||||
if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && token && !isDownloadTokenValid(token))
|
||||
throw new ExtendedError("Invalid download token", 403, "You are not authorized to download this file");
|
||||
|
||||
const _fileMeta = gdrive.files.get({
|
||||
fileId: decryptData(data.id),
|
||||
fields: "id, name, size, mimeType, webContentLink",
|
||||
});
|
||||
const _fileContent = gdrive.files.get(
|
||||
{
|
||||
fileId: decryptData(data.id),
|
||||
alt: "media",
|
||||
},
|
||||
{ responseType: "stream" },
|
||||
);
|
||||
|
||||
const [fileMeta, fileContent] = await Promise.all([_fileMeta, _fileContent]);
|
||||
const fileSize = Number(fileMeta.data.size) ?? 0;
|
||||
if (fileSize > gIndexConfig.apiConfig.maxFileSize && !disableLimit) {
|
||||
return NextResponse.redirect(fileMeta.data.webContentLink!, {
|
||||
status: 302,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
}
|
||||
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
fileContent.data.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
fileContent.data.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
fileContent.data.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": fileMeta.data.mimeType ?? "application/octet-stream",
|
||||
"Content-Disposition": `${preview ? "inline;" : "attachment;"} filename="${encodeURIComponent(fileName)}"`,
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
decryptData,
|
||||
encryptData,
|
||||
} from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
import { checkPathPassword } from "utils/passwordHelper";
|
||||
|
||||
import {
|
||||
APIValidateResponse,
|
||||
ErrorResponse,
|
||||
} from "types/api/response";
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
type FilePath = {
|
||||
name: string;
|
||||
encryptedId: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
const reqStart = Date.now();
|
||||
const pathArray = params.path;
|
||||
|
||||
try {
|
||||
const fetchRootId = gdrive.files.get({
|
||||
fileId: gIndexConfig.apiConfig.rootFolder,
|
||||
fields: "id",
|
||||
});
|
||||
const fetchPathId = pathArray.map(async (path) => {
|
||||
const query = ["trashed = false", `name = '${path}'`];
|
||||
const fetchFolderContents = await gdrive.files.list({
|
||||
q: `${query.join(" and ")}`,
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
});
|
||||
|
||||
if (!fetchFolderContents.data.files?.length) {
|
||||
throw new Error(`Path "${path}" is not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
data: fetchFolderContents.data.files.map(
|
||||
(file) => ({
|
||||
id: file.id,
|
||||
parents: file.parents?.[0],
|
||||
mimeType: file.mimeType,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const [rootId, pathId] = await Promise.all([
|
||||
fetchRootId,
|
||||
Promise.all(fetchPathId),
|
||||
]);
|
||||
|
||||
const selectedPath: string[] = [];
|
||||
const mappedPath: FilePath[] = [];
|
||||
|
||||
// Check path validity
|
||||
pathId.forEach((path, index) => {
|
||||
let checkPath =
|
||||
index === 0
|
||||
? path.data.find(
|
||||
(file) => file.parents === rootId.data.id,
|
||||
)
|
||||
: path.data.find(
|
||||
(file) =>
|
||||
file.parents === selectedPath[index - 1],
|
||||
);
|
||||
if (!checkPath)
|
||||
throw new ExtendedError(
|
||||
"Path not found",
|
||||
404,
|
||||
`Can't find path "${path.path}". Please check your path again.`,
|
||||
);
|
||||
selectedPath.push(checkPath.id as string);
|
||||
mappedPath.push({
|
||||
name: path.path,
|
||||
encryptedId: encryptData(checkPath.id as string),
|
||||
mimeType: checkPath.mimeType as string,
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json(mappedPath, {
|
||||
status: 200,
|
||||
});
|
||||
|
||||
// Check for protected folder
|
||||
const fetchProtectedFolder = mappedPath.map(
|
||||
async (path) => {
|
||||
if (
|
||||
path.mimeType !==
|
||||
"application/vnd.google-apps.folder"
|
||||
)
|
||||
return;
|
||||
|
||||
const query = [
|
||||
"trashed = false",
|
||||
`'${decryptData(path.encryptedId)}' in parents`,
|
||||
`name = '${gIndexConfig.apiConfig.specialFile.password}'`,
|
||||
];
|
||||
|
||||
const fetchFolderContents = await gdrive.files.list(
|
||||
{
|
||||
q: `${query.join(" and ")}`,
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
},
|
||||
);
|
||||
if (!fetchFolderContents.data.files?.length) return;
|
||||
|
||||
return {
|
||||
path: path.name,
|
||||
passwordId: fetchFolderContents.data.files[0].id,
|
||||
};
|
||||
},
|
||||
);
|
||||
let protectedFolder = await Promise.all(
|
||||
fetchProtectedFolder,
|
||||
);
|
||||
console.log(protectedFolder);
|
||||
protectedFolder = protectedFolder.filter(
|
||||
(item) => item !== undefined,
|
||||
);
|
||||
const nearestFolderPassword =
|
||||
protectedFolder[protectedFolder.length - 1] ?? null;
|
||||
|
||||
console.log(
|
||||
"After nearestFolderPassword",
|
||||
nearestFolderPassword,
|
||||
);
|
||||
|
||||
if (
|
||||
nearestFolderPassword &&
|
||||
nearestFolderPassword.passwordId
|
||||
) {
|
||||
const fetchPassword = await gdrive.files.get(
|
||||
{
|
||||
fileId: nearestFolderPassword.passwordId,
|
||||
alt: "media",
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
const userPassword =
|
||||
request.cookies.get(Constant.cookies_SitePassword)
|
||||
?.value ?? null;
|
||||
if (!userPassword) {
|
||||
throw new ExtendedError(
|
||||
"Password required",
|
||||
401,
|
||||
`You need to enter password to access this file / folder`,
|
||||
);
|
||||
}
|
||||
|
||||
const nearestPathIndex = mappedPath.findIndex(
|
||||
(path) => path.name === nearestFolderPassword.path,
|
||||
);
|
||||
const currentPath =
|
||||
"/" +
|
||||
mappedPath
|
||||
.slice(0, nearestPathIndex + 1)
|
||||
.map((path) => path.name)
|
||||
.join("/");
|
||||
|
||||
const validPassword = checkPathPassword(
|
||||
currentPath,
|
||||
userPassword,
|
||||
fetchPassword.data as string,
|
||||
);
|
||||
if (!validPassword) {
|
||||
throw new ExtendedError(
|
||||
"Invalid password",
|
||||
401,
|
||||
`The password you entered is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const payload: APIValidateResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
path: mappedPath.map((path) => path.name).join("/"),
|
||||
valid: true,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APIFileResponse, APIFilesResponse, ErrorResponse } from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { encryptedId: string } }) {
|
||||
const reqStart = Date.now();
|
||||
try {
|
||||
const id = decryptData(params.encryptedId);
|
||||
if (id === gIndexConfig.apiConfig.rootFolder)
|
||||
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_VERCEL_URL}/api/files`, { status: 301 });
|
||||
|
||||
const file = await gdrive.files.get({
|
||||
fileId: id,
|
||||
fields: gIndexConfig.apiConfig.defaultField,
|
||||
});
|
||||
|
||||
if (!file || file.data.trashed) throw new ExtendedError("File not found", 404, "Can't find requested file.");
|
||||
if (file.data.mimeType !== "application/vnd.google-apps.folder") {
|
||||
const res: APIFileResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
file: {
|
||||
mimeType: file.data.mimeType as string,
|
||||
fileExtension: file.data.fileExtension as string,
|
||||
encryptedId: params.encryptedId,
|
||||
name: file.data.name as string,
|
||||
trashed: file.data.trashed as boolean,
|
||||
modifiedTime: file.data.modifiedTime as string,
|
||||
encryptedWebContentLink: encryptData(file.data.webContentLink as string),
|
||||
size: Number(file.data.size ?? 0),
|
||||
thumbnailLink: file.data.thumbnailLink as string,
|
||||
imageMediaMetadata: file.data.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.data.imageMediaMetadata.width),
|
||||
height: Number(file.data.imageMediaMetadata.height),
|
||||
rotation: Number(file.data.imageMediaMetadata.rotation),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.data.videoMediaMetadata
|
||||
? {
|
||||
width: Number(file.data.videoMediaMetadata.width),
|
||||
height: Number(file.data.videoMediaMetadata.height),
|
||||
durationMillis: Number(file.data.videoMediaMetadata.durationMillis),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json(res, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { pageToken } = getSearchParams(request.url, ["pageToken"]);
|
||||
|
||||
const filterName = gIndexConfig.apiConfig.hiddenFiles.map((item) => `name != '${item}'`).join(" and ");
|
||||
const query: string[] = [...gIndexConfig.apiConfig.defaultQuery, `'${id}' in parents`, `${filterName}`];
|
||||
const fetchFolderContents = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: gIndexConfig.apiConfig.defaultOrder,
|
||||
pageSize: gIndexConfig.apiConfig.itemsPerPage,
|
||||
pageToken: pageToken || undefined,
|
||||
});
|
||||
|
||||
const folders: IGDriveFiles[] =
|
||||
fetchFolderContents.data.files
|
||||
?.filter((item) => item.mimeType === "application/vnd.google-apps.folder")
|
||||
.map((item) => ({
|
||||
mimeType: item.mimeType as string,
|
||||
encryptedId: encryptData(item.id as string),
|
||||
name: item.name as string,
|
||||
trashed: item.trashed as boolean,
|
||||
modifiedTime: item.modifiedTime as string,
|
||||
})) ?? [];
|
||||
const files: IGDriveFiles[] =
|
||||
fetchFolderContents.data.files
|
||||
?.filter((item) => item.mimeType !== "application/vnd.google-apps.folder")
|
||||
.map((item) => ({
|
||||
mimeType: item.mimeType as string,
|
||||
fileExtension: item.fileExtension as string,
|
||||
encryptedId: encryptData(item.id as string),
|
||||
name: item.name as string,
|
||||
trashed: item.trashed as boolean,
|
||||
modifiedTime: item.modifiedTime as string,
|
||||
encryptedWebContentLink: encryptData(item.webContentLink as string),
|
||||
size: Number(item.size ?? 0),
|
||||
thumbnailLink: item.thumbnailLink as string,
|
||||
imageMediaMetadata: item.imageMediaMetadata
|
||||
? {
|
||||
width: Number(item.imageMediaMetadata.width ?? 0),
|
||||
height: Number(item.imageMediaMetadata.height ?? 0),
|
||||
rotation: Number(item.imageMediaMetadata.rotation ?? 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: item.videoMediaMetadata
|
||||
? {
|
||||
width: Number(item.videoMediaMetadata.width ?? 0),
|
||||
height: Number(item.videoMediaMetadata.height ?? 0),
|
||||
durationMillis: Number(item.videoMediaMetadata.durationMillis ?? 0),
|
||||
}
|
||||
: undefined,
|
||||
})) ?? [];
|
||||
const readmeExists =
|
||||
fetchFolderContents.data.files?.some((item) => item.name === gIndexConfig.apiConfig.specialFile.readme) ?? false;
|
||||
const bannerExists =
|
||||
fetchFolderContents.data.files?.some(
|
||||
(item) =>
|
||||
item.name?.startsWith(gIndexConfig.apiConfig.specialFile.banner) && item.mimeType?.startsWith("image/"),
|
||||
) ?? false;
|
||||
const passwordExists =
|
||||
fetchFolderContents.data.files?.some((item) => item.name === gIndexConfig.apiConfig.specialFile.password) ??
|
||||
false;
|
||||
|
||||
const res: APIFilesResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
folders,
|
||||
files,
|
||||
nextPageToken: fetchFolderContents.data.nextPageToken || undefined,
|
||||
readmeExists,
|
||||
bannerExists,
|
||||
passwordExists,
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import privateIndexProtection from "utils/apiHelper/privateIndexProtection";
|
||||
import { encryptData } from "utils/encryptionHelper/hash";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import {
|
||||
APIFilesResponse,
|
||||
ErrorResponse,
|
||||
} from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
try {
|
||||
await privateIndexProtection(request);
|
||||
const { pageToken } = getSearchParams(request.url, [
|
||||
"pageToken",
|
||||
]);
|
||||
|
||||
const query: string[] = [
|
||||
...gIndexConfig.apiConfig.defaultQuery,
|
||||
`'${gIndexConfig.apiConfig.rootFolder}' in parents`,
|
||||
];
|
||||
const fetchFolderContents = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: gIndexConfig.apiConfig.defaultOrder,
|
||||
pageSize: gIndexConfig.apiConfig.itemsPerPage,
|
||||
pageToken: pageToken || undefined,
|
||||
});
|
||||
|
||||
const folders: IGDriveFiles[] =
|
||||
fetchFolderContents.data.files
|
||||
?.filter(
|
||||
(item) =>
|
||||
item.mimeType ===
|
||||
"application/vnd.google-apps.folder",
|
||||
)
|
||||
.map((item) => ({
|
||||
mimeType: item.mimeType as string,
|
||||
encryptedId: encryptData(item.id as string),
|
||||
name: item.name as string,
|
||||
trashed: item.trashed as boolean,
|
||||
modifiedTime: item.modifiedTime as string,
|
||||
})) ?? [];
|
||||
const files: IGDriveFiles[] =
|
||||
fetchFolderContents.data.files
|
||||
?.filter(
|
||||
(item) =>
|
||||
item.mimeType !==
|
||||
"application/vnd.google-apps.folder" &&
|
||||
!gIndexConfig.apiConfig.hiddenFiles.includes(
|
||||
item.name as string,
|
||||
),
|
||||
)
|
||||
.map((item) => ({
|
||||
mimeType: item.mimeType as string,
|
||||
fileExtension: item.fileExtension as string,
|
||||
encryptedId: encryptData(item.id as string),
|
||||
name: item.name as string,
|
||||
trashed: item.trashed as boolean,
|
||||
modifiedTime: item.modifiedTime as string,
|
||||
encryptedWebContentLink: encryptData(
|
||||
item.webContentLink as string,
|
||||
),
|
||||
size: Number(item.size ?? 0),
|
||||
thumbnailLink: item.thumbnailLink as string,
|
||||
imageMediaMetadata: item.imageMediaMetadata
|
||||
? {
|
||||
width: Number(
|
||||
item.imageMediaMetadata.width ?? 0,
|
||||
),
|
||||
height: Number(
|
||||
item.imageMediaMetadata.height ?? 0,
|
||||
),
|
||||
rotation: Number(
|
||||
item.imageMediaMetadata.rotation ?? 0,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: item.videoMediaMetadata
|
||||
? {
|
||||
width: Number(
|
||||
item.videoMediaMetadata.width ?? 0,
|
||||
),
|
||||
height: Number(
|
||||
item.videoMediaMetadata.height ?? 0,
|
||||
),
|
||||
durationMillis: Number(
|
||||
item.videoMediaMetadata.durationMillis ??
|
||||
0,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
})) ?? [];
|
||||
const readmeExists =
|
||||
fetchFolderContents.data.files?.some(
|
||||
(item) =>
|
||||
item.name ===
|
||||
gIndexConfig.apiConfig.specialFile.readme,
|
||||
) ?? false;
|
||||
const bannerExists =
|
||||
fetchFolderContents.data.files?.some(
|
||||
(item) =>
|
||||
item.name?.startsWith(
|
||||
gIndexConfig.apiConfig.specialFile.banner,
|
||||
) && item.mimeType?.startsWith("image/"),
|
||||
) ?? false;
|
||||
const passwordExists =
|
||||
fetchFolderContents.data.files?.some(
|
||||
(item) =>
|
||||
item.name ===
|
||||
gIndexConfig.apiConfig.specialFile.password,
|
||||
) ?? false;
|
||||
|
||||
const res: APIFilesResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
folders,
|
||||
files,
|
||||
nextPageToken:
|
||||
fetchFolderContents.data.nextPageToken || undefined,
|
||||
readmeExists,
|
||||
bannerExists,
|
||||
passwordExists,
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import gIndexConfig from "config";
|
||||
import { drive_v3 } from "googleapis";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import {
|
||||
decryptData,
|
||||
encryptData,
|
||||
} from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import {
|
||||
APIGetFileResponse,
|
||||
ErrorResponse,
|
||||
} from "types/api/response";
|
||||
|
||||
function isHiddenFile(name: string) {
|
||||
return gIndexConfig.apiConfig.hiddenFiles.find((item) =>
|
||||
name.startsWith(item),
|
||||
)
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
function generateFileObject(
|
||||
data: drive_v3.Schema$File,
|
||||
): IGDriveFiles {
|
||||
return {
|
||||
mimeType: data.mimeType as string,
|
||||
fileExtension: (data.fileExtension as string) ?? null,
|
||||
encryptedId: encryptData(data.id as string),
|
||||
name: data.name as string,
|
||||
trashed: (data.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(
|
||||
data.modifiedTime as string,
|
||||
).toLocaleDateString(),
|
||||
encryptedWebContentLink: encryptData(
|
||||
data.webContentLink as string,
|
||||
),
|
||||
size: Number(data.size ?? 0),
|
||||
thumbnailLink: (data.thumbnailLink as string) ?? null,
|
||||
imageMediaMetadata: data.imageMediaMetadata
|
||||
? {
|
||||
width: Number(data.imageMediaMetadata.width ?? 0),
|
||||
height: Number(
|
||||
data.imageMediaMetadata.height ?? 0,
|
||||
),
|
||||
rotation: Number(
|
||||
data.imageMediaMetadata.rotation ?? 0,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
videoMediaMetadata: data.videoMediaMetadata
|
||||
? {
|
||||
width: Number(data.videoMediaMetadata.width ?? 0),
|
||||
height: Number(
|
||||
data.videoMediaMetadata.height ?? 0,
|
||||
),
|
||||
durationMillis: Number(
|
||||
data.videoMediaMetadata.durationMillis ?? 0,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
function generateFolderObject(
|
||||
data: drive_v3.Schema$File,
|
||||
): IGDriveFiles {
|
||||
return {
|
||||
mimeType: data.mimeType as string,
|
||||
encryptedId: encryptData(data.id as string),
|
||||
name: data.name as string,
|
||||
trashed: (data.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(
|
||||
data.modifiedTime as string,
|
||||
).toLocaleDateString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
try {
|
||||
const { encryptedId, isFile, pageToken } =
|
||||
getSearchParams(request.url, [
|
||||
"encryptedId",
|
||||
"isFile",
|
||||
"pageToken",
|
||||
]);
|
||||
|
||||
let data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
} = {
|
||||
file: null,
|
||||
files: [],
|
||||
folders: [],
|
||||
pageToken: null,
|
||||
};
|
||||
if (isFile && encryptedId) {
|
||||
const fileContent = await gdrive.files.get({
|
||||
fileId: decryptData(encryptedId),
|
||||
fields: gIndexConfig.apiConfig.defaultField,
|
||||
});
|
||||
if (isHiddenFile(fileContent.data.name as string))
|
||||
throw new ExtendedError(
|
||||
"File not found",
|
||||
404,
|
||||
"File you are looking for is not found",
|
||||
);
|
||||
|
||||
const payload: IGDriveFiles = generateFileObject(
|
||||
fileContent.data,
|
||||
);
|
||||
data.file = payload;
|
||||
} else {
|
||||
const filterName = gIndexConfig.apiConfig.hiddenFiles
|
||||
.map((item) => `name != '${item}'`)
|
||||
.join(" and ");
|
||||
const query: string[] = [
|
||||
...gIndexConfig.apiConfig.defaultQuery,
|
||||
`'${
|
||||
encryptedId
|
||||
? decryptData(encryptedId)
|
||||
: gIndexConfig.apiConfig.rootFolder
|
||||
}' in parents`,
|
||||
`${filterName}`,
|
||||
];
|
||||
const folderContent = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: gIndexConfig.apiConfig.defaultOrder,
|
||||
pageSize: gIndexConfig.apiConfig.itemsPerPage,
|
||||
pageToken: pageToken ?? undefined,
|
||||
});
|
||||
|
||||
const listFolders: IGDriveFiles[] =
|
||||
folderContent.data.files
|
||||
?.filter(
|
||||
(item) =>
|
||||
item.mimeType ===
|
||||
"application/vnd.google-apps.folder",
|
||||
)
|
||||
.map((item) => generateFolderObject(item)) ?? [];
|
||||
const listFiles: IGDriveFiles[] =
|
||||
folderContent.data.files
|
||||
?.filter(
|
||||
(item) =>
|
||||
item.mimeType !==
|
||||
"application/vnd.google-apps.folder",
|
||||
)
|
||||
.map((item) => generateFileObject(item)) ?? [];
|
||||
|
||||
data = {
|
||||
...data,
|
||||
files: listFiles,
|
||||
folders: listFolders,
|
||||
pageToken: folderContent.data.nextPageToken ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const payload: APIGetFileResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
data,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import {
|
||||
decryptData,
|
||||
encryptData,
|
||||
} from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import {
|
||||
APIGetPasswordResponse,
|
||||
ErrorResponse,
|
||||
} from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
|
||||
try {
|
||||
const { path } = getSearchParams(request.url, ["path"]);
|
||||
if (!path)
|
||||
throw new ExtendedError(
|
||||
"Path is required",
|
||||
400,
|
||||
"Can't find path in query, please check your query",
|
||||
);
|
||||
const mappedPath: Record<
|
||||
"name" | "id" | "mimeType",
|
||||
string
|
||||
>[] = JSON.parse(decryptData(path));
|
||||
const passwordParents = mappedPath.map(
|
||||
(path) => `'${decryptData(path.id)}' in parents`,
|
||||
);
|
||||
const query: string[] = [
|
||||
"trashed = false",
|
||||
`name = '${
|
||||
gIndexConfig.apiConfig.specialFile.password
|
||||
}' and (${passwordParents.join(" or ")})`,
|
||||
];
|
||||
const passwordData = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: "files(id, name, parents)",
|
||||
});
|
||||
|
||||
const protectedPaths = mappedPath.map((path) => {
|
||||
const isPasswordExist = passwordData.data.files?.find(
|
||||
(file) =>
|
||||
file.parents?.[0] === decryptData(path.id),
|
||||
);
|
||||
if (isPasswordExist)
|
||||
return {
|
||||
name: path.name,
|
||||
passwordId: isPasswordExist.id,
|
||||
};
|
||||
});
|
||||
|
||||
const _passwordContent: Promise<{
|
||||
path: string;
|
||||
password: string;
|
||||
}>[] = [];
|
||||
protectedPaths.forEach((path) => {
|
||||
if (!path) return;
|
||||
_passwordContent.push(
|
||||
gdrive.files
|
||||
.get(
|
||||
{
|
||||
fileId: path.passwordId as string,
|
||||
alt: "media",
|
||||
},
|
||||
{ responseType: "text" },
|
||||
)
|
||||
.then((res) => ({
|
||||
path: path.name as string,
|
||||
password: res.data as string,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
const passwordContent = await Promise.all(
|
||||
_passwordContent,
|
||||
);
|
||||
|
||||
let prevPath = [""];
|
||||
let password: Record<
|
||||
"relativePath" | "password",
|
||||
string
|
||||
>[] = [];
|
||||
|
||||
mappedPath.forEach((path) => {
|
||||
prevPath.push(path.name);
|
||||
const isPasswordExist = passwordContent.find(
|
||||
(password) => password.path === path.name,
|
||||
);
|
||||
if (isPasswordExist) {
|
||||
password.push({
|
||||
relativePath: prevPath.join("/"),
|
||||
password: encryptData(isPasswordExist.password),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const payload: APIGetPasswordResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
data: password,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import { decryptData } from "utils/encryptionHelper/hash";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import {
|
||||
APIGetReadmeResponse,
|
||||
ErrorResponse,
|
||||
} from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
try {
|
||||
const { encryptedId } = getSearchParams(request.url, [
|
||||
"encryptedId",
|
||||
]);
|
||||
|
||||
const query: string[] = [
|
||||
...gIndexConfig.apiConfig.defaultQuery,
|
||||
`'${
|
||||
encryptedId
|
||||
? decryptData(encryptedId)
|
||||
: gIndexConfig.apiConfig.rootFolder
|
||||
}' in parents`,
|
||||
];
|
||||
const folderContent = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: gIndexConfig.apiConfig.defaultOrder,
|
||||
pageSize: gIndexConfig.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
});
|
||||
const isReadmeExist = folderContent.data.files?.find(
|
||||
(file) =>
|
||||
file.name ===
|
||||
gIndexConfig.apiConfig.specialFile.readme,
|
||||
);
|
||||
let data: string | null = null;
|
||||
|
||||
if (isReadmeExist) {
|
||||
const fileContent = await gdrive.files.get(
|
||||
{
|
||||
fileId: isReadmeExist.id as string,
|
||||
alt: "media",
|
||||
},
|
||||
{ responseType: "text" },
|
||||
);
|
||||
data = fileContent.data as string;
|
||||
}
|
||||
|
||||
const payload: APIGetReadmeResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
data,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import { addNewPassword } from "utils/passwordHelper";
|
||||
|
||||
import { ErrorResponse } from "types/api/response";
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
|
||||
try {
|
||||
const { path, password } = getSearchParams(
|
||||
request.url,
|
||||
["path", "password"],
|
||||
);
|
||||
if (!path || !password)
|
||||
throw new ExtendedError(
|
||||
"Missing parameters",
|
||||
400,
|
||||
"Path or password is missing",
|
||||
);
|
||||
|
||||
const cookies = request.cookies.get(
|
||||
Constant.cookies_SitePassword,
|
||||
);
|
||||
if (!cookies)
|
||||
throw new ExtendedError(
|
||||
"No cookies found",
|
||||
400,
|
||||
"Site password not found",
|
||||
);
|
||||
const newPassword = addNewPassword(
|
||||
path!,
|
||||
password!,
|
||||
cookies.value,
|
||||
true,
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
newPassword: {
|
||||
path,
|
||||
password,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Set-Cookie": newPassword as string,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
|
||||
import { ErrorResponse } from "types/api/response";
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
|
||||
try {
|
||||
const cookies = request.cookies.get(
|
||||
Constant.cookies_SitePassword,
|
||||
);
|
||||
if (!cookies)
|
||||
throw new ExtendedError(
|
||||
"No cookies found",
|
||||
400,
|
||||
"Site password not found",
|
||||
);
|
||||
const rawToken = decryptData(cookies.value);
|
||||
const token = JSON.parse(rawToken);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
password: token,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import { ErrorResponse } from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { encryptedId: string } }) {
|
||||
const reqStart = Date.now();
|
||||
const { encryptedId } = params;
|
||||
try {
|
||||
const id = decryptData(encryptedId);
|
||||
if (!id) throw new ExtendedError("Invalid encryptedId", 400, "EncryptedId provided is invalid");
|
||||
|
||||
const path: string[] = [];
|
||||
let lastId = id;
|
||||
const fileContent = await gdrive.files.get({
|
||||
fileId: lastId,
|
||||
fields: "id, name, mimeType, parents",
|
||||
});
|
||||
if (fileContent) {
|
||||
path.push(fileContent.data.name as string);
|
||||
if (fileContent.data.parents) {
|
||||
lastId = fileContent.data.parents[0] as string;
|
||||
while (lastId) {
|
||||
const folderContent = await gdrive.files.get({
|
||||
fileId: lastId,
|
||||
fields: "id, name, mimeType, parents",
|
||||
});
|
||||
if (folderContent.data.id === gIndexConfig.apiConfig.rootFolder) {
|
||||
lastId = "";
|
||||
break;
|
||||
}
|
||||
if (folderContent) {
|
||||
path.push(folderContent.data.name as string);
|
||||
if (folderContent.data.parents) {
|
||||
if (folderContent.data.parents[0] === gIndexConfig.apiConfig.rootFolder) {
|
||||
lastId = "";
|
||||
break;
|
||||
} else {
|
||||
lastId = folderContent.data.parents[0] as string;
|
||||
}
|
||||
} else {
|
||||
lastId = "";
|
||||
}
|
||||
} else {
|
||||
lastId = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const redirectURL = new URL(path.reverse().join("/"), process.env.NEXT_PUBLIC_VERCEL_URL);
|
||||
return NextResponse.redirect(redirectURL.toString(), {
|
||||
status: 302,
|
||||
headers: {
|
||||
"Cache-Control": gIndexConfig.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import getSearchParams from "utils/apiHelper/getSearchParams";
|
||||
import { encryptData } from "utils/encryptionHelper/hash";
|
||||
import ExtendedError from "utils/extendedError";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APISearchResponse, ErrorResponse } from "types/api/response";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const reqStart = Date.now();
|
||||
try {
|
||||
const { query } = getSearchParams(request.url, ["query"]);
|
||||
if (!query) throw new ExtendedError("Missing query", 400, "Search query is required");
|
||||
|
||||
const searchFiles = await gdrive.files.list({
|
||||
q: [...gIndexConfig.apiConfig.defaultQuery, `name contains '${query}'`].join(" and "),
|
||||
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: "name_natural desc",
|
||||
pageSize: gIndexConfig.apiConfig.searchResult,
|
||||
pageToken: undefined,
|
||||
});
|
||||
|
||||
const files: (IGDriveFiles & { redirect: string })[] =
|
||||
searchFiles.data.files?.map((data) => ({
|
||||
mimeType: data.mimeType ?? "Unknown",
|
||||
encryptedId: encryptData(data.id as string),
|
||||
name: data.name as string,
|
||||
trashed: data.trashed ?? false,
|
||||
modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(),
|
||||
fileExtension: (data.fileExtension as string) ?? null,
|
||||
encryptedWebContentLink: undefined,
|
||||
size: Number(data.size) ?? 0,
|
||||
thumbnailLink: (data.thumbnailLink as string) ?? null,
|
||||
imageMediaMetadata: null,
|
||||
videoMediaMetadata: null,
|
||||
redirect: `/api/search/redirect/${encryptData(data.id as string)}`,
|
||||
})) ?? [];
|
||||
|
||||
const payload: APISearchResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
files: files,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const res: ErrorResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
error: {
|
||||
code: error.code || 500,
|
||||
message: error.message,
|
||||
reason: error.errors?.[0].reason,
|
||||
},
|
||||
};
|
||||
return NextResponse.json(res, {
|
||||
status: error.code || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Icon, IconifyIcon } from "@iconify/react";
|
||||
import { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
fullWidth?: boolean;
|
||||
variant?:
|
||||
| "primary"
|
||||
| "accent"
|
||||
| "success"
|
||||
| "danger"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "transparent";
|
||||
size?: "small" | "medium" | "large";
|
||||
pill?: boolean;
|
||||
square?: boolean;
|
||||
rounded?: "none" | "small" | "medium" | "large";
|
||||
startIcon?: string | IconifyIcon;
|
||||
endIcon?: string | IconifyIcon;
|
||||
children: ReactNode;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Button(props: ButtonProps) {
|
||||
const {
|
||||
fullWidth = false,
|
||||
variant = "primary",
|
||||
size = "medium",
|
||||
pill = false,
|
||||
square = false,
|
||||
rounded = "none",
|
||||
loading = false,
|
||||
startIcon,
|
||||
endIcon,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={twMerge(
|
||||
"outline-none border-none capitalize transition-smooth",
|
||||
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
|
||||
"rounded-lg w-fit flex items-center justify-center flex-grow",
|
||||
fullWidth && "w-full",
|
||||
rounded === "none" && "rounded-none",
|
||||
rounded === "small" && "rounded-sm",
|
||||
rounded === "medium" && "rounded-md",
|
||||
rounded === "large" && "rounded-lg",
|
||||
pill && "rounded-full",
|
||||
size === "small" && "px-2 py-1 text-sm gap-1",
|
||||
size === "medium" && "px-4 py-2 text-base gap-2",
|
||||
size === "large" && "px-8 py-4 text-lg gap-4",
|
||||
square && "aspect-square",
|
||||
variant === "primary" &&
|
||||
"bg-primary-600 text-primary-50 hover:bg-primary-500 active:bg-primary-700",
|
||||
variant === "accent" &&
|
||||
"bg-accent-600 text-accent-50 hover:bg-accent-500 active:bg-accent-700",
|
||||
variant === "success" &&
|
||||
"bg-success-600 text-success-50 hover:bg-success-500 active:bg-success-700",
|
||||
variant === "danger" &&
|
||||
"bg-danger-600 text-danger-50 hover:bg-danger-500 active:bg-danger-700",
|
||||
variant === "warning" &&
|
||||
"bg-warning-600 text-warning-50 hover:bg-warning-500 active:bg-warning-700",
|
||||
variant === "info" &&
|
||||
"bg-info-600 text-info-50 hover:bg-info-500 active:bg-info-700",
|
||||
variant === "transparent" &&
|
||||
"bg-transparent text-primary-50 hover:bg-primary-500 active:bg-primary-700",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-primary-700 disabled:hover:bg-primary-700 disabled:active:bg-primary-700 disabled:text-primary-50",
|
||||
className && className,
|
||||
)}
|
||||
disabled={loading ?? props.disabled}
|
||||
{...rest}
|
||||
>
|
||||
{props.loading ? (
|
||||
<Icon
|
||||
icon={"mdi:loading"}
|
||||
color='white'
|
||||
className={twMerge(
|
||||
"w-5 h-5 animate-spin",
|
||||
size === "small" && "w-4 h-4",
|
||||
size === "medium" && "w-5 h-5",
|
||||
size === "large" && "w-6 h-6",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{startIcon && (
|
||||
<Icon
|
||||
icon={startIcon}
|
||||
color='white'
|
||||
className={twMerge(
|
||||
"w-5 h-5",
|
||||
size === "small" && "w-4 h-4",
|
||||
size === "medium" && "w-5 h-5",
|
||||
size === "large" && "w-6 h-6",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{endIcon && (
|
||||
<Icon
|
||||
icon={endIcon}
|
||||
color='white'
|
||||
className={twMerge(
|
||||
"w-5 h-5",
|
||||
size === "small" && "w-4 h-4",
|
||||
size === "medium" && "w-5 h-5",
|
||||
size === "large" && "w-6 h-6",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ButtonGroupProps {
|
||||
children: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
rounded?: "none" | "small" | "medium" | "large";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ButtonGroup(
|
||||
props: ButtonGroupProps,
|
||||
) {
|
||||
const {
|
||||
children,
|
||||
fullWidth = false,
|
||||
rounded = "large",
|
||||
className,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row items-center justify-center overflow-hidden",
|
||||
fullWidth && "w-full",
|
||||
rounded === "none" && "rounded-none",
|
||||
rounded === "small" && "rounded-sm",
|
||||
rounded === "medium" && "rounded-md",
|
||||
rounded === "large" && "rounded-lg",
|
||||
className && className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Icon, IconifyIcon } from "@iconify/react";
|
||||
import { ForwardRefRenderFunction, forwardRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ButtonIconProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: string | IconifyIcon;
|
||||
size?: "small" | "medium" | "large";
|
||||
variant?: "primary" | "accent" | "success" | "danger" | "warning" | "info" | "transparent";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ButtonIcon: ForwardRefRenderFunction<HTMLButtonElement, ButtonIconProps> = (props, ref) => {
|
||||
const { icon, size = "medium", variant = "primary", className, ...rest } = props;
|
||||
let iconSize = 24;
|
||||
switch (size) {
|
||||
case "small":
|
||||
iconSize = 16;
|
||||
break;
|
||||
case "medium":
|
||||
iconSize = 24;
|
||||
break;
|
||||
case "large":
|
||||
iconSize = 32;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"outline-none border-none capitalize transition-smooth aspect-square",
|
||||
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
|
||||
"rounded-full flex items-center justify-center w-fit h-fit p-0",
|
||||
size === "small" && "w-6 h-6",
|
||||
size === "medium" && "w-8 h-8",
|
||||
size === "large" && "w-12 h-12",
|
||||
variant === "primary" && "bg-primary-600/25 text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
|
||||
variant === "accent" && "bg-accent-600/25 text-accent-50 hover:bg-accent-500/50 active:bg-accent-700",
|
||||
variant === "success" && "bg-success-600/25 text-success-50 hover:bg-success-500/50 active:bg-success-700",
|
||||
variant === "danger" && "bg-danger-600/25 text-danger-50 hover:bg-danger-500/50 active:bg-danger-700",
|
||||
variant === "warning" && "bg-warning-600/25 text-warning-50 hover:bg-warning-500/50 active:bg-warning-700",
|
||||
variant === "info" && "bg-info-600/25 text-info-50 hover:bg-info-500/50 active:bg-info-700",
|
||||
variant === "transparent" && "bg-transparent text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
|
||||
props.disabled && "opacity-50 cursor-not-allowed",
|
||||
className && className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
color='white'
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(ButtonIcon);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ClickAwayProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClickAway: (e: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
export default function ClickAway(props: ClickAwayProps) {
|
||||
const { children, open, onClickAway, className, ...rest } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const body = document.querySelector("body");
|
||||
const isScrollbarRendered = document.documentElement.clientHeight < document.documentElement.scrollHeight;
|
||||
const isMobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
if (open && isScrollbarRendered) {
|
||||
body?.style.setProperty("overflow", "hidden");
|
||||
if (!isMobile) body?.style.setProperty("padding-right", "4px");
|
||||
} else {
|
||||
body?.style.removeProperty("overflow");
|
||||
if (!isMobile) body?.style.removeProperty("padding-right");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-full h-full bg-primary-950/50 backdrop-blur-none transition-smooth",
|
||||
className && className,
|
||||
)}
|
||||
onClick={onClickAway}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import GridItem from "components/File/GridItem";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
|
||||
interface GridViewProps {
|
||||
data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
};
|
||||
isProtected: boolean;
|
||||
}
|
||||
|
||||
export default function ViewGrid(props: GridViewProps) {
|
||||
return (
|
||||
<div className='w-full gap-4 grid grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4'>
|
||||
{props.data.folders.map((folder) => (
|
||||
<GridItem
|
||||
key={folder.encryptedId}
|
||||
file={folder}
|
||||
isProtected={props.isProtected}
|
||||
/>
|
||||
))}
|
||||
{props.data.files.map((file) => (
|
||||
<GridItem
|
||||
key={file.encryptedId}
|
||||
file={file}
|
||||
isProtected={props.isProtected}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import ListItem from "components/File/ListItem";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
|
||||
interface ViewListProps {
|
||||
data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
};
|
||||
isProtected: boolean;
|
||||
}
|
||||
|
||||
export default function ViewList(props: ViewListProps) {
|
||||
return (
|
||||
<div className='w-full flex flex-col gap-2 rounded-lg'>
|
||||
{props.data.folders.map((folder) => (
|
||||
<ListItem
|
||||
key={folder.encryptedId}
|
||||
file={folder}
|
||||
isProtected={props.isProtected}
|
||||
/>
|
||||
))}
|
||||
{props.data.files.map((file) => (
|
||||
<ListItem
|
||||
key={file.encryptedId}
|
||||
file={file}
|
||||
isProtected={props.isProtected}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import { useRouter } from "next/router";
|
||||
import { poppins } from "pages/_app";
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import Button from "components/Button";
|
||||
import Markdown from "components/Markdown";
|
||||
|
||||
import { addNewPassword } from "utils/passwordHelper";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APIGetFileResponse } from "types/api/response";
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
import Header from "../Header";
|
||||
import PasswordLayout from "../Password";
|
||||
import ViewGrid from "./Grid";
|
||||
import ViewList from "./List";
|
||||
|
||||
const Loading = () => (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading view...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ExplorerLayoutProps {
|
||||
data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
};
|
||||
readmeFile: string | null;
|
||||
isProtected: boolean;
|
||||
isFileProtected?: boolean;
|
||||
encryptedId?: string;
|
||||
}
|
||||
|
||||
export default function ExplorerLayout(props: ExplorerLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<ExplorerLayoutProps["data"]>(props.data);
|
||||
const [view, setView] = useState<"grid" | "list">(() => {
|
||||
const viewType = localStorage.getItem(Constant.storage_ViewType);
|
||||
if (!viewType) {
|
||||
localStorage.setItem(Constant.storage_ViewType, "list");
|
||||
return "list";
|
||||
}
|
||||
if (viewType === "grid") {
|
||||
return "grid";
|
||||
} else {
|
||||
return "list";
|
||||
}
|
||||
});
|
||||
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
|
||||
const [isButtonLoading, setIsButtonLoading] = useState<boolean>(false);
|
||||
const [ViewComponent, setViewComponent] = useState<ReactNode>(
|
||||
<ViewList
|
||||
key={"file-explorer"}
|
||||
data={data}
|
||||
isProtected={props.isFileProtected ?? false}
|
||||
/>,
|
||||
);
|
||||
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
|
||||
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
new Promise(async (resolve) => {
|
||||
if (view === "list") {
|
||||
setViewComponent(
|
||||
<ViewList
|
||||
key={"file-explorer"}
|
||||
data={data}
|
||||
isProtected={props.isFileProtected ?? false}
|
||||
/>,
|
||||
);
|
||||
} else if (view === "grid") {
|
||||
setViewComponent(
|
||||
<ViewGrid
|
||||
key={"file-explorer"}
|
||||
data={data}
|
||||
isProtected={props.isFileProtected ?? false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
resolve(true);
|
||||
}).then(() => {
|
||||
setIsViewLoading(false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view, data]);
|
||||
useEffect(() => {
|
||||
setShowPasswordLayout(props.isProtected);
|
||||
}, [props.isProtected]);
|
||||
|
||||
useEffect(() => {
|
||||
const paths = router.asPath.split("/").filter((path) => path !== "");
|
||||
const newPaths: Record<"label" | "path", string>[] = [];
|
||||
paths.forEach((path, index) => {
|
||||
newPaths.push({
|
||||
label: path,
|
||||
path: `/${paths.slice(0, index + 1).join("/")}`,
|
||||
});
|
||||
});
|
||||
|
||||
setBreadcrumbPaths(newPaths);
|
||||
}, [router]);
|
||||
|
||||
const handleChangeView = useCallback(
|
||||
(view: "grid" | "list") => {
|
||||
setIsViewLoading(true);
|
||||
setView(view);
|
||||
localStorage.setItem(Constant.storage_ViewType, view);
|
||||
},
|
||||
[setView],
|
||||
);
|
||||
const handleLoadNextPage = useCallback(
|
||||
async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsButtonLoading(true);
|
||||
if (!data.pageToken) {
|
||||
setIsButtonLoading(false);
|
||||
return;
|
||||
}
|
||||
const newData = await axios.get<APIGetFileResponse>("/api/getData", {
|
||||
params: {
|
||||
encryptedId: props.encryptedId,
|
||||
pageToken: data.pageToken,
|
||||
},
|
||||
});
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
files: [...prev.files, ...newData.data.data.files],
|
||||
folders: [...prev.folders, ...newData.data.data.folders],
|
||||
pageToken: newData.data.data.pageToken,
|
||||
}));
|
||||
setIsButtonLoading(false);
|
||||
},
|
||||
[data, props.encryptedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='w-full flex h-full flex-col gap-4 relative'>
|
||||
{/* Top */}
|
||||
<Header
|
||||
viewSelector={{ view, setView: handleChangeView }}
|
||||
showPasswordLayout={showPasswordLayout}
|
||||
/>
|
||||
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
|
||||
{/* Header */}
|
||||
{!showPasswordLayout && (
|
||||
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb paths={breadcrumbPaths} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{showPasswordLayout ? (
|
||||
<PasswordLayout
|
||||
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 1] ?? "/")}
|
||||
onSubmitted={(password) => {
|
||||
const cookie =
|
||||
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
|
||||
undefined;
|
||||
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
|
||||
router.reload();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4",
|
||||
!props.readmeFile && "grid-cols-1 tablet:grid-cols-12 desktop:grid-cols-12",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"col-span-full desktop:col-span-8 overflow-auto",
|
||||
!props.readmeFile && "desktop:col-span-full",
|
||||
)}
|
||||
>
|
||||
{isViewLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className='w-full flex flex-col gap-4'>
|
||||
{data.files.length || data.folders.length ? (
|
||||
<>{ViewComponent}</>
|
||||
) : (
|
||||
<div className='w-full min-h-[5rem] grid place-items-center'>
|
||||
Can't find any files or folders in this directory
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.pageToken && (
|
||||
<Button
|
||||
variant='accent'
|
||||
fullWidth
|
||||
rounded='large'
|
||||
className='mt-4'
|
||||
loading={isButtonLoading}
|
||||
onClick={handleLoadNextPage}
|
||||
>
|
||||
Load more...
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.readmeFile && (
|
||||
<div className='col-span-full desktop:col-span-4'>
|
||||
<div
|
||||
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 rounded-lg sticky top-4 overflow-auto ${poppins.className}`}
|
||||
>
|
||||
<Markdown content={props.readmeFile} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import Button from "components/Button";
|
||||
import ButtonGroup from "components/ButtonGroup";
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
import Modal from "components/Modal";
|
||||
import Tooltip from "components/Tooltip";
|
||||
|
||||
import bytesToReadable from "utils/bytesFormat";
|
||||
import { getPreviewIcon } from "utils/previewHelper";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APISearchResponse } from "types/api/response";
|
||||
|
||||
interface HeaderProps {
|
||||
viewSelector?: {
|
||||
view: "grid" | "list";
|
||||
setView: (view: "grid" | "list") => void;
|
||||
};
|
||||
showPasswordLayout?: boolean;
|
||||
}
|
||||
|
||||
export default function Header(props: HeaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchModalOpen, setSearchModalOpen] = useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>("");
|
||||
const [axiosCancelToken, setAxiosCancelToken] = useState<ReturnType<typeof axios.CancelToken.source> | null>(null); // eslint-disable-line
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchResults, setSearchResults] = useState<(IGDriveFiles & { redirect: string })[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery) {
|
||||
setSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
setSearchLoading(true);
|
||||
if (axiosCancelToken) {
|
||||
axiosCancelToken.cancel();
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchModalOpen) {
|
||||
setSearchResults([]);
|
||||
setSearchQuery("");
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}, [searchModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchQuery) {
|
||||
setSearchLoading(false);
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const source = axios.CancelToken.source();
|
||||
setAxiosCancelToken(source);
|
||||
|
||||
axios
|
||||
.get<APISearchResponse>("/api/search", {
|
||||
params: {
|
||||
query: debouncedSearchQuery,
|
||||
},
|
||||
cancelToken: source.token,
|
||||
})
|
||||
.then((res) => {
|
||||
setSearchResults(res.data.files);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (axios.isCancel(err)) {
|
||||
return;
|
||||
} else {
|
||||
console.error(err);
|
||||
throw new Error(err);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setSearchLoading(false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<h1 className='text-2xl font-medium'>{gIndexConfig.siteConfig.siteName}</h1>
|
||||
|
||||
{!props.showPasswordLayout && (
|
||||
<div className='flex items-center gap-4'>
|
||||
{props.viewSelector && (
|
||||
<ButtonGroup className='border border-primary-700 tablet:flex hidden'>
|
||||
<Button
|
||||
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
|
||||
startIcon={"ion:list"}
|
||||
onClick={() => props.viewSelector?.setView("list")}
|
||||
>
|
||||
List view
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
|
||||
startIcon={"ion:grid"}
|
||||
onClick={() => props.viewSelector?.setView("grid")}
|
||||
>
|
||||
Grid view
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
<Tooltip content='Search file'>
|
||||
<ButtonIcon
|
||||
icon='ion:search'
|
||||
variant='transparent'
|
||||
onClick={() => {
|
||||
setSearchModalOpen(true);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!props.showPasswordLayout && props.viewSelector && (
|
||||
<div className='flex w-full items-center tablet:hidden'>
|
||||
<ButtonGroup className='border border-primary-700 w-full'>
|
||||
<Button
|
||||
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
|
||||
size='small'
|
||||
startIcon={"ion:list"}
|
||||
onClick={() => props.viewSelector?.setView("list")}
|
||||
>
|
||||
List view
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
|
||||
size='small'
|
||||
startIcon={"ion:grid"}
|
||||
onClick={() => props.viewSelector?.setView("grid")}
|
||||
>
|
||||
Grid view
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
key={"search-modal"}
|
||||
open={searchModalOpen}
|
||||
onClose={() => setSearchModalOpen(false)}
|
||||
title='Search'
|
||||
>
|
||||
<div className='w-full flex flex-col gap-2'>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
placeholder='Input search query'
|
||||
className='w-full px-4 py-2 bg-primary-900 outline-none border border-primary-500 rounded-lg'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{searchLoading ? (
|
||||
<div className='w-full py-8 flex items-center justify-center gap-2 animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Searching file...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-2 max-h-96 overflow-auto'>
|
||||
{searchResults.length === 0 ? (
|
||||
<div className='w-full py-8 flex items-center justify-center gap-2'>
|
||||
<span>No results found</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{searchResults.map((file) => (
|
||||
<Link
|
||||
href={file.redirect}
|
||||
key={file.encryptedId}
|
||||
className='flex items-center gap-4 w-full flex-grow-0 flex-shrink bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
|
||||
>
|
||||
{file.thumbnailLink &&
|
||||
(file.mimeType.startsWith("video") || file.mimeType.startsWith("image")) ? (
|
||||
<img
|
||||
src={file.thumbnailLink}
|
||||
alt={file.name}
|
||||
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
|
||||
loading='lazy'
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
icon={
|
||||
file.mimeType === "application/vnd.google-apps.folder"
|
||||
? "ion:folder"
|
||||
: getPreviewIcon(file.fileExtension!, file.mimeType!)
|
||||
}
|
||||
color='currentColor'
|
||||
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
|
||||
/>
|
||||
)}
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-medium'>{file.name}</span>
|
||||
<div className='flex items-center justify-start gap-2 text-primary-500'>
|
||||
<span className='text-sm whitespace-nowrap'>
|
||||
{[
|
||||
file.mimeType && file.mimeType.includes("folder") && "Folder",
|
||||
file.fileExtension && file.fileExtension.toUpperCase(),
|
||||
]
|
||||
.filter((item) => item)
|
||||
.join(" ・ ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-start gap-2 text-primary-500'>
|
||||
<span className='text-sm whitespace-nowrap'>
|
||||
{[
|
||||
new Date(file.modifiedTime).toLocaleDateString("default", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
file.size ? bytesToReadable(file.size) : undefined,
|
||||
]
|
||||
.filter((item) => item)
|
||||
.join(" ・ ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import gIndexConfig from "config";
|
||||
import { NextSeo, NextSeoProps } from "next-seo";
|
||||
import { useRouter } from "next/router";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
addNewPassword,
|
||||
checkPathPassword,
|
||||
} from "utils/passwordHelper";
|
||||
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
import PasswordLayout from "../Password";
|
||||
|
||||
interface LoaderLayoutProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
seo?: NextSeoProps;
|
||||
}
|
||||
|
||||
export default function LoaderLayout(
|
||||
props: LoaderLayoutProps,
|
||||
) {
|
||||
const router = useRouter();
|
||||
const { children, seo, className, ...rest } = props;
|
||||
const sitePassword =
|
||||
process.env.NEXT_PUBLIC_SITE_PASSWORD;
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isPasswordUnlocked, setIsPasswordUnlocked] =
|
||||
useState<boolean>(
|
||||
gIndexConfig.siteConfig.privateIndex ? false : true,
|
||||
);
|
||||
|
||||
const handlePrivateIndexCheck = () => {
|
||||
const cookie =
|
||||
document.cookie
|
||||
.split(";")
|
||||
.find((cookie) =>
|
||||
cookie.startsWith(
|
||||
`${Constant.cookies_SitePassword}=`,
|
||||
),
|
||||
) ?? undefined;
|
||||
if (cookie) {
|
||||
const password = cookie.split("=")[1];
|
||||
if (!password) return setIsPasswordUnlocked(false);
|
||||
if (!sitePassword)
|
||||
throw new Error("Can't find site password");
|
||||
|
||||
const valid = checkPathPassword(
|
||||
"site-wide",
|
||||
password,
|
||||
sitePassword as string,
|
||||
);
|
||||
if (valid) return setIsPasswordUnlocked(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
new Promise((resolve) =>
|
||||
resolve(handlePrivateIndexCheck()),
|
||||
).then(() => setIsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const routeChangeStart = () => setIsLoading(true);
|
||||
const routeChangeComplete = () => {
|
||||
handlePrivateIndexCheck();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", routeChangeStart);
|
||||
router.events.on(
|
||||
"routeChangeComplete",
|
||||
routeChangeComplete,
|
||||
);
|
||||
router.events.on(
|
||||
"routeChangeError",
|
||||
routeChangeComplete,
|
||||
);
|
||||
|
||||
return () => {
|
||||
router.events.off(
|
||||
"routeChangeStart",
|
||||
routeChangeStart,
|
||||
);
|
||||
router.events.off(
|
||||
"routeChangeComplete",
|
||||
routeChangeComplete,
|
||||
);
|
||||
router.events.off(
|
||||
"routeChangeError",
|
||||
routeChangeComplete,
|
||||
);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.events]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo {...seo} />
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Validating path...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`w-full h-auto flex flex-col p-4 tablet:px-8 desktop:px-16 ${className}`}
|
||||
{...rest}
|
||||
key={router.asPath}
|
||||
>
|
||||
{gIndexConfig.siteConfig.privateIndex &&
|
||||
!isPasswordUnlocked ? (
|
||||
<PasswordLayout
|
||||
path='Private Mode Password'
|
||||
onSubmitted={(password) => {
|
||||
const cookie =
|
||||
document.cookie
|
||||
.split(";")
|
||||
.find((cookie) =>
|
||||
cookie.startsWith(
|
||||
`${Constant.cookies_SitePassword}=`,
|
||||
),
|
||||
) ?? undefined;
|
||||
addNewPassword(
|
||||
"site-wide",
|
||||
password,
|
||||
cookie?.split("=")[1] ?? undefined,
|
||||
);
|
||||
setIsLoading(true);
|
||||
const validPassword = () => {
|
||||
const cookie =
|
||||
document.cookie
|
||||
.split(";")
|
||||
.find((cookie) =>
|
||||
cookie.startsWith(
|
||||
`${Constant.cookies_SitePassword}=`,
|
||||
),
|
||||
) ?? undefined;
|
||||
if (cookie) {
|
||||
const password = cookie.split("=")[1];
|
||||
if (!password) return false;
|
||||
if (!sitePassword)
|
||||
throw new Error(
|
||||
"Can't find site password",
|
||||
);
|
||||
|
||||
const valid = checkPathPassword(
|
||||
"site-wide",
|
||||
password,
|
||||
sitePassword as string,
|
||||
);
|
||||
return valid;
|
||||
}
|
||||
};
|
||||
new Promise((resolve) =>
|
||||
resolve(validPassword()),
|
||||
)
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
setIsPasswordUnlocked(true);
|
||||
} else {
|
||||
setIsPasswordUnlocked(false);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "components/Button";
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
|
||||
interface PasswordLayoutProps {
|
||||
onSubmitted: (password: string) => void;
|
||||
path: string;
|
||||
}
|
||||
export default function PasswordLayout(
|
||||
props: PasswordLayoutProps,
|
||||
) {
|
||||
const router = useRouter();
|
||||
const [showPassword, setShowPassword] =
|
||||
useState<boolean>(false);
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [pending, setPending] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className='col-span-full text-center min-h-[50vh] flex-grow flex flex-col items-center justify-center'>
|
||||
<figure>
|
||||
<img
|
||||
src='/images/password.png'
|
||||
alt="You don't have access"
|
||||
className='w-64 h-64 tablet:w-96 tablet:h-96'
|
||||
/>
|
||||
</figure>
|
||||
|
||||
<div className='w-full flex items-center justify-center flex-col my-8'>
|
||||
<span className='text-lg font-medium'>
|
||||
The folder / file you are trying to access is
|
||||
protected with a password.
|
||||
</span>
|
||||
<pre className='max-w-sm w-full tablet:max-w-2xl'>
|
||||
<code className='text-sm'>{props.path}</code>
|
||||
</pre>
|
||||
<span>Please enter the password to continue:</span>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSubmitted(password);
|
||||
setPending(true);
|
||||
}}
|
||||
className='w-full max-w-lg flex items-center border border-primary-500 rounded-lg'
|
||||
>
|
||||
<div className='w-full flex items-center px-2 bg-primary-900 rounded-l-lg overflow-hidden'>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete='off'
|
||||
className='w-full px-2 py-2 bg-primary-900 outline-none'
|
||||
placeholder='Enter password'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<ButtonIcon
|
||||
type='button'
|
||||
variant='transparent'
|
||||
icon={showPassword ? "ion:eye-off" : "ion:eye"}
|
||||
size='small'
|
||||
className='p-1'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='accent'
|
||||
type='submit'
|
||||
className='rounded-r-lg w-24'
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Icon
|
||||
icon='mdi:loading'
|
||||
className='animate-spin'
|
||||
width={24}
|
||||
height={24}
|
||||
color='currentColor'
|
||||
/>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import gIndexConfig from "config";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { poppins } from "pages/_app";
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import Button from "components/Button";
|
||||
|
||||
import bytesToReadable from "utils/bytesFormat";
|
||||
import { durationToReadable } from "utils/durationFormat";
|
||||
import { decryptData } from "utils/encryptionHelper/hash";
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
import { addNewPassword } from "utils/passwordHelper";
|
||||
import { getPreviewComponent } from "utils/previewHelper";
|
||||
import { createDownloadToken } from "utils/tokenHelper";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
import PasswordLayout from "../Password";
|
||||
|
||||
const Loading = () => (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading view...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PreviewLayoutProps {
|
||||
data: {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
};
|
||||
isProtected: boolean;
|
||||
isFileProtected: boolean;
|
||||
encryptedId?: string;
|
||||
}
|
||||
|
||||
export default function PreviewLayout(props: PreviewLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<PreviewLayoutProps["data"]>(props.data);
|
||||
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
|
||||
const [PreviewComponent, setPreviewComponent] = useState<ReactNode>();
|
||||
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
|
||||
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
|
||||
const [downloadToken] = useState<string | null>(() => {
|
||||
if (gIndexConfig.apiConfig.allowDownloadProtectedFile) return null;
|
||||
|
||||
if (props.isFileProtected) {
|
||||
return createDownloadToken();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [expiredIn, setExpiredIn] = useState<string>("");
|
||||
|
||||
const fileMetadata = useMemo<
|
||||
{
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | number | null;
|
||||
}[]
|
||||
>(() => {
|
||||
const metadata = [];
|
||||
if (data.file) {
|
||||
if (data.file.name)
|
||||
metadata.push({
|
||||
key: "name",
|
||||
label: "Name",
|
||||
value: data.file.name,
|
||||
});
|
||||
if (data.file.mimeType)
|
||||
metadata.push({
|
||||
key: "mimeType",
|
||||
label: "Type",
|
||||
value: data.file.mimeType,
|
||||
});
|
||||
if (data.file.modifiedTime)
|
||||
metadata.push({
|
||||
key: "modifiedTime",
|
||||
label: "Last Modified",
|
||||
value: new Date(data.file.modifiedTime).toLocaleDateString("default", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
});
|
||||
if (data.file.size)
|
||||
metadata.push({
|
||||
key: "size",
|
||||
label: "Size",
|
||||
value: bytesToReadable(data.file.size),
|
||||
});
|
||||
if (data.file.imageMediaMetadata && data.file.imageMediaMetadata.width && data.file.imageMediaMetadata.height)
|
||||
metadata.push({
|
||||
key: "imageMediaMetadata",
|
||||
label: "Dimension",
|
||||
value: `${data.file.imageMediaMetadata.width}x${data.file.imageMediaMetadata.height}`,
|
||||
});
|
||||
if (data.file.videoMediaMetadata)
|
||||
metadata.push(
|
||||
{
|
||||
key: "videoMediaMetadata",
|
||||
label: "Dimension",
|
||||
value: `${data.file.videoMediaMetadata.width}x${data.file.videoMediaMetadata.height}`,
|
||||
},
|
||||
{
|
||||
key: "videoMediaMetadata",
|
||||
label: "Duration",
|
||||
value: durationToReadable(data.file.videoMediaMetadata.durationMillis),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}, [data.file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadToken) return;
|
||||
const interval = setInterval(() => {
|
||||
const expiredAt = Number(decryptData(downloadToken));
|
||||
const date = new Date(expiredAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
const hours = Math.floor(diff / 1000 / 60 / 60);
|
||||
const minutes = Math.floor(diff / 1000 / 60) % 60;
|
||||
const seconds = Math.floor(diff / 1000) % 60;
|
||||
|
||||
setExpiredIn(`${hours}:${minutes}:${seconds}`);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [downloadToken]);
|
||||
|
||||
useEffect(() => {
|
||||
new Promise(async (resolve) => {
|
||||
const Preview = getPreviewComponent(props.data.file?.fileExtension ?? "unknown", props.data.file?.mimeType ?? "");
|
||||
setPreviewComponent(<Preview file={props.data.file!} />);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
resolve(true);
|
||||
}).then(() => {
|
||||
setIsViewLoading(false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
useEffect(() => {
|
||||
setShowPasswordLayout(props.isProtected);
|
||||
}, [props.isProtected]);
|
||||
|
||||
useEffect(() => {
|
||||
const paths = router.asPath.split("/").filter((path) => path !== "");
|
||||
const newPaths: Record<"label" | "path", string>[] = [];
|
||||
paths.forEach((path, index) => {
|
||||
newPaths.push({
|
||||
label: path,
|
||||
path: `/${paths.slice(0, index + 1).join("/")}`,
|
||||
});
|
||||
});
|
||||
|
||||
setBreadcrumbPaths(newPaths);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className='w-full flex h-full flex-col gap-4 relative'>
|
||||
{/* Top */}
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<h1 className='text-2xl font-medium'>{gIndexConfig.siteConfig.siteName}</h1>
|
||||
|
||||
{!showPasswordLayout && (
|
||||
<div className='flex items-center gap-4'>
|
||||
{/* Disable until figure out how to search from root */}
|
||||
{/* <Tooltip content='Search file'>
|
||||
<ButtonIcon
|
||||
icon='ion:search'
|
||||
variant='transparent'
|
||||
/>
|
||||
</Tooltip> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
|
||||
{/* Header */}
|
||||
{!showPasswordLayout && (
|
||||
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb paths={breadcrumbPaths} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{showPasswordLayout ? (
|
||||
<PasswordLayout
|
||||
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 2] ?? "/")}
|
||||
onSubmitted={(password) => {
|
||||
const cookie =
|
||||
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
|
||||
undefined;
|
||||
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
|
||||
router.reload();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={twMerge("w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4")}>
|
||||
<div className={twMerge("col-span-full desktop:col-span-8 overflow-auto")}>
|
||||
{isViewLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className='w-full flex flex-col gap-4'>
|
||||
<div
|
||||
className={`w-full flex flex-col gap-2 bg-primary-900 p-4 mb-4 rounded-lg overflow-auto ${poppins.className}`}
|
||||
>
|
||||
{PreviewComponent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='col-span-full desktop:col-span-4'>
|
||||
<div className='sticky top-4'>
|
||||
<div
|
||||
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 mb-4 rounded-lg overflow-auto ${poppins.className}`}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon
|
||||
icon={"ion:information-circle"}
|
||||
className='w-6 h-6'
|
||||
/>
|
||||
<h2 className='text-2xl font-medium'>File info</h2>
|
||||
</div>
|
||||
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
{fileMetadata.map((item) => (
|
||||
<div
|
||||
className='grid grid-cols-3'
|
||||
key={`${item.key}-${item.label.toLowerCase()}`}
|
||||
>
|
||||
<span className='col-span-1 font-bold'>{item.label}</span>
|
||||
<p className='col-span-2 break-all whitespace-pre-line'>{item.value ?? "-"}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 my-4 rounded-lg overflow-auto ${poppins.className}`}
|
||||
>
|
||||
<Link
|
||||
href={generatedDownloadLink(data.file!, props.isFileProtected, false, downloadToken)}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='accent'
|
||||
startIcon={"ion:download"}
|
||||
rounded='large'
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='primary'
|
||||
startIcon={"ion:copy"}
|
||||
rounded='large'
|
||||
onClick={() => {
|
||||
try {
|
||||
const url = generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken);
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success("URL copied to clipboard.");
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to copy URL to clipboard.");
|
||||
console.error(error.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy URL
|
||||
</Button>
|
||||
<div className='w-full px-2 bg-primary-950 outline-none rounded-lg border border-primary-500 select-all'>
|
||||
<div className='overflow-x-scroll py-2 whitespace-nowrap'>
|
||||
{generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken)}
|
||||
</div>
|
||||
</div>
|
||||
{!gIndexConfig.apiConfig.allowDownloadProtectedFile && props.isFileProtected && (
|
||||
<span className='text-center mt-4 text-sm'>
|
||||
This file is inside protected folder.
|
||||
<br />
|
||||
Download link will be expired in <b>{expiredIn}</b>.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { jetbrainsMono } from "pages/_app";
|
||||
import { useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "react-toastify";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypePrism from "rehype-prism-plus";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkSlug from "remark-slug";
|
||||
import remarkToc from "remark-toc";
|
||||
|
||||
import Button from "components/Button";
|
||||
|
||||
import "styles/highlight.css";
|
||||
import "styles/markdown.css";
|
||||
|
||||
interface PreProps extends React.HTMLAttributes<HTMLPreElement> {}
|
||||
function PreComponent(props: PreProps) {
|
||||
const codeRef = useRef<HTMLPreElement | null>(null);
|
||||
const [showCopy, setShowCopy] = useState<boolean>(false);
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [copyTimeout, setCopyTimeout] = useState<NodeJS.Timeout>();
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!codeRef.current) return;
|
||||
if (copyTimeout) clearTimeout(copyTimeout);
|
||||
try {
|
||||
navigator.clipboard.writeText(codeRef.current.textContent as string);
|
||||
setIsError(false);
|
||||
setIsCopied(true);
|
||||
setCopyTimeout(
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 2000),
|
||||
);
|
||||
toast.success("Code copied to clipboard");
|
||||
} catch (error: any) {
|
||||
setIsCopied(false);
|
||||
setIsError(true);
|
||||
setCopyTimeout(
|
||||
setTimeout(() => {
|
||||
setIsError(false);
|
||||
}, 2000),
|
||||
);
|
||||
toast.error("Failed to copy code to clipboard");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full h-full relative`}
|
||||
onMouseEnter={() => {
|
||||
setShowCopy(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setShowCopy(false);
|
||||
setIsCopied(false);
|
||||
setIsError(false);
|
||||
}}
|
||||
>
|
||||
<div className={`absolute top-0 right-0 p-2 ${showCopy ? "opacity-100" : "opacity-0"} transition-smooth`}>
|
||||
<Button
|
||||
size='small'
|
||||
rounded='medium'
|
||||
variant={isError ? "danger" : isCopied ? "success" : "primary"}
|
||||
startIcon={isCopied ? "ion:checkmark" : isError ? "ion:close" : "ion:copy"}
|
||||
disabled={isCopied || isError}
|
||||
onClick={() => {
|
||||
handleCopy();
|
||||
}}
|
||||
>
|
||||
{isCopied ? "Copied!" : isError ? "Failed to copy" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
ref={codeRef}
|
||||
{...props}
|
||||
className='overflow-x-auto'
|
||||
>
|
||||
{props.children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
}
|
||||
export default function Markdown(props: MarkdownProps) {
|
||||
return (
|
||||
<div className='markdown w-full'>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw, [rehypePrism, { ignoreMissing: true }]]}
|
||||
components={{
|
||||
pre: PreComponent,
|
||||
code: ({ node, inline, className, children, ...props }) => (
|
||||
<code
|
||||
className={`${className} ${jetbrainsMono.className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{props.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
import ClickAway from "components/ClickAway";
|
||||
|
||||
interface ModalProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
footer?: ReactNode;
|
||||
keepMounted?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal(props: ModalProps) {
|
||||
let DELAY = 10;
|
||||
|
||||
const [showModal, setShowModal] = useState<boolean>(props.open);
|
||||
const [keepMounted, setKeepMounted] = useState<boolean>(false);
|
||||
const [shouldRender, setShouldRender] = useState<boolean>(false);
|
||||
|
||||
const [shouldUnmount, setShouldUnmount] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClose = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleClose);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleClose);
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
if (showModal && !shouldRender) {
|
||||
timeoutId = setTimeout(() => {
|
||||
setShouldRender(true);
|
||||
}, DELAY);
|
||||
} else if (!showModal && shouldRender) {
|
||||
timeoutId = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
}, DELAY);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [showModal, shouldRender, DELAY]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(props.open);
|
||||
if (props.open) setShouldUnmount(false);
|
||||
}, [props.open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal && props.keepMounted) setKeepMounted(true);
|
||||
}, [showModal, props.keepMounted]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowModal(false);
|
||||
if (!keepMounted) setKeepMounted(false);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!showModal && !keepMounted && shouldRender) {
|
||||
// const timeout = setTimeout(() => {
|
||||
// setShouldUnmount(true);
|
||||
// }, 250);
|
||||
// return () => {
|
||||
// clearTimeout(timeout);
|
||||
// };
|
||||
// } else {
|
||||
// setShouldUnmount(false);
|
||||
// }
|
||||
// }, [showModal, keepMounted, shouldRender, DELAY]);
|
||||
if (shouldUnmount) return null;
|
||||
|
||||
return (
|
||||
<ClickAway
|
||||
open={showModal}
|
||||
onClickAway={handleClose}
|
||||
className={`fixed grid place-items-center top-0 left-0 z-[60] w-full h-full transition-smooth ${
|
||||
shouldRender ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
onTransitionEnd={() => {
|
||||
if (!showModal && !keepMounted) {
|
||||
setShouldUnmount(true);
|
||||
} else {
|
||||
setShouldUnmount(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='w-full px-4 max-w-screen-lg'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className='bg-primary-900 text-primary-50 rounded-lg py-2 px-4 flex flex-col gap-2 w-full'>
|
||||
{/* Header */}
|
||||
<div className='flex flex-row items-center justify-between py-2'>
|
||||
<span className='text-xl font-medium w-full line-clamp-1'>{props.title}</span>
|
||||
<ButtonIcon
|
||||
icon={"ion:close"}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
|
||||
{/* Body */}
|
||||
<div className='py-2'>{props.children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{props.footer && <div className='py-2'>{props.footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</ClickAway>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import AudioPlayer from "react-h5-audio-player";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function AudioPreview(props: PreviewProps) {
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{isError ? (
|
||||
<ErrorPreview />
|
||||
) : (
|
||||
<AudioPlayer
|
||||
src={generatedDownloadLink(props.file, false)}
|
||||
showJumpControls={false}
|
||||
showFilledVolume
|
||||
style={{
|
||||
borderRadius: "0.375rem",
|
||||
}}
|
||||
onError={() => setIsError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Markdown from "components/Markdown";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function CodePreview(props: PreviewProps) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get(generatedDownloadLink(props.file, false))
|
||||
.then((res) => {
|
||||
const isObject = typeof res.data === "object";
|
||||
setContent(
|
||||
`\`\`\`${props.file.fileExtension}\n${isObject ? JSON.stringify(res.data, null, 2) : res.data}\`\`\``,
|
||||
);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsError(true);
|
||||
});
|
||||
}, [props.file, props.file.fileExtension]);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading content...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
|
||||
import gIndexConfig from "config";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
function LoadingRender() {
|
||||
return (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading content...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ErrorRender() {
|
||||
return <ErrorPreview />;
|
||||
}
|
||||
|
||||
export default function DocumentPreview(props: PreviewProps) {
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<DocViewer
|
||||
documents={[
|
||||
{
|
||||
uri: generatedDownloadLink(props.file, false, true),
|
||||
},
|
||||
]}
|
||||
pluginRenderers={DocViewerRenderers}
|
||||
config={{
|
||||
header: {
|
||||
disableHeader: true,
|
||||
},
|
||||
loadingRenderer: {
|
||||
overrideComponent: LoadingRender,
|
||||
},
|
||||
noRenderer: {
|
||||
overrideComponent: ErrorRender,
|
||||
},
|
||||
}}
|
||||
className='min-h-[70vh] max-h-[70vh] w-full h-full rounded-md'
|
||||
theme={{
|
||||
disableThemeScrollbar: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
interface ErrorPreviewProps {
|
||||
error?: string;
|
||||
}
|
||||
export default function ErrorPreview(props: ErrorPreviewProps) {
|
||||
return (
|
||||
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
|
||||
<h2 className='text-2xl font-bold'>Failed to load file preview</h2>
|
||||
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
|
||||
Error has been occured while loading preview, it may be caused by{" "}
|
||||
<span className='font-medium underline'>invalid file type</span> or{" "}
|
||||
<span className='font-medium underline'>network error</span>.
|
||||
<br />
|
||||
You can try again later or download the file to view.
|
||||
</span>
|
||||
|
||||
{props.error && (
|
||||
<>
|
||||
<span className='font-medium underline'>Error details</span>
|
||||
<span className='text-sm tablet:text-base text-primary-300 my-2'>{props.error}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import gIndexConfig from "config";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function ImagePreview(props: PreviewProps) {
|
||||
const [imgSrc, setImgSrc] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setIsError(false);
|
||||
setIsLoading(true);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setIsError(true);
|
||||
setErrorMessage("Image took too long to load");
|
||||
return;
|
||||
}, 15000);
|
||||
|
||||
fetch(generatedDownloadLink(props.file, false))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImgSrc(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsError(true);
|
||||
setErrorMessage(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [props.file]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading image...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isError ? (
|
||||
<ErrorPreview />
|
||||
) : (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={props.file.name}
|
||||
className='w-full h-full rounded-md'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import JSZip from "jszip";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import ButtonIcon from "components/ButtonIcon";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function MangaPreview(props: PreviewProps) {
|
||||
const [maxPageIndex, setMaxPageIndex] = useState<number>(4);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [viewPageIndex, setViewPageIndex] = useState<number>(0);
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
|
||||
|
||||
useEffect(() => {
|
||||
const mangaImages: string[] = [];
|
||||
axios
|
||||
.get(generatedDownloadLink(props.file, false, false, null, true), {
|
||||
responseType: "blob",
|
||||
})
|
||||
.then(async (res) => {
|
||||
const zipData = await JSZip.loadAsync(res.data);
|
||||
for (let i = 0; i <= maxPageIndex; i++) {
|
||||
const file = zipData.file(Object.keys(zipData.files)[i]);
|
||||
if (file) {
|
||||
const blob = await file.async("blob");
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
mangaImages.push(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
}
|
||||
setImages(mangaImages);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [props.file, maxPageIndex]);
|
||||
|
||||
const handleCurrentView = useCallback(
|
||||
(pageNumber: number) => {
|
||||
if (pageNumber < 0 || pageNumber > maxPageIndex) return;
|
||||
setViewPageIndex(pageNumber);
|
||||
},
|
||||
[maxPageIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
<blockquote className='w-full h-auto flex items-center my-2 bg-primary-800 border-l-4 border-l-accent-500 pl-3 pr-2 py-2 text-sm tablet:text-base rounded-md'>
|
||||
Preview only shows the first 5 pages. Please download the file to view the rest.
|
||||
</blockquote>
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading content...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isError ? (
|
||||
<ErrorPreview />
|
||||
) : (
|
||||
<div className='flex flex-col'>
|
||||
<div className='w-full h-full relative'>
|
||||
<img
|
||||
src={images[viewPageIndex]}
|
||||
alt={`${props.file.name} - page ${viewPageIndex}`}
|
||||
className={`w-full h-full object-contain bg-primary-950 rounded-md ${
|
||||
viewSize === "fit" ? "max-h-[70vh]" : ""
|
||||
}`}
|
||||
/>
|
||||
<ButtonIcon
|
||||
icon={viewSize === "fit" ? "ion:expand" : "ion:contract"}
|
||||
className='absolute bottom-2 right-2'
|
||||
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-row gap-2 items-center justify-center my-2'>
|
||||
<ButtonIcon
|
||||
variant='transparent'
|
||||
icon={"ion:chevron-back"}
|
||||
onClick={() => handleCurrentView(viewPageIndex - 1)}
|
||||
disabled={viewPageIndex === 0}
|
||||
/>
|
||||
<span>
|
||||
{viewPageIndex + 1} / {maxPageIndex + 1}
|
||||
</span>
|
||||
<ButtonIcon
|
||||
variant='transparent'
|
||||
icon={"ion:chevron-forward"}
|
||||
onClick={() => handleCurrentView(viewPageIndex + 1)}
|
||||
disabled={viewPageIndex === maxPageIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Markdown from "components/Markdown";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function RichPreview(props: PreviewProps) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get(generatedDownloadLink(props.file))
|
||||
.then((res) => {
|
||||
setContent(res.data);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsError(true);
|
||||
});
|
||||
}, [props.file]);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{isLoading ? (
|
||||
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Loading content...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export default function UnknownPreview() {
|
||||
return (
|
||||
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
|
||||
<h2 className='text-2xl font-bold'>File preview not available</h2>
|
||||
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
|
||||
This file type is not supported for preview.
|
||||
<br />
|
||||
You can download the file to view.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import ReactPlayer from "react-player";
|
||||
|
||||
import { generatedDownloadLink } from "utils/generateDownloadLink";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
import ErrorPreview from "./Error";
|
||||
|
||||
export default function VideoPreview(props: PreviewProps) {
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{isError ? (
|
||||
<ErrorPreview />
|
||||
) : (
|
||||
<ReactPlayer
|
||||
key={props.file.encryptedId}
|
||||
url={generatedDownloadLink(props.file, false)}
|
||||
controls
|
||||
pip
|
||||
wrapper={({ children }) => (
|
||||
<div className='w-full h-[60vh] bg-primary-950 rounded-md overflow-hidden'>{children}</div>
|
||||
)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
maxHeight: "60vh",
|
||||
}}
|
||||
onError={() => setIsError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Popper, PopperProps } from "react-popper";
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
placement?: PopperProps<unknown>["placement"];
|
||||
delay?: number;
|
||||
}
|
||||
export default function Tooltip(props: TooltipProps) {
|
||||
const [refElement, setRefElement] = useState<HTMLElement | null>(null);
|
||||
const [showTooltip, setShowTooltip] = useState<boolean>(false);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseEnter() {
|
||||
if (props.delay) {
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setShowTooltip(true);
|
||||
}, props.delay);
|
||||
} else {
|
||||
setShowTooltip(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (props.delay) {
|
||||
window.clearTimeout(timeoutRef.current ?? undefined);
|
||||
}
|
||||
setShowTooltip(false);
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
setShowTooltip(false);
|
||||
}
|
||||
|
||||
if (refElement) {
|
||||
refElement.addEventListener("mouseenter", handleMouseEnter);
|
||||
refElement.addEventListener("mouseleave", handleMouseLeave);
|
||||
refElement.addEventListener("click", handleMouseLeave);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refElement) {
|
||||
refElement.removeEventListener("mouseenter", handleMouseEnter);
|
||||
refElement.removeEventListener("mouseleave", handleMouseLeave);
|
||||
refElement.removeEventListener("click", handleMouseLeave);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
};
|
||||
}, [refElement, props.delay]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setRefElement}>{props.children}</div>
|
||||
<Popper
|
||||
placement={props.placement ?? "top"}
|
||||
modifiers={[
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 10],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hide",
|
||||
enabled: true,
|
||||
phase: "main",
|
||||
requires: ["computeStyles"],
|
||||
fn: ({ state }) => {
|
||||
const { height, width, x, y } = state.rects.reference;
|
||||
const { innerHeight, innerWidth } = window;
|
||||
state.styles.popper = {
|
||||
...state.styles.popper,
|
||||
visibility: height + y > innerHeight || width + x > innerWidth ? "hidden" : "visible",
|
||||
};
|
||||
},
|
||||
},
|
||||
]}
|
||||
referenceElement={refElement as HTMLElement}
|
||||
strategy='absolute'
|
||||
>
|
||||
{({ ref, style, placement }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
data-placement={placement}
|
||||
className={`bg-primary-800 max-w-sm text-primary-50 text-sm font-medium px-2 py-1 rounded-md shadow-md transition-smooth ${
|
||||
showTooltip ? `opacity-100 pointer-events-auto` : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{props.content}
|
||||
</div>
|
||||
)}
|
||||
</Popper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}</>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import colors from "tailwindcss/colors";
|
||||
|
||||
const config: gIndexConfig = {
|
||||
version: "1.0.0",
|
||||
/**
|
||||
* Base path of the app, used for generating links
|
||||
* If you're not using Vercel, you need to change this to your domain name
|
||||
*
|
||||
* 2023/09/10: This is not used anymore, edit via env variable instead
|
||||
*
|
||||
* @default process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
*/
|
||||
basePath:
|
||||
process.env.NODE_ENV === "development" ? "http://localhost:3000" : `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`,
|
||||
|
||||
/**
|
||||
* Hashed key for fetching protected files / folders from the server
|
||||
* This key will bypass the file / folder password
|
||||
*
|
||||
* 2023/09/10: This is not used anymore
|
||||
*/
|
||||
masterKey: "masterkey",
|
||||
|
||||
/**
|
||||
* How long the cache will be stored in the browser
|
||||
* Used for all pages and api routes
|
||||
* Default is 5 minutes (300/60 = 5min)
|
||||
*
|
||||
* @default "max-age=300, s-maxage=300, stale-while-revalidate, public"
|
||||
*/
|
||||
cacheControl: "max-age=300, s-maxage=300, stale-while-revalidate, public",
|
||||
|
||||
apiConfig: {
|
||||
/**
|
||||
* Starting point of the drive.
|
||||
* Will be used for '/' route.
|
||||
*
|
||||
* Since service account can't access 'root' folder
|
||||
* You need to create a new folder and share it with the service account
|
||||
* Then, copy the folder id and paste it here
|
||||
*/
|
||||
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j",
|
||||
|
||||
defaultQuery: ["trashed = false", "(not mimeType contains 'google-apps' or mimeType contains 'folder')"],
|
||||
defaultField:
|
||||
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed",
|
||||
defaultOrder: "folder, name asc, modifiedTime desc",
|
||||
itemsPerPage: 50,
|
||||
searchResult: 5,
|
||||
|
||||
/**
|
||||
* Special file name that will be used for certain purposes
|
||||
* These files will be ignored when searching for files
|
||||
* and will be hidden from the files list by default
|
||||
*/
|
||||
specialFile: {
|
||||
password: ".password",
|
||||
readme: ".readme.md",
|
||||
/**
|
||||
* Banner will be used for opengraph image for folder
|
||||
* By default, all folder will use default og image
|
||||
*/
|
||||
banner: ".banner",
|
||||
},
|
||||
hiddenFiles: [".password", ".readme.md", ".banner"],
|
||||
|
||||
/**
|
||||
* Allow user to download protected file without password.
|
||||
* If this set to false, download link will have temporary token attached to it
|
||||
* If this set to true, user can download the file without password as long as they have the link
|
||||
*
|
||||
* Default: false
|
||||
*/
|
||||
allowDownloadProtectedFile: false,
|
||||
/**
|
||||
* Duration in hours.
|
||||
*
|
||||
* Default: 6 hours
|
||||
*/
|
||||
temporaryTokenDuration: 6,
|
||||
|
||||
/**
|
||||
* Maximum file size that can be downloaded via api routes
|
||||
* If it's larger than this, it will be redirected to the file url
|
||||
*
|
||||
* If you're using Vercel, they have a limit of ~4 - ~4.5MB response size
|
||||
* ref: https://vercel.com/docs/platform/limits#serverless-function-payload-size-limit
|
||||
* If you're using another platform, you can match the limit with your platform
|
||||
* Or you can set this to 0 to disable the limit
|
||||
*
|
||||
* Default: 4MB
|
||||
*/
|
||||
maxFileSize: 4 * 1024 * 1024,
|
||||
},
|
||||
|
||||
siteConfig: {
|
||||
/**
|
||||
* This value will be used for metadata
|
||||
*/
|
||||
siteName: "next-gdrive-index",
|
||||
siteDescription: "A simple file browser for Google Drive",
|
||||
siteIcon: "/flaticon.svg",
|
||||
favIcon: "/favicon.svg",
|
||||
twitterHandle: "@mbaharip_",
|
||||
|
||||
/**
|
||||
* Tailwind color name.
|
||||
* Ref: https://tailwindcss.com/docs/customizing-colors
|
||||
*/
|
||||
defaultAccentColor: "teal",
|
||||
|
||||
/**
|
||||
* Site wide password protection
|
||||
* If this is set, all files and folders will be protected by this password
|
||||
*
|
||||
* The site password are set from Environment Variable (NEXT_GDRIVE_INDEX_PASSWORD)
|
||||
* It's because I don't want to store sensitive data in the code
|
||||
*/
|
||||
privateIndex: false,
|
||||
|
||||
/**
|
||||
* Example item:
|
||||
* {
|
||||
* icon: string, // icon name from iconify (https://icon-sets.iconify.design/)
|
||||
* name: string,
|
||||
* href: string,
|
||||
* external?: boolean
|
||||
* }
|
||||
*/
|
||||
navbarItems: [
|
||||
{
|
||||
icon: "ion:document-text",
|
||||
name: "Documentation",
|
||||
href: "https://github.com/mbahArip/next-gdrive-index/wiki",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
icon: "ion:logo-github",
|
||||
name: "Github",
|
||||
href: "https://www.github.com/mbaharip",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
icon: "ion:logo-paypal",
|
||||
name: "Buy me a coffee",
|
||||
href: "https://www.paypal.me/mbaharip",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
icon: "ion:mail",
|
||||
name: "Contact",
|
||||
href: "mailto:support@mbaharip.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
interface gIndexConfig {
|
||||
version: string;
|
||||
basePath: string;
|
||||
masterKey: string;
|
||||
cacheControl: string;
|
||||
|
||||
apiConfig: {
|
||||
rootFolder: string;
|
||||
defaultQuery: string[];
|
||||
defaultField: string;
|
||||
defaultOrder: string;
|
||||
itemsPerPage: number;
|
||||
searchResult: number;
|
||||
|
||||
specialFile: {
|
||||
password: string;
|
||||
readme: string;
|
||||
banner: string;
|
||||
};
|
||||
hiddenFiles: string[];
|
||||
|
||||
allowDownloadProtectedFile: boolean;
|
||||
temporaryTokenDuration: number;
|
||||
maxFileSize: number;
|
||||
};
|
||||
siteConfig: {
|
||||
siteName: string;
|
||||
siteDescription: string;
|
||||
siteIcon: string;
|
||||
favIcon: string;
|
||||
twitterHandle: string;
|
||||
|
||||
defaultAccentColor: keyof typeof colors;
|
||||
|
||||
privateIndex: boolean;
|
||||
|
||||
navbarItems: {
|
||||
icon: string;
|
||||
name: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
@@ -1,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;
|
||||
@@ -1,4 +1,8 @@
|
||||
import React, { createContext, useState, useEffect } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type TLayout = "grid" | "list";
|
||||
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import Button from "components/Button";
|
||||
import LoaderLayout from "components/Layout/Loader";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<LoaderLayout
|
||||
seo={{
|
||||
title: "Not Found",
|
||||
}}
|
||||
>
|
||||
<div className='w-full flex h-full flex-col gap-4 relative items-center justify-center'>
|
||||
<figure>
|
||||
<img
|
||||
src='/images/404.png'
|
||||
alt='Page not found'
|
||||
className='w-64 h-64 tablet:w-96 tablet:h-96 object-contain'
|
||||
/>
|
||||
</figure>
|
||||
|
||||
<div className='w-full flex items-center justify-center flex-col my-8'>
|
||||
<span className='text-lg font-medium'>
|
||||
The page you are trying to access is not found.
|
||||
</span>
|
||||
<span>
|
||||
Please check the URL or go back to the homepage.
|
||||
</span>
|
||||
|
||||
<div className='flex items-center justify-between flex-col tablet:flex-row gap-2 tablet:gap-4 my-4 tablet:my-12'>
|
||||
<Link href='/'>
|
||||
<Button
|
||||
variant='accent'
|
||||
rounded='large'
|
||||
className='w-full tablet:w-auto'
|
||||
>
|
||||
Go back to homepage
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LoaderLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import axios from "axios";
|
||||
import gIndexConfig from "config";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import ExplorerLayout from "components/Layout/Explorer";
|
||||
import LoaderLayout from "components/Layout/Loader";
|
||||
import PasswordLayout from "components/Layout/Password";
|
||||
import PreviewLayout from "components/Layout/Preview";
|
||||
|
||||
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
|
||||
import gdrive from "utils/gdriveInstance";
|
||||
import { addNewPassword, checkPathPassword } from "utils/passwordHelper";
|
||||
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APIGetFileResponse, APIGetPasswordResponse, APIGetReadmeResponse } from "types/api/response";
|
||||
import { Constant } from "types/constant";
|
||||
|
||||
interface FilePathPageProps {
|
||||
mappedEncryptedPath: Record<"name" | "id" | "mimeType", string>[];
|
||||
}
|
||||
interface StateDataProps {
|
||||
file: IGDriveFiles | null;
|
||||
files: IGDriveFiles[];
|
||||
folders: IGDriveFiles[];
|
||||
pageToken: string | null;
|
||||
}
|
||||
export default function FilePathPage(props: FilePathPageProps) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<StateDataProps>({
|
||||
file: null,
|
||||
files: [],
|
||||
folders: [],
|
||||
pageToken: null,
|
||||
});
|
||||
const [readmeFile, setReadmeFile] = useState<string | null>(null);
|
||||
const [isProtected, setIsProtected] = useState<boolean>(false);
|
||||
const [nearestProtected, setNearestProtected] = useState<string>("");
|
||||
const [isFileProtected, setIsFileProtected] = useState<boolean>(false);
|
||||
const [isFile, setIsFile] = useState<boolean>(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
|
||||
useEffect(() => {
|
||||
setIsLoadingData(true);
|
||||
|
||||
const lastPathMimeType = props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].mimeType;
|
||||
let isLastPathFile = false;
|
||||
if (lastPathMimeType === "application/vnd.google-apps.folder") {
|
||||
isLastPathFile = false;
|
||||
setIsFile(false);
|
||||
} else {
|
||||
isLastPathFile = true;
|
||||
setIsFile(true);
|
||||
}
|
||||
|
||||
const _getPassword = axios.get<APIGetPasswordResponse>("/api/getPassword", {
|
||||
params: {
|
||||
path: encryptData(JSON.stringify(props.mappedEncryptedPath)),
|
||||
},
|
||||
});
|
||||
const _getData = axios.get<APIGetFileResponse>("/api/getData", {
|
||||
params: {
|
||||
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
|
||||
isFile: isLastPathFile ? "1" : undefined,
|
||||
},
|
||||
});
|
||||
const _getReadme = axios.get<APIGetReadmeResponse>("/api/getReadme", {
|
||||
params: {
|
||||
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
|
||||
},
|
||||
});
|
||||
|
||||
Promise.all([_getPassword, _getData, _getReadme])
|
||||
.then(async ([passwordData, fileData, readmeData]) => {
|
||||
// Check password
|
||||
if (passwordData.data.data.length) {
|
||||
let isPathProtected = true;
|
||||
const savedPasswordCookie =
|
||||
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
|
||||
undefined;
|
||||
const savedPasswordValue = savedPasswordCookie?.split("=")[1] ?? undefined;
|
||||
|
||||
if (savedPasswordValue) {
|
||||
// Check only nearest path
|
||||
const nearestPassword = passwordData.data.data[passwordData.data.data.length - 1];
|
||||
setNearestProtected(nearestPassword.relativePath);
|
||||
const isPasswordValid = checkPathPassword(
|
||||
nearestPassword.relativePath,
|
||||
savedPasswordValue,
|
||||
decryptData(nearestPassword.password),
|
||||
);
|
||||
if (isPasswordValid) {
|
||||
isPathProtected = false;
|
||||
}
|
||||
}
|
||||
|
||||
setIsProtected(isPathProtected);
|
||||
setIsFileProtected(true);
|
||||
}
|
||||
|
||||
// Assign files
|
||||
setData(fileData.data.data);
|
||||
|
||||
// Fetch readme
|
||||
setReadmeFile(readmeData.data.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw new Error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingData(false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.mappedEncryptedPath]);
|
||||
|
||||
return (
|
||||
<LoaderLayout
|
||||
seo={{
|
||||
title: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].name,
|
||||
}}
|
||||
>
|
||||
{isLoadingData ? (
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
|
||||
<div className='w-fit h-fit relative grid place-items-center'>
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-12 -top-1 relative'
|
||||
/>
|
||||
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
|
||||
</div>
|
||||
<span>Fetching {isFile ? "file info" : "folder contents"}...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isProtected ? (
|
||||
<PasswordLayout
|
||||
path={nearestProtected}
|
||||
onSubmitted={(password) => {
|
||||
const cookie =
|
||||
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
|
||||
undefined;
|
||||
addNewPassword(decodeURIComponent(nearestProtected), password, cookie?.split("=")[1] ?? undefined);
|
||||
router.reload();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isFile ? (
|
||||
<PreviewLayout
|
||||
key={router.asPath}
|
||||
data={data}
|
||||
isProtected={false}
|
||||
isFileProtected={isFileProtected}
|
||||
encryptedId={props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id}
|
||||
/>
|
||||
) : (
|
||||
<ExplorerLayout
|
||||
key={router.asPath}
|
||||
data={data}
|
||||
readmeFile={readmeFile}
|
||||
isProtected={false}
|
||||
isFileProtected={isFileProtected}
|
||||
encryptedId={props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</LoaderLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { path } = context.params as { path: string[] };
|
||||
|
||||
const fetchRootId = gdrive.files.get({
|
||||
fileId: gIndexConfig.apiConfig.rootFolder,
|
||||
fields: "id",
|
||||
});
|
||||
const fetchPathId = path.map(async (path) => {
|
||||
const query = ["trashed = false", `name = '${path}'`];
|
||||
const fetchFolderContents = await gdrive.files.list({
|
||||
q: `${query.join(" and ")}`,
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
});
|
||||
|
||||
if (!fetchFolderContents.data.files?.length) {
|
||||
return {
|
||||
path,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
data: fetchFolderContents.data.files.map((file) => ({
|
||||
id: file.id,
|
||||
parents: file.parents?.[0],
|
||||
mimeType: file.mimeType,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const [rootId, pathId] = await Promise.all([fetchRootId, Promise.all(fetchPathId)]);
|
||||
|
||||
if (pathId.some((path) => !path.data.length)) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedPath: string[] = [];
|
||||
const mappedPath: Record<"name" | "id" | "mimeType", string>[] = [];
|
||||
const rejectedPath: string[] = [];
|
||||
|
||||
// Check path validity
|
||||
pathId.forEach((path, index) => {
|
||||
let checkPath =
|
||||
index === 0
|
||||
? path.data.find((file) => file.parents === rootId.data.id)
|
||||
: path.data.find((file) => file.parents === selectedPath[index - 1]);
|
||||
if (!checkPath) {
|
||||
rejectedPath.push(path.path);
|
||||
return;
|
||||
}
|
||||
selectedPath.push(checkPath.id as string);
|
||||
mappedPath.push({
|
||||
name: path.path,
|
||||
id: checkPath.id as string,
|
||||
mimeType: checkPath.mimeType as string,
|
||||
});
|
||||
});
|
||||
if (rejectedPath.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
mappedEncryptedPath: mappedPath.map((path) => ({
|
||||
name: path.name,
|
||||
id: encryptData(path.id),
|
||||
mimeType: path.mimeType,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
+309
-216
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user