mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 05:31:57 +00:00
+6
-5
@@ -1,9 +1,10 @@
|
||||
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
|
||||
|
||||
# Base64 Encoded Service Account JSON
|
||||
GD_SERVICE_B64=
|
||||
# Secret Key for Encryption
|
||||
ENCRYPTION_KEY=
|
||||
# Index password, used when private mode is enabled
|
||||
SITE_PASSWORD=
|
||||
|
||||
# Only fill with the domain name, without the protocol and trailing slash
|
||||
# This will be used, and will fallback to VERCEL_URL if it's not available
|
||||
# Example: https://drive-demo.mbaharip.com -> drive-demo.mbaharip.com
|
||||
# [Optional] Only domain, without protocol (ex: mbaharip.com)
|
||||
# Needed if you're not using Vercel
|
||||
NEXT_PUBLIC_DOMAIN=
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: [
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "inline-type-imports",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
checksVoidReturn: {
|
||||
attributes: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
ignorePatterns: [],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@next/next/no-img-element": "off"
|
||||
},
|
||||
"ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_page/**/*"]
|
||||
}
|
||||
@@ -35,6 +35,7 @@ next-env.d.ts
|
||||
/docs
|
||||
/src/pages/api/legacy/
|
||||
/**/*/_legacy/
|
||||
# /**/*/_*/
|
||||
/data
|
||||
/.*/
|
||||
/src/_app
|
||||
|
||||
@@ -25,9 +25,9 @@ module.exports = {
|
||||
importOrder: [
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^~/(app|components)/(.*)$",
|
||||
"^~/(utils|hooks|context)/(.*)$",
|
||||
"^~/(utils|hooks|context|lib)/(.*)$",
|
||||
"^~/types/(.*)$",
|
||||
"^~/(config|styles)/(.*)$",
|
||||
"^~/(config|styles|actions|constant)/(.*)$",
|
||||
"^(config|actions)$",
|
||||
"^[./]",
|
||||
],
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
> [!IMPORTANT]
|
||||
> It is **RECOMMENDED** to generate configuration from your deployment instead of demo site
|
||||
@@ -7,17 +7,15 @@
|
||||
<p align='center'>
|
||||
<a href='https://drive-demo.mbaharip.com' target='_blank'>Demo Site</a>
|
||||
·
|
||||
<a href='https://drive-demo.mbaharip.com/deploy' target='_blank'>Deploy / Version Upgrade Guide</a>
|
||||
<a href='https://drive-demo.mbaharip.com/ngdi-internal/deploy' target='_blank'>Deploy / Version Upgrade Guide</a>
|
||||
·
|
||||
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ' target='_blank'>FAQ</a>
|
||||
<a href='#' target='_blank'>How to Deploy (Youtube)</a>
|
||||
</p>
|
||||
<p align='center'>
|
||||
<img src='https://img.shields.io/github/package-json/v/mbaharip/next-gdrive-index?label=prod' alt='Demo version' />
|
||||
<img src='https://img.shields.io/github/package-json/v/mbaharip/next-gdrive-index/v2?label=dev' alt='Dev' />
|
||||
<img src='https://img.shields.io/github/package-json/v/mbaharip/next-gdrive-index?label=Production' alt='Demo version' />
|
||||
<img src='https://img.shields.io/github/package-json/v/mbaharip/next-gdrive-index/v2?label=Preview' alt='Dev' />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<!-- Generate TOC -->
|
||||
|
||||
## Table of Contents
|
||||
@@ -28,6 +26,8 @@
|
||||
- [Known Issues](#known-issues)
|
||||
- [File size limit](#file-size-limit)
|
||||
- [No support for Google Docs, Sheets, and Slides](#no-support-for-google-docs-sheets-and-slides)
|
||||
- [No support for Shortcut](#no-support-for-shortcut)
|
||||
- [Encryption cause error](#encryption-cause-error)
|
||||
- [Can't seek on audio and video preview](#cant-seek-on-audio-and-video-preview)
|
||||
- [Shared Drive is not supported](#shared-drive-is-not-supported)
|
||||
- [Might be Implemented](#might-be-implemented)
|
||||
@@ -44,11 +44,11 @@
|
||||
`next-gdrive-index` is a Google Drive directory index.
|
||||
The aim of this project is to simplify the process of sharing files using Google Drive, and also add some features that _I think_ is useful when sharing a files.
|
||||
|
||||
> This project are **HEAVILY INSPIRED** by [onedrive-vercel-index](https://github.com/spencerwooo/onedrive-vercel-index) by [SpencerWooo](https://github.com/spencerwooo).
|
||||
This project are **HEAVILY INSPIRED** by [onedrive-vercel-index](https://github.com/spencerwooo/onedrive-vercel-index) by [SpencerWooo](https://github.com/spencerwooo).
|
||||
|
||||
## Why I made this?
|
||||
|
||||
> **TLDR;**
|
||||
> **TLDR;** \
|
||||
> It is cheaper to use Google Drive than other similar service
|
||||
|
||||
There are a lot cloud storage service like Onedrive, Dropbox, Mega, etc.
|
||||
@@ -64,8 +64,8 @@ _Price are converted to IDR, since it's easier for me to compare this using my o
|
||||
| **Dropbox**\* | 2GB | 2TB | ~191k IDR<br>~159k IDR | 10.5GB / 1k / mo<br>12.5GB / 1k / yr | O |
|
||||
| **MEGA** | **<u>20GB</u>** | 2TB | ~174k IDR<br>~145k IDR | 11.5GB / 1k / mo<br>13.8GB / 1k / yr | O |
|
||||
|
||||
\* Price are in USD, and there are no regional price for IDR
|
||||
\*\* There are no 2TB plan
|
||||
> \* Price are in USD, and there are no regional price for IDR \
|
||||
> \*\* There are no 2TB plan
|
||||
|
||||
By using this data, I picked Google Drive instead other service.
|
||||
I know there are a lot of people selling cheap education account especially for Google Drive and Onedrive, but most of the time <u>those account doesn't last long</u>.
|
||||
@@ -82,11 +82,12 @@ I know there are a lot of people selling cheap education account especially for
|
||||
- Document preview
|
||||
- Code / Text / Markdown preview
|
||||
- Manga preview (cbz)
|
||||
- **Embed media**, embed media like video or audio to your site
|
||||
- **File search**, search by the file or folder name
|
||||
- **Direct download**, download directly via API route instead of google drive link ( Size limit can be adjusted )
|
||||
- **Raw file link**, embed your media files
|
||||
- **Light/Dark mode**, choose your side!
|
||||
- **Customizable Theme**, we are using `shadcn/ui` now! you can customize your site [https;//drive-demo.mbaharip.com/deploy#theme]
|
||||
- **Customizable Theme**, we are using `shadcn/ui` now! you can customize your site with the theme you like
|
||||
- **Links**, add social, or information link on the navbar
|
||||
- **Sponsors**, using this for thing for community? add a sponsor / donate button on your navbar!
|
||||
|
||||
@@ -94,21 +95,32 @@ I know there are a lot of people selling cheap education account especially for
|
||||
|
||||
### File size limit
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This only apply if `maxFileSize` is enabled / more than 0
|
||||
|
||||
You need to set the file sharing permission to `Anyone with the link can view` on the root folder.
|
||||
|
||||
**Why?**
|
||||
The download link will be redirected if the file you're trying to download is bigger than the `maxFileSize`, and most of platform are limiting the response body size (ex: Vercel limit is 4MB).
|
||||
The download link will be redirected if the file you're trying to download is bigger than the `maxFileSize`, and most of platform are limiting the response body size (ex: Vercel limit is 4MB).
|
||||
If you don't set the permission, people can't access or download the file.
|
||||
|
||||
This will <u>expose the file ID</u>, and people can access the file directly from Google Drive.
|
||||
But it <u>only apply to the file</u>, and they can't see or browse the folder directly from Google Drive.
|
||||
> [!WARNING]
|
||||
> This will **expose the file ID**
|
||||
> While they can view the file directly from Google Drive, they can't see or browse the folder that contains the file from Google Drive.
|
||||
|
||||
### 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.
|
||||
|
||||
### No support for Shortcut
|
||||
|
||||
While I think it's important, I don't have any plan to implement this for now.
|
||||
|
||||
### ~Encryption cause error~
|
||||
|
||||
~~It seems the configurator are generating wrong encrypted folder ID, so it will cause error when you try to access the folder.~~
|
||||
Should be fixed on v2.0.4, waiting for feedback / confirmation
|
||||
|
||||
### ~~Can't seek on audio and video preview~~
|
||||
|
||||
~~It looks like you can't seek the audio and video preview, so you need to listen / watch from the beginning.~~
|
||||
@@ -133,7 +145,7 @@ It's either from multiple Google Drive account with multiple Service Account, or
|
||||
|
||||
### Authentication
|
||||
|
||||
Probably a good feature if you are a content creator that only want the one who subscribed to you get the files.
|
||||
Probably a good feature if you are a content creator that only want the one who subscribed to you get the files.
|
||||
It might need a database, but idk if I can implement it without the need of database
|
||||
|
||||
## Running on Local
|
||||
@@ -163,7 +175,7 @@ It might need a database, but idk if I can implement it without the need of data
|
||||
Want to add new feature or improve the existing one? or you find a bug and fixed it yourself?
|
||||
|
||||
- Please check the issue tab first to see if someone already reporting a bug, or if you want to check any new feature / enhancement that not yet implemented
|
||||
- Use code base from `v2` branch instead of the `main`, since it was made for development on current version
|
||||
- Use code base from the latest version branch instead of the `main`, since it was made for development on current version
|
||||
- Create a Pull Request, and wait for me checking your code
|
||||
|
||||
## Support and Donations
|
||||
|
||||
+8
-4
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "~/components",
|
||||
"utils": "~/utils/cn"
|
||||
}
|
||||
"utils": "~/lib/utils",
|
||||
"ui": "~/components/ui",
|
||||
"lib": "~/lib",
|
||||
"hooks": "~/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
||||
+9
-15
@@ -1,23 +1,17 @@
|
||||
/* eslint-disable */
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
pageExtensions: ["tsx", "ts"],
|
||||
webpack: (config) => {
|
||||
config.resolve.alias.canvas = false;
|
||||
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
"~": path.resolve(__dirname, "./"),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
// async headers() {
|
||||
// return [
|
||||
// {
|
||||
// source: "/:path*",
|
||||
// headers: [
|
||||
// {
|
||||
// key: "Cache-Control",
|
||||
// value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ];
|
||||
// },
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
+49
-58
@@ -1,73 +1,63 @@
|
||||
{
|
||||
"name": "next-gdrive-index",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"dev:turbo": "next dev -p 3000 --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"cypress": "cypress open",
|
||||
"pretty": "prettier --write ."
|
||||
"lint:fix": "next lint --fix",
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json}\" --cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cyntler/react-doc-viewer": "^1.13.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-context-menu": "^2.1.5",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-context-menu": "^2.2.4",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-menubar": "^1.0.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"axios": "^1.3.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.0.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@vidstack/react": "^1.12.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"fflate": "^0.8.2",
|
||||
"googleapis": "^118.0.0",
|
||||
"input-otp": "^1.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"lucide-react": "^0.363.0",
|
||||
"next": "^14.1.4",
|
||||
"next-themes": "^0.3.0",
|
||||
"framer-motion": "^11.13.3",
|
||||
"googleapis": "^144.0.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.1.6",
|
||||
"next-themes": "^0.4.4",
|
||||
"nextjs-toploader": "^1.6.11",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "^18",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^18",
|
||||
"react-h5-audio-player": "^3.9.1",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-is": "^18.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-player": "^2.13.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"react-resizable-panels": "^2.0.16",
|
||||
"react-toastify": "^9.1.3",
|
||||
"rehype-katex": "^6.0.3",
|
||||
"rehype-prism-plus": "^1.6.3",
|
||||
"rehype-raw": "6.1.1",
|
||||
@@ -76,33 +66,34 @@
|
||||
"remark-math": "^5.1.1",
|
||||
"remark-slug": "^7.0.1",
|
||||
"remark-toc": "^8.0.1",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-debouncy": "^5.0.1",
|
||||
"vaul": "^0.9.0",
|
||||
"zod": "^3.22.4"
|
||||
"vaul": "^1.1.1",
|
||||
"zod": "^3.24.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/eslint": "^8.56.10",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "^20",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/three": "^0.150.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "8.38.0",
|
||||
"eslint-config-next": "^14.1.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "3.0.0",
|
||||
"prettier-plugin-tailwindcss": "0.5.12",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 416 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,281 @@
|
||||
"use server";
|
||||
|
||||
import { type AsyncZippable, strToU8, zipSync } from "fflate";
|
||||
import { type z } from "zod";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import {
|
||||
configurationTemplate,
|
||||
environmentTemplate,
|
||||
parseEnvironment,
|
||||
parseVersion1Config,
|
||||
parseVersion2Config,
|
||||
} from "~/lib/configurationHelper";
|
||||
import { base64Decode, base64Encode, encryptionService } from "~/lib/utils.server";
|
||||
|
||||
import { type Schema_App_Configuration, type Schema_App_Configuration_Env } from "~/types/schema";
|
||||
|
||||
export async function GenerateServiceAccountB64(serviceAccount: string): Promise<ActionResponseSchema<string>> {
|
||||
const b64 = base64Encode(serviceAccount, "standard");
|
||||
|
||||
// Test
|
||||
const decoded = base64Decode(b64, "standard");
|
||||
if (decoded === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to decode the service account",
|
||||
error: "Something went wrong while encoding the service account, please try again",
|
||||
};
|
||||
}
|
||||
if (decoded !== serviceAccount) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Encode failed to match the original string",
|
||||
error: "Something went wrong while testing the encoding, please try again",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Service account generated",
|
||||
data: b64,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ProcessEnvironmentConfig(
|
||||
configuration: string,
|
||||
): Promise<ActionResponseSchema<z.infer<typeof Schema_App_Configuration_Env>>> {
|
||||
const data = parseEnvironment(configuration);
|
||||
if ("message" in data && "details" in data) {
|
||||
return {
|
||||
success: false,
|
||||
message: data.message,
|
||||
error: data.details.join("\n"),
|
||||
};
|
||||
}
|
||||
// Verify service account
|
||||
const serviceAccount = data.GD_SERVICE_B64;
|
||||
const decoded = base64Decode(serviceAccount, "standard");
|
||||
if (decoded === null) {
|
||||
data.GD_SERVICE_B64 = "";
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message: "Environment loaded, but service account is invalid",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Environment loaded",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ProcessConfiguration(
|
||||
configuration: string,
|
||||
version: "v1" | "v2" | "latest",
|
||||
): Promise<ActionResponseSchema<Omit<z.infer<typeof Schema_App_Configuration>, "environment">>> {
|
||||
const data = version === "v1" ? parseVersion1Config(configuration) : parseVersion2Config(configuration);
|
||||
if ("message" in data && "details" in data) {
|
||||
return {
|
||||
success: false,
|
||||
message: data.message,
|
||||
error: data.details.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Configuration loaded, but folder ID are not processed",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GenerateConfiguration(
|
||||
values: z.infer<typeof Schema_App_Configuration>,
|
||||
): Promise<ActionResponseSchema<{ configuration: string; env: string; zip: Blob }>> {
|
||||
const configurationMap: { key: string; value: string }[] = [
|
||||
{
|
||||
key: "version",
|
||||
value: values.version,
|
||||
},
|
||||
{
|
||||
key: "showGuideButton",
|
||||
value: values.site.guideButton.toString(),
|
||||
},
|
||||
{
|
||||
key: "cacheControl",
|
||||
value: `${values.api.cache.public ? "public, " : ""}max-age=${values.api.cache.maxAge}, s-maxage=${
|
||||
values.api.cache.sMaxAge
|
||||
}${values.api.cache.staleWhileRevalidate ? ", stale-while-revalidate" : ""}`,
|
||||
},
|
||||
{
|
||||
key: "api.rootFolder",
|
||||
value: await encryptionService.encrypt(values.api.rootFolder, values.environment.ENCRYPTION_KEY),
|
||||
},
|
||||
{
|
||||
key: "api.isTeamDrive",
|
||||
value: values.api.isTeamDrive.toString(),
|
||||
},
|
||||
{
|
||||
key: "api.sharedDrive",
|
||||
value: values.api.sharedDrive
|
||||
? await encryptionService.encrypt(values.api.sharedDrive, values.environment.ENCRYPTION_KEY)
|
||||
: "",
|
||||
},
|
||||
{
|
||||
key: "api.itemsPerPage",
|
||||
value: values.api.itemsPerPage.toString(),
|
||||
},
|
||||
{
|
||||
key: "api.searchResult",
|
||||
value: values.api.searchResult.toString(),
|
||||
},
|
||||
{
|
||||
key: "api.specialFile.password",
|
||||
value: values.api.specialFile.password,
|
||||
},
|
||||
{
|
||||
key: "api.specialFile.readme",
|
||||
value: values.api.specialFile.readme,
|
||||
},
|
||||
{
|
||||
key: "api.specialFile.banner",
|
||||
value: values.api.specialFile.banner,
|
||||
},
|
||||
{
|
||||
key: "api.hiddenFiles",
|
||||
value: `[${values.api.hiddenFiles.map((file) => `"${file}"`).join(", ")}]`,
|
||||
},
|
||||
{
|
||||
key: "api.proxyThumbnail",
|
||||
value: values.api.proxyThumbnail.toString(),
|
||||
},
|
||||
{
|
||||
key: "api.streamMaxSize",
|
||||
value: values.api.streamMaxSize.toString(),
|
||||
},
|
||||
{
|
||||
key: "api.maxFileSize",
|
||||
value: values.api.maxFileSize.toString(),
|
||||
},
|
||||
{
|
||||
key: "api.allowDownloadProtectedFile",
|
||||
value: values.api.allowDownloadProtectedFile.toString(),
|
||||
},
|
||||
{
|
||||
key: "api.temporaryTokenDuration",
|
||||
value: values.api.temporaryTokenDuration.toString(),
|
||||
},
|
||||
{
|
||||
key: "site.siteName",
|
||||
value: values.site.siteName,
|
||||
},
|
||||
{
|
||||
key: "site.siteNameTemplate",
|
||||
value: values.site.siteNameTemplate,
|
||||
},
|
||||
{
|
||||
key: "site.siteDescription",
|
||||
value: values.site.siteDescription,
|
||||
},
|
||||
{
|
||||
key: "site.siteAuthor",
|
||||
value: values.site.siteAuthor,
|
||||
},
|
||||
{
|
||||
key: "site.robots",
|
||||
value: values.site.robots,
|
||||
},
|
||||
{
|
||||
key: "site.twitterHandle",
|
||||
value: values.site.twitterHandle,
|
||||
},
|
||||
{
|
||||
key: "site.showFileExtension",
|
||||
value: values.site.showFileExtension.toString(),
|
||||
},
|
||||
{
|
||||
key: "site.privateIndex",
|
||||
value: values.site.privateIndex.toString(),
|
||||
},
|
||||
{
|
||||
key: "site.breadcrumbMax",
|
||||
value: values.site.breadcrumbMax.toString(),
|
||||
},
|
||||
{
|
||||
key: "site.toaster.position",
|
||||
value: values.site.toaster.position,
|
||||
},
|
||||
{
|
||||
key: "site.toaster.duration",
|
||||
value: values.site.toaster.duration.toString(),
|
||||
},
|
||||
{
|
||||
key: "site.navbarItems",
|
||||
value: JSON.stringify(values.site.navbarItems, null, 2),
|
||||
},
|
||||
{
|
||||
key: "site.supports",
|
||||
value: JSON.stringify(values.site.supports, null, 2),
|
||||
},
|
||||
{
|
||||
key: "site.footer",
|
||||
value: JSON.stringify(values.site.footer, null, 2),
|
||||
},
|
||||
];
|
||||
let configuration = configurationTemplate;
|
||||
for (const { key, value } of configurationMap) {
|
||||
configuration = configuration.replace(`{{ ${key} }}`, value);
|
||||
}
|
||||
|
||||
const envMap: { key: string; value: string }[] = [
|
||||
{
|
||||
key: "serviceAccount",
|
||||
value: values.environment.GD_SERVICE_B64,
|
||||
},
|
||||
{
|
||||
key: "key",
|
||||
value: values.environment.ENCRYPTION_KEY,
|
||||
},
|
||||
{
|
||||
key: "password",
|
||||
value: values.environment.SITE_PASSWORD ?? "",
|
||||
},
|
||||
{
|
||||
key: "domain",
|
||||
value: values.environment.NEXT_PUBLIC_DOMAIN ?? "",
|
||||
},
|
||||
];
|
||||
let env = environmentTemplate;
|
||||
for (const { key, value } of envMap) {
|
||||
env = env.replace(`{{ ${key} }}`, value);
|
||||
}
|
||||
|
||||
const struct: AsyncZippable = {
|
||||
".env": [
|
||||
strToU8(env),
|
||||
{
|
||||
level: 9,
|
||||
},
|
||||
],
|
||||
"gIndex.config.ts": [
|
||||
strToU8(configuration),
|
||||
{
|
||||
level: 9,
|
||||
},
|
||||
],
|
||||
};
|
||||
const zip = zipSync(struct);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Configuration generated",
|
||||
data: {
|
||||
configuration,
|
||||
env,
|
||||
zip: new Blob([zip], { type: "application/zip" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
"use server";
|
||||
|
||||
import { type z } from "zod";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { encryptionService, gdrive } from "~/lib/utils.server";
|
||||
|
||||
import { Schema_File, Schema_File_Shortcut } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
/**
|
||||
* List files in a folder
|
||||
* @param {object} options
|
||||
* @param {string} options.id (optional) - Folder ID to fetch, default is root folder
|
||||
* @param {string} options.pageToken (optional) - Page token to fetch next page
|
||||
*/
|
||||
export async function ListFiles({ id, pageToken }: { id?: string; pageToken?: string } = {}): Promise<
|
||||
ActionResponseSchema<{
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string | null;
|
||||
}>
|
||||
> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and ");
|
||||
const filterQuery: string = [...config.apiConfig.defaultQuery, `'${decryptedId}' in parents`, filterName].join(
|
||||
" and ",
|
||||
);
|
||||
|
||||
const { data } = await gdrive.files.list({
|
||||
q: filterQuery,
|
||||
fields: `files(${config.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: pageToken,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
if (!data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: "No files found",
|
||||
data: {
|
||||
files: [],
|
||||
nextPageToken: data.nextPageToken,
|
||||
},
|
||||
};
|
||||
|
||||
const files: z.infer<typeof Schema_File>[] = [];
|
||||
for (const file of data.files) {
|
||||
files.push({
|
||||
encryptedId: await encryptionService.encrypt(file.id!),
|
||||
encryptedWebContentLink: file.webContentLink ? await encryptionService.encrypt(file.webContentLink) : undefined,
|
||||
name: file.name!,
|
||||
mimeType: file.mimeType!,
|
||||
trashed: file.trashed ?? false,
|
||||
modifiedTime: new Date(file.modifiedTime!).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension ?? undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink ?? undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation ?? 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = Schema_File.array().safeParse(files);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to parse files",
|
||||
error: parsed.error.message,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Files found",
|
||||
data: {
|
||||
files: parsed.data,
|
||||
nextPageToken: data.nextPageToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file details
|
||||
* @param id - File ID to fetch
|
||||
*/
|
||||
export async function GetFile(id: string): Promise<ActionResponseSchema<z.infer<typeof Schema_File> | null>> {
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: config.apiConfig.defaultField,
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (!data.id)
|
||||
return {
|
||||
success: false,
|
||||
message: "File not found",
|
||||
error: "NotFound",
|
||||
};
|
||||
|
||||
const file: z.infer<typeof Schema_File> = {
|
||||
encryptedId: await encryptionService.encrypt(data.id),
|
||||
encryptedWebContentLink: data.webContentLink ? await encryptionService.encrypt(data.webContentLink) : undefined,
|
||||
name: data.name!,
|
||||
mimeType: data.mimeType!,
|
||||
trashed: data.trashed ?? false,
|
||||
modifiedTime: new Date(data.modifiedTime!).toLocaleDateString(),
|
||||
fileExtension: data.fileExtension ?? undefined,
|
||||
size: data.size ? Number(data.size) : undefined,
|
||||
thumbnailLink: data.thumbnailLink ?? undefined,
|
||||
imageMediaMetadata: data.imageMediaMetadata
|
||||
? {
|
||||
width: Number(data.imageMediaMetadata.width),
|
||||
height: Number(data.imageMediaMetadata.height),
|
||||
rotation: Number(data.imageMediaMetadata.rotation ?? 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: data.videoMediaMetadata
|
||||
? {
|
||||
durationMillis: Number(data.videoMediaMetadata.durationMillis),
|
||||
height: Number(data.videoMediaMetadata.height),
|
||||
width: Number(data.videoMediaMetadata.width),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const parsed = Schema_File.safeParse(file);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to parse file",
|
||||
error: parsed.error.message,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "File found",
|
||||
data: parsed.data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get readme file inside a folder
|
||||
* @param id - Folder ID to fetch, default is root folder
|
||||
*/
|
||||
export async function GetReadme(id: string | null = null): Promise<
|
||||
ActionResponseSchema<{
|
||||
type: "markdown" | "txt";
|
||||
content: string;
|
||||
} | null>
|
||||
> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const filterQuery: string = [
|
||||
"trashed = false",
|
||||
"(not mimeType contains 'folder')",
|
||||
`name = '${config.apiConfig.specialFile.readme}'`,
|
||||
`'${decryptedId}' in parents`,
|
||||
].join(" and ");
|
||||
|
||||
const { data } = await gdrive.files.list({
|
||||
q: filterQuery,
|
||||
fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: "No README found",
|
||||
data: null,
|
||||
};
|
||||
|
||||
let file;
|
||||
if (data.files.length === 1) {
|
||||
file = data.files[0];
|
||||
} else {
|
||||
file = data.files.find((file) => file.mimeType === "text/markdown");
|
||||
if (!file) {
|
||||
file = data.files.find((file) => file.mimeType === "application/vnd.google-apps.shortcut");
|
||||
}
|
||||
}
|
||||
|
||||
if (!file)
|
||||
return {
|
||||
success: true,
|
||||
message: "No README found",
|
||||
data: null,
|
||||
};
|
||||
|
||||
switch (file.mimeType) {
|
||||
case "application/vnd.google-apps.shortcut":
|
||||
const { data: shortcutData } = await gdrive.files.get({
|
||||
fileId: file.id!,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
const parsedData = Schema_File_Shortcut.safeParse(shortcutData);
|
||||
if (!parsedData.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to parse shortcut data",
|
||||
error: parsedData.error.message,
|
||||
};
|
||||
|
||||
if (
|
||||
!parsedData.data.shortcutDetails.targetId ||
|
||||
(parsedData.data.shortcutDetails.targetMimeType !== "text/markdown" &&
|
||||
parsedData.data.shortcutDetails.targetMimeType !== "text/plain")
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
message: "Shortcut target is not a markdown file or plain text",
|
||||
data: null,
|
||||
};
|
||||
|
||||
const { data: shortcutContent } = await gdrive.files.get(
|
||||
{
|
||||
fileId: parsedData.data.shortcutDetails.targetId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "README found",
|
||||
data: {
|
||||
type: parsedData.data.shortcutDetails.targetMimeType === "text/markdown" ? "markdown" : "txt",
|
||||
content: shortcutContent as string,
|
||||
},
|
||||
};
|
||||
case "text/markdown":
|
||||
case "text/plain":
|
||||
const { data: content } = await gdrive.files.get(
|
||||
{
|
||||
fileId: file.id!,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: "README found",
|
||||
data: {
|
||||
type: file.mimeType === "text/markdown" ? "markdown" : "txt",
|
||||
content: content as string,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return {
|
||||
success: true,
|
||||
message: "No README found",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get banner file inside a folder
|
||||
* @param id - Folder ID to fetch, default is root folder
|
||||
*/
|
||||
export async function GetBanner(id: string | null = null): Promise<ActionResponseSchema<string | null>> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const filterQuery: string = [
|
||||
...config.apiConfig.defaultQuery,
|
||||
`name contains '${config.apiConfig.specialFile.banner}'`,
|
||||
`'${decryptedId}' in parents`,
|
||||
].join(" and ");
|
||||
|
||||
const { data } = await gdrive.files.list({
|
||||
q: filterQuery,
|
||||
fields: `files(${config.apiConfig.defaultField},parents)`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
if (!data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: "No banner found",
|
||||
data: null,
|
||||
};
|
||||
|
||||
const encryptedId = await encryptionService.encrypt(data.files[0]?.id ?? "");
|
||||
return {
|
||||
success: true,
|
||||
message: "Banner found",
|
||||
data: encryptedId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content of text file
|
||||
* @param id - File ID to fetch
|
||||
*/
|
||||
export async function GetContent(id: string): Promise<ActionResponseSchema<string>> {
|
||||
const decryptedId = await encryptionService.decrypt(id);
|
||||
|
||||
const { data, status, statusText } = await gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
if (status !== 200)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to fetch content",
|
||||
error: statusText,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Content found",
|
||||
data: data as string,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { COOKIES_NAME, COOKIES_OPTIONS } from "~/constant";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { toUrlPath } from "~/lib/utils";
|
||||
import { encryptionService, gdriveNoCache } from "~/lib/utils.server";
|
||||
|
||||
import config from "config";
|
||||
|
||||
/**
|
||||
* Remove all saved passwords.
|
||||
*/
|
||||
export async function ClearSavedPasswords(): Promise<ActionResponseSchema> {
|
||||
const store = await cookies();
|
||||
if (store.has(COOKIES_NAME.indexPassword)) {
|
||||
store.delete(COOKIES_NAME.indexPassword);
|
||||
}
|
||||
if (store.has(COOKIES_NAME.folderPassword)) {
|
||||
store.delete(COOKIES_NAME.folderPassword);
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "All saved passwords cleared",
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password of the index is correct.
|
||||
* Only used if the index is password-protected.
|
||||
*/
|
||||
export async function CheckIndexPassword(): Promise<ActionResponseSchema> {
|
||||
let response: ActionResponseSchema = {
|
||||
success: true,
|
||||
message: "Index is public",
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
// Skip if public
|
||||
if (!config.siteConfig.privateIndex) return response;
|
||||
|
||||
// Check if password is set
|
||||
if (!process.env.SITE_PASSWORD) {
|
||||
response = {
|
||||
success: false,
|
||||
message: "Password is not set",
|
||||
error:
|
||||
"Index password is not set, please set the `SITE_PASSWORD` environment variable, or disable `privateIndex` in the config file!",
|
||||
};
|
||||
return response;
|
||||
}
|
||||
const store = await cookies();
|
||||
|
||||
const password = store.get(COOKIES_NAME.indexPassword)?.value;
|
||||
if (!password) {
|
||||
response = {
|
||||
success: false,
|
||||
message: "Index is locked",
|
||||
error: "",
|
||||
};
|
||||
return response;
|
||||
}
|
||||
const decryptedPassword = await encryptionService.decrypt(password);
|
||||
|
||||
if (decryptedPassword !== process.env.SITE_PASSWORD) {
|
||||
response = {
|
||||
success: false,
|
||||
message: "Incorrect password",
|
||||
error: "Incorrect password, please try again!",
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
response = {
|
||||
success: true,
|
||||
message: "Index is unlocked",
|
||||
data: undefined,
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the index / full site password.
|
||||
* @param {string} password - The password to set.
|
||||
* @returns {ActionResponseSchema}
|
||||
*/
|
||||
export async function SetIndexPassword(password: string): Promise<ActionResponseSchema> {
|
||||
try {
|
||||
const store = await cookies();
|
||||
const encryptedPassword = await encryptionService.encrypt(password);
|
||||
store.set(COOKIES_NAME.indexPassword, encryptedPassword, COOKIES_OPTIONS);
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Password set",
|
||||
data: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[Actions/Password/SetIndexPassword] ${e.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to set password",
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password of the current folder is correct.
|
||||
*/
|
||||
export async function CheckPagePassword(
|
||||
paths: {
|
||||
path: string;
|
||||
id: string;
|
||||
}[],
|
||||
): Promise<ActionResponseSchema> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedRootId = await encryptionService.decrypt(config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const pathsArray = paths;
|
||||
|
||||
const folderIds: string[] = [];
|
||||
for (const path of pathsArray) {
|
||||
const decryptedId = await encryptionService.decrypt(path.id);
|
||||
if (decryptedId === decryptedRootId) continue;
|
||||
folderIds.push(decryptedId);
|
||||
}
|
||||
|
||||
const filterQuery: string = [
|
||||
`name = '${config.apiConfig.specialFile.password}'`,
|
||||
`trashed = false`,
|
||||
`(${folderIds.map((id) => `'${id}' in parents`).join(" or ")})`,
|
||||
].join(" and ");
|
||||
|
||||
const findPasswordResponse = await gdriveNoCache.files.list({
|
||||
q: filterQuery,
|
||||
fields: "files(id,name,mimeType,parents)",
|
||||
pageSize: 1000,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
if (!findPasswordResponse.data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: `No password file found on current path (${toUrlPath(paths)})`,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
const protectedFolderPassword = findPasswordResponse.data.files
|
||||
.filter((item) => folderIds.includes(item.parents?.[0] ?? ""))
|
||||
?.shift();
|
||||
if (!protectedFolderPassword)
|
||||
return {
|
||||
success: true,
|
||||
message: `All folders on current path are public (${toUrlPath(paths)})`,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
const store = await cookies();
|
||||
const storedPassword = JSON.parse(store.get(COOKIES_NAME.folderPassword)?.value ?? "{}") as Record<string, string>;
|
||||
|
||||
const currentFolderIndex = folderIds.findIndex((item) => item === protectedFolderPassword.parents?.[0]);
|
||||
const currentFolder = paths[currentFolderIndex]!;
|
||||
const currentFolderPath = toUrlPath(paths.slice(0, currentFolderIndex + 1));
|
||||
const currentFolderPassword = storedPassword[currentFolderPath];
|
||||
|
||||
if (!currentFolderPassword)
|
||||
return {
|
||||
success: false,
|
||||
message: "Folder is locked",
|
||||
error: `Please enter password for '${currentFolderPath}'`,
|
||||
};
|
||||
|
||||
const savedPasswordValue = await encryptionService.decrypt(currentFolderPassword);
|
||||
const { data: passwordFile } = await gdriveNoCache.files.get(
|
||||
{
|
||||
fileId: protectedFolderPassword.id!,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
if (!passwordFile)
|
||||
return {
|
||||
success: false,
|
||||
message: "Password file not found",
|
||||
error: `Failed to get password file for '${currentFolder.path}', please try again!`,
|
||||
};
|
||||
|
||||
if (savedPasswordValue !== passwordFile)
|
||||
return {
|
||||
success: false,
|
||||
message: "Incorrect password",
|
||||
error: `The password for '${currentFolder.path}' is incorrect`,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Folder is unlocked",
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the password for the current folder.
|
||||
* @param path - The path of the folder.
|
||||
* @param password - The password to set.
|
||||
*/
|
||||
export async function SetPagePassword(path: string, password: string): Promise<ActionResponseSchema> {
|
||||
try {
|
||||
const store = await cookies();
|
||||
const storedPassword = JSON.parse(store.get(COOKIES_NAME.folderPassword)?.value ?? "{}") as Record<string, string>;
|
||||
const encryptedPassword = await encryptionService.encrypt(password);
|
||||
|
||||
storedPassword[path] = encryptedPassword;
|
||||
store.set(COOKIES_NAME.folderPassword, JSON.stringify(storedPassword), COOKIES_OPTIONS);
|
||||
revalidatePath(path, "page");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Password set",
|
||||
data: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[Actions/Password/SetPagePassword] ${e.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to set password",
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use server";
|
||||
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { encryptionService, gdrive } from "~/lib/utils.server";
|
||||
|
||||
import config from "config";
|
||||
|
||||
/**
|
||||
* Get file paths from the root folder to the file.
|
||||
* @param {string} fileName - The file name.
|
||||
* @param {string} parentId - The parent ID of the file.
|
||||
* @returns {string} - The file path.
|
||||
*/
|
||||
export async function GetFilePaths(fileName: string, parentId?: string): Promise<ActionResponseSchema<string>> {
|
||||
const decryptedRootId = await encryptionService.decrypt(config.apiConfig.rootFolder);
|
||||
if (!decryptedRootId)
|
||||
return { success: false, message: "Failed to decrypt root folder ID", error: "Failed to decrypt root folder ID" };
|
||||
|
||||
const paths: string[] = [fileName];
|
||||
let parentIdTemp = parentId;
|
||||
while (parentIdTemp) {
|
||||
if (parentIdTemp === decryptedRootId) break;
|
||||
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: parentIdTemp,
|
||||
fields: "id,name,parents",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (!data.name) break;
|
||||
|
||||
paths.unshift(data.name);
|
||||
parentIdTemp = data.parents?.[0];
|
||||
}
|
||||
|
||||
return { success: true, message: "File paths retrieved", data: paths.join("/") };
|
||||
}
|
||||
|
||||
type PathFetch = {
|
||||
index: number;
|
||||
path: string;
|
||||
data: {
|
||||
id: string;
|
||||
parents?: string;
|
||||
mimeType: string;
|
||||
}[];
|
||||
};
|
||||
/**
|
||||
* Validate paths and return the ID of each path.
|
||||
* @param paths - The paths to validate.
|
||||
* @returns {ActionResponseSchema<{ id: string; path: string; mimeType: string; }[]>} - The validated paths.
|
||||
*/
|
||||
export async function ValidatePaths(
|
||||
paths: string[],
|
||||
): Promise<ActionResponseSchema<{ id: string; path: string; mimeType: string }[]>> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedRootId = await encryptionService.decrypt(config.apiConfig.rootFolder);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const promises: Promise<PathFetch | null>[] = [];
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const list = gdrive.files
|
||||
.list({
|
||||
q: `name = '${decodeURIComponent(path)}' and trashed = false`,
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
})
|
||||
.then(({ data }) => {
|
||||
if (!data.files?.length) return null;
|
||||
|
||||
const object: PathFetch = {
|
||||
index,
|
||||
path,
|
||||
data: data.files.map((file) => ({
|
||||
id: file.id!,
|
||||
parents: file.parents?.[0],
|
||||
mimeType: file.mimeType!,
|
||||
})),
|
||||
};
|
||||
return object;
|
||||
});
|
||||
promises.push(list);
|
||||
}
|
||||
|
||||
const pathData = await Promise.all(promises);
|
||||
|
||||
// Try to find index of invalid path (null value from promise)
|
||||
// If found, return error message
|
||||
const invalidPathIndex = pathData.findIndex((path) => !path);
|
||||
if (invalidPathIndex !== -1)
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid path",
|
||||
error: `Failed to find path: ${paths[invalidPathIndex]}`,
|
||||
};
|
||||
const filteredPathData = pathData.filter((path) => path) as PathFetch[];
|
||||
|
||||
// Validate each path
|
||||
let isValid = true;
|
||||
let invalidPath: string | undefined;
|
||||
const validatedPaths: PathFetch[] = [];
|
||||
|
||||
for (const path of filteredPathData) {
|
||||
if (!isValid) break;
|
||||
if (!path.data.length) {
|
||||
isValid = false;
|
||||
invalidPath = path.path;
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for current path index
|
||||
* If it's 0 / the first path, make sure the parent is the root folder
|
||||
* If it's not, make sure the parent is the previous path's ID
|
||||
*/
|
||||
for (const item of path.data) {
|
||||
if (path.index === 0) {
|
||||
if (item.parents !== decryptedRootId && item.parents !== decryptedSharedDrive) break;
|
||||
validatedPaths.push(path);
|
||||
} else {
|
||||
const previousPath = validatedPaths[path.index - 1];
|
||||
if (!previousPath) break;
|
||||
if (item.parents !== previousPath.data?.[0]?.id) break;
|
||||
validatedPaths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (validatedPaths.length !== filteredPathData.length) {
|
||||
isValid = false;
|
||||
invalidPath = filteredPathData[validatedPaths.length]?.path;
|
||||
}
|
||||
if (!isValid)
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid path",
|
||||
error: invalidPath ? `Failed to validate path: ${invalidPath}` : "Failed when validating paths",
|
||||
};
|
||||
|
||||
const response: { path: string; id: string; mimeType: string }[] = [];
|
||||
for (const item of validatedPaths) {
|
||||
response.push({
|
||||
id: await encryptionService.encrypt(item.data[0]?.id ?? ""),
|
||||
path: decodeURIComponent(item.path),
|
||||
mimeType: item.data[0]?.mimeType ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Paths validated",
|
||||
data: response,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use server";
|
||||
|
||||
import { type z } from "zod";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { encryptionService, gdrive } from "~/lib/utils.server";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
import { GetFilePaths } from "./paths";
|
||||
|
||||
export async function SearchFiles(query: string): Promise<ActionResponseSchema<z.infer<typeof Schema_File>[]>> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedSharedDrive = isSharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive!)
|
||||
: undefined;
|
||||
|
||||
const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and ");
|
||||
const filterQuery: string = [...config.apiConfig.defaultQuery, `name contains '${query}'`, filterName].join(" and ");
|
||||
|
||||
const { data } = await gdrive.files.list({
|
||||
q: filterQuery,
|
||||
fields: `files(${config.apiConfig.defaultField})`,
|
||||
orderBy: "name_natural desc",
|
||||
pageSize: config.apiConfig.searchResult,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
if (!data.files?.length)
|
||||
return {
|
||||
success: true,
|
||||
message: "No files found",
|
||||
data: [],
|
||||
};
|
||||
|
||||
const files: z.infer<typeof Schema_File>[] = [];
|
||||
for (const file of data.files) {
|
||||
files.push({
|
||||
encryptedId: await encryptionService.encrypt(file.id!),
|
||||
encryptedWebContentLink: file.webContentLink ? await encryptionService.encrypt(file.webContentLink) : undefined,
|
||||
name: file.name!,
|
||||
mimeType: file.mimeType!,
|
||||
trashed: file.trashed ?? false,
|
||||
modifiedTime: new Date(file.modifiedTime!).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension ?? undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink ?? undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation ?? 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = Schema_File.array().safeParse(files);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to parse search results",
|
||||
error: parsed.error.message,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Search completed",
|
||||
data: parsed.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GetSearchResultPath(id: string): Promise<ActionResponseSchema<string>> {
|
||||
const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive);
|
||||
const decryptedId = await encryptionService.decrypt(id ?? config.apiConfig.rootFolder);
|
||||
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: "id,name,parents",
|
||||
supportsAllDrives: isSharedDrive,
|
||||
});
|
||||
if (!data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get file information",
|
||||
error: "Failed to get file information, might be due to invalid file ID!",
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = await GetFilePaths(data.name!, data.parents?.[0]);
|
||||
if (!filePath.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to get file path",
|
||||
error: filePath.error,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "File path found",
|
||||
data: filePath.data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { type z } from "zod";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { encryptionService } from "~/lib/utils.server";
|
||||
|
||||
import { type Schema_File, Schema_FileToken } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
/**
|
||||
* Create a token to download a file
|
||||
* @param file - File data
|
||||
* @param expiredIn - Token expiration time in milliseconds (default to configuration)
|
||||
*/
|
||||
export async function CreateFileToken(
|
||||
file: z.infer<typeof Schema_File>,
|
||||
expiredIn: number = 3600 * 1000 * config.apiConfig.temporaryTokenDuration,
|
||||
): Promise<ActionResponseSchema<string>> {
|
||||
const tokenObject = {
|
||||
id: file.encryptedId,
|
||||
exp: Date.now() + expiredIn,
|
||||
iat: Date.now(),
|
||||
};
|
||||
const parsedTokenObject = Schema_FileToken.safeParse(tokenObject);
|
||||
if (!parsedTokenObject.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to create token",
|
||||
error: parsedTokenObject.error.message,
|
||||
};
|
||||
|
||||
const token = await encryptionService.encrypt(JSON.stringify(parsedTokenObject.data));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Token created",
|
||||
data: token,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a file token and return the token data
|
||||
* @param token - The token to validate
|
||||
*/
|
||||
export async function ValidateFileToken(
|
||||
token: string,
|
||||
): Promise<ActionResponseSchema<z.infer<typeof Schema_FileToken>>> {
|
||||
const decryptedToken = await encryptionService.decrypt(token);
|
||||
const parsedToken = Schema_FileToken.safeParse(JSON.parse(decryptedToken));
|
||||
if (!parsedToken.success)
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to validate token",
|
||||
error: parsedToken.error.message,
|
||||
};
|
||||
const currentTime = Date.now();
|
||||
if (parsedToken.data.exp < currentTime)
|
||||
return {
|
||||
success: false,
|
||||
message: "Token expired",
|
||||
error: "Download URL expired",
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Token validated",
|
||||
data: parsedToken.data,
|
||||
};
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { getting_started, migration, new_user, shared_drive } from "~/data/docs";
|
||||
|
||||
import { Icon, Markdown } from "~/components/Global";
|
||||
import { ConfigurationForm } from "~/components/Guide/Configuration";
|
||||
import { ThemeForm } from "~/components/Guide/Theme";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const Sections = ["start", "new-user", "shared-drive", "migrating", "config", "theme"] as const;
|
||||
type Section = (typeof Sections)[number];
|
||||
type SectionItem = {
|
||||
id: Section;
|
||||
title: string;
|
||||
icon: keyof typeof icons;
|
||||
};
|
||||
|
||||
export default function DeployGuidePage() {
|
||||
const sectionMenu = useMemo<SectionItem[]>(
|
||||
() => [
|
||||
{
|
||||
id: "new-user",
|
||||
title: "New User Guide",
|
||||
icon: "UserPlus",
|
||||
},
|
||||
{
|
||||
id: "migrating",
|
||||
title: "Migrating from v1",
|
||||
icon: "GitBranch",
|
||||
},
|
||||
{
|
||||
id: "shared-drive",
|
||||
title: "Shared Drive Guide",
|
||||
icon: "Database",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative mx-auto w-full max-w-screen-desktop", "gap-3 p-3", "flex-grow-0", "flex flex-col")}>
|
||||
<Alert className='bg-blue-50 text-blue-600 dark:bg-blue-950/25 dark:text-blue-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='Info'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>About this guide</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className='w-full whitespace-pre-wrap text-pretty'>{`If you can see this guide on your deployment.
|
||||
Please set the \`showDeployGuide\` to false on the config files. Since it will take up the \`deploy\` route.`}</p>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<CardTitle className='text-3xl'>Deployment Guide</CardTitle>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown
|
||||
content={getting_started}
|
||||
className='px-0'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue='new-user'>
|
||||
<TabsList>
|
||||
{sectionMenu.map((item) => (
|
||||
<TabsTrigger
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
value={item.id}
|
||||
className='scroll-m-24'
|
||||
>
|
||||
{item.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='new-user'>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<CardTitle className='text-3xl'>New User Guide</CardTitle>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown
|
||||
content={new_user}
|
||||
className='px-0'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='shared-drive'>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<CardTitle className='text-3xl'>Shared Drive Guide</CardTitle>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown
|
||||
content={shared_drive}
|
||||
view='markdown'
|
||||
className='px-0'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='migrating'>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<CardTitle className='text-3xl'>Migrating from v1</CardTitle>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown
|
||||
content={migration}
|
||||
className='px-0'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>The value of the form will be reset when you switch tabs.</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue='config'>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value='config'
|
||||
id='config'
|
||||
className='scroll-m-24'
|
||||
>
|
||||
Configuration
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='theme'
|
||||
id='theme'
|
||||
className='scroll-m-24'
|
||||
>
|
||||
Theme Customization
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='config'>
|
||||
<ConfigurationForm />
|
||||
</TabsContent>
|
||||
<TabsContent value='theme'>
|
||||
<ThemeForm />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+97
-105
@@ -1,54 +1,49 @@
|
||||
import { Metadata, ResolvedMetadata } from "next";
|
||||
import { type Metadata, type ResolvedMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Actions, Explorer, FilePath, Readme } from "~/components/Explorer";
|
||||
import { Password } from "~/components/Layout";
|
||||
import { PreviewLayout } from "~/components/Preview";
|
||||
import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer";
|
||||
import { ErrorComponent, Password } from "~/components/layout";
|
||||
import { PreviewLayout } from "~/components/preview";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
import { getFileType } from "~/lib/previewHelper";
|
||||
import { formatPathToBreadcrumb } from "~/lib/utils";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CheckPassword, CheckPaths, GetBanner, GetFile, GetFiles, GetReadme } from "actions";
|
||||
import config from "config";
|
||||
|
||||
import DeployGuidePage from "./deploy";
|
||||
import { GetBanner, GetFile, GetReadme, ListFiles } from "~/actions/files";
|
||||
import { CheckPagePassword } from "~/actions/password";
|
||||
import { ValidatePaths } from "~/actions/paths";
|
||||
import { CreateFileToken } from "~/actions/token";
|
||||
|
||||
export const revalidate = 60;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
rest: string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params: { rest } }: Props, parent: ResolvedMetadata): Promise<Metadata> {
|
||||
if (rest.length === 1 && rest[0] === "deploy" && config.showDeployGuide) return { title: "Deploy Guide" };
|
||||
export async function generateMetadata({ params }: Props, parent: ResolvedMetadata): Promise<Metadata> {
|
||||
const { rest } = await params;
|
||||
|
||||
const paths = await CheckPaths(rest);
|
||||
const paths = await ValidatePaths(rest);
|
||||
if (!paths.success) return { title: "Not Found" };
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId) return { title: "Not Found" };
|
||||
const data = await GetFile(encryptedId);
|
||||
const currentPath = paths.data.pop();
|
||||
if (!currentPath?.id) return { title: "Not Found" };
|
||||
|
||||
const banner = await GetBanner(encryptedId);
|
||||
const banner = await GetBanner(currentPath.id);
|
||||
|
||||
return {
|
||||
title: data.name,
|
||||
description: data.mimeType?.includes("folder") ? `Browse ${data.name} files` : `View ${data.name}`,
|
||||
title: decodeURIComponent(currentPath.path),
|
||||
description: currentPath.mimeType.includes("folder")
|
||||
? `Browse ${currentPath.path} files`
|
||||
: `View ${currentPath.path}`,
|
||||
openGraph: {
|
||||
images: banner
|
||||
images: banner.success
|
||||
? [
|
||||
{
|
||||
url: `/api/og/${banner}`,
|
||||
url: `/api/og/${banner.data}`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
@@ -57,99 +52,96 @@ export async function generateMetadata({ params: { rest } }: Props, parent: Reso
|
||||
},
|
||||
};
|
||||
}
|
||||
export default async function RestPage({ params }: Props) {
|
||||
const { rest } = await params;
|
||||
|
||||
export default async function RestPage({ params: { rest } }: Props) {
|
||||
if (rest.length === 1 && rest[0] === "deploy" && config.showDeployGuide) return <DeployGuidePage />;
|
||||
|
||||
const paths = await CheckPaths(rest);
|
||||
const paths = await ValidatePaths(rest);
|
||||
if (!paths.success) notFound();
|
||||
const unlocked = await CheckPassword(paths.data);
|
||||
|
||||
const unlocked = await CheckPagePassword(paths.data);
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path)
|
||||
throw new Error(`No path returned from password checking${unlocked.message && `, ${unlocked.message}`}`);
|
||||
return (
|
||||
<Password
|
||||
path={unlocked.path}
|
||||
checkPaths={paths.data}
|
||||
errorMessage={unlocked.message}
|
||||
type='path'
|
||||
paths={paths.data}
|
||||
errorMessage={unlocked.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId) throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
const currentPath = paths.data[paths.data.length - 1];
|
||||
if (!currentPath) return <ErrorComponent error={new Error("Failed to get current path")} />;
|
||||
|
||||
const promise = [];
|
||||
|
||||
const { data: file } = await gdrive.files.get({
|
||||
fileId: await decryptData(encryptedId),
|
||||
fields: "mimeType, fileExtension",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (!file.mimeType?.includes("folder")) {
|
||||
promise.push(GetFile(encryptedId));
|
||||
} else {
|
||||
promise.push(GetFiles({ id: encryptedId }));
|
||||
}
|
||||
promise.push(GetReadme(encryptedId));
|
||||
|
||||
const [data, readme] = await Promise.all(promise).then((values) => {
|
||||
const file = Schema_File.safeParse(values[0]);
|
||||
|
||||
if (file.success) {
|
||||
return values as [z.infer<typeof Schema_File>, string];
|
||||
} else {
|
||||
return values as [{ files: z.infer<typeof Schema_File>[]; nextPageToken?: string }, string];
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
|
||||
<FilePath
|
||||
data={rest.map((item, index, array) => ({
|
||||
label: decodeURIComponent(item),
|
||||
href: index === array.length - 1 ? undefined : `${item}`,
|
||||
}))}
|
||||
/>
|
||||
const Layout: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => (
|
||||
<div className='flex h-fit w-full flex-col gap-4'>
|
||||
<FileBreadcrumb data={formatPathToBreadcrumb(paths.data)} />
|
||||
|
||||
<section
|
||||
slot='content'
|
||||
className='w-full'
|
||||
>
|
||||
{!("files" in data) ? (
|
||||
<PreviewLayout
|
||||
data={data}
|
||||
fileType={file.fileExtension && file.mimeType ? getFileType(file.fileExtension, file.mimeType) : "unknown"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<Actions />
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Explorer
|
||||
encryptedId={encryptedId}
|
||||
files={data.files}
|
||||
nextPageToken={data.nextPageToken}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
|
||||
{readme && (
|
||||
<Readme
|
||||
content={readme}
|
||||
title={"README.md"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (currentPath.mimeType.includes("folder")) {
|
||||
const [data, readme] = await Promise.all([ListFiles({ id: currentPath.id }), GetReadme(currentPath.id)]);
|
||||
if (!data.success) return <ErrorComponent error={new Error(data.error)} />;
|
||||
if (!readme.success) return <ErrorComponent error={new Error(readme.error)} />;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex w-full items-center justify-between gap-4'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<FileActions />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='p-2 pt-0 tablet:p-4 tablet:pt-0'>
|
||||
<FileExplorerLayout
|
||||
encryptedId={currentPath.id}
|
||||
files={data.data.files}
|
||||
nextPageToken={data.data.nextPageToken ?? undefined}
|
||||
showBackButton
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{readme.data && (
|
||||
<FileReadme
|
||||
content={readme.data.content}
|
||||
title={`README.${readme.data.type === "markdown" ? "md" : "txt"}`}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const file = await GetFile(currentPath.id);
|
||||
if (!file.success) {
|
||||
if (file.error === "NotFound") notFound();
|
||||
return <ErrorComponent error={new Error(file.error)} />;
|
||||
}
|
||||
if (!file.data) return <ErrorComponent error={new Error("Failed to get file data")} />;
|
||||
const token = await CreateFileToken(file.data);
|
||||
if (!token.success) return <ErrorComponent error={new Error(token.error)} />;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PreviewLayout
|
||||
data={file.data}
|
||||
fileType={
|
||||
file.data?.fileExtension && file.data?.mimeType
|
||||
? getFileType(file.data.fileExtension, file.data.mimeType)
|
||||
: "unknown"
|
||||
}
|
||||
token={token.data}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,821 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod";
|
||||
|
||||
import { decryptData, encryptData } from "~/utils/encryptionHelper";
|
||||
import gdrive, { gdriveNoCache } from "~/utils/gdriveInstance";
|
||||
|
||||
import { Constant } from "~/types/constant";
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export async function CheckSitePassword(): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
// Skip if the index is public
|
||||
if (!config.siteConfig.privateIndex)
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
if (!process.env.SITE_PASSWORD)
|
||||
throw new Error(
|
||||
"Index password not set, please set the SITE_PASSWORD environment variable or disable privateIndex in the config file",
|
||||
);
|
||||
|
||||
const store = cookies();
|
||||
if (!store.has(Constant.cookies_SitePassword))
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
const password = store.get(Constant.cookies_SitePassword)?.value || "";
|
||||
const decryptedPassword = await decryptData(password);
|
||||
if (decryptedPassword !== process.env.SITE_PASSWORD)
|
||||
throw new Error("Saved password is incorrect, please re-enter the password");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function SetSitePassword(password: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const store = cookies();
|
||||
const encryptedPassword = await encryptData(password);
|
||||
store.set(Constant.cookies_SitePassword, encryptedPassword, {
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
httpOnly: true,
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
|
||||
maxAge: 1000 * 60 * 60 * 24 * 365,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Password set",
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function ClearPassword(): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const store = cookies();
|
||||
if (store.has(Constant.cookies_FolderPassword)) {
|
||||
store.delete(Constant.cookies_FolderPassword);
|
||||
}
|
||||
if (store.has(Constant.cookies_SitePassword)) {
|
||||
store.delete(Constant.cookies_SitePassword);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "Password cleared",
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function CheckPaths(paths: string[]): Promise<
|
||||
| {
|
||||
success: false;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
success: true;
|
||||
data: { path: string; id: string }[];
|
||||
message?: string;
|
||||
}
|
||||
> {
|
||||
try {
|
||||
let decryptedSharedDrive;
|
||||
if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) {
|
||||
decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive);
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (const path of paths) {
|
||||
promises.push(
|
||||
gdrive.files
|
||||
.list({
|
||||
q: `name = '${decodeURIComponent(path)}' and trashed = false`,
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
})
|
||||
.then(({ data }) => {
|
||||
if (!data.files?.length) return null;
|
||||
return {
|
||||
path,
|
||||
data: data.files.map((file) => ({
|
||||
id: file.id,
|
||||
parents: file.parents?.[0],
|
||||
mimeType: file.mimeType,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const data = await Promise.all(promises);
|
||||
const notFoundIndex = data.findIndex((item) => !item);
|
||||
if (notFoundIndex !== -1) throw new Error(`Path not found: ${paths[notFoundIndex]}`);
|
||||
|
||||
// Check if each path is valid
|
||||
let valid = true;
|
||||
let invalidPath: string | undefined;
|
||||
const decryptedRootId = await decryptData(config.apiConfig.rootFolder);
|
||||
const dataWithIndex = data.map((item, index) => {
|
||||
return {
|
||||
...item,
|
||||
index: index,
|
||||
};
|
||||
});
|
||||
const filteredPaths: { path: string; data: { id: string; parents: string; mimeType: string }[] }[] = [];
|
||||
|
||||
for (const item of dataWithIndex) {
|
||||
if (!valid) break;
|
||||
|
||||
// Loop through data
|
||||
// If the current index is 0, check if the parents is the root folder or shared drive
|
||||
// If the current index is not 0, check if the parents is the previous data id from the filteredPaths
|
||||
|
||||
// Use filteredPaths because it will only contain the valid path
|
||||
// So if filteredPaths length is not equal to paths length, then it means there is an invalid path
|
||||
for (const path of item.data || []) {
|
||||
if (item.index === 0) {
|
||||
if (path.parents === decryptedRootId || path.parents === decryptedSharedDrive) {
|
||||
filteredPaths.push({
|
||||
path: item.path!,
|
||||
data: [{ id: path.id!, parents: path.parents!, mimeType: path.mimeType! }],
|
||||
});
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (path.parents === filteredPaths[item.index - 1]?.data?.[0].id) {
|
||||
filteredPaths.push({
|
||||
path: item.path!,
|
||||
data: [{ id: path.id!, parents: path.parents!, mimeType: path.mimeType! }],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filteredPaths.length !== paths.length) {
|
||||
valid = false;
|
||||
invalidPath = dataWithIndex[filteredPaths.length]?.path;
|
||||
}
|
||||
|
||||
if (!valid) throw new Error(`Invalid path: ${invalidPath}`);
|
||||
|
||||
const ids: { path: string; id: string }[] = [];
|
||||
for (const item of filteredPaths) {
|
||||
if (item) {
|
||||
const encryptedId = await encryptData(item.data[0].id as string);
|
||||
ids.push({
|
||||
path: decodeURIComponent(item.path),
|
||||
id: encryptedId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: ids,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function CheckPassword(paths: { path: string; id: string }[]): Promise<
|
||||
| {
|
||||
success: false;
|
||||
message?: string;
|
||||
path?: string;
|
||||
}
|
||||
| {
|
||||
success: true;
|
||||
message?: string;
|
||||
}
|
||||
> {
|
||||
try {
|
||||
const ids: string[] = [];
|
||||
for (const path of paths) {
|
||||
ids.push(await decryptData(path.id));
|
||||
}
|
||||
let decryptedSharedDrive;
|
||||
if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) {
|
||||
decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive);
|
||||
}
|
||||
const query: string[] = [
|
||||
`name = '${config.apiConfig.specialFile.password}'`,
|
||||
`trashed = false`,
|
||||
`(${ids.map((id) => `'${id}' in parents`).join(" or ")})`, // Filter by paths id
|
||||
];
|
||||
const { data: password } = await gdriveNoCache.files
|
||||
.list({
|
||||
q: query.join(" and "),
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
pageSize: 1000,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
throw new Error("Failed to get password file");
|
||||
});
|
||||
|
||||
// To save processing time, skip if password file not found
|
||||
if (!password.files?.length) return { success: true };
|
||||
|
||||
const protectedFolder = password.files.filter((file) => ids.includes(file.parents?.[0] as string))?.shift(); // Shift to get only the nearest folder
|
||||
|
||||
// To save processing time, skip if all of the paths are not protected
|
||||
if (!protectedFolder) return { success: true };
|
||||
|
||||
const store = cookies();
|
||||
const folderIndex = ids.findIndex((item) => item === protectedFolder.parents?.[0]);
|
||||
|
||||
const cookiesValue = JSON.parse(store.get(Constant.cookies_FolderPassword)?.value || "{}");
|
||||
const currentFolder = paths[folderIndex];
|
||||
if (!cookiesValue[currentFolder.id])
|
||||
throw {
|
||||
// message: `Password for '${currentFolder.path}' is not set, please enter the password`,
|
||||
message: `Please enter password for '${currentFolder.path}'`,
|
||||
path: currentFolder.id,
|
||||
};
|
||||
|
||||
const savedPassword = await decryptData(cookiesValue[currentFolder.id]);
|
||||
const { data: passwordFile } = await gdrive.files.get(
|
||||
{
|
||||
fileId: protectedFolder.id as string,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
if (savedPassword !== passwordFile)
|
||||
throw {
|
||||
message: `Password for '${currentFolder.path}' is incorrect, please re-enter the password`,
|
||||
path: currentFolder.id,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
} else {
|
||||
const e = error as { message: string; path: string };
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
path: e.path,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function SetPassword(
|
||||
path: string,
|
||||
password: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const store = cookies();
|
||||
const cookiesValue = JSON.parse(store.get(Constant.cookies_FolderPassword)?.value || "{}");
|
||||
const updatedValue = {
|
||||
...cookiesValue,
|
||||
[path]: await encryptData(password),
|
||||
};
|
||||
store.set(Constant.cookies_FolderPassword, JSON.stringify(updatedValue), {
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
httpOnly: true,
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
|
||||
maxAge: 1000 * 60 * 60 * 24 * 365,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Password set",
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function GetFiles({ id, pageToken }: { id?: string; pageToken?: string }): Promise<{
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
}> {
|
||||
try {
|
||||
let decryptedId: string | undefined;
|
||||
if (id) decryptedId = await decryptData(id);
|
||||
else decryptedId = await decryptData(config.apiConfig.rootFolder);
|
||||
|
||||
let decryptedSharedDrive;
|
||||
if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) {
|
||||
decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive);
|
||||
}
|
||||
|
||||
const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and ");
|
||||
const query: string = [...config.apiConfig.defaultQuery, `'${decryptedId}' in parents`, `${filterName}`].join(
|
||||
" and ",
|
||||
);
|
||||
|
||||
const data = await gdrive.files.list({
|
||||
q: query,
|
||||
fields: `files(${config.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: pageToken,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
|
||||
const encryptedData: z.infer<typeof Schema_File>[] = [];
|
||||
if (!data.data.files?.length) return { files: [], nextPageToken: undefined };
|
||||
for (const file of data.data.files) {
|
||||
encryptedData.push({
|
||||
mimeType: file.mimeType as string,
|
||||
encryptedId: await encryptData(file.id as string),
|
||||
name: file.name as string,
|
||||
trashed: (file.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(file.modifiedTime as string).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension || undefined,
|
||||
encryptedWebContentLink: file.webContentLink ? await encryptData(file.webContentLink) : undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink || undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation || 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
const parsedContent = Schema_File.array().parse(encryptedData);
|
||||
|
||||
return {
|
||||
files: parsedContent,
|
||||
nextPageToken: data.data.nextPageToken ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function GetFile(encryptedId: string): Promise<z.infer<typeof Schema_File>> {
|
||||
try {
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const { data: file } = await gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: config.apiConfig.defaultField,
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
|
||||
const payload: z.infer<typeof Schema_File> = {
|
||||
encryptedId,
|
||||
mimeType: file.mimeType as string,
|
||||
name: file.name as string,
|
||||
trashed: (file.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(file.modifiedTime as string).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension || undefined,
|
||||
encryptedWebContentLink: file.webContentLink ? await encryptData(file.webContentLink) : undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink || undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation || 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
const parsedContent = Schema_File.parse(payload);
|
||||
|
||||
return parsedContent;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function SearchFile(
|
||||
keyword: string,
|
||||
nextPageToken?: string,
|
||||
): Promise<{
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
}> {
|
||||
try {
|
||||
let decryptedSharedDrive;
|
||||
if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) {
|
||||
decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive);
|
||||
}
|
||||
|
||||
const query: string[] = [
|
||||
...config.apiConfig.defaultQuery,
|
||||
`name contains '${keyword}'`,
|
||||
config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and "),
|
||||
];
|
||||
const data = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${config.apiConfig.defaultField}), nextPageToken`,
|
||||
orderBy: "name_natural desc",
|
||||
pageSize: config.apiConfig.searchResult,
|
||||
pageToken: nextPageToken,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
const encryptedData: z.infer<typeof Schema_File>[] = [];
|
||||
if (!data.data.files?.length) return { files: [], nextPageToken: undefined };
|
||||
for (const file of data.data.files) {
|
||||
encryptedData.push({
|
||||
mimeType: file.mimeType as string,
|
||||
encryptedId: await encryptData(file.id as string),
|
||||
name: file.name as string,
|
||||
trashed: (file.trashed as boolean) ?? false,
|
||||
modifiedTime: new Date(file.modifiedTime as string).toLocaleDateString(),
|
||||
fileExtension: file.fileExtension || undefined,
|
||||
encryptedWebContentLink: file.webContentLink ? await encryptData(file.webContentLink) : undefined,
|
||||
size: file.size ? Number(file.size) : undefined,
|
||||
thumbnailLink: file.thumbnailLink || undefined,
|
||||
imageMediaMetadata: file.imageMediaMetadata
|
||||
? {
|
||||
width: Number(file.imageMediaMetadata.width),
|
||||
height: Number(file.imageMediaMetadata.height),
|
||||
rotation: Number(file.imageMediaMetadata.rotation || 0),
|
||||
}
|
||||
: undefined,
|
||||
videoMediaMetadata: file.videoMediaMetadata
|
||||
? {
|
||||
width: Number(file.videoMediaMetadata.width),
|
||||
height: Number(file.videoMediaMetadata.height),
|
||||
durationMillis: Number(file.videoMediaMetadata.durationMillis),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
const parsedContent = Schema_File.array().parse(encryptedData);
|
||||
|
||||
return {
|
||||
files: parsedContent,
|
||||
nextPageToken: data.data.nextPageToken ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function RedirectSearchFile(encryptedId: string): Promise<string> {
|
||||
try {
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: await decryptData(encryptedId),
|
||||
fields: "id, name, parents",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
|
||||
const paths: string[] = [data.name as string];
|
||||
let parentId = data.parents?.[0];
|
||||
const decryptedRootId = await decryptData(config.apiConfig.rootFolder);
|
||||
while (parentId) {
|
||||
if (parentId === decryptedRootId) break;
|
||||
const { data: parent } = await gdrive.files.get({
|
||||
fileId: parentId,
|
||||
fields: "id, name, parents",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
paths.unshift(parent.name as string);
|
||||
parentId = parent.parents?.[0];
|
||||
}
|
||||
|
||||
return paths.join("/");
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GetContent(encryptedId: string): Promise<string> {
|
||||
try {
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const { data: file } = await gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
if (typeof file !== "string") throw new Error("It seems the file is not a text file");
|
||||
|
||||
return file as string;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function GetReadme(encryptedId: string | undefined): Promise<string | null> {
|
||||
try {
|
||||
let decryptedId;
|
||||
if (encryptedId) decryptedId = await decryptData(encryptedId);
|
||||
else decryptedId = await decryptData(config.apiConfig.rootFolder);
|
||||
|
||||
let decryptedSharedDrive;
|
||||
if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) {
|
||||
decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive);
|
||||
}
|
||||
|
||||
const query: string[] = [
|
||||
"trashed = false",
|
||||
"(not mimeType contains 'folder')",
|
||||
`name = '${config.apiConfig.specialFile.readme}'`,
|
||||
`'${decryptedId}' in parents`,
|
||||
];
|
||||
const { data } = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!data.files?.length) return null;
|
||||
|
||||
let file;
|
||||
|
||||
if (data.files.length === 1) {
|
||||
file = data.files[0];
|
||||
} else {
|
||||
// Check by mimetype, use google-apps.shortcut last
|
||||
file = data.files.find((item) => item.mimeType === "text/markdown");
|
||||
if (!file) {
|
||||
file = data.files.find((item) => item.mimeType === "application/vnd.google-apps.shortcut");
|
||||
}
|
||||
}
|
||||
|
||||
if (!file) return null;
|
||||
if (
|
||||
file.mimeType !== "text/markdown" &&
|
||||
file.mimeType !== "application/vnd.google-apps.shortcut" &&
|
||||
file.mimeType !== "text/plain"
|
||||
)
|
||||
return null;
|
||||
|
||||
if (file.mimeType === "application/vnd.google-apps.shortcut") {
|
||||
const { data: shortcutData } = (await gdrive.files.get({
|
||||
fileId: file.id as string,
|
||||
fields: "shortcutDetails",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
})) as {
|
||||
data: { shortcutDetails: { targetId: string; targetMimeType: string } };
|
||||
};
|
||||
if (!shortcutData.shortcutDetails.targetId) return null;
|
||||
if (shortcutData.shortcutDetails.targetMimeType !== "text/markdown") return null;
|
||||
|
||||
const { data: content } = await gdrive.files.get(
|
||||
{
|
||||
fileId: shortcutData.shortcutDetails.targetId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
return content as string;
|
||||
}
|
||||
|
||||
const { data: content } = await gdrive.files.get(
|
||||
{
|
||||
fileId: file.id as string,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
return content as string;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function GetBanner(encryptedId: string | undefined): Promise<string | null> {
|
||||
try {
|
||||
let decryptedId;
|
||||
if (encryptedId) decryptedId = await decryptData(encryptedId);
|
||||
else decryptedId = await decryptData(config.apiConfig.rootFolder);
|
||||
|
||||
let decryptedSharedDrive;
|
||||
if (config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive) {
|
||||
decryptedSharedDrive = await decryptData(config.apiConfig.sharedDrive);
|
||||
}
|
||||
|
||||
const query: string[] = [
|
||||
...config.apiConfig.defaultQuery,
|
||||
`name contains '${config.apiConfig.specialFile.banner}'`,
|
||||
`'${decryptedId}' in parents`,
|
||||
];
|
||||
const { data } = await gdrive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
...(decryptedSharedDrive && {
|
||||
supportsAllDrives: true,
|
||||
includeItemsFromAllDrives: true,
|
||||
driveId: decryptedSharedDrive,
|
||||
corpora: "drive",
|
||||
}),
|
||||
});
|
||||
if (!data.files?.length) return null;
|
||||
|
||||
return await encryptData(data.files[0].id as string);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GetWebContent(encrypted: string): Promise<string> {
|
||||
try {
|
||||
return await decryptData(encrypted);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function CreateDownloadToken(duration: number = config.apiConfig.temporaryTokenDuration): Promise<string> {
|
||||
try {
|
||||
const data = {
|
||||
expiredAt: Date.now() + duration * 60 * 60 * 1000 * config.apiConfig.temporaryTokenDuration,
|
||||
};
|
||||
const token = await encryptData(JSON.stringify(data));
|
||||
return token;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function CheckDownloadToken(token: string): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
const decryptToken = JSON.parse(await decryptData(token));
|
||||
if (decryptToken.expiredAt < Date.now()) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Download token expired, please click the download button again to get a new token",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function GenerateAESKey(): Promise<string> {
|
||||
try {
|
||||
const key = crypto.randomBytes(8).toString("hex");
|
||||
return key;
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
export async function VerifyAESKey(data: string, key: string): Promise<boolean> {
|
||||
try {
|
||||
let paddedKey;
|
||||
if (key.length < 16) {
|
||||
paddedKey = key.padEnd(16, "0");
|
||||
} else if (key.length > 16) {
|
||||
paddedKey = key.slice(0, 16);
|
||||
} else {
|
||||
paddedKey = key;
|
||||
}
|
||||
|
||||
const encrypt = await encryptData(data, paddedKey);
|
||||
const decrypt = await decryptData(encrypt, paddedKey);
|
||||
return !!encrypt && !!decrypt;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { encryptionService, gdrive } from "~/lib/utils.server";
|
||||
|
||||
import { GetFile } from "~/actions/files";
|
||||
import { CheckIndexPassword, CheckPagePassword } from "~/actions/password";
|
||||
import { ValidatePaths } from "~/actions/paths";
|
||||
import { ValidateFileToken } from "~/actions/token";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ rest: string[] }> }) {
|
||||
const { rest } = await params;
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const forceRedirect = sp.get("redirect") === "1";
|
||||
const token = sp.get("token");
|
||||
const paths = rest.map((path) => {
|
||||
if (path.startsWith("/")) return decodeURIComponent(path.slice(1));
|
||||
return decodeURIComponent(path);
|
||||
});
|
||||
|
||||
try {
|
||||
if (!config.apiConfig.allowDownloadProtectedFile) {
|
||||
if (!token) {
|
||||
throw new Error("[401] Token not found", {
|
||||
cause: "This endpoint requires a token to download the file",
|
||||
});
|
||||
}
|
||||
|
||||
const validateToken = await ValidateFileToken(token);
|
||||
if (!validateToken.success) {
|
||||
throw new Error(`[401] ${validateToken.message}`, {
|
||||
cause: validateToken.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const validatedPaths = await ValidatePaths(paths);
|
||||
if (!validatedPaths.success) {
|
||||
throw new Error(`[404] ${validatedPaths.message}`, {
|
||||
cause: validatedPaths.error,
|
||||
});
|
||||
}
|
||||
|
||||
const currentFile = validatedPaths.data.pop();
|
||||
if (!currentFile) {
|
||||
throw new Error("[404] File not found", {
|
||||
cause: "Failed to get current file",
|
||||
});
|
||||
}
|
||||
|
||||
if (config.siteConfig.privateIndex && !config.apiConfig.allowDownloadProtectedFile) {
|
||||
const [indexUnlocked, pageUnlocked] = await Promise.all([
|
||||
CheckIndexPassword(),
|
||||
CheckPagePassword(validatedPaths.data),
|
||||
]);
|
||||
if (!indexUnlocked.success) {
|
||||
throw new Error(`[401] ${indexUnlocked.message}`, {
|
||||
cause: indexUnlocked.error,
|
||||
});
|
||||
}
|
||||
if (!pageUnlocked.success) {
|
||||
throw new Error(`[401] ${pageUnlocked.message}`, {
|
||||
cause: pageUnlocked.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const file = await GetFile(currentFile.id);
|
||||
if (!file.success) {
|
||||
throw new Error(`[404] ${file.message}`, {
|
||||
cause: file.error,
|
||||
});
|
||||
}
|
||||
if (!file.data?.encryptedWebContentLink) {
|
||||
throw new Error("[500] No download link found", {
|
||||
cause: "No download link found",
|
||||
});
|
||||
}
|
||||
|
||||
const fileSize = Number(file.data?.size ?? 0);
|
||||
if ((config.apiConfig.maxFileSize > 0 && fileSize > config.apiConfig.maxFileSize) || forceRedirect) {
|
||||
const decryptedContentUrl = await encryptionService.decrypt(file.data.encryptedWebContentLink);
|
||||
const contentUrl = new URL(decryptedContentUrl);
|
||||
contentUrl.searchParams.set("confirm", "1");
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: contentUrl.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const content = await gdrive.files.get(
|
||||
{
|
||||
fileId: await encryptionService.decrypt(file.data.encryptedId),
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
acknowledgeAbuse: true,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
},
|
||||
);
|
||||
const fileBuffer = await new Promise<Buffer>((res, rej) => {
|
||||
const chunks: Buffer<ArrayBufferLike>[] = [];
|
||||
content.data.on("data", (chunk) => {
|
||||
chunks.push(Buffer.from(chunk as ArrayBufferLike));
|
||||
});
|
||||
content.data.on("end", () => {
|
||||
res(Buffer.concat(chunks));
|
||||
});
|
||||
content.data.on("error", (err) => {
|
||||
rej(err);
|
||||
});
|
||||
});
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": file.data.mimeType,
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
"Content-Disposition": `attachment; filename="${file.data.name}"`,
|
||||
"Cache-Control": config.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
const message = e.message.replace(/\[.*\]/, "").trim();
|
||||
const status = /\[.*\]/.exec(e.message)?.[0].replace(/\[|\]/g, "").trim() ?? 500;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
scope: "api/download",
|
||||
message,
|
||||
cause: e.cause ?? "Unknown",
|
||||
},
|
||||
{
|
||||
status: Number(status),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
|
||||
|
||||
import { CheckDownloadToken, CheckPassword, CheckPaths, CheckSitePassword, RedirectSearchFile } from "actions";
|
||||
import config from "config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{
|
||||
params: { encryptedId },
|
||||
}: {
|
||||
params: {
|
||||
encryptedId: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
try {
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const token = sp.get("token");
|
||||
if (!token) throw new Error("Token not found");
|
||||
|
||||
const tokenValidity = await CheckDownloadToken(token);
|
||||
if (!tokenValidity.success) throw new Error(tokenValidity.message);
|
||||
|
||||
if (config.siteConfig.privateIndex && !config.apiConfig.allowDownloadProtectedFile) {
|
||||
const unlocked = await CheckSitePassword();
|
||||
if (!unlocked.success) {
|
||||
return new NextResponse(
|
||||
`It seems like this site is protected by password, and you haven't entered the password yet.
|
||||
|
||||
If you've already entered the password, please make sure your browser is not blocking cookies from this site.`,
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const _filePaths = RedirectSearchFile(encryptedId);
|
||||
const _fileMeta = gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: "id, name, mimeType, size, fileExtension, webContentLink",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
|
||||
const [fileMeta, filePaths] = await Promise.all([_fileMeta, _filePaths]);
|
||||
|
||||
if (!config.apiConfig.allowDownloadProtectedFile) {
|
||||
const checkPath = await CheckPaths(filePaths.split("/"));
|
||||
if (!checkPath.success) throw new Error("File not found");
|
||||
const unlocked = await CheckPassword(checkPath.data);
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path) throw new Error("No path returned from password checking");
|
||||
|
||||
const lockedIndex = checkPath.data.findIndex((path) => path.id === unlocked.path);
|
||||
// Get all path until the locked index, then join them
|
||||
const path = checkPath.data
|
||||
.slice(0, lockedIndex + 1)
|
||||
.map((path) => path.path)
|
||||
.join("/");
|
||||
return new NextResponse(
|
||||
`The file you're trying to access is protected by password.
|
||||
Please open the file link and enter the password to access the file, then try to download the file again.
|
||||
|
||||
Protected Path: ${new URL(path, config.basePath).toString()}
|
||||
|
||||
If you've already entered the password, please make sure your browser is not blocking cookies from this site.`,
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fileSize = Number(fileMeta.data.size || 0);
|
||||
if (!fileMeta.data.webContentLink) throw new Error("No download link found");
|
||||
|
||||
if (config.apiConfig.maxFileSize && fileSize > config.apiConfig.maxFileSize) {
|
||||
const contentUrl = new URL(fileMeta.data.webContentLink);
|
||||
contentUrl.searchParams.set("confirm", "1");
|
||||
return NextResponse.redirect(contentUrl, {
|
||||
status: 302,
|
||||
});
|
||||
}
|
||||
|
||||
const fileContent = await gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
},
|
||||
);
|
||||
|
||||
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-Length": fileBuffer.length.toString(),
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(
|
||||
fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
|
||||
)}"`,
|
||||
"Cache-Control": config.cacheControl,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return new NextResponse(e.message, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { encryptData } from "~/utils/encryptionHelper";
|
||||
import { encryptionService } from "~/lib/utils.server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -11,13 +11,19 @@ export async function GET(request: NextRequest) {
|
||||
const key = sp.get("key");
|
||||
if (!query) return new NextResponse("Add query parameter 'q' with the value to encrypt", { status: 400 });
|
||||
|
||||
const encrypted = await encryptData(query, key || process.env.ENCRYPTION_KEY);
|
||||
if (process.env.NODE_ENV !== "development" && !key) {
|
||||
throw new Error("Key is required in production environment");
|
||||
}
|
||||
|
||||
const encrypted = await encryptionService.encrypt(query, key ?? undefined);
|
||||
const decrypted = await encryptionService.decrypt(encrypted, key ?? undefined);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: key ? "Encrypted with provided key" : "Encrypted with environment key",
|
||||
value: encrypted,
|
||||
key,
|
||||
encryptedValue: encrypted,
|
||||
decryptedValue: decrypted,
|
||||
key: key ?? process.env.ENCRYPTION_KEY,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
import { encryptionService, gdrive } from "~/lib/utils.server";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
encryptedId: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params: { encryptedId } }: Props) {
|
||||
export async function GET(request: NextRequest, { params }: Props) {
|
||||
const { encryptedId } = await params;
|
||||
|
||||
try {
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const decryptedId = await encryptionService.decrypt(encryptedId);
|
||||
const { data } = await gdrive.files.get({
|
||||
fileId: decryptedId,
|
||||
fields: "id, name, mimeType, webContentLink",
|
||||
@@ -29,7 +32,7 @@ export async function GET(request: NextRequest, { params: { encryptedId } }: Pro
|
||||
return new NextResponse(bufferData, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": data.mimeType || "application/octet-stream",
|
||||
"Content-Type": data.mimeType ?? "application/octet-stream",
|
||||
"Content-Length": bufferData.length.toString(),
|
||||
"Content-Disposition": `inline; filename="${data.name}"`,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { IS_DEV } from "~/constant";
|
||||
|
||||
import { encryptionService, gdriveNoCache } from "~/lib/utils.server";
|
||||
|
||||
import { GetFile } from "~/actions/files";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{
|
||||
encryptedId: string;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
const { encryptedId } = await params;
|
||||
const sp = new URL(request.url).searchParams;
|
||||
const isInline = sp.get("inline") === "1";
|
||||
const isFull = sp.get("full") === "1";
|
||||
const origin = request.headers.get("Origin") ?? request.headers.get("Referer") ?? request.headers.get("Host") ?? null;
|
||||
|
||||
try {
|
||||
if (!origin) {
|
||||
throw new Error("[500] Invalid request", {
|
||||
cause: "Request headers is invalid",
|
||||
});
|
||||
}
|
||||
|
||||
// // Only allow if the request is from the same domain or the referer is the same domain
|
||||
if (!IS_DEV && !origin?.toLowerCase().includes(config.basePath.toLowerCase())) {
|
||||
throw new Error("[403] Unauthorized", {
|
||||
cause: "Request is not allowed",
|
||||
});
|
||||
}
|
||||
|
||||
const decryptedId = await encryptionService.decrypt(encryptedId);
|
||||
const file = await GetFile(encryptedId);
|
||||
if (!file.success) {
|
||||
throw new Error(`[404] ${file.message}`, {
|
||||
cause: file.error,
|
||||
});
|
||||
}
|
||||
if (!file.data?.encryptedWebContentLink) {
|
||||
throw new Error("[500] No download link found", {
|
||||
cause: "No download link found",
|
||||
});
|
||||
}
|
||||
|
||||
const fileSize = Number(file.data.size ?? 0);
|
||||
const isFullyLoaded = isFull ? true : Number(request.headers.get("Range")?.split("-")[1] ?? 0) === fileSize - 1;
|
||||
|
||||
if (config.apiConfig.streamMaxSize && fileSize > config.apiConfig.streamMaxSize) {
|
||||
throw new Error("[400] File is too large to stream", {
|
||||
cause: "File size is larger than the maximum allowed size, please download the file instead",
|
||||
});
|
||||
}
|
||||
|
||||
const ranges = request.headers.get("Range") ?? "bytes=0-";
|
||||
const chunkSize = 5 * 1024 * 1024; // Load 5MB at a time
|
||||
let rangeStart = 0;
|
||||
let rangeEnd = Math.min(chunkSize, fileSize - 1);
|
||||
const rangeSize = /bytes=(\d+)-(\d+)?/.exec(ranges);
|
||||
if (rangeSize) {
|
||||
rangeStart = Number(rangeSize?.[1] ?? 0);
|
||||
|
||||
if (isFullyLoaded) {
|
||||
rangeEnd = fileSize - 1;
|
||||
} else {
|
||||
rangeEnd = Math.min(rangeStart + chunkSize, fileSize - 1);
|
||||
}
|
||||
}
|
||||
|
||||
const content = await gdriveNoCache.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
acknowledgeAbuse: true,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
headers: {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Range": `bytes=${rangeStart}-${rangeEnd}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const headers = new Headers(content.headers);
|
||||
if (isInline) {
|
||||
headers.set("Content-Disposition", `inline; filename="${file.data.name}"`);
|
||||
}
|
||||
|
||||
return new NextResponse(content.data as unknown as BodyInit, {
|
||||
status: isFullyLoaded ? 200 : content.status,
|
||||
headers: headers,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
const message = e.message.replace(/\[.*\]/, "").trim();
|
||||
const status = /\[.*\]/.exec(e.message)?.[0].replace(/\[|\]/g, "").trim() ?? 500;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
scope: "api/preview",
|
||||
message,
|
||||
cause: e.cause ?? "Unknown",
|
||||
},
|
||||
{
|
||||
status: Number(status),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { encryptionService } from "~/lib/utils.server";
|
||||
|
||||
import { CheckPassword, CheckPaths, GetFile } from "actions";
|
||||
import config from "config";
|
||||
import { GetFile } from "~/actions/files";
|
||||
import { ValidatePaths } from "~/actions/paths";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: NextRequest, { params: { rest } }: { params: { rest: string[] } }) {
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ rest: string[] }> }) {
|
||||
const { rest } = await params;
|
||||
const ALLOWED_TYPES = ["image/*", "video/*", "audio/*"]; // Based from mime types
|
||||
const paths = rest.map((path) => {
|
||||
if (path.startsWith("/")) return decodeURIComponent(path.slice(1));
|
||||
return decodeURIComponent(path);
|
||||
});
|
||||
|
||||
try {
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const token = sp.get("token");
|
||||
if (!token) throw new Error("Token not found");
|
||||
|
||||
const paths = await CheckPaths(rest);
|
||||
if (!paths.success) throw new Error(paths.message);
|
||||
|
||||
if (!config.apiConfig.allowDownloadProtectedFile) {
|
||||
const unlocked = await CheckPassword(paths.data);
|
||||
if (!unlocked.success)
|
||||
throw new Error(unlocked.path ? unlocked.message : "No path returned from password checking");
|
||||
const validatedPaths = await ValidatePaths(paths);
|
||||
if (!validatedPaths.success) {
|
||||
throw new Error(`[404] ${validatedPaths.message}`, {
|
||||
cause: validatedPaths.error,
|
||||
});
|
||||
}
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId) throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
if (token !== encryptedId) throw new Error("Invalid token");
|
||||
const currentFile = validatedPaths.data.pop();
|
||||
if (!currentFile) {
|
||||
throw new Error("[404] File not found", {
|
||||
cause: "Failed to get current file",
|
||||
});
|
||||
}
|
||||
if (ALLOWED_TYPES.every((type) => !new RegExp(type.replace("*", ".*")).test(currentFile.mimeType))) {
|
||||
throw new Error("[400] Invalid file type", {
|
||||
cause: "Raw link only available for video, image, and audio files",
|
||||
});
|
||||
}
|
||||
|
||||
const data = await GetFile(encryptedId);
|
||||
if (data.mimeType?.includes("folder")) throw new Error("Can't download folder");
|
||||
if (!data.mimeType.includes("video") && !data.mimeType.includes("image") && !data.mimeType.includes("audio"))
|
||||
throw new Error("Raw link only available for video, image, and audio files");
|
||||
if (!data.encryptedWebContentLink) throw new Error("No download link found");
|
||||
const fileMeta = await GetFile(currentFile.id);
|
||||
if (!fileMeta.success) {
|
||||
throw new Error(`[500] ${fileMeta.message}`, {
|
||||
cause: fileMeta.error,
|
||||
});
|
||||
}
|
||||
if (!fileMeta.data?.encryptedWebContentLink) {
|
||||
throw new Error("[500] No download link found", {
|
||||
cause: "No download link found",
|
||||
});
|
||||
}
|
||||
|
||||
const decryptedWebContent = await decryptData(data.encryptedWebContentLink);
|
||||
const decryptedLink = await encryptionService.decrypt(fileMeta.data.encryptedWebContentLink);
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: decryptedWebContent,
|
||||
Location: decryptedLink,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return new NextResponse(e.message, {
|
||||
status: 500,
|
||||
});
|
||||
const message = e.message.replace(/\[.*\]/, "").trim();
|
||||
const status = /\[.*\]/.exec(e.message)?.[0].replace(/\[|\]/g, "").trim() ?? 500;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
scope: "api/raw",
|
||||
message,
|
||||
cause: e.cause ?? "Unknown",
|
||||
},
|
||||
{
|
||||
status: Number(status),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
|
||||
import { isDev } from "~/utils/isDev";
|
||||
|
||||
import { CheckDownloadToken, CheckPassword, CheckPaths, RedirectSearchFile } from "actions";
|
||||
import config from "config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{
|
||||
params: { encryptedId },
|
||||
}: {
|
||||
params: {
|
||||
encryptedId: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
try {
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const token = sp.get("token");
|
||||
if (!token) throw new Error("Token not found");
|
||||
|
||||
// Only allow if the request is from the same domain or the referer is the same domain
|
||||
if (!isDev && !request.headers.get("Referer")?.includes(config.basePath)) {
|
||||
throw new Error("Invalid request");
|
||||
}
|
||||
|
||||
const tokenValidity = await CheckDownloadToken(token);
|
||||
if (!tokenValidity.success) throw new Error(tokenValidity.message);
|
||||
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
|
||||
const _filePaths = RedirectSearchFile(encryptedId);
|
||||
const _fileMeta = gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
fields: "id, name, mimeType, size, fileExtension, webContentLink",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Range": request.headers.get("Range") || `bytes=0-${1024 * 1024 - 1}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [filePaths, fileMeta] = await Promise.all([_filePaths, _fileMeta]);
|
||||
|
||||
const isFull = Number(request.headers.get("Range")?.split("-")[1] || 0) === Number(fileMeta.data.size || "1") - 1;
|
||||
|
||||
const fileSize = Number(fileMeta.data.size || 0);
|
||||
if (!fileMeta.data.webContentLink) throw new Error("No download link found");
|
||||
|
||||
if (config.apiConfig.streamMaxSize && fileSize > config.apiConfig.streamMaxSize) {
|
||||
throw new Error("File is too large to stream");
|
||||
}
|
||||
|
||||
if (!config.apiConfig.allowDownloadProtectedFile) {
|
||||
const checkPath = await CheckPaths(filePaths.split("/"));
|
||||
if (!checkPath.success) throw new Error("File not found");
|
||||
const unlocked = await CheckPassword(checkPath.data);
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path) throw new Error("No path returned from password checking");
|
||||
|
||||
const lockedIndex = checkPath.data.findIndex((path) => path.id === unlocked.path);
|
||||
// Get all path until the locked index, then join them
|
||||
const path = checkPath.data
|
||||
.slice(0, lockedIndex + 1)
|
||||
.map((path) => path.path)
|
||||
.join("/");
|
||||
return new NextResponse(
|
||||
`The file you're trying to access is protected by password.
|
||||
Please open the file link and enter the password to access the file, then try to download the file again.
|
||||
|
||||
Protected Path: ${new URL(path, config.basePath).toString()}
|
||||
|
||||
If you've already entered the password, please make sure your browser is not blocking cookies from this site.`,
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ranges = request.headers.get("Range") || "bytes=0-";
|
||||
const chunkSize = 5 * 1024 * 1024; // Load 5MB at a time
|
||||
let rangeStart = 0;
|
||||
let rangeEnd = Math.min(chunkSize, fileSize - 1);
|
||||
const rangeRegex = /bytes=(\d+)-(\d+)?/;
|
||||
const rangeSize = ranges.match(rangeRegex);
|
||||
if (rangeSize) {
|
||||
rangeStart = parseInt(rangeSize[1], 10);
|
||||
|
||||
if (isFull) {
|
||||
rangeEnd = fileSize - 1;
|
||||
} else {
|
||||
rangeEnd = Math.min(rangeStart + chunkSize, fileSize - 1);
|
||||
}
|
||||
}
|
||||
|
||||
const contentRange = `bytes=${rangeStart}-${rangeEnd}/${fileSize}`;
|
||||
const contentLength = rangeEnd ? rangeEnd - rangeStart + 1 : fileSize;
|
||||
|
||||
const fileContent = await gdrive.files.get(
|
||||
{
|
||||
fileId: decryptedId,
|
||||
alt: "media",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
acknowledgeAbuse: true,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
headers: {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Range": `bytes=${rangeStart}-${rangeEnd}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const stream = fileContent.data as NodeJS.ReadableStream;
|
||||
const fileRange = fileContent.headers["content-range"];
|
||||
const fileLength = fileContent.headers["content-length"];
|
||||
|
||||
const readable = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on("data", (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
stream.on("end", () => {
|
||||
controller.close();
|
||||
});
|
||||
stream.on("error", (error) => {
|
||||
controller.error(error);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(readable, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Range": fileRange || contentRange,
|
||||
"Content-Length": fileLength || contentLength.toString(),
|
||||
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return new NextResponse(e.message, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { IS_DEV } from "~/constant";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { isDev } from "~/utils/isDev";
|
||||
import { encryptionService } from "~/lib/utils.server";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
encryptedId: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, { params: { encryptedId } }: Props) {
|
||||
export async function GET(request: NextRequest, { params }: Props) {
|
||||
const { encryptedId } = await params;
|
||||
try {
|
||||
const searchParams = new URL(request.nextUrl).searchParams;
|
||||
const size = searchParams.get("size") || "512";
|
||||
const size = searchParams.get("size") ?? "512";
|
||||
|
||||
// Only allow if the request is from the same domain or the referer is the same domain
|
||||
if (!isDev && !request.headers.get("Referer")?.includes(config.basePath)) {
|
||||
if (!IS_DEV && !request.headers.get("Referer")?.includes(config.basePath)) {
|
||||
throw new Error("Invalid request");
|
||||
}
|
||||
|
||||
@@ -27,10 +30,7 @@ export async function GET(request: NextRequest, { params: { encryptedId } }: Pro
|
||||
throw new Error("Invalid size");
|
||||
}
|
||||
|
||||
const defaultImage = NextResponse.redirect(new URL("/og.png", config.basePath), {
|
||||
status: 302,
|
||||
});
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const decryptedId = await encryptionService.decrypt(encryptedId);
|
||||
|
||||
const url = `https://drive.google.com/thumbnail?id=${decryptedId}&sz=w${size}`;
|
||||
|
||||
|
||||
+6
-43
@@ -1,49 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
import ErrorComponent from "~/components/layout/Error";
|
||||
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset?: () => void }) {
|
||||
return (
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-3")}>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={"2rem"}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-center text-destructive'>Something went wrong</span>
|
||||
<span className='text-center text-sm text-destructive'>More details can be found in the console</span>
|
||||
</div>
|
||||
<code className='w-full whitespace-pre-wrap rounded-[var(--radius)] bg-muted px-3 py-1.5 text-sm text-muted-foreground'>
|
||||
{error.message}
|
||||
</code>
|
||||
|
||||
<div className='flex w-full flex-col items-center gap-3 pt-8'>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Back to previous page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorComponent
|
||||
error={error}
|
||||
reset={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/* Version: ${pkg.version} */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Shadcn/ui theme */
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 89.8%;
|
||||
/* --ring: 0 0% 3.9%; */
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 14.9%;
|
||||
/* --ring: 0 0% 83.1%; */
|
||||
}
|
||||
|
||||
div.preview * {
|
||||
@apply !transition-none;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@layer base {
|
||||
:root {
|
||||
/* @apply text-[14px] tablet:text-[16px]; */
|
||||
@apply text-[100%];
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
/* @apply outline outline-1 outline-red-500; */
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply h-1.5 w-1.5;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-primary/5;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-primary/25 hover:bg-primary/50;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||
}
|
||||
h2 {
|
||||
@apply scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0;
|
||||
}
|
||||
h3 {
|
||||
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
|
||||
}
|
||||
h4 {
|
||||
@apply scroll-m-20 text-xl font-semibold tracking-tight;
|
||||
}
|
||||
.paragraph {
|
||||
@apply leading-7 [&:not(:first-child)]:mt-6;
|
||||
}
|
||||
blockquote {
|
||||
@apply mt-6 border-l-2 pl-6 italic;
|
||||
}
|
||||
ul {
|
||||
@apply my-6 ml-6 list-disc [&>li]:mt-2;
|
||||
}
|
||||
.lead {
|
||||
@apply text-xl text-muted-foreground;
|
||||
}
|
||||
.large {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
.muted {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
small {
|
||||
@apply text-sm font-medium leading-none;
|
||||
}
|
||||
}
|
||||
+47
-30
@@ -1,19 +1,20 @@
|
||||
import { Metadata } from "next";
|
||||
import { type Metadata } from "next";
|
||||
import { JetBrains_Mono, Outfit, Source_Sans_3 } from "next/font/google";
|
||||
import "plyr-react/plyr.css";
|
||||
import { headers } from "next/headers";
|
||||
import { BASE_URL } from "~/constant";
|
||||
import { type ActionResponseSchema } from "~/types";
|
||||
|
||||
import { Footer, Navbar, Password, ThemeProvider } from "~/components/Layout";
|
||||
import ToTop from "~/components/Layout/ToTop";
|
||||
import { Footer, Navbar, Password, Provider, ToTop } from "~/components/layout";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatFooter } from "~/utils/footerFormatter";
|
||||
import { cn, formatFooterContent } from "~/lib/utils";
|
||||
|
||||
import { CheckIndexPassword } from "~/actions/password";
|
||||
import "~/styles/code-highlight.css";
|
||||
import "~/styles/globals.css";
|
||||
import "~/styles/markdown.css";
|
||||
|
||||
import { CheckSitePassword } from "actions";
|
||||
import config from "config";
|
||||
|
||||
import "./globals.css";
|
||||
import "./markdown.css";
|
||||
|
||||
const sourceSans3 = Source_Sans_3({
|
||||
weight: ["300", "400", "600", "700"],
|
||||
style: ["normal", "italic"],
|
||||
@@ -37,10 +38,10 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(config.basePath),
|
||||
metadataBase: new URL(BASE_URL.includes("http") ? BASE_URL : `https://${BASE_URL}`),
|
||||
title: {
|
||||
default: config.siteConfig.siteName,
|
||||
template: config.siteConfig.siteNameTemplate?.replace("%t", config.siteConfig.siteName) || "%s",
|
||||
template: config.siteConfig.siteNameTemplate?.replace("%t", config.siteConfig.siteName) ?? "%s",
|
||||
},
|
||||
description: config.siteConfig.siteDescription,
|
||||
authors: config.siteConfig.siteAuthor
|
||||
@@ -54,13 +55,13 @@ export const metadata: Metadata = {
|
||||
url: config.siteConfig.favIcon,
|
||||
},
|
||||
],
|
||||
keywords: ["gdrive", "index", "nextjs", "reactjs"],
|
||||
keywords: ["gdrive", "index", "nextjs", "reactjs", "google drive"],
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: config.siteConfig.siteName,
|
||||
images: [
|
||||
{
|
||||
url: "/og.png",
|
||||
url: "/og.webp",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
@@ -74,52 +75,68 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
let unlocked: Awaited<ReturnType<typeof CheckSitePassword>> = {
|
||||
const head = await headers();
|
||||
const pathname = head.get("X-Pathname") ?? "/";
|
||||
let unlocked: ActionResponseSchema = {
|
||||
success: true,
|
||||
message: "",
|
||||
message: "Index is public",
|
||||
data: undefined,
|
||||
};
|
||||
if (config.siteConfig.privateIndex) {
|
||||
unlocked = await CheckSitePassword();
|
||||
unlocked = await CheckIndexPassword();
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang='en'>
|
||||
<html
|
||||
lang='en'
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={cn(
|
||||
"h-full bg-background font-sans text-foreground",
|
||||
"overflow-x-hidden stroke-foreground font-sans text-foreground",
|
||||
pathname.startsWith("/ngdi-internal/embed/") ? "h-fit bg-transparent" : "h-full min-h-screen bg-background",
|
||||
jetbrainsMono.variable,
|
||||
sourceSans3.variable,
|
||||
outfit.variable,
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
<Provider
|
||||
theme={{
|
||||
attribute: "class",
|
||||
enableSystem: true,
|
||||
disableTransitionOnChange: true,
|
||||
}}
|
||||
tooltip={{
|
||||
delayDuration: 500,
|
||||
}}
|
||||
toaster={{
|
||||
position: config.siteConfig.toaster?.position,
|
||||
duration: config.siteConfig.toaster?.duration,
|
||||
pauseWhenPageIsHidden: true,
|
||||
}}
|
||||
>
|
||||
<Navbar />
|
||||
<main
|
||||
slot='content'
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-screen-desktop",
|
||||
"mx-auto h-auto w-full max-w-screen-desktop",
|
||||
"relative left-0 top-0",
|
||||
"flex flex-grow flex-col gap-3 p-6",
|
||||
"flex flex-grow flex-col gap-4 px-2 py-6 mobile:px-3 tablet:px-4",
|
||||
"tablet:gap-6",
|
||||
)}
|
||||
>
|
||||
{config.siteConfig.privateIndex && !unlocked.success ? (
|
||||
<Password
|
||||
path='global'
|
||||
errorMessage={unlocked.message}
|
||||
type='global'
|
||||
errorMessage={unlocked.error}
|
||||
/>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
</main>
|
||||
{config.siteConfig.footer && <Footer content={formatFooter(config.siteConfig.footer)} />}
|
||||
<Footer content={formatFooterContent(config.siteConfig.footer ?? [])} />
|
||||
<ToTop />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+2
-4
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Loader } from "~/components/Global";
|
||||
import { PageLoader } from "~/components/layout";
|
||||
|
||||
export default function RootLoading() {
|
||||
return <Loader message='Loading data...' />;
|
||||
return <PageLoader message='Loading data...' />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type Metadata } from "next";
|
||||
|
||||
import { ConfiguratorPage } from "~/components/internal";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Configurator",
|
||||
description: "Generate configuration file for your index",
|
||||
};
|
||||
|
||||
export default function InternalConfiguratorPage() {
|
||||
return <ConfiguratorPage />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type Metadata } from "next";
|
||||
|
||||
import { DeployPage } from "~/components/internal";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Deploying Guide",
|
||||
description: "Read on how to deploy your own index instance",
|
||||
};
|
||||
|
||||
export default function InternalDeployPage() {
|
||||
return <DeployPage />;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type Metadata, type ResolvedMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { EmbedPage } from "~/components/internal";
|
||||
import { ErrorComponent } from "~/components/layout";
|
||||
|
||||
import { GetFile } from "~/actions/files";
|
||||
import { CheckPagePassword } from "~/actions/password";
|
||||
import { ValidatePaths } from "~/actions/paths";
|
||||
import { CreateFileToken } from "~/actions/token";
|
||||
|
||||
export const revalidate = 60;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{
|
||||
rest: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props, parent: ResolvedMetadata): Promise<Metadata> {
|
||||
const { rest } = await params;
|
||||
|
||||
const paths = await ValidatePaths(rest);
|
||||
if (!paths.success) return { title: "Not Found" };
|
||||
|
||||
const currentPath = paths.data.pop();
|
||||
if (!currentPath?.id) return { title: "Not Found" };
|
||||
|
||||
if (!currentPath.mimeType.includes("video") && !currentPath.mimeType.includes("audio")) return { title: "Not Found" };
|
||||
|
||||
return {
|
||||
title: `${decodeURIComponent(currentPath.path)} (embed)`,
|
||||
description: `Embed view of ${currentPath.path}`,
|
||||
openGraph: {
|
||||
images: parent.openGraph?.images,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function InternalEmbedPage({ params }: Props) {
|
||||
const { rest } = await params;
|
||||
|
||||
const paths = await ValidatePaths(rest);
|
||||
if (!paths.success) notFound();
|
||||
|
||||
const unlocked = await CheckPagePassword(paths.data);
|
||||
if (!unlocked.success) return <ErrorComponent error={new Error("Protected file cannot be embedded")} />;
|
||||
|
||||
const currentPath = paths.data.pop();
|
||||
if (!currentPath) return <ErrorComponent error={new Error("Failed to get current path")} />;
|
||||
|
||||
if (!currentPath.mimeType.includes("video") && !currentPath.mimeType.includes("audio"))
|
||||
return (
|
||||
<div className='max-w-screen fixed left-0 top-0 grid h-full max-h-screen w-full place-items-center bg-transparent p-2'>
|
||||
<ErrorComponent error={new Error("Only video and audio files can be embedded")} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const file = await GetFile(currentPath.id);
|
||||
if (!file.success) {
|
||||
if (file.error === "NotFound") notFound();
|
||||
return <ErrorComponent error={new Error(file.error)} />;
|
||||
}
|
||||
if (!file.data) return <ErrorComponent error={new Error("Failed to get file data")} />;
|
||||
const token = await CreateFileToken(file.data);
|
||||
if (!token.success) return <ErrorComponent error={new Error(token.error)} />;
|
||||
|
||||
return (
|
||||
<div className='max-w-screen fixed left-0 top-0 grid h-full max-h-screen w-full place-items-center bg-transparent p-2'>
|
||||
<EmbedPage
|
||||
file={file.data}
|
||||
type={currentPath.mimeType.includes("video") ? "video" : "audio"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type Metadata } from "next";
|
||||
|
||||
import { Status } from "~/components/global";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Reserved Internal Path",
|
||||
description: "This path is preserved for all internal pages, make sure you don't use this path on your google drive",
|
||||
};
|
||||
|
||||
export default async function InternalRootPage() {
|
||||
return (
|
||||
<div className='grid grow place-items-center'>
|
||||
<Status
|
||||
icon='Lock'
|
||||
message="This path is preserved for all internal pages, make sure you don't use this path on your google drive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NotFound } from "~/components/Layout";
|
||||
import { NotFoundComponent } from "~/components/layout";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return <NotFound />;
|
||||
export default function NotFound() {
|
||||
return <NotFoundComponent />;
|
||||
}
|
||||
|
||||
+22
-20
@@ -1,21 +1,24 @@
|
||||
import { Actions, Explorer, FilePath, Readme } from "~/components/Explorer";
|
||||
import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer";
|
||||
import { ErrorComponent } from "~/components/layout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { GetReadme, ListFiles } from "~/actions/files";
|
||||
|
||||
import { GetFiles, GetReadme } from "actions";
|
||||
import config from "config";
|
||||
|
||||
export const revalidate = 60;
|
||||
// export const dynamic = "force-dynamic";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RootPage() {
|
||||
const [data, readme] = await Promise.all([GetFiles({}), GetReadme(undefined)]);
|
||||
const [data, readme] = await Promise.all([ListFiles(), GetReadme()]);
|
||||
if (!data.success) return <ErrorComponent error={new Error(data.error)} />;
|
||||
if (!readme.success) return <ErrorComponent error={new Error(readme.error)} />;
|
||||
|
||||
return (
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
|
||||
<FilePath data={[]} />
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-4")}>
|
||||
<FileBreadcrumb />
|
||||
|
||||
<section
|
||||
slot='content'
|
||||
@@ -23,27 +26,26 @@ export default async function RootPage() {
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<div className='flex w-full items-center justify-between gap-4'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<Actions />
|
||||
<FileActions />
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Explorer
|
||||
|
||||
<CardContent className='p-2 pt-0 tablet:p-4 tablet:pt-0'>
|
||||
<FileExplorerLayout
|
||||
encryptedId={config.apiConfig.rootFolder}
|
||||
files={data.files}
|
||||
nextPageToken={data.nextPageToken}
|
||||
root
|
||||
files={data.data.files}
|
||||
nextPageToken={data.data.nextPageToken ?? undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{readme && (
|
||||
<Readme
|
||||
content={readme}
|
||||
title={"README.md"}
|
||||
{readme.data && (
|
||||
<FileReadme
|
||||
content={readme.data.content}
|
||||
title={`README.${readme.data.type === "markdown" ? "md" : "txt"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
.rhap_container {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
padding: 10px 15px;
|
||||
/* background-color: #fff; */
|
||||
background-color: hsl(var(--muted));
|
||||
/* box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.2); */
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
.rhap_container:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
.rhap_container svg {
|
||||
vertical-align: initial;
|
||||
}
|
||||
|
||||
.rhap_header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rhap_footer {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.rhap_main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.rhap_stacked .rhap_controls-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.rhap_horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
.rhap_horizontal .rhap_controls-section {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.rhap_horizontal-reverse {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.rhap_horizontal-reverse .rhap_controls-section {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.rhap_stacked-reverse {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.rhap_stacked-reverse .rhap_controls-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rhap_progress-section {
|
||||
display: flex;
|
||||
flex: 3 1 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
flex: 1 0 auto;
|
||||
align-self: center;
|
||||
margin: 0 calc(10px + 1%);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.rhap_progress-container:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.rhap_time {
|
||||
/* color: #333; */
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.rhap_progress-bar {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
/* background-color: #dddddd; */
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rhap_progress-filled {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
/* background-color: #868686; */
|
||||
background-color: hsl(var(--primary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rhap_progress-bar-show-download {
|
||||
/* background-color: rgba(221, 221, 221, 0.5); */
|
||||
background-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.rhap_download-progress {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
/* background-color: #dddddd; */
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rhap_progress-indicator {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: -10px;
|
||||
top: -8px;
|
||||
/* background: #868686; */
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 50px;
|
||||
/* box-shadow: rgba(134, 134, 134, 0.5) 0 0 5px; */
|
||||
box-shadow: hsl(var(--border) 0 0 5px);
|
||||
}
|
||||
|
||||
.rhap_controls-section {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_additional-controls {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_repeat-button {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
/* color: #868686; */
|
||||
color: hsl(var(--primary));
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.rhap_main-controls {
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_main-controls-button {
|
||||
margin: 0 3px;
|
||||
/* color: #868686; */
|
||||
color: hsl(var(--primary));
|
||||
font-size: 35px;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.rhap_play-pause-button {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.rhap_volume-controls {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rhap_volume-button {
|
||||
flex: 0 0 26px;
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
/* color: #868686; */
|
||||
color: hsl(var(--primary));
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.rhap_volume-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 1 100px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.rhap_volume-bar-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rhap_volume-bar-area:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.rhap_volume-bar {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
/* background: #dddddd; */
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.rhap_volume-indicator {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
/* background: #868686; */
|
||||
background: hsl(var(--primary));
|
||||
opacity: 0.9;
|
||||
border-radius: 50px;
|
||||
/* box-shadow: rgba(134, 134, 134, 0.5) 0 0 3px; */
|
||||
box-shadow: hsl(var(--border) 0 0 3px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rhap_volume-indicator:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.rhap_volume-filled {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
/* background-color: #868686; */
|
||||
background-color: hsl(var(--primary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Utils */
|
||||
.rhap_button-clear {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rhap_button-clear:hover {
|
||||
opacity: 0.9;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
.rhap_button-clear:active {
|
||||
opacity: 0.95;
|
||||
}
|
||||
.rhap_button-clear:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { RedirectSearchFile, SearchFile } from "actions";
|
||||
|
||||
export default function Actions() {
|
||||
const loading = useLoading();
|
||||
const { layout, setLayout } = useContext(LayoutContext);
|
||||
|
||||
const [layoutOpen, setLayoutOpen] = useState<boolean>(false);
|
||||
const [snap, setSnap] = useState<number | string | null>(0.3);
|
||||
const [searchOpen, setSearchOpen] = useState<boolean>(false);
|
||||
const [searchInput, setSearchInput] = useState<string>("");
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState<string>("");
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchError, setSearchError] = useState<string>("");
|
||||
const [searchResults, setSearchResults] = useState<z.infer<typeof Schema_File>[]>([]);
|
||||
const [nextPageToken, setNextPageToken] = useState<string>();
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInput) {
|
||||
setSearchLoading(true);
|
||||
setDebouncedSearchInput("");
|
||||
setSearchError("");
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchInput(searchInput);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [searchInput]);
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchInput) return;
|
||||
onSearch();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const onSearch = async () => {
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const data = await SearchFile(debouncedSearchInput, nextPageToken);
|
||||
setSearchResults(data.files);
|
||||
setNextPageToken(data.nextPageToken);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setSearchError(e.message);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
<div className={cn("w-fit", "flex items-center justify-end gap-1.5")}>
|
||||
<Skeleton className='my-0.5 h-8 w-16 mobile:w-24' /> <Skeleton className='my-0.5 h-8 w-8' />
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='actions'
|
||||
className={cn("w-fit", "flex items-center justify-end gap-1.5")}
|
||||
>
|
||||
<ResponsiveDropdown
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
trigger={
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='flex w-24 items-center justify-between gap-1.5'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Icon
|
||||
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
<span>{layout === "grid" ? "Grid" : "List"}</span>
|
||||
</div>
|
||||
{isDesktop && (
|
||||
<Icon
|
||||
name='ChevronDown'
|
||||
className={cn("transition", layoutOpen && "rotate-180")}
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
mobile={{
|
||||
title: "File Layout",
|
||||
description: "Choose a layout for the files explorer",
|
||||
content: (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={layout === "grid" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setLayout("grid");
|
||||
setLayoutOpen(false);
|
||||
}}
|
||||
className='justify-start gap-3'
|
||||
>
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
<span>Grid</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={layout === "list" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setLayout("list");
|
||||
setLayoutOpen(false);
|
||||
}}
|
||||
className='justify-start gap-3'
|
||||
>
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
<span>List</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
desktop={{
|
||||
align: "end",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
key: "grid",
|
||||
icon: "LayoutGrid",
|
||||
title: "Grid",
|
||||
onClick: () => {
|
||||
setLayout("grid");
|
||||
setLayoutOpen(false);
|
||||
},
|
||||
className: cn(layout === "grid" && "bg-primary text-primary-foreground"),
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
key: "list",
|
||||
icon: "LayoutList",
|
||||
title: "List",
|
||||
onClick: () => {
|
||||
setLayout("list");
|
||||
setLayoutOpen(false);
|
||||
},
|
||||
className: cn(layout === "list" && "bg-primary text-primary-foreground"),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
{isDesktop ? (
|
||||
<Dialog
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>Search for files in your drive</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-1.5'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<Separator />
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-full max-h-[66dvh] flex-grow flex-col gap-1.5 overflow-y-auto'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<Loader message='Searching...' />
|
||||
) : searchError ? (
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
shouldScaleBackground
|
||||
fadeFromIndex={0}
|
||||
snapPoints={[0.3, 1]}
|
||||
activeSnapPoint={snap}
|
||||
setActiveSnapPoint={setSnap}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
<DrawerContent className='h-full max-h-[95%]'>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Search</DrawerTitle>
|
||||
<DrawerDescription>Search for files in your drive</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-1.5 px-4'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-fit flex-grow flex-col gap-1.5 overflow-y-auto px-4'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<Loader message='Searching...' />
|
||||
) : searchError ? (
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
"px-1.5 py-1 pr-10", // since action button is size-8
|
||||
"rounded-[var(--radius)]",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
"hover:bg-muted/50",
|
||||
"cursor-pointer transition",
|
||||
)}
|
||||
onClick={async () => {
|
||||
toast.loading("Getting file path...", {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const paths = await RedirectSearchFile(data.encryptedId);
|
||||
router.push(`/${paths}`);
|
||||
toast.success("Redirecting...", {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message, {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
|
||||
{data.thumbnailLink && (data.mimeType.startsWith("video") || data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={"1.5rem"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex w-full flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{data.fileExtension ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "") : data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
|
||||
{data.mimeType.includes("folder") ? "folder" : data.fileExtension}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
<span className='whitespace-nowrap text-sm'>{bytesToReadable(data.size || 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>{new Date(data.modifiedTime).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
import { Schema_Breadcrumb } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_Breadcrumb>[];
|
||||
};
|
||||
export default function FilePath({ data }: Props) {
|
||||
const loading = useLoading();
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
if (loading) return <Skeleton className='my-2 h-5 w-1/2' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='file-path'
|
||||
className='flex w-full flex-nowrap items-center'
|
||||
>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{/* Root */}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={"/"}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon
|
||||
name='FolderRoot'
|
||||
size={"1rem"}
|
||||
/>
|
||||
~
|
||||
</div>
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{!!data.length ? (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
|
||||
{data.length >= config.siteConfig.breadcrumbMax && (
|
||||
<>
|
||||
<ResponsiveDropdown
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<div>
|
||||
<BreadcrumbLink asChild>
|
||||
<BreadcrumbEllipsis className='w-4 cursor-pointer' />
|
||||
</BreadcrumbLink>
|
||||
</div>
|
||||
}
|
||||
mobile={{
|
||||
title: "Navigate",
|
||||
description: "Navigate to parent directories",
|
||||
content: data.slice(0, -config.siteConfig.breadcrumbMax + 1).map((item, idx, array) => (
|
||||
<BreadcrumbItem key={`mobile-${item.label}/${item.href}`}>
|
||||
<BreadcrumbLink asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='w-full justify-start'
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
desktop={{
|
||||
align: "start",
|
||||
items: data.slice(0, -config.siteConfig.breadcrumbMax + 1).map((item, idx, array) => ({
|
||||
type: "item",
|
||||
key: `desktop-${item.label}/${item.href}`,
|
||||
title: item.label,
|
||||
onClick: () => {
|
||||
router.push(
|
||||
`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`,
|
||||
);
|
||||
},
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
|
||||
<Fragment key={`${item.label}/${item.href}`}>
|
||||
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.href ? (
|
||||
<>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${data
|
||||
.map((item) => item.href)
|
||||
.join("/")
|
||||
.replace(/\/\//g, "/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbPage className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.label}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{item.href && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DrawerClose } from "~/components/ui/drawer";
|
||||
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export default function FileGrid({ data, disabled }: Props) {
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)].join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Copying link...", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(filePath, config.basePath).toString());
|
||||
|
||||
toast.success("Link copied!", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Preparing download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full'>
|
||||
<ResponsiveDropdown
|
||||
slot='actions'
|
||||
open={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
trigger={
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
desktop={{
|
||||
align: "end",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
key: "copy",
|
||||
title: "Copy link",
|
||||
icon: "Link",
|
||||
onClick: onCopy,
|
||||
disabled: disabled,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
key: "download",
|
||||
title: "Download",
|
||||
icon: "Download",
|
||||
onClick: onDownload,
|
||||
disabled: data.mimeType.includes("folder") || disabled,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={{
|
||||
title: "Actions",
|
||||
description: "What would you like to do with this file?",
|
||||
content: (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onDownload}
|
||||
disabled={disabled || data.mimeType.includes("folder")}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Link
|
||||
slot='data'
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"h-full w-full rounded-[var(--radius)]",
|
||||
"border border-border transition",
|
||||
"flex flex-col content-stretch justify-stretch gap-3",
|
||||
"hover:bg-muted/50",
|
||||
)}
|
||||
onClick={async (e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
nProgress.done(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex flex-shrink flex-grow flex-col items-center gap-3", "relative")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative grid h-32 w-full flex-shrink-0 place-items-center overflow-hidden bg-muted/25'>
|
||||
{data.thumbnailLink && (data.mimeType.startsWith("video") || data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='rounded-top-[var(--radius)] absolute -z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-cover opacity-50'
|
||||
/>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain backdrop-blur'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={"2rem"}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-xs text-foreground'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis || 0)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex h-full w-full flex-col justify-between gap-1.5 px-3 py-1.5'>
|
||||
<span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 whitespace-pre-wrap break-all text-sm",
|
||||
config.siteConfig.showFileExtension && !data.mimeType.includes("folder") ? "hidden" : "",
|
||||
)}
|
||||
>
|
||||
{data.mimeType.includes("folder") ? "folder" : data.fileExtension || "unknown"}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
{config.siteConfig.showFileExtension ? null : (
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
<span className='whitespace-nowrap text-sm'>{bytesToReadable(data.size || 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>{new Date(data.modifiedTime).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Grid, List } from "~/components/Explorer";
|
||||
import { ButtonLoading, Icon, Loader, Status } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
|
||||
import { ButtonState, Schema_File } from "~/types/schema";
|
||||
|
||||
import { GetFiles } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
encryptedId: string;
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
root?: boolean;
|
||||
};
|
||||
export default function Explorer({ encryptedId, files, nextPageToken, root }: Props) {
|
||||
const { layout } = useContext(LayoutContext);
|
||||
const loading = useLoading();
|
||||
const pathname = usePathname();
|
||||
const prevPath = useMemo<string>(() => {
|
||||
const path = pathname.split("/").slice(0, -1).join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const [fileList, setFileList] = useState<z.infer<typeof Schema_File>[]>(files);
|
||||
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
|
||||
const [loadMoreState, setLoadMoreState] = useState<ButtonState>("idle");
|
||||
|
||||
const onLoadMore = async () => {
|
||||
setLoadMoreState("loading");
|
||||
try {
|
||||
if (!nextToken) throw new Error("No more files to load");
|
||||
const id = await decryptData(encryptedId);
|
||||
const data = await GetFiles({ id, pageToken: nextToken });
|
||||
const uniqueData = [...fileList, ...data.files].filter(
|
||||
(item, index, array) => index === array.findIndex((i) => i.encryptedId === item.encryptedId),
|
||||
);
|
||||
|
||||
setFileList(uniqueData);
|
||||
setNextToken(data.nextPageToken);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadMoreState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loader message='Loading files...' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='explorer'
|
||||
className={cn("mx-auto w-full max-w-screen-desktop", "flex flex-grow-0 flex-col gap-3 p-3")}
|
||||
>
|
||||
{!root && (
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
className='justify-start'
|
||||
asChild
|
||||
>
|
||||
<Link href={prevPath}>
|
||||
<Icon
|
||||
name='CornerLeftUp'
|
||||
size={"1rem"}
|
||||
className='mr-3'
|
||||
/>
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!fileList.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='There are no files here'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
slot='file-container'
|
||||
className={cn(
|
||||
"w-full",
|
||||
layout === "list"
|
||||
? "flex flex-col gap-1.5"
|
||||
: "grid grid-cols-1 gap-3 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{fileList.map((file) => (
|
||||
<div
|
||||
slot='item'
|
||||
key={file.encryptedId}
|
||||
className='group'
|
||||
>
|
||||
{layout === "list" ? <List data={file} /> : <Grid data={file} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextToken && (
|
||||
<ButtonLoading
|
||||
className='w-full'
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
state={loadMoreState}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
Load more files
|
||||
</ButtonLoading>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DrawerClose } from "~/components/ui/drawer";
|
||||
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export default function FileList({ data, disabled }: Props) {
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)].join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Copying link...", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(filePath, config.basePath).toString());
|
||||
|
||||
toast.success("Link copied!", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Preparing download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full w-full flex-row-reverse items-center'>
|
||||
<ResponsiveDropdown
|
||||
slot='actions'
|
||||
open={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
trigger={
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
desktop={{
|
||||
align: "end",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
key: "copy",
|
||||
title: "Copy link",
|
||||
icon: "Link",
|
||||
onClick: onCopy,
|
||||
disabled: disabled,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
key: "download",
|
||||
title: "Download",
|
||||
icon: "Download",
|
||||
onClick: onDownload,
|
||||
disabled: data.mimeType.includes("folder") || disabled,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={{
|
||||
title: "Actions",
|
||||
description: "What would you like to do with this file?",
|
||||
content: (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onDownload}
|
||||
disabled={disabled || data.mimeType.includes("folder")}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Link
|
||||
slot='data'
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"relative rounded-[var(--radius)] transition",
|
||||
"px-1.5 py-1 pr-10",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
"hover:bg-muted/50",
|
||||
)}
|
||||
onClick={async (e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
nProgress.done(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
|
||||
{data.thumbnailLink && (data.mimeType.startsWith("video") || data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={"1.5rem"}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-[10px] text-foreground tablet:text-xs'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis || 0)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex flex-grow flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 whitespace-pre-wrap break-all text-sm",
|
||||
config.siteConfig.showFileExtension && !data.mimeType.includes("folder") ? "hidden" : "",
|
||||
)}
|
||||
>
|
||||
{data.mimeType.includes("folder") ? "folder" : data.fileExtension || "unknown"}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
{config.siteConfig.showFileExtension ? null : (
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
<span className='whitespace-nowrap text-sm'>{bytesToReadable(data.size || 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>{new Date(data.modifiedTime).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { default as Actions } from "./Actions";
|
||||
export { default as FilePath } from "./FilePath";
|
||||
export { default as Grid } from "./Grid";
|
||||
export { default as Explorer } from "./Layout";
|
||||
export { default as List } from "./List";
|
||||
export { default as Readme } from "./Readme";
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Button, ButtonProps } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { ButtonState } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
state?: ButtonState;
|
||||
icon?: keyof typeof icons;
|
||||
} & ButtonProps;
|
||||
|
||||
export default function ButtonLoading({ state = "idle", icon, ...props }: Props) {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
disabled={state === "loading"}
|
||||
>
|
||||
<div className='relative flex w-full items-center justify-center'>
|
||||
{icon && (
|
||||
<Icon
|
||||
name={icon}
|
||||
size={"1rem"}
|
||||
className={cn(
|
||||
"mr-1.5 transition-all duration-300 ease-in-out",
|
||||
state === "loading" ? "size-0 opacity-0" : "size-4 opacity-100",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={"1rem"}
|
||||
className={cn(
|
||||
"animate-spin transition-all",
|
||||
state === "loading" ? "mr-1.5 size-4 opacity-100" : "mr-0 size-0 opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className='relative transition-all duration-300 ease-in-out'>{props.children}</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LucideProps, icons } from "lucide-react";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
type Props = {
|
||||
name: keyof typeof icons;
|
||||
wrapper?: ReactElement<"div">;
|
||||
} & LucideProps;
|
||||
|
||||
export default function Icon({ name, wrapper, ...props }: Props) {
|
||||
const LucideIcon = icons[name];
|
||||
|
||||
return (
|
||||
<div {...wrapper}>
|
||||
<LucideIcon {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypePrism from "rehype-prism-plus";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkSlug from "remark-slug";
|
||||
import remarkToc from "remark-toc";
|
||||
|
||||
import "~/app/highlight.css";
|
||||
import { Icon } from "~/components/Global";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
view?: "markdown" | "raw";
|
||||
className?: string;
|
||||
};
|
||||
export default function Markdown({ content, view, className }: Props) {
|
||||
const [viewState, setViewState] = useState<"markdown" | "raw">(view || "markdown");
|
||||
|
||||
useEffect(() => {
|
||||
if (view) setViewState(view);
|
||||
}, [view]);
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='markdown-container'
|
||||
className={cn("flex flex-col p-3", className)}
|
||||
>
|
||||
{viewState === "raw" ? (
|
||||
<div className={cn("markdown w-full rounded-[var(--radius)]")}>
|
||||
<pre className='w-full whitespace-pre-wrap break-words bg-transparent !p-0 font-sans shadow shadow-background'>
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("markdown w-full rounded-[var(--radius)]")}>
|
||||
<ReactMarkdown
|
||||
className='w-full'
|
||||
disallowedElements={["script"]}
|
||||
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc, remarkBreaks]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw, [rehypePrism, { ignoreMissing: true }]]}
|
||||
components={{
|
||||
p: ({ node, children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='paragraph text-balance'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
pre: PreComponent,
|
||||
code: ({ node, inline, className, children, ...props }) => (
|
||||
<code
|
||||
className={`${className} font-mono !text-sm`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (isCopied || isError) return;
|
||||
if (copyTimeout) clearTimeout(copyTimeout);
|
||||
const toastId = `copy-${Math.random() * 1000}`;
|
||||
toast.loading("Copying code to clipboard...", {
|
||||
id: toastId,
|
||||
});
|
||||
try {
|
||||
navigator.clipboard.writeText(codeRef.current.textContent as string);
|
||||
setIsError(false);
|
||||
setIsCopied(true);
|
||||
setCopyTimeout(
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 2000),
|
||||
);
|
||||
toast.success("Code copied to clipboard", {
|
||||
id: toastId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
setIsCopied(false);
|
||||
setIsError(true);
|
||||
setCopyTimeout(
|
||||
setTimeout(() => {
|
||||
setIsError(false);
|
||||
}, 2000),
|
||||
);
|
||||
toast.error("Failed to copy code to clipboard", {
|
||||
id: toastId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full w-full`}
|
||||
onMouseEnter={() => {
|
||||
setShowCopy(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setShowCopy(false);
|
||||
setIsCopied(false);
|
||||
setIsError(false);
|
||||
}}
|
||||
>
|
||||
<div className={`transition-smooth absolute right-0 top-0 p-1.5`}>
|
||||
<div
|
||||
className='cursor-pointer rounded-[var(--radius)] bg-background p-1.5'
|
||||
onClick={() => {
|
||||
handleCopy();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={isCopied ? "Check" : isError ? "X" : "Copy"}
|
||||
className={isCopied ? "text-green-500" : isError ? "text-red-500" : "text-foreground"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
ref={codeRef}
|
||||
{...props}
|
||||
className='overflow-x-auto border border-border shadow shadow-background'
|
||||
>
|
||||
{props.children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Props = {
|
||||
icon: keyof typeof icons;
|
||||
message?: string;
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
export default function Status({ icon, message, destructive }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[33dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
destructive ? "text-destructive" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={icon}
|
||||
size={"1.5rem"}
|
||||
/>
|
||||
<p>{message || "Something went wrong"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { default as ButtonLoading } from "./ButtonLoading";
|
||||
export { default as Icon } from "./Icon";
|
||||
export { default as Loader } from "./Loader";
|
||||
export { default as Markdown } from "./Markdown";
|
||||
export { default as Status } from "./Status";
|
||||
@@ -1,342 +0,0 @@
|
||||
import { FormEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { fileLoadHelper } from "~/utils/fileLoadHelper";
|
||||
import { parseConfigFile } from "~/utils/parseConfigFile";
|
||||
|
||||
import { ConfigurationCategory, ConfigurationKeys, ConfigurationValue, Schema_App_Configuration } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
state: {
|
||||
get: z.input<typeof Schema_App_Configuration>;
|
||||
set: <
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
category: T,
|
||||
key: K,
|
||||
value: ConfigurationValue<T, K>,
|
||||
) => void;
|
||||
};
|
||||
error: {
|
||||
get: Partial<Record<ConfigurationKeys<"api">, string>>;
|
||||
set: <T extends ConfigurationKeys<"api">>(key: T, value: string) => void;
|
||||
};
|
||||
onReset: (category: ConfigurationCategory | "all") => void;
|
||||
};
|
||||
|
||||
export default function ConfigApi({ state: { get, set }, error, onReset }: Props) {
|
||||
const inputs: ConfigInputs<"api", ConfigurationKeys<"api">>[] = [
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "rootFolder",
|
||||
title: "Root Folder ID",
|
||||
description: `starting point of the drive, will be used as the root folder for the explorer
|
||||
This ID will be encrypted in the config file`,
|
||||
required: true,
|
||||
value: get.api.rootFolder,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.rootFolder,
|
||||
onError: (err) => error.set("rootFolder", err),
|
||||
placeholder: "Starting point of the drive",
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "sharedGroup",
|
||||
columns: 3,
|
||||
children: [
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "isTeamDrive",
|
||||
title: "Use Team Drive",
|
||||
description: "Enable this option if you are using a Team Drive instead of a personal drive",
|
||||
required: true,
|
||||
value: get.api.isTeamDrive,
|
||||
onValueChange: (key, value) => {
|
||||
set("api", key, value);
|
||||
if (value === false) {
|
||||
set("api", "sharedDrive", "");
|
||||
error.set("sharedDrive", "");
|
||||
}
|
||||
},
|
||||
error: error.get.isTeamDrive,
|
||||
onError: (err) => error.set("isTeamDrive", err),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "sharedDrive",
|
||||
title: "Shared Drive ID",
|
||||
description: `The ID of the Shared Drive
|
||||
This ID will be encrypted in the config file`,
|
||||
required: true,
|
||||
value: get.api.sharedDrive,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.sharedDrive,
|
||||
onError: (err) => error.set("sharedDrive", err),
|
||||
disabled: !get.api.isTeamDrive,
|
||||
placeholder: get.api.isTeamDrive ? "Make sure to copy the Drive ID" : "You need to enable Team Drive first",
|
||||
columnSpan: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "pagination",
|
||||
columns: 2,
|
||||
children: [
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "itemsPerPage",
|
||||
title: "Items per page",
|
||||
description: `Set how many items to display per page in the file explorer
|
||||
It's recommended to keep this value to a reasonable number, since it will affect the load time of the page
|
||||
|
||||
Default: 50`,
|
||||
required: true,
|
||||
value: get.api.itemsPerPage,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.itemsPerPage,
|
||||
onError: (err) => error.set("itemsPerPage", err),
|
||||
minMax: { min: 1 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 1) return { success: false, message: "Items per page must be greater than 0" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "searchResult",
|
||||
title: "Search Results",
|
||||
description: `Set how many search results to display
|
||||
It's recommended to set this to a small number, since it will affect search performance
|
||||
|
||||
Default: 5`,
|
||||
required: true,
|
||||
value: get.api.searchResult,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.searchResult,
|
||||
onError: (err) => error.set("searchResult", err),
|
||||
minMax: { min: 1 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 1) return { success: false, message: "Search result must be greater than 0" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "proxyThumbnail",
|
||||
title: "Allow Proxy Thumbnail",
|
||||
description: `Use API route instead of Google Drive thumbnail link
|
||||
|
||||
If your files thumbnail are not accessible, you can enable this option
|
||||
This will fetch the thumbnail image via API route`,
|
||||
required: true,
|
||||
value: get.api.proxyThumbnail,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.proxyThumbnail,
|
||||
onError: (err) => error.set("proxyThumbnail", err),
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "protectedFile",
|
||||
columns: 2,
|
||||
children: [
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "allowDownloadProtectedFile",
|
||||
title: "Allow Download Protected File",
|
||||
description: `Allow users to download files inside the protected folder
|
||||
|
||||
If set to true, users will be able to download the file without entering the password as long as they have the direct link
|
||||
If set to false, users will need to enter the password before downloading the file, even if they have the direct link`,
|
||||
required: true,
|
||||
value: get.api.allowDownloadProtectedFile,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.allowDownloadProtectedFile,
|
||||
onError: (err) => error.set("allowDownloadProtectedFile", err),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "temporaryTokenDuration",
|
||||
title: "Temporary Token Duration (in hours)",
|
||||
description: `Set how long the download token will be valid
|
||||
If the token is expired, users will need to re-open the file page to get a new token`,
|
||||
required: true,
|
||||
value: get.api.temporaryTokenDuration,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.temporaryTokenDuration,
|
||||
minMax: { min: 1, max: 24 * 30 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 1) return { success: false, message: "Token duration must be greater than 0" };
|
||||
if (num > 24 * 30)
|
||||
return { success: false, message: "Token duration can't be more than 720 hours (30 days)" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "fileSize",
|
||||
columns: 2,
|
||||
children: [
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "maxFileSize",
|
||||
title: "Max Direct Download Size (in MB)",
|
||||
description: `Max file size that can be downloaded directly from the server
|
||||
Please check your deploy platform for the maximum response size limit
|
||||
If you're using Vercel, the maximum size is around 4 - 4.5MB, any file larger than this size will return an error
|
||||
|
||||
Set to 0 to disable the limit`,
|
||||
required: true,
|
||||
value: get.api.maxFileSize / 1024 / 1024,
|
||||
onValueChange: (key, value) => set("api", key, value * 1024 * 1024),
|
||||
error: error.get.maxFileSize,
|
||||
minMax: { min: 0 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 0) return { success: false, message: "Size must be greater than or equal to 0" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "streamMaxSize",
|
||||
title: "Max Stream Size (in MB)",
|
||||
description: `For previewing large files, the file will be streamed in chunks
|
||||
Make sure to set this value to a reasonable number, since it will count towards your server bandwidth usage
|
||||
|
||||
This will also affect the maximum file size that can be previewed. Especially for media files like video, audio, and images
|
||||
(Manga preview will automatically limited to the first 5MB)
|
||||
|
||||
Set to 0 to disable the limit
|
||||
Default: 100`,
|
||||
required: true,
|
||||
value: get.api.streamMaxSize / 1024 / 1024,
|
||||
onValueChange: (key, value) => set("api", key, value * 1024 * 1024),
|
||||
error: error.get.streamMaxSize,
|
||||
minMax: { min: 0 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 0) return { success: false, message: "Size must be greater than or equal to 0" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function onFileLoad() {
|
||||
fileLoadHelper({
|
||||
accept: ".config.ts",
|
||||
fileName: "gindex.config.ts",
|
||||
async onLoad(result) {
|
||||
const config = parseConfigFile(result);
|
||||
if ("success" in config) return toast.error(config.message);
|
||||
|
||||
if (get.environment.ENCRYPTION_KEY) {
|
||||
try {
|
||||
const root = config.api.rootFolder;
|
||||
const shared = config.api.sharedDrive || null;
|
||||
if (root) {
|
||||
const decrypted = await decryptData(root, get.environment.ENCRYPTION_KEY);
|
||||
if (decrypted) set("api", "rootFolder", decrypted);
|
||||
}
|
||||
if (shared) {
|
||||
const decrypted = await decryptData(shared, get.environment.ENCRYPTION_KEY);
|
||||
if (decrypted) set("api", "sharedDrive", decrypted);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error("Skipping decryption of folder ID, please check your Encryption Key and try again.");
|
||||
}
|
||||
} else {
|
||||
toast.error("Can't decrypt folder ID without Encryption Key");
|
||||
}
|
||||
config.api.isTeamDrive && set("api", "isTeamDrive", config.api.isTeamDrive);
|
||||
config.api.itemsPerPage && set("api", "itemsPerPage", config.api.itemsPerPage);
|
||||
config.api.searchResult && set("api", "searchResult", config.api.searchResult);
|
||||
config.api.proxyThumbnail && set("api", "proxyThumbnail", config.api.proxyThumbnail);
|
||||
config.api.allowDownloadProtectedFile &&
|
||||
set("api", "allowDownloadProtectedFile", config.api.allowDownloadProtectedFile);
|
||||
config.api.temporaryTokenDuration && set("api", "temporaryTokenDuration", config.api.temporaryTokenDuration);
|
||||
config.api.maxFileSize && set("api", "maxFileSize", config.api.maxFileSize);
|
||||
config.api.streamMaxSize && set("api", "streamMaxSize", config.api.streamMaxSize);
|
||||
|
||||
// Other config
|
||||
config.api.defaultQuery && set("api", "defaultQuery", config.api.defaultQuery);
|
||||
config.api.defaultField && set("api", "defaultField", config.api.defaultField);
|
||||
config.api.defaultOrder && set("api", "defaultOrder", config.api.defaultOrder);
|
||||
config.api.specialFile && set("api", "specialFile", config.api.specialFile);
|
||||
config.api.hiddenFiles && set("api", "hiddenFiles", config.api.hiddenFiles);
|
||||
|
||||
// Site config
|
||||
config.site.siteName && set("site", "siteName", config.site.siteName);
|
||||
config.site.siteNameTemplate && set("site", "siteNameTemplate", config.site.siteNameTemplate);
|
||||
config.site.siteDescription && set("site", "siteDescription", config.site.siteDescription);
|
||||
config.site.siteIcon && set("site", "siteIcon", config.site.siteIcon);
|
||||
config.site.favIcon && set("site", "favIcon", config.site.favIcon);
|
||||
config.site.siteAuthor && set("site", "siteAuthor", config.site.siteAuthor);
|
||||
config.site.twitterHandle && set("site", "twitterHandle", config.site.twitterHandle);
|
||||
config.site.robots && set("site", "robots", config.site.robots);
|
||||
|
||||
config.site.showFileExtension && set("site", "showFileExtension", config.site.showFileExtension);
|
||||
config.site.footer && set("site", "footer", config.site.footer);
|
||||
config.site.privateIndex && set("site", "privateIndex", config.site.privateIndex);
|
||||
config.site.breadcrumbMax && set("site", "breadcrumbMax", config.site.breadcrumbMax);
|
||||
config.site.toaster && set("site", "toaster", config.site.toaster);
|
||||
config.site.navbarItems && set("site", "navbarItems", config.site.navbarItems);
|
||||
config.site.supports && set("site", "supports", config.site.supports);
|
||||
|
||||
toast.success("API configuration has been loaded.");
|
||||
},
|
||||
});
|
||||
}
|
||||
function onFormReset(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
onReset("api");
|
||||
toast.success("API section has been reset to default values.");
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
onReset={onFormReset}
|
||||
>
|
||||
<ConfigurationHeader
|
||||
title='API Configuration'
|
||||
onLoad={onFileLoad}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
{inputs.map((props, index) => (
|
||||
<ConfigurationInput<"api", ConfigurationKeys<"api">>
|
||||
key={String(props.inputKey)}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { fileLoadHelper } from "~/utils/fileLoadHelper";
|
||||
|
||||
import {
|
||||
ButtonState,
|
||||
ConfigurationCategory,
|
||||
ConfigurationKeys,
|
||||
ConfigurationValue,
|
||||
Schema_App_Configuration,
|
||||
Schema_ServiceAccount,
|
||||
} from "~/types/schema";
|
||||
|
||||
import { GenerateAESKey, VerifyAESKey } from "actions";
|
||||
|
||||
type Props = {
|
||||
state: {
|
||||
get: z.input<typeof Schema_App_Configuration>;
|
||||
set: <
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
category: T,
|
||||
key: K,
|
||||
value: ConfigurationValue<T, K>,
|
||||
) => void;
|
||||
};
|
||||
error: {
|
||||
get: Partial<Record<ConfigurationKeys<"environment">, string>>;
|
||||
set: <T extends ConfigurationKeys<"environment">>(key: T, value: string) => void;
|
||||
};
|
||||
onReset: (category: ConfigurationCategory | "all") => void;
|
||||
};
|
||||
|
||||
export default function ConfigEnvironment({ state: { get, set }, error, onReset }: Props) {
|
||||
const [encryptionState, setEncryptionState] = useState<ButtonState>("idle");
|
||||
const [serviceAuthState, setServiceAuthState] = useState<ButtonState>("idle");
|
||||
const [isRevealPassword, setIsRevealPassword] = useState<boolean>(false);
|
||||
|
||||
const inputs: ConfigInputs<"environment", ConfigurationKeys<"environment">>[] = [
|
||||
{
|
||||
inputKey: "ENCRYPTION_KEY",
|
||||
title: "Encryption Key",
|
||||
description:
|
||||
"The key used to encrypt and decrypt sensitive data. Must be a alphanumeric string of 16 characters.",
|
||||
type: "text",
|
||||
required: true,
|
||||
action: {
|
||||
label: "Generate",
|
||||
state: encryptionState,
|
||||
icon: "RefreshCw",
|
||||
async onClick(e) {
|
||||
e.preventDefault();
|
||||
setEncryptionState("loading");
|
||||
error.set("ENCRYPTION_KEY", "");
|
||||
|
||||
try {
|
||||
const key = await GenerateAESKey();
|
||||
const isValid = await VerifyAESKey("This is a test", key);
|
||||
if (!isValid) throw new Error("Invalid key generated, please try again.");
|
||||
|
||||
set("environment", "ENCRYPTION_KEY", key);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
error.set("ENCRYPTION_KEY", e.message);
|
||||
} finally {
|
||||
setEncryptionState("idle");
|
||||
}
|
||||
},
|
||||
},
|
||||
value: get.environment.ENCRYPTION_KEY,
|
||||
onValueChange: (key, value) => set("environment", key, value),
|
||||
error: error.get.ENCRYPTION_KEY,
|
||||
onError: (err) => error.set("ENCRYPTION_KEY", err),
|
||||
validation: async (value) => {
|
||||
if (!value) return { success: false, message: "Encryption key is required" };
|
||||
if (value.length !== 16) return { success: false, message: "Encryption key must be 16 characters long" };
|
||||
|
||||
const valid = await VerifyAESKey("This is a test", value);
|
||||
if (!valid) return { success: false, message: "Invalid encryption key" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
placeholder: "16 alphanumeric characters secret key",
|
||||
},
|
||||
{
|
||||
inputKey: "GD_SERVICE_B64",
|
||||
title: "Google Drive Service Account",
|
||||
description: `The base64 encoded Google Drive Service Account JSON file.
|
||||
To avoid error when input, please use the "Load JSON" button to load the file.`,
|
||||
type: "text",
|
||||
required: true,
|
||||
readOnly: true,
|
||||
action: {
|
||||
label: "Load JSON",
|
||||
state: serviceAuthState,
|
||||
icon: "Upload",
|
||||
async onClick(e) {
|
||||
e.preventDefault();
|
||||
setServiceAuthState("loading");
|
||||
|
||||
try {
|
||||
fileLoadHelper({
|
||||
accept: ".json",
|
||||
onLoad(result) {
|
||||
const objectFile = JSON.parse(result);
|
||||
const parse = Schema_ServiceAccount.safeParse(objectFile);
|
||||
if (!parse.success)
|
||||
return toast.error("Invalid Service Account JSON file. Please make sure the file is correct.");
|
||||
|
||||
set("environment", "GD_SERVICE_B64", btoa(result));
|
||||
error.set("GD_SERVICE_B64", "");
|
||||
|
||||
toast.success("Service Account JSON file has been loaded successfully.");
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
console.error(e.message);
|
||||
error.set("GD_SERVICE_B64", e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setServiceAuthState("idle");
|
||||
}
|
||||
},
|
||||
},
|
||||
value: get.environment.GD_SERVICE_B64,
|
||||
onValueChange: (key, value) => set("environment", key, value),
|
||||
error: error.get.GD_SERVICE_B64,
|
||||
onError: (err) => error.set("GD_SERVICE_B64", err),
|
||||
placeholder: "Base64 encoded JSON file",
|
||||
},
|
||||
{
|
||||
inputKey: "NEXT_PUBLIC_DOMAIN",
|
||||
title: "Site Domain",
|
||||
description: `The domain for the site, without the protocol`,
|
||||
type: "text",
|
||||
value: get.environment.NEXT_PUBLIC_DOMAIN,
|
||||
onValueChange: (key, value) => set("environment", key, value),
|
||||
error: error.get.NEXT_PUBLIC_DOMAIN,
|
||||
onError: (err) => error.set("NEXT_PUBLIC_DOMAIN", err),
|
||||
placeholder: "e.g. drive-demo.mbaharip.com or mbaharip.com",
|
||||
},
|
||||
{
|
||||
inputKey: "SITE_PASSWORD",
|
||||
title: "Site Password",
|
||||
description: `The password to access the site
|
||||
Only required if the site is set to private`,
|
||||
type: isRevealPassword ? "text" : "password",
|
||||
action: {
|
||||
label: isRevealPassword ? "Hide" : "Reveal",
|
||||
icon: isRevealPassword ? "EyeOff" : "Eye",
|
||||
onClick(e) {
|
||||
e.preventDefault();
|
||||
setIsRevealPassword((prev) => !prev);
|
||||
},
|
||||
state: "idle",
|
||||
},
|
||||
value: get.environment.SITE_PASSWORD,
|
||||
onValueChange: (key, value) => set("environment", key, value),
|
||||
error: error.get.SITE_PASSWORD,
|
||||
onError: (err) => error.set("SITE_PASSWORD", err),
|
||||
validation: async (value) => {
|
||||
if (get.site.privateIndex && !value) return { success: false, message: "Site password is required" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
placeholder: "Not required if the site is public",
|
||||
},
|
||||
];
|
||||
|
||||
function onFileLoad() {
|
||||
fileLoadHelper({
|
||||
accept: ".env, .env.local, .env.development, .env.production",
|
||||
onLoad(result) {
|
||||
const lines = result.split("\n");
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of lines) {
|
||||
const [key, value] = line.split("=");
|
||||
if (!key || !value) continue;
|
||||
const formattedValue = value
|
||||
?.replace(/"/g, "")
|
||||
.replace(/'/g, "")
|
||||
.replace(/\\/g, "")
|
||||
.replace(/\r/g, "")
|
||||
.trim();
|
||||
env[key] = formattedValue;
|
||||
}
|
||||
set("environment", "ENCRYPTION_KEY", env.ENCRYPTION_KEY);
|
||||
set("environment", "GD_SERVICE_B64", env.GD_SERVICE_B64);
|
||||
set("environment", "NEXT_PUBLIC_DOMAIN", env.NEXT_PUBLIC_DOMAIN);
|
||||
set("environment", "SITE_PASSWORD", env.SITE_PASSWORD);
|
||||
toast.success("Environment section has been loaded successfully.");
|
||||
},
|
||||
});
|
||||
}
|
||||
function onFormReset(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
onReset("environment");
|
||||
toast.success("Environment section has been reset to default values.");
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
onReset={onFormReset}
|
||||
>
|
||||
<ConfigurationHeader
|
||||
title='Environment Configuration'
|
||||
onLoad={onFileLoad}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
{inputs.map((props, index) => (
|
||||
<ConfigurationInput<"environment", ConfigurationKeys<"environment">>
|
||||
key={props.inputKey}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ButtonLoading } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { ButtonState } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
onLoad?: () => void;
|
||||
};
|
||||
export default function ConfigurationHeader({ title, onLoad }: Props) {
|
||||
const [loadState, setLoadState] = useState<ButtonState>("idle");
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='header'
|
||||
className='flex flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-between'
|
||||
>
|
||||
<h4>{title}</h4>
|
||||
|
||||
<div
|
||||
slot='actions'
|
||||
className='flex w-full items-center gap-3 tablet:w-fit'
|
||||
>
|
||||
{onLoad && (
|
||||
<ButtonLoading
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
state={loadState}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setLoadState("loading");
|
||||
|
||||
try {
|
||||
onLoad();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadState("idle");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Load File
|
||||
</ButtonLoading>
|
||||
)}
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
type='reset'
|
||||
size={"sm"}
|
||||
>
|
||||
Reset Section
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { ButtonLoading, Icon } from "~/components/Global";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { ButtonState, ConfigurationCategory, ConfigurationKeys, ConfigurationValue } from "~/types/schema";
|
||||
|
||||
export type ConfigInputs<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
> = ConfigInputProps<T, K> | ConfigInputGroupProps<T, K> | ConfigInputSeparatorProps;
|
||||
|
||||
type ConfigInputProps<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
> = {
|
||||
inputKey: K;
|
||||
title: string;
|
||||
type: "text" | "password" | "number" | "select";
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
state: ButtonState;
|
||||
icon?: keyof typeof icons;
|
||||
};
|
||||
value: ConfigurationValue<T, K>;
|
||||
onValueChange: (key: K, value: any) => void;
|
||||
error?: string;
|
||||
onError?: (error: string) => void;
|
||||
validation?: (value: string) =>
|
||||
| { success: boolean; message: string }
|
||||
| Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
readOnly?: boolean;
|
||||
minMax?: { min?: number; max?: number };
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type ConfigInputGroupProps<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
> = {
|
||||
inputKey: string;
|
||||
type: "group";
|
||||
columns: number;
|
||||
children: (ConfigInputProps<T, K> & {
|
||||
columnSpan?: number;
|
||||
})[];
|
||||
};
|
||||
type ConfigInputSeparatorProps = {
|
||||
inputKey: string;
|
||||
type: "separator";
|
||||
};
|
||||
export default function ConfigurationInput<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(props: ConfigInputProps<T, K> | ConfigInputGroupProps<T, K> | ConfigInputSeparatorProps) {
|
||||
const key = String(props.inputKey);
|
||||
return (
|
||||
<>
|
||||
{props.type !== "separator" && props.type !== "group" && (
|
||||
<>
|
||||
<Inputs<T, K> {...props} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.type === "separator" && <Separator />}
|
||||
|
||||
{props.type === "group" && (
|
||||
<div
|
||||
slot='group'
|
||||
className='grid w-full gap-1.5'
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{props.children.map((child, index) => (
|
||||
<Inputs<T, K>
|
||||
key={`${child.inputKey as string}@${index}`}
|
||||
columnSpan={child.columnSpan}
|
||||
{...child}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Inputs<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
props: ConfigInputProps<T, K> & {
|
||||
columnSpan?: number;
|
||||
},
|
||||
) {
|
||||
const key = String(props.inputKey);
|
||||
return (
|
||||
<div
|
||||
id={key}
|
||||
slot={`input-${key}`}
|
||||
className='flex w-full flex-col gap-1.5'
|
||||
style={{
|
||||
gridColumn: `span ${props.columnSpan || 1} / span ${props.columnSpan || 1}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
slot='label'
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
aria-required={props.required}
|
||||
>
|
||||
{props.title} <span className='text-sm text-muted-foreground'>{!props.required ? "(optional)" : ""}</span>
|
||||
</Label>
|
||||
{props.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className='cursor-default'
|
||||
>
|
||||
<Icon
|
||||
name='Info'
|
||||
size={"1rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
className='max-w-screen-sm'
|
||||
>
|
||||
<p className='max-w-screen-sm !whitespace-pre-wrap'>{props.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
slot='input'
|
||||
className='grid w-full grid-cols-6 gap-1.5'
|
||||
>
|
||||
<div className={cn("w-full", props.action ? "col-span-5" : "col-span-full")}>
|
||||
{props.type === "text" || props.type === "password" || props.type === "number" ? (
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
type={props.type}
|
||||
value={props.value as string}
|
||||
readOnly={props.readOnly || false}
|
||||
min={props.minMax?.min}
|
||||
max={props.minMax?.max}
|
||||
disabled={props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
onChange={(e) => {
|
||||
if (props.error) {
|
||||
props.onError?.("");
|
||||
}
|
||||
props.onValueChange(props.inputKey, e.target.value as ConfigurationValue<T, K>);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
props.onError?.("");
|
||||
|
||||
if (props.required && !(props.value as string).trim()) {
|
||||
throw new Error(`${props.title} is required.`);
|
||||
}
|
||||
|
||||
if (props.validation) {
|
||||
const result = await props.validation(props.value as string);
|
||||
if (!result.success) throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
props.onError?.(e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
props.type === "select" && (
|
||||
<Select
|
||||
disabled={props.disabled}
|
||||
value={String(props.value)}
|
||||
onValueChange={(value) => {
|
||||
props.onValueChange(props.inputKey, (value as string) === "true");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select an option' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>Enabled</SelectItem>
|
||||
<SelectItem value='false'>Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{props.action && (
|
||||
<ButtonLoading
|
||||
variant={"secondary"}
|
||||
onClick={props.action.onClick}
|
||||
state={props.action.state}
|
||||
icon={props.action.icon}
|
||||
>
|
||||
{props.action.label}
|
||||
</ButtonLoading>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div slot='message'>
|
||||
<span className={cn("block text-sm text-destructive", props.error ? "opacity-100" : "select-none opacity-0")}>
|
||||
{props.error}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AsyncZippable, strToU8, zip } from "fflate";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { generateConfig } from "~/data/template";
|
||||
|
||||
import { ButtonLoading, Loader, Markdown } from "~/components/Global";
|
||||
import { ConfigAPI, ConfigEnvironment, ConfigSite } from "~/components/Guide/Configuration";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { downloadBlob } from "~/utils/downloadBlob";
|
||||
|
||||
import {
|
||||
ButtonState,
|
||||
ConfigurationCategory,
|
||||
ConfigurationKeys,
|
||||
ConfigurationValue,
|
||||
Schema_App_Configuration,
|
||||
} from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export default function ConfigurationForm() {
|
||||
const loading = useLoading();
|
||||
const initialConfiguration = useMemo<z.input<typeof Schema_App_Configuration>>(() => {
|
||||
return {
|
||||
environment: {
|
||||
GD_SERVICE_B64: "",
|
||||
ENCRYPTION_KEY: "",
|
||||
SITE_PASSWORD: "",
|
||||
NEXT_PUBLIC_DOMAIN: "",
|
||||
},
|
||||
api: {
|
||||
...config.apiConfig,
|
||||
rootFolder: "",
|
||||
isTeamDrive: false,
|
||||
sharedDrive: "",
|
||||
itemsPerPage: 50,
|
||||
searchResult: 5,
|
||||
|
||||
proxyThumbnail: true,
|
||||
streamMaxSize: 100 * 1024 * 1024,
|
||||
|
||||
allowDownloadProtectedFile: false,
|
||||
temporaryTokenDuration: 6,
|
||||
maxFileSize: 4 * 1024 * 1024,
|
||||
},
|
||||
site: {
|
||||
...config.siteConfig,
|
||||
siteName: "next-gdrive-index",
|
||||
siteNameTemplate: "%s - %t",
|
||||
siteDescription: "A Google Drive Index built using Next.js",
|
||||
siteAuthor: "mbahArip",
|
||||
twitterHandle: "@mbahArip",
|
||||
|
||||
showFileExtension: false,
|
||||
footer: ["{{ siteName }} *v{{ version }}* @ {{ repository }}", "{{ year }} - Made with ❤️ by **{{ author }}**"],
|
||||
|
||||
privateIndex: false,
|
||||
breadcrumbMax: 3,
|
||||
|
||||
toaster: {
|
||||
position: "bottom-right",
|
||||
duration: 3000,
|
||||
},
|
||||
|
||||
navbarItems: [],
|
||||
supports: [],
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
const [configuration, setConfiguration] = useState<z.input<typeof Schema_App_Configuration>>(initialConfiguration);
|
||||
const [error, setError] = useState<Schema_App_Configuration_Error>({
|
||||
environment: {},
|
||||
api: {},
|
||||
site: {},
|
||||
});
|
||||
|
||||
const [downloadState, setDownloadState] = useState<ButtonState>("idle");
|
||||
|
||||
function onConfigurationChange<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(category: T, key: K, value: ConfigurationValue<T, K>) {
|
||||
setConfiguration((prev) => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...prev[category],
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
function onErrorChange<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(category: T, key: K, value: string) {
|
||||
setError((prev) => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...prev[category],
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function onReset(category: ConfigurationCategory | "all") {
|
||||
if (category === "all") {
|
||||
setConfiguration(initialConfiguration);
|
||||
setError({
|
||||
environment: {},
|
||||
api: {},
|
||||
site: {},
|
||||
});
|
||||
} else {
|
||||
setConfiguration((prev) => ({
|
||||
...prev,
|
||||
[category]: initialConfiguration[category],
|
||||
}));
|
||||
setError((prev) => ({
|
||||
...prev,
|
||||
[category]: {},
|
||||
}));
|
||||
}
|
||||
}
|
||||
async function onDownload(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
setDownloadState("loading");
|
||||
toast.loading("Generating configuration file...", {
|
||||
id: "generate-config",
|
||||
});
|
||||
|
||||
try {
|
||||
// Check required fields
|
||||
const requiredEmpty = [
|
||||
!configuration.environment.ENCRYPTION_KEY.length, // Encryption Key are required
|
||||
!configuration.environment.GD_SERVICE_B64.length, // Service Account are required
|
||||
!configuration.api.rootFolder.length, // Root Folder are required
|
||||
configuration.api.isTeamDrive && !configuration.api.sharedDrive?.length, // If isTeamDrive, sharedDrive are required
|
||||
!configuration.site.siteName.length, // Site Name are required
|
||||
!configuration.site.siteDescription.length, // Site Description are required
|
||||
configuration.site.privateIndex && !configuration.environment.SITE_PASSWORD?.length, // If privateIndex, SITE_PASSWORD are required
|
||||
];
|
||||
if (requiredEmpty.filter((v) => v).length) {
|
||||
throw new Error("Looks like you missed some required fields.");
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
for (const err of Object.values(error.environment)) {
|
||||
if (err) errors.push(err);
|
||||
}
|
||||
for (const err of Object.values(error.api)) {
|
||||
if (err) errors.push(err);
|
||||
}
|
||||
for (const err of Object.values(error.site)) {
|
||||
if (err) errors.push(err);
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new Error("Errors found in the configuration form. Please fix them first");
|
||||
}
|
||||
|
||||
// Create string content
|
||||
const envContent = Object.entries(configuration.environment)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
const configContent = await generateConfig(configuration);
|
||||
|
||||
// Create zip structure
|
||||
const structure: AsyncZippable = {
|
||||
".env": [
|
||||
strToU8(envContent),
|
||||
{
|
||||
level: 6,
|
||||
},
|
||||
],
|
||||
"gIndex.config.ts": [
|
||||
strToU8(configContent),
|
||||
{
|
||||
level: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
zip(structure, {}, (err, data) => {
|
||||
if (err) throw err;
|
||||
|
||||
const zipBlob = new Blob([data], { type: "application/zip" });
|
||||
downloadBlob(zipBlob, `${Date.now()}__gIndex-config.zip`);
|
||||
toast.success("Configuration file generated successfully", {
|
||||
id: "generate-config",
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message, {
|
||||
id: "generate-config",
|
||||
});
|
||||
} finally {
|
||||
setDownloadState("idle");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Loader message='Loading configuration...' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex items-end justify-between gap-3'>
|
||||
<CardTitle id='config'>App Configuration</CardTitle>
|
||||
<small className='text-muted-foreground'>v{config.version}</small>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-3 py-3'>
|
||||
<Markdown
|
||||
content={`This configuration only covering the most basic things you need to get started.
|
||||
You can check the configuration file itself to see all the available configuration. _(Each configuration has a description to help you understand it)_
|
||||
If you're migrating from previous version, you can load your old environment and config file to update the configuration.
|
||||
|
||||
> If you found any bugs or issues with this configuration, please report it to the [issue tracker](https://github.com/mbahArip/next-gdrive-index/issues)
|
||||
> I'll try to fix it as soon as possible`}
|
||||
/>
|
||||
|
||||
<ConfigEnvironment
|
||||
state={{
|
||||
get: configuration,
|
||||
set: onConfigurationChange,
|
||||
}}
|
||||
error={{
|
||||
get: error.environment,
|
||||
set: (key, value) => onErrorChange("environment", key, value),
|
||||
}}
|
||||
onReset={onReset}
|
||||
/>
|
||||
|
||||
<ConfigAPI
|
||||
state={{
|
||||
get: configuration,
|
||||
set: onConfigurationChange,
|
||||
}}
|
||||
error={{
|
||||
get: error.api,
|
||||
set: (key, value) => onErrorChange("api", key, value),
|
||||
}}
|
||||
onReset={onReset}
|
||||
/>
|
||||
|
||||
<ConfigSite
|
||||
state={{
|
||||
get: configuration,
|
||||
set: onConfigurationChange,
|
||||
}}
|
||||
error={{
|
||||
get: error.site,
|
||||
set: (key, value) => onErrorChange("site", key, value),
|
||||
}}
|
||||
onReset={onReset}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
variant={"destructive"}
|
||||
className='bg-destructive text-destructive-foreground'
|
||||
>
|
||||
<AlertTitle>
|
||||
<h4>Configuration Issue</h4>
|
||||
</AlertTitle>
|
||||
<AlertDescription className='flex flex-col gap-1.5'>
|
||||
<p className='w-full whitespace-pre-wrap text-pretty py-1.5'>{`After some report, it seems that the config will generate incorrect encrypted ID, and somehow it only happen on deployment.
|
||||
I'm sorry for the inconvenience, but you need to re-encrypt the id using the encryption endpoint.
|
||||
I'll try to fix this issue as soon as possible`}</p>
|
||||
<code className='rounded-[var(--radius)] bg-muted px-2 py-1 text-sm text-muted-foreground'>{`https://drive-demo.mbaharip.com/api/internal/encrypt?q=<ID>&key=<ENCRYPTION_KEY>`}</code>
|
||||
<code className='rounded-[var(--radius)] bg-muted px-2 py-1 text-sm text-muted-foreground'>{`https://<your-deployment-or-localhost>/api/internal/encrypt?q=<ID>&key=<ENCRYPTION_KEY>`}</code>
|
||||
<p className='w-full whitespace-pre-wrap text-pretty'>
|
||||
{`Replace <ID> with Root Folder ID and Shared Drive ID (if you use Team Drive) and <ENCRYPTION_KEY> with your Encryption Key from the Environment Configuration section.`}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='flex w-full flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-end'>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
size={"sm"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onReset("all");
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</Button>
|
||||
<ButtonLoading
|
||||
state={downloadState}
|
||||
size={"sm"}
|
||||
icon={"Save"}
|
||||
onClick={onDownload}
|
||||
>
|
||||
Download Config
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type Schema_App_Configuration_Error = {
|
||||
environment: Partial<Record<ConfigurationKeys<"environment">, string>>;
|
||||
api: Partial<Record<ConfigurationKeys<"api">, string>>;
|
||||
site: Partial<Record<ConfigurationKeys<"site">, string>>;
|
||||
};
|
||||
@@ -1,226 +0,0 @@
|
||||
import { FormEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { ConfigurationCategory, ConfigurationKeys, ConfigurationValue, Schema_App_Configuration } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
state: {
|
||||
get: z.input<typeof Schema_App_Configuration>;
|
||||
set: <
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
category: T,
|
||||
key: K,
|
||||
value: ConfigurationValue<T, K>,
|
||||
) => void;
|
||||
};
|
||||
error: {
|
||||
get: Partial<Record<ConfigurationKeys<"site">, string>>;
|
||||
set: <T extends ConfigurationKeys<"site">>(key: T, value: string) => void;
|
||||
};
|
||||
onReset: (category: ConfigurationCategory | "all") => void;
|
||||
};
|
||||
|
||||
export default function ConfigSite({ state: { get, set }, error, onReset }: Props) {
|
||||
const metadataInputs: ConfigInputs<"site", ConfigurationKeys<"site">>[] = [
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "siteName",
|
||||
title: "Site Name",
|
||||
required: true,
|
||||
value: get.site.siteName,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.siteName,
|
||||
onError: (err) => error.set("siteName", err),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "siteNameTemplate",
|
||||
title: "Site Name Template",
|
||||
description: `The template used to generate the site name
|
||||
|
||||
Available variables:
|
||||
%s - Page title
|
||||
%t - Site name`,
|
||||
required: true,
|
||||
value: get.site.siteNameTemplate,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.siteNameTemplate,
|
||||
onError: (err) => error.set("siteNameTemplate", err),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "siteDescription",
|
||||
title: "Site Description",
|
||||
required: true,
|
||||
value: get.site.siteDescription,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.siteDescription,
|
||||
onError: (err) => error.set("siteDescription", err),
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "author",
|
||||
columns: 2,
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "siteAuthor",
|
||||
title: "Site Author",
|
||||
description: `Will be used for metadata, also affect the footer variable`,
|
||||
value: get.site.siteAuthor,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.siteAuthor,
|
||||
onError: (err) => error.set("siteAuthor", err),
|
||||
placeholder: "Your username here",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "twitterHandle",
|
||||
title: "Twitter Handle",
|
||||
description: `Will be used for metadata, also affect the footer variable`,
|
||||
value: get.site.twitterHandle,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.twitterHandle,
|
||||
onError: (err) => error.set("twitterHandle", err),
|
||||
placeholder: "e.g. @__mbaharip__",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const inputs: ConfigInputs<"site", ConfigurationKeys<"site">>[] = [
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "privateIndex",
|
||||
title: "Private Index",
|
||||
description: `Lock the whole site behind a password
|
||||
Will use the Site Password in the Environment section`,
|
||||
required: true,
|
||||
value: get.site.privateIndex,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.privateIndex,
|
||||
onError: (err) => error.set("privateIndex", err),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "showFileExtension",
|
||||
title: "Show File Extension",
|
||||
description: `Show file extension in file explorer
|
||||
|
||||
e.g. "file.mp4" instead of "file"`,
|
||||
required: true,
|
||||
value: get.site.showFileExtension,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.showFileExtension,
|
||||
onError: (err) => error.set("showFileExtension", err),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "favIcon",
|
||||
title: "Favicon",
|
||||
description: `The favicon of the site`,
|
||||
required: true,
|
||||
value: get.site.favIcon,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.favIcon,
|
||||
onError: (err) => error.set("favIcon", err),
|
||||
},
|
||||
];
|
||||
|
||||
function onFormReset(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
onReset("site");
|
||||
toast.success("Site section has been reset to default values.");
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
onReset={onFormReset}
|
||||
>
|
||||
<ConfigurationHeader title='Site Configuration' />
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
<div className='grid grid-cols-3 gap-3'>
|
||||
<div className='col-span-2 flex flex-col gap-3'>
|
||||
{metadataInputs.map((props, index) => (
|
||||
<ConfigurationInput<"site", ConfigurationKeys<"site">>
|
||||
key={String(props.inputKey)}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex flex-col items-start gap-1.5 pt-3'>
|
||||
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-border px-3 py-1.5'>
|
||||
<img
|
||||
src={get.site.favIcon}
|
||||
className='size-4'
|
||||
alt='favicon'
|
||||
/>
|
||||
<span className='text-sm'>{get.site.siteName}</span>
|
||||
<Icon
|
||||
name='X'
|
||||
size={"0.75rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-border px-3 py-1.5'>
|
||||
<img
|
||||
src={get.site.favIcon}
|
||||
className='size-4'
|
||||
alt='favicon'
|
||||
/>
|
||||
<span className='text-sm'>
|
||||
{get.site.siteNameTemplate!.replace("%s", "Page Title").replace("%t", get.site.siteName)}
|
||||
</span>
|
||||
<Icon
|
||||
name='X'
|
||||
size={"0.75rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-fit select-none overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'>
|
||||
<img
|
||||
src={"/og.png"}
|
||||
alt='Opengraph Preview'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className='flex flex-col px-3 py-1.5'>
|
||||
<span className='line-clamp-1 w-full text-lg font-medium'>
|
||||
{(get.site.siteNameTemplate || "%s").replace("%s", "Page Title").replace("%t", get.site.siteName)}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{get.environment.NEXT_PUBLIC_DOMAIN || "http://localhost:3000"}
|
||||
</span>
|
||||
<span className='line-clamp-2 text-sm text-muted-foreground'>{get.site.siteDescription}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inputs.map((props, index) => (
|
||||
<ConfigurationInput<"site", ConfigurationKeys<"site">>
|
||||
key={String(props.inputKey)}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export { default as ConfigurationForm } from "./Layout";
|
||||
|
||||
export { default as ConfigAPI } from "./ApiForm";
|
||||
export { default as ConfigEnvironment } from "./EnvironmentForm";
|
||||
export { default as ConfigSite } from "./SiteForm";
|
||||
|
||||
export { default as ConfigurationHeader } from "./Header";
|
||||
export { default as ConfigurationInput } from "./Input";
|
||||
export type { ConfigInputs } from "./Input";
|
||||
@@ -1,201 +0,0 @@
|
||||
import { Fragment } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ThemeInput, type ThemeInputProps } from "~/components/Guide/Theme";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Slider } from "~/components/ui/slider";
|
||||
|
||||
import { parseThemeValue } from "~/utils/parseConfigFile";
|
||||
|
||||
import { Schema_Theme, ThemeKeys } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
currentTheme: "light" | "dark";
|
||||
state: {
|
||||
get: {
|
||||
light: z.input<typeof Schema_Theme>;
|
||||
dark: z.input<typeof Schema_Theme>;
|
||||
};
|
||||
set: (theme: "light" | "dark", key: ThemeKeys, value: string) => void;
|
||||
};
|
||||
};
|
||||
export default function ThemeFormInputs({ currentTheme, state: { get, set } }: Props) {
|
||||
const inputs: ThemeInputProps[][] = [
|
||||
[
|
||||
{
|
||||
inputKey: "background",
|
||||
title: "Background",
|
||||
value: parseThemeValue(get[currentTheme].background),
|
||||
onChange: (val) => set(currentTheme, "background", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "foreground",
|
||||
title: "Text",
|
||||
value: parseThemeValue(get[currentTheme].foreground),
|
||||
onChange: (val) => set(currentTheme, "foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
inputKey: "card",
|
||||
title: "Card",
|
||||
value: parseThemeValue(get[currentTheme].card),
|
||||
onChange: (val) => set(currentTheme, "card", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "card-foreground",
|
||||
title: "Card Text",
|
||||
value: parseThemeValue(get[currentTheme]["card-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "card-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "popover",
|
||||
title: "Popover",
|
||||
value: parseThemeValue(get[currentTheme].popover),
|
||||
onChange: (val) => set(currentTheme, "popover", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "popover-foreground",
|
||||
title: "Popover Text",
|
||||
value: parseThemeValue(get[currentTheme]["popover-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "popover-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "accent",
|
||||
title: "Accent",
|
||||
value: parseThemeValue(get[currentTheme].accent),
|
||||
onChange: (val) => set(currentTheme, "accent", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "accent-foreground",
|
||||
title: "Accent Text",
|
||||
value: parseThemeValue(get[currentTheme]["accent-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "accent-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
inputKey: "primary",
|
||||
title: "Primary",
|
||||
value: parseThemeValue(get[currentTheme].primary),
|
||||
onChange: (val) => set(currentTheme, "primary", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "primary-foreground",
|
||||
title: "Primary Text",
|
||||
value: parseThemeValue(get[currentTheme]["primary-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "primary-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "secondary",
|
||||
title: "Secondary",
|
||||
value: parseThemeValue(get[currentTheme].secondary),
|
||||
onChange: (val) => set(currentTheme, "secondary", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "secondary-foreground",
|
||||
title: "Secondary Text",
|
||||
value: parseThemeValue(get[currentTheme]["secondary-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "secondary-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "muted",
|
||||
title: "Muted",
|
||||
value: parseThemeValue(get[currentTheme].muted),
|
||||
onChange: (val) => set(currentTheme, "muted", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "muted-foreground",
|
||||
title: "Muted Text",
|
||||
value: parseThemeValue(get[currentTheme]["muted-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "muted-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "destructive",
|
||||
title: "Destructive",
|
||||
value: parseThemeValue(get[currentTheme].destructive),
|
||||
onChange: (val) => set(currentTheme, "destructive", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "destructive-foreground",
|
||||
title: "Destructive Text",
|
||||
value: parseThemeValue(get[currentTheme]["destructive-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "destructive-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
inputKey: "border",
|
||||
title: "Element Border",
|
||||
value: parseThemeValue(get[currentTheme].border),
|
||||
onChange: (val) => set(currentTheme, "border", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "input",
|
||||
title: "Input Border",
|
||||
value: parseThemeValue(get[currentTheme].input),
|
||||
onChange: (val) => set(currentTheme, "input", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "ring",
|
||||
title: "Focus Ring",
|
||||
value: parseThemeValue(get[currentTheme].ring),
|
||||
onChange: (val) => set(currentTheme, "ring", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "radius",
|
||||
title: "Roundness",
|
||||
value: { h: 0, s: 0, l: 0 },
|
||||
onChange: (val) => {},
|
||||
children: (
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<Slider
|
||||
className='w-full'
|
||||
defaultValue={[parseFloat(get[currentTheme].radius) * 16 || 0]}
|
||||
min={0}
|
||||
max={24}
|
||||
value={[parseFloat(get[currentTheme].radius) * 16 || 0]}
|
||||
onValueChange={(val) => {
|
||||
const value = val[0] / 16;
|
||||
set(currentTheme, "radius", `${value}rem`);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={parseFloat(get[currentTheme].radius) * 16 || 0}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value) / 16;
|
||||
set(currentTheme, "radius", `${value}rem`);
|
||||
}}
|
||||
type='number'
|
||||
className='w-16 min-w-0 '
|
||||
min={0}
|
||||
max={24}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='flex h-auto flex-grow flex-col justify-between gap-3'>
|
||||
<div
|
||||
slot='theme-form'
|
||||
className='flex flex-col gap-1.5'
|
||||
>
|
||||
{inputs.map((item, i) => (
|
||||
<Fragment key={`input-group-${i}`}>
|
||||
{item.map((input) => (
|
||||
<ThemeInput
|
||||
key={input.inputKey}
|
||||
{...input}
|
||||
/>
|
||||
))}
|
||||
{i !== inputs.length - 1 && <Separator className='my-1.5' />}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PopoverTrigger } from "@radix-ui/react-popover";
|
||||
import { HslColor, HslColorPicker } from "react-colorful";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ButtonLoading, Icon } from "~/components/Global";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Popover, PopoverContent } from "~/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { Schema_Theme } from "~/types/schema";
|
||||
|
||||
export type ThemeInputProps = {
|
||||
inputKey: keyof z.infer<typeof Schema_Theme>;
|
||||
title: string;
|
||||
description?: string;
|
||||
value: HslColor;
|
||||
onChange: (color: HslColor) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export default function ThemeInput(props: ThemeInputProps) {
|
||||
return (
|
||||
<div
|
||||
id={props.inputKey}
|
||||
slot={`input-${props.inputKey}`}
|
||||
className='grid w-full grid-cols-3 gap-6'
|
||||
>
|
||||
<div
|
||||
slot='label'
|
||||
className='flex items-center gap-3'
|
||||
>
|
||||
<span className='text-base'>{props.title}</span>
|
||||
{props.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className='cursor-default'
|
||||
>
|
||||
<Icon
|
||||
name='Info'
|
||||
size='1rem'
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
className='max-w-screen-sm'
|
||||
>
|
||||
<p className='max-w-screen-sm !whitespace-pre-wrap'>{props.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
slot='input'
|
||||
className='col-span-2 flex flex-grow items-center justify-end gap-3'
|
||||
>
|
||||
{props.children ? (
|
||||
props.children
|
||||
) : (
|
||||
<>
|
||||
<span className='select-none text-sm text-muted-foreground'>
|
||||
hsl({props.value.h}, {props.value.s}%, {props.value.l}%)
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div className='size-8 rounded-[var(--radius)] border border-border p-0.5'>
|
||||
<div
|
||||
className='size-full rounded-[calc(var(--radius)-0.125rem)]'
|
||||
style={{
|
||||
background: `hsl(${props.value.h}, ${props.value.s}%, ${props.value.l}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='flex flex-col gap-3'>
|
||||
<div className='flex gap-6'>
|
||||
<HslColorPicker
|
||||
color={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<div className='flex flex-grow flex-col items-center justify-between'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor={`${props.inputKey}-h`}>Hue</Label>
|
||||
<Input
|
||||
id={`${props.inputKey}-h`}
|
||||
name={`${props.inputKey}-h`}
|
||||
type='number'
|
||||
value={props.value.h}
|
||||
min={0}
|
||||
max={360}
|
||||
onChange={(e) => {
|
||||
const value = props.value;
|
||||
props.onChange({
|
||||
h: Number(e.target.value),
|
||||
s: value.s,
|
||||
l: value.l,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor={`${props.inputKey}-s`}>Saturation</Label>
|
||||
<Input
|
||||
id={`${props.inputKey}-s`}
|
||||
name={`${props.inputKey}-s`}
|
||||
type='number'
|
||||
value={props.value.s}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => {
|
||||
const value = props.value;
|
||||
props.onChange({
|
||||
h: value.h,
|
||||
s: Number(e.target.value),
|
||||
l: value.l,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor={`${props.inputKey}-l`}>Lightness</Label>
|
||||
<Input
|
||||
id={`${props.inputKey}-l`}
|
||||
name={`${props.inputKey}-l`}
|
||||
type='number'
|
||||
value={props.value.l}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => {
|
||||
const value = props.value;
|
||||
props.onChange({
|
||||
h: value.h,
|
||||
s: value.s,
|
||||
l: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<ButtonLoading
|
||||
className='w-full'
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
state='idle'
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const text = `hsl(${props.value.h}, ${props.value.s}%, ${props.value.l}%)`;
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`${props.title} color copied!`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ButtonLoading>
|
||||
<ButtonLoading
|
||||
className='w-full'
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
state='idle'
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const color = text.match(
|
||||
/\bhsl\(\s*(\d+)\s*,\s*(\d+(?:\.\d+)?)\s*%\s*,\s*(\d+(?:\.\d+)?)\s*%\s*\)/,
|
||||
);
|
||||
if (!color) {
|
||||
throw new Error("Color does not match the format!");
|
||||
}
|
||||
const h = Number(color[1]);
|
||||
const s = Number(color[2]);
|
||||
const l = Number(color[3]);
|
||||
if (isNaN(h) || isNaN(s) || isNaN(l)) {
|
||||
throw new Error("HSL values are not numbers!");
|
||||
}
|
||||
props.onChange({ h, s, l });
|
||||
toast.success(`Color pasted!`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Paste
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { generateTheme } from "~/data/template";
|
||||
|
||||
import { ButtonLoading, Icon, Loader, Markdown } from "~/components/Global";
|
||||
import { FormInputs, ThemePreview } from "~/components/Guide/Theme";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { downloadBlob } from "~/utils/downloadBlob";
|
||||
import { fileLoadHelper } from "~/utils/fileLoadHelper";
|
||||
import { parseThemeValue } from "~/utils/parseConfigFile";
|
||||
|
||||
import { ButtonState, Schema_Theme, ThemeKeys } from "~/types/schema";
|
||||
|
||||
export default function ThemeForm() {
|
||||
const loading = useLoading();
|
||||
|
||||
const initialTheme = useMemo<Record<"light" | "dark", z.input<typeof Schema_Theme>>>(
|
||||
() => ({
|
||||
light: {
|
||||
"background": "0 0% 100%",
|
||||
"foreground": "0 0% 3.9%",
|
||||
"card": "0 0% 100%",
|
||||
"card-foreground": "0 0% 3.9%",
|
||||
"popover": "0 0% 100%",
|
||||
"popover-foreground": "0 0% 3.9%",
|
||||
"primary": "0 0% 9%",
|
||||
"primary-foreground": "0 0% 98%",
|
||||
"secondary": "0 0% 96.1%",
|
||||
"secondary-foreground": "0 0% 9%",
|
||||
"muted": "0 0% 96.1%",
|
||||
"muted-foreground": "0 0% 45.1%",
|
||||
"accent": "0 0% 96.1%",
|
||||
"accent-foreground": "0 0% 9%",
|
||||
"destructive": "0 84.2% 60.2%",
|
||||
"destructive-foreground": "0 0% 98%",
|
||||
"border": "0 0% 89.8%",
|
||||
"input": "0 0% 89.8%",
|
||||
"ring": "0 0% 89.8%",
|
||||
"radius": "0.5rem",
|
||||
},
|
||||
dark: {
|
||||
"background": "0 0% 3.9%",
|
||||
"foreground": "0 0% 98%",
|
||||
"card": "0 0% 3.9%",
|
||||
"card-foreground": "0 0% 98%",
|
||||
"popover": "0 0% 3.9%",
|
||||
"popover-foreground": "0 0% 98%",
|
||||
"primary": "0 0% 98%",
|
||||
"primary-foreground": "0 0% 9%",
|
||||
"secondary": "0 0% 14.9%",
|
||||
"secondary-foreground": "0 0% 98%",
|
||||
"muted": "0 0% 14.9%",
|
||||
"muted-foreground": "0 0% 63.9%",
|
||||
"accent": "0 0% 14.9%",
|
||||
"accent-foreground": "0 0% 98%",
|
||||
"destructive": "0 62.8% 30.6%",
|
||||
"destructive-foreground": "0 0% 98%",
|
||||
"border": "0 0% 14.9%",
|
||||
"input": "0 0% 14.9%",
|
||||
"ring": "0 0% 14.9%",
|
||||
"radius": "0.5rem",
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const [currentTheme, setCurrentTheme] = useState<"light" | "dark">("light");
|
||||
const [theme, setTheme] = useState<Record<"light" | "dark", z.input<typeof Schema_Theme>>>(initialTheme);
|
||||
|
||||
const [codeValue, setCodeValue] = useState<string>("");
|
||||
const [pasteDialog, setPasteDialog] = useState<boolean>(false);
|
||||
const [pasteState, setPasteState] = useState<ButtonState>("idle");
|
||||
|
||||
const [downloadState, setDownloadState] = useState<ButtonState>("idle");
|
||||
const [resetState, setResetState] = useState<ButtonState>("idle");
|
||||
|
||||
function assignTheme(theme: "light" | "dark", values: string[]) {
|
||||
for (const value of values) {
|
||||
const [k, v] = value.split(":").map((v) => v.replace("--", "").trim());
|
||||
if (k === "radius") {
|
||||
onThemeChange(theme, k as ThemeKeys, v.replace(";", ""));
|
||||
} else {
|
||||
const color = parseThemeValue(v);
|
||||
if (!color) continue;
|
||||
onThemeChange(theme, k as ThemeKeys, `${color.h} ${color.s}% ${color.l}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function onLoadCSS() {
|
||||
fileLoadHelper({
|
||||
accept: ".css",
|
||||
async onLoad(result) {
|
||||
const themeRegex = /\/\* Shadcn\/ui theme \*\/.*?\:root \{(.*?)\}.*?.dark \{(.*?) \}/gm;
|
||||
const themeMatch = themeRegex.exec(result.replace(/\r?\n?/g, ""));
|
||||
if (!themeMatch) return toast.error("Can't find theme, are you sure it's the right file?");
|
||||
|
||||
const light = themeMatch[1]
|
||||
.replace(/\s{2,4}/g, "\n")
|
||||
.split("\n")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length && (v.startsWith("/*") && v.endsWith("*/") ? false : true));
|
||||
const dark = themeMatch[2]
|
||||
.replace(/\s{2,4}/g, "\n")
|
||||
.split("\n")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length && (v.startsWith("/*") && v.endsWith("*/") ? false : true));
|
||||
|
||||
if (!light || !dark)
|
||||
return toast.error("File loaded doesn't seem like shadcn theme, are you sure it's the right file?");
|
||||
|
||||
assignTheme("light", light);
|
||||
assignTheme("dark", dark);
|
||||
|
||||
toast.success("Theme loaded successfully");
|
||||
},
|
||||
});
|
||||
}
|
||||
function onLoadCode(value: string) {
|
||||
const themeRegex = /\@layer base.*?\:root \{(.*?)\}.*?.dark \{(.*?) \}/gm;
|
||||
const themeMatch = themeRegex.exec(value.replace(/\r\n/g, ""));
|
||||
if (!themeMatch) throw new Error("Unknown format, make sure it's satisfies the shadcn/ui theme format");
|
||||
|
||||
const light = themeMatch[1]
|
||||
.replace(/\s{2,4}/g, "\n")
|
||||
.split("\n")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length && (v.startsWith("/*") && v.endsWith("*/") ? false : true));
|
||||
const dark = themeMatch[2]
|
||||
.replace(/\s{2,4}/g, "\n")
|
||||
.split("\n")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length && (v.startsWith("/*") && v.endsWith("*/") ? false : true));
|
||||
|
||||
if (!light || !dark) throw new Error("It doesn't seem like shadcn theme, are you sure it's the right format?");
|
||||
|
||||
assignTheme("light", light);
|
||||
assignTheme("dark", dark);
|
||||
|
||||
toast.success("Theme loaded successfully");
|
||||
}
|
||||
function onThemeChange(theme: "light" | "dark", key: ThemeKeys, value: string) {
|
||||
setTheme((prev) => ({
|
||||
...prev,
|
||||
[theme]: {
|
||||
...prev[theme],
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function onDownload(copy?: boolean) {
|
||||
setDownloadState("loading");
|
||||
try {
|
||||
const generated = generateTheme(theme);
|
||||
if (copy) {
|
||||
await navigator.clipboard.writeText(generated);
|
||||
return toast.success("Theme copied to clipboard");
|
||||
}
|
||||
|
||||
const blob = new Blob([generated], { type: "text/css" });
|
||||
downloadBlob(blob, "globals.css");
|
||||
toast.success("Theme downloaded successfully");
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setDownloadState("idle");
|
||||
}
|
||||
}
|
||||
async function onReset() {
|
||||
setResetState("loading");
|
||||
try {
|
||||
setTheme(initialTheme);
|
||||
toast.success("Theme reset to default");
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setResetState("idle");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Loader message='Loading theme...' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<CardTitle id='theme'>Theme Customization</CardTitle>
|
||||
<div
|
||||
slot='actions'
|
||||
className='flex w-full items-center gap-3 tablet:w-fit'
|
||||
>
|
||||
<Dialog
|
||||
open={pasteDialog}
|
||||
onOpenChange={setPasteDialog}
|
||||
>
|
||||
<DialogTrigger>
|
||||
<></>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Paste CSS Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste the CSS code from your theme file
|
||||
<br />
|
||||
<Link
|
||||
href={"https://ui.shadcn.com/themes"}
|
||||
target='_blank'
|
||||
className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
Get themes from shadcn/ui
|
||||
</Link>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className='space-y-3'
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPasteState("loading");
|
||||
|
||||
try {
|
||||
if (!codeValue) throw new Error("No CSS code provided");
|
||||
|
||||
const flatten = codeValue.replace(/\r\n/g, "");
|
||||
onLoadCode(flatten);
|
||||
setPasteDialog(false);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setPasteState("idle");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
id='css-paste'
|
||||
name='css-paste'
|
||||
className='w-full'
|
||||
rows={10}
|
||||
placeholder='Paste your CSS code here'
|
||||
value={codeValue}
|
||||
onChange={(e) => setCodeValue(e.target.value)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"destructive"}
|
||||
type='reset'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<ButtonLoading
|
||||
state={pasteState}
|
||||
size={"sm"}
|
||||
type='submit'
|
||||
icon='Save'
|
||||
>
|
||||
Load Theme
|
||||
</ButtonLoading>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
>
|
||||
<Icon
|
||||
name='Import'
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
Import Theme
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={onLoadCSS}>
|
||||
<Icon
|
||||
name='Upload'
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
Load from file
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
// setPasteDialog(true);
|
||||
const themeRegex = /\@layer base \{\s+\:root \{\s+.*\} \.dark \{\s+.*\}/gm;
|
||||
const clipboard = await navigator.clipboard.readText();
|
||||
if (clipboard) {
|
||||
const flattenClipboard = clipboard.replace(/\r\n/g, "").trim();
|
||||
const match = themeRegex.exec(flattenClipboard);
|
||||
if (match) {
|
||||
setCodeValue(clipboard.trim());
|
||||
toast.success("Theme found in clipboard! Click 'Load Theme' to apply it");
|
||||
}
|
||||
}
|
||||
|
||||
setPasteDialog(true);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name='Code'
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
Paste CSS code
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-3 py-3'>
|
||||
<Markdown
|
||||
content={`You can personalize the theme of your index by changing the color of the elements.
|
||||
You can either use default colors from [shadcn/ui](https://ui.shadcn.com/themes) or create your own theme by changing the color code of each element.`}
|
||||
/>
|
||||
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<div className='flex items-center'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={currentTheme === "light" ? "default" : "outline"}
|
||||
className='rounded-r-none'
|
||||
onClick={() => setCurrentTheme("light")}
|
||||
>
|
||||
<Icon
|
||||
name='Sun'
|
||||
size='1rem'
|
||||
className='mr-2'
|
||||
/>
|
||||
Light theme
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={currentTheme === "dark" ? "default" : "outline"}
|
||||
className='rounded-l-none'
|
||||
onClick={() => setCurrentTheme("dark")}
|
||||
>
|
||||
<Icon
|
||||
name='Moon'
|
||||
size='1rem'
|
||||
className='mr-2'
|
||||
/>
|
||||
Dark theme
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
state={downloadState}
|
||||
icon={"Save"}
|
||||
onClick={() => onDownload()}
|
||||
>
|
||||
Download theme
|
||||
</ButtonLoading>
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
state={resetState}
|
||||
variant={"destructive"}
|
||||
icon='History'
|
||||
onClick={onReset}
|
||||
>
|
||||
Reset theme
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-3 rounded-[var(--radius)] border border-border p-4 tablet:grid-cols-2'>
|
||||
<FormInputs
|
||||
currentTheme={currentTheme}
|
||||
state={{
|
||||
get: theme,
|
||||
set: onThemeChange,
|
||||
}}
|
||||
/>
|
||||
<ThemePreview theme={theme[currentTheme]} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PopoverTrigger } from "@radix-ui/react-popover";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Grid as FileGrid, List as FileList } from "~/components/Explorer";
|
||||
import { ButtonLoading } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Popover, PopoverContent } from "~/components/ui/popover";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { ButtonState, Schema_File, Schema_Theme } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
theme: z.input<typeof Schema_Theme>;
|
||||
};
|
||||
export default function ThemePreview({ theme }: Props) {
|
||||
const fileMockData = useMemo<z.infer<typeof Schema_File>>(
|
||||
() => ({
|
||||
encryptedId: "1",
|
||||
name: "File Name",
|
||||
modifiedTime: "2021-09-01T00:00:00.000Z",
|
||||
mimeType: "text/plain",
|
||||
trashed: false,
|
||||
size: 1024 * 1024 * 100,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||
const [toastState, setToastState] = useState<ButtonState>("idle");
|
||||
const [loadingState, setLoadingState] = useState<ButtonState>("idle");
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='container'
|
||||
className='overflow-hidden rounded-[var(--radius)] border border-border'
|
||||
>
|
||||
<div
|
||||
slot='preview'
|
||||
ref={setPortalContainer}
|
||||
className='preview flex flex-col gap-3 bg-background p-3 text-foreground'
|
||||
style={
|
||||
{
|
||||
"--background": theme.background,
|
||||
"--foreground": theme.foreground,
|
||||
"--card": theme.card,
|
||||
"--card-foreground": theme["card-foreground"],
|
||||
"--popover": theme.popover,
|
||||
"--popover-foreground": theme["popover-foreground"],
|
||||
"--primary": theme.primary,
|
||||
"--primary-foreground": theme["primary-foreground"],
|
||||
"--secondary": theme.secondary,
|
||||
"--secondary-foreground": theme["secondary-foreground"],
|
||||
"--muted": theme.muted,
|
||||
"--muted-foreground": theme["muted-foreground"],
|
||||
"--accent": theme.accent,
|
||||
"--accent-foreground": theme["accent-foreground"],
|
||||
"--destructive": theme.destructive,
|
||||
"--destructive-foreground": theme["destructive-foreground"],
|
||||
"--border": theme.border,
|
||||
"--input": theme.input,
|
||||
"--ring": theme.ring,
|
||||
"--radius": theme.radius,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Wrapper
|
||||
title='Button'
|
||||
extra={`Primary, Secondary, Accent, Destructive, Input Border`}
|
||||
className='grid grid-cols-3 gap-1.5'
|
||||
>
|
||||
{["default", "secondary", "destructive", "outline", "ghost", "link"].map((btn) => (
|
||||
<Button
|
||||
key={btn}
|
||||
size={"sm"}
|
||||
variant={btn as any}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className='capitalize'
|
||||
>
|
||||
{btn}
|
||||
</Button>
|
||||
))}
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper
|
||||
title='Input'
|
||||
extra={`Muted, Input Border`}
|
||||
className='space-y-1.5'
|
||||
>
|
||||
<Input placeholder='Input' />
|
||||
<Textarea placeholder='Textarea' />
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper
|
||||
title='Popover'
|
||||
extra={`Popover, Element Border`}
|
||||
className='grid grid-cols-3 gap-1.5'
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
>
|
||||
Hover me
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Hello</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
>
|
||||
Click me
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent portal={portalContainer}>
|
||||
<p>Hello</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
>
|
||||
Dropdown
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portal={portalContainer}>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
<DropdownMenuItem>Item 2</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>Item 3</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper
|
||||
title='Overlay'
|
||||
extra={`Background, Primary, Muted, Element Border`}
|
||||
className='grid grid-cols-3 gap-1.5'
|
||||
>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Open Dialog
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent portal={portalContainer}>Dialog Content</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Open Drawer
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent portal={portalContainer}>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Drawer Title</DrawerTitle>
|
||||
<DrawerDescription>Description</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='p-6'>Drawer only used for mobile devices</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Open Sheet
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent portal={portalContainer}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
<SheetDescription>Description</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div>Sheet only used for mobile devices</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ButtonLoading
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
state={toastState}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setToastState("loading");
|
||||
const style = {
|
||||
background: `hsl(${theme.background})`,
|
||||
color: `hsl(${theme.foreground})`,
|
||||
borderRadius: `var(${theme.radius})`,
|
||||
border: `1px solid hsl(${theme.border})`,
|
||||
};
|
||||
toast.loading("Toast!", { duration: 1500, style });
|
||||
toast.success("Toast!", { duration: 1500, style });
|
||||
toast.error("Toast!", { duration: 1500, style });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
setToastState("idle");
|
||||
}}
|
||||
>
|
||||
Toast!
|
||||
</ButtonLoading>
|
||||
<NextTopLoader color={`hsl(${theme.primary})`} />
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className={"w-full"}
|
||||
state={loadingState}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setLoadingState("loading");
|
||||
|
||||
nProgress.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
nProgress.done();
|
||||
setLoadingState("idle");
|
||||
}}
|
||||
>
|
||||
Loading bar
|
||||
</ButtonLoading>
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper
|
||||
title='File'
|
||||
className='grid grid-cols-1 gap-1.5'
|
||||
>
|
||||
<FileList
|
||||
data={fileMockData}
|
||||
disabled
|
||||
/>
|
||||
<FileGrid
|
||||
data={fileMockData}
|
||||
disabled
|
||||
/>
|
||||
</Wrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type WrapperProps = {
|
||||
title: string;
|
||||
extra?: string;
|
||||
className?: string;
|
||||
};
|
||||
function Wrapper({ title, extra, className, children }: React.PropsWithChildren<WrapperProps>) {
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
<div className='flex flex-col'>
|
||||
<h3>{title}</h3>
|
||||
{extra && <p className='text-xs opacity-50'>{extra}</p>}
|
||||
</div>
|
||||
<div className={cn("w-full", className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { default as FormInputs } from "./FormInputs";
|
||||
export { default as ThemeInput } from "./Input";
|
||||
export type { ThemeInputProps } from "./Input";
|
||||
export { default as ThemeForm } from "./Layout";
|
||||
export { default as ThemePreview } from "./Preview";
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./Configuration";
|
||||
export * from "./Theme";
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
|
||||
import { isDev } from "~/utils/isDev";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
};
|
||||
export default function Footer({ content }: Props) {
|
||||
return (
|
||||
<footer className='flex w-full items-center justify-center pb-3'>
|
||||
<ReactMarkdown
|
||||
className='flex w-full select-none flex-col items-center justify-center text-center'
|
||||
components={{
|
||||
p: ({ node, children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='muted text-balance text-sm'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ node, children, ...props }) => {
|
||||
const isExternal = props.href?.startsWith("http");
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
{isDev && (
|
||||
<div className='fixed bottom-0 z-[999] rounded-t-[var(--radius)] border border-b-0 border-border bg-primary px-3 py-1 text-xs text-primary-foreground'>
|
||||
Dev Mode
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,584 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetFooter, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { ClearPassword } from "actions";
|
||||
import config from "config";
|
||||
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const { theme, themes, setTheme } = useTheme();
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [themeOpen, setThemeOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop) setOpen(false);
|
||||
}, [isDesktop]);
|
||||
|
||||
async function onClearPassword() {
|
||||
const promise = new Promise(async (resolve, reject) => {
|
||||
const clear = await ClearPassword();
|
||||
if (!clear.success) reject(new Error(clear.message));
|
||||
resolve(clear.message);
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Clearing all saved password...",
|
||||
success: () => {
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
return "All saved password cleared successfully!";
|
||||
},
|
||||
error: (error) => error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("sticky left-0 top-0 z-50", "h-12 w-full")}>
|
||||
<nav
|
||||
slot='nav'
|
||||
className={cn(
|
||||
"bg-background",
|
||||
"h-12 w-full",
|
||||
"px-6 py-3",
|
||||
"flex flex-grow flex-row items-center justify-between",
|
||||
"border-b border-border",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-row items-center justify-between", "mx-auto w-full max-w-screen-desktop")}>
|
||||
<Link
|
||||
href={"/"}
|
||||
onClick={() => setOpen(false)}
|
||||
className='flex items-center gap-3'
|
||||
>
|
||||
<img
|
||||
src={config.siteConfig.siteIcon}
|
||||
alt={config.siteConfig.siteName}
|
||||
className={cn("h-10 w-10")}
|
||||
loading='eager'
|
||||
/>
|
||||
<span className={cn("large", "hidden", "tablet:block")}>{config.siteConfig.siteName}</span>
|
||||
</Link>
|
||||
|
||||
{isDesktop ? (
|
||||
<div className='flex'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<Tooltip key={item.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
pathname === item.href ? "cursor-default opacity-100" : "cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>{item.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<Separator
|
||||
orientation='vertical'
|
||||
className='mx-3 my-auto h-6'
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={theme === "system" ? "LaptopMinimal" : theme === "light" ? "Sun" : "Moon"}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Site theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align='start'>
|
||||
{themes.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item}
|
||||
disabled={item === theme}
|
||||
className='w-full'
|
||||
onClick={() => setTheme(item)}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className={cn("capitalize")}>{item}</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{config.siteConfig.supports.length && (
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Heart'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Support</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align='center'>
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<DropdownMenuItem key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={"_blank"}
|
||||
rel={"noopener noreferrer"}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
<span className='muted'>{item.currency}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{config.showDeployGuide && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={"/deploy"}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={"Book"}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
<span className='text-sm'>Guide</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Deploy Guide</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Separator
|
||||
orientation='vertical'
|
||||
className='mx-3 my-auto h-6'
|
||||
/>
|
||||
|
||||
<Dialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LogOut'
|
||||
className='text-red-500'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Clear all saved password</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DialogContent>
|
||||
<DialogTitle>Clear all saved password?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You will need to re-enter the password to access the protected content.
|
||||
<br />
|
||||
<b>Are you sure you want to continue?</b>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={onClearPassword}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Drawer
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name={theme === "system" ? "LaptopMinimal" : theme === "light" ? "Sun" : "Moon"}
|
||||
size={"1.125rem"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Theme</DrawerTitle>
|
||||
<DrawerDescription>Choose your preferred theme</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-1.5 px-4'>
|
||||
{themes.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={theme === item ? "default" : "outline"}
|
||||
size={"sm"}
|
||||
className='w-full'
|
||||
disabled={item === theme}
|
||||
onClick={() => {
|
||||
setTheme(item);
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span
|
||||
className={cn(
|
||||
"capitalize",
|
||||
// item === theme && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name='Menu'
|
||||
size={"1.125rem"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex flex-col items-center gap-3 pt-12'>
|
||||
<div className='flex w-full flex-grow flex-col gap-6 overflow-y-auto'>
|
||||
<div className='flex flex-col'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
size={"1.125rem"}
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.external && (
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
size={"0.75rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{config.showDeployGuide && (
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={"/deploy"}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Icon
|
||||
name={"Book"}
|
||||
className='text-foreground'
|
||||
size={"1.125rem"}
|
||||
/>
|
||||
Deploy Guide
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='hidden flex-col'>
|
||||
<p className='large'>Theme</p>
|
||||
<Separator className='my-1.5' />
|
||||
{themes.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
className='w-full'
|
||||
disabled={item === theme}
|
||||
onClick={() => {
|
||||
setTheme(item);
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<span
|
||||
className={cn(
|
||||
"capitalize",
|
||||
// item === theme && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
className={
|
||||
cn()
|
||||
// item === theme && "text-muted-foreground",
|
||||
}
|
||||
size={"1.125rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{config.siteConfig.supports.length && (
|
||||
<div className='flex flex-col'>
|
||||
<p className='large'>Support & Donation</p>
|
||||
<Separator className='my-1.5' />
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={"_blank"}
|
||||
rel={"noopener noreferrer"}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
>
|
||||
<small>{item.name}</small>
|
||||
<small className='muted'>{item.currency}</small>
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className='w-full'>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
className='w-full gap-3'
|
||||
size={"sm"}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>Clear all saved password?</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
You will need to re-enter the password to access the protected content.
|
||||
<br />
|
||||
<b>Are you sure you want to continue?</b>
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerFooter className='gap-1.5'>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={onClearPassword}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Status } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-12")}>
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='The file you are looking for does not exist'
|
||||
/>
|
||||
|
||||
<div className={cn("w-full", "flex flex-col items-center gap-3")}>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Back to Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Icon, Loader } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { CheckPassword, CheckSitePassword, SetPassword, SetSitePassword } from "actions";
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
checkPaths?: { path: string; id: string }[];
|
||||
errorMessage?: string;
|
||||
};
|
||||
export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
const router = useRouter();
|
||||
const loading = useLoading();
|
||||
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [submitLoading, setSubmitLoading] = useState<boolean>(false);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setSubmitLoading(true);
|
||||
toast.loading("Checking password...", {
|
||||
id: "password",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!input) throw new Error("Password is required");
|
||||
if (path === "global") {
|
||||
const set = await SetSitePassword(input);
|
||||
if (!set.success) throw new Error(set.message);
|
||||
|
||||
const check = await CheckSitePassword();
|
||||
if (!check.success) throw new Error(check.message);
|
||||
} else {
|
||||
if (!checkPaths) throw new Error("No path found, try to refresh the page.");
|
||||
const set = await SetPassword(path, input);
|
||||
if (!set.success) throw new Error(set.message);
|
||||
|
||||
const check = await CheckPassword(checkPaths);
|
||||
if (!check.success) throw new Error(check.message);
|
||||
}
|
||||
|
||||
toast.success("Password accepted! Refreshing...", {
|
||||
id: "password",
|
||||
});
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: "password",
|
||||
});
|
||||
setSubmitLoading(false);
|
||||
} finally {
|
||||
setInput("");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loader message='Checking password...' />;
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-3")}>
|
||||
<img
|
||||
src='/images/undraw_vault.svg'
|
||||
alt='Password illustration'
|
||||
loading='eager'
|
||||
className={cn("h-48 w-64 object-contain")}
|
||||
/>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<h3 className='text-balance text-center'>
|
||||
{path === "global"
|
||||
? "This site are password protected"
|
||||
: "The folder or file you are trying to access is password protected"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={cn("w-full", "grid grid-cols-1 gap-3", "tablet:grid-cols-4")}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
name='password'
|
||||
type='password'
|
||||
placeholder='Password'
|
||||
className={cn("tablet:col-span-3")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className='gap-3'
|
||||
type='submit'
|
||||
disabled={submitLoading}
|
||||
>
|
||||
{submitLoading ? (
|
||||
<>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={"1rem"}
|
||||
className='animate-spin'
|
||||
/>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>Submit</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<span className={cn("text-pretty text-center text-destructive", errorMessage ? "visible" : "invisible")}>
|
||||
{errorMessage || "Nothing wrong here, just a password wall"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
import { HTMLProps } from "react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
type DropdownItem =
|
||||
| {
|
||||
type: "label";
|
||||
key: string;
|
||||
title: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
| {
|
||||
type: "separator";
|
||||
key: string;
|
||||
className?: string;
|
||||
}
|
||||
| {
|
||||
type: "item";
|
||||
key: string;
|
||||
title: React.ReactNode;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
icon?: keyof typeof icons;
|
||||
className?: string;
|
||||
}
|
||||
| {
|
||||
type: "checkbox";
|
||||
key: string;
|
||||
title: React.ReactNode;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
| {
|
||||
type: "radio";
|
||||
key: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
items: {
|
||||
key: string;
|
||||
value: string;
|
||||
title: string;
|
||||
className?: string;
|
||||
}[];
|
||||
};
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
trigger: React.ReactNode;
|
||||
mobile: {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
closeOnFooter?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
};
|
||||
desktop: {
|
||||
align?: "start" | "center" | "end";
|
||||
alignOffset?: number;
|
||||
placement?: "top" | "right" | "bottom" | "left";
|
||||
placementOffset?: number;
|
||||
items: DropdownItem[];
|
||||
};
|
||||
} & HTMLProps<HTMLDivElement>;
|
||||
export default function ResponsiveDropdown({ open, onOpenChange, trigger, mobile, desktop, ...rest }: Props) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='responsive-menu'
|
||||
{...rest}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={desktop.align}
|
||||
alignOffset={desktop.alignOffset}
|
||||
side={desktop.placement}
|
||||
sideOffset={desktop.placementOffset}
|
||||
>
|
||||
{desktop.items.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case "label":
|
||||
return (
|
||||
<DropdownMenuLabel
|
||||
key={item.key}
|
||||
className={item.className}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuLabel>
|
||||
);
|
||||
case "separator":
|
||||
return (
|
||||
<DropdownMenuSeparator
|
||||
key={item.key}
|
||||
className={item.className}
|
||||
/>
|
||||
);
|
||||
case "checkbox":
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.key}
|
||||
checked={item.checked}
|
||||
onCheckedChange={item.onCheckedChange}
|
||||
disabled={item.disabled}
|
||||
className={item.className}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
case "radio":
|
||||
return (
|
||||
<DropdownMenuRadioGroup
|
||||
value={item.value}
|
||||
onValueChange={item.onValueChange}
|
||||
>
|
||||
{item.items.map((radio) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={radio.key}
|
||||
value={radio.value}
|
||||
className={radio.className}
|
||||
>
|
||||
{radio.title}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.key}
|
||||
disabled={item.disabled}
|
||||
onClick={item.onClick}
|
||||
className={item.className}
|
||||
>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
name={item.icon}
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
)}
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
{(mobile.title || mobile.description) && (
|
||||
<DrawerHeader className='text-start'>
|
||||
{mobile.title && <DrawerTitle>{mobile.title}</DrawerTitle>}
|
||||
{mobile.description && <DrawerDescription>{mobile.description}</DrawerDescription>}
|
||||
</DrawerHeader>
|
||||
)}
|
||||
|
||||
<div className='grid gap-1.5 px-4'>{mobile.content}</div>
|
||||
|
||||
{(mobile.footer || mobile.closeOnFooter) && (
|
||||
<DrawerFooter>
|
||||
<Separator />
|
||||
{mobile.footer}
|
||||
|
||||
{mobile.closeOnFooter && (
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant='secondary'
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
)}
|
||||
</DrawerFooter>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemeProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { TooltipProvider } from "~/components/ui/tooltip";
|
||||
|
||||
import LayoutProvider from "~/context/layoutContext";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<NextTopLoader color='hsl(var(--primary))' />
|
||||
<NextThemeProvider {...props}>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<div
|
||||
vaul-drawer-wrapper=''
|
||||
className={cn(
|
||||
"bg-background font-sans text-foreground",
|
||||
"h-full min-h-screen w-full",
|
||||
"flex flex-col items-start",
|
||||
// "tablet:flex-row",
|
||||
"pr-1.5",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<Toaster
|
||||
position={config.siteConfig.toaster?.position}
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "hsl(var(--background))",
|
||||
color: "hsl(var(--foreground))",
|
||||
borderRadius: "var(--radius)",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
},
|
||||
duration: config.siteConfig.toaster?.duration,
|
||||
loading: {
|
||||
duration: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</NextThemeProvider>
|
||||
</LayoutProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { default as Footer } from "./Footer";
|
||||
export { default as Navbar } from "./Navbar";
|
||||
export { default as NotFound } from "./NotFound";
|
||||
export { default as Password } from "./Password";
|
||||
export { default as ResponsiveDropdown } from "./ResponsiveDropdown";
|
||||
export { default as ThemeProvider } from "./ThemeProvider";
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Loader, Status } from "~/components/Global";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
|
||||
const Plyr = dynamic(() => import("plyr-react"), {
|
||||
ssr: false,
|
||||
loading: () => <Loader message='Loading player...' />,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewAudio({ file }: Props) {
|
||||
const [audioSrc, setAudioSrc] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loading = useLoading(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No audio to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setAudioSrc(`/api/stream/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<Loader message='Loading audio...' />
|
||||
) : error ? (
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full'>
|
||||
<Plyr
|
||||
source={{
|
||||
type: "audio",
|
||||
sources: [
|
||||
{
|
||||
src: audioSrc,
|
||||
type: file.mimeType,
|
||||
size: file.size,
|
||||
},
|
||||
],
|
||||
title: file.name,
|
||||
}}
|
||||
options={{
|
||||
controls: [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"duration",
|
||||
"mute",
|
||||
"volume",
|
||||
"settings",
|
||||
"fullscreen",
|
||||
],
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
loop: {
|
||||
active: false,
|
||||
},
|
||||
speed: {
|
||||
selected: 1,
|
||||
options: [0.5, 1, 1.5, 2],
|
||||
},
|
||||
keyboard: {
|
||||
focused: true,
|
||||
global: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import DocViewer, { DocRenderer } from "@cyntler/react-doc-viewer";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
const GoogleDocsViewerRenderer: DocRenderer = ({ mainState: { currentDocument, documentLoading } }) => {
|
||||
if (!currentDocument || !currentDocument.uri) return null;
|
||||
|
||||
const viewerUrl = new URL(`/gview`, "https://docs.google.com");
|
||||
viewerUrl.searchParams.set("url", currentDocument.uri);
|
||||
viewerUrl.searchParams.set("embedded", "true");
|
||||
|
||||
if (documentLoading) {
|
||||
return <Loader message='Loading document...' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={viewerUrl.toString()}
|
||||
className='m-0 h-full max-h-[70dvh] min-h-[70dvh] w-full overflow-hidden rounded-[var(--radius)] border border-border p-0 !text-black'
|
||||
frameBorder={0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
GoogleDocsViewerRenderer.fileTypes = [
|
||||
"application/vnd.google-apps.document",
|
||||
"application/vnd.google-apps.presentation",
|
||||
"application/vnd.google-apps.spreadsheet",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"text/csv",
|
||||
"application/pdf",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
];
|
||||
GoogleDocsViewerRenderer.weight = 100;
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
|
||||
export default function PreviewDoc({ file }: Props) {
|
||||
const [docSrc, setDocSrc] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loading = useLoading(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("Nothing to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
const streamURL = new URL(`/api/stream/${file.encryptedId}`, config.basePath);
|
||||
streamURL.searchParams.set("token", token);
|
||||
setDocSrc(streamURL.toString());
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<Loader message='Loading document...' />
|
||||
) : error ? (
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='h-fit w-full space-y-3 overflow-hidden rounded-[var(--radius)]'>
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
It might failed to load the document, you can try to refresh the page or download the document to view
|
||||
it.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<DocViewer
|
||||
key={file.encryptedId}
|
||||
documents={[
|
||||
{
|
||||
uri: docSrc,
|
||||
fileName: file.name,
|
||||
fileType: file.mimeType,
|
||||
},
|
||||
]}
|
||||
pluginRenderers={[GoogleDocsViewerRenderer]}
|
||||
config={{
|
||||
header: {
|
||||
disableHeader: true,
|
||||
},
|
||||
loadingRenderer: {
|
||||
overrideComponent: () => <Loader message='Loading document...' />,
|
||||
showLoadingTimeout: 10000,
|
||||
},
|
||||
noRenderer: {
|
||||
overrideComponent: () => (
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message='Failed to load document'
|
||||
destructive
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
className={cn(
|
||||
"h-full max-h-[70dvh] min-h-[70dvh] w-full overflow-hidden rounded-[var(--radius)] !text-black",
|
||||
"rounded-[var(--radius)] [&>div#proxy-renderer]:overflow-hidden ",
|
||||
)}
|
||||
theme={{
|
||||
disableThemeScrollbar: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewImage({ file }: Props) {
|
||||
const [imgSrc, setImgSrc] = useState<string>(`/api/thumb/${file.encryptedId}?size=4`);
|
||||
const [imgLoaded, setImgLoaded] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loading = useLoading(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No image to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
|
||||
const streamURL = new URL(`/api/thumb/${file.encryptedId}?size=1000`, config.basePath);
|
||||
streamURL.searchParams.set("token", token);
|
||||
fetch(streamURL, {
|
||||
headers: {
|
||||
Range: `bytes=0-${(file.size || 1) - 1}`,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error("Could not load image");
|
||||
}
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const urlobject = URL.createObjectURL(blob);
|
||||
setImgSrc(urlobject);
|
||||
const timeout = setTimeout(() => {
|
||||
setImgLoaded(true);
|
||||
clearTimeout(timeout);
|
||||
}, 150); // Add a delay to show the image
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<Loader message='Loading image...' />
|
||||
) : error ? (
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='h-fit w-full space-y-3 overflow-hidden'>
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
This image is a preview and may not be the full resolution. Please download the file for the full
|
||||
resolution.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={file.name}
|
||||
className={cn(
|
||||
"h-full max-h-[70dvh] w-full rounded-[var(--radius)] bg-muted object-contain object-center transition",
|
||||
imgLoaded ? "blur-none" : "animate-pulse blur",
|
||||
)}
|
||||
onError={(e) => {
|
||||
console.error(e);
|
||||
setError("Could not preview this image, try downloading the file");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ButtonLoading } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Drawer, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTrigger } from "~/components/ui/drawer";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
import { ButtonState, Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewInformation({ file }: Props) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const showRaw = useMemo<boolean>(() => {
|
||||
return file.mimeType.startsWith("image") || file.mimeType.startsWith("video") || file.mimeType.startsWith("audio");
|
||||
}, [file]);
|
||||
const showViewDoc = useMemo<boolean>(() => {
|
||||
const fileExtensionFallback = file.name.split(".").pop();
|
||||
const fileExt = file.fileExtension ?? fileExtensionFallback;
|
||||
if (!fileExt) return false;
|
||||
const fileType = getFileType(fileExt, file.mimeType);
|
||||
return fileType === "document" || fileType === "pdf";
|
||||
}, [file]);
|
||||
|
||||
const fileInfo = useMemo<{ label: string; value: string }[]>(() => {
|
||||
const value = [
|
||||
{
|
||||
label: "File Name",
|
||||
value: file.name,
|
||||
},
|
||||
{
|
||||
label: "Mime Type",
|
||||
value: file.mimeType,
|
||||
},
|
||||
{
|
||||
label: "Size",
|
||||
value: `${bytesToReadable(file.size || 0)} (${file.size || 0} bytes)`,
|
||||
},
|
||||
{
|
||||
label: "Last Modified",
|
||||
value: file.modifiedTime,
|
||||
},
|
||||
];
|
||||
if (file.imageMediaMetadata) {
|
||||
value.push({
|
||||
label: "Dimension",
|
||||
value: `${file.imageMediaMetadata.width} x ${file.imageMediaMetadata.height}`,
|
||||
});
|
||||
}
|
||||
if (file.videoMediaMetadata) {
|
||||
value.push({
|
||||
label: "Dimension",
|
||||
value: `${file.videoMediaMetadata.width} x ${file.videoMediaMetadata.height}`,
|
||||
});
|
||||
value.push({
|
||||
label: "Duration",
|
||||
value: durationToReadable(file.videoMediaMetadata.durationMillis),
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}, [file]);
|
||||
|
||||
const [viewerState, setViewerState] = useState<ButtonState>("idle");
|
||||
const [copyRawState, setCopyRawState] = useState<ButtonState>("idle");
|
||||
const [copyDownloadState, setCopyDownloadState] = useState<ButtonState>("idle");
|
||||
const [downloadState, setDownloadState] = useState<ButtonState>("idle");
|
||||
const [isRawExplanationOpen, setIsRawExplanationOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const onCopyRaw = async () => {
|
||||
setCopyRawState("loading");
|
||||
try {
|
||||
const rawURL = new URL(`/api/raw/${pathname}`.replace(/\/+/g, "/"), config.basePath);
|
||||
rawURL.searchParams.append("token", file.encryptedId);
|
||||
toast.promise(navigator.clipboard.writeText(rawURL.toString()), {
|
||||
loading: "Copying link...",
|
||||
success: "Raw link copied!",
|
||||
error: "Failed to copy link",
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
} finally {
|
||||
setCopyRawState("idle");
|
||||
}
|
||||
};
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCopyDownloadState("loading");
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
await navigator.clipboard.writeText(
|
||||
new URL(`/api/download/${file.encryptedId}?token=${token}`, config.basePath).toString(),
|
||||
);
|
||||
toast.success("Download link copied!", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
} finally {
|
||||
setCopyDownloadState("idle");
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDownloadState("loading");
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${file.encryptedId}?token=${token}`);
|
||||
}, 250);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
} finally {
|
||||
setDownloadState("idle");
|
||||
}
|
||||
};
|
||||
const onOpenViewer = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Creating view token...", {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
setViewerState("loading");
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create view token");
|
||||
const streamURL = new URL(`/api/stream/${file.encryptedId}`, config.basePath);
|
||||
streamURL.searchParams.set("token", token);
|
||||
toast.success("Opening viewer link...", {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
const viewerUrl = new URL(`/gview`, "https://docs.google.com");
|
||||
viewerUrl.searchParams.set("url", streamURL.toString());
|
||||
viewerUrl.searchParams.set("embedded", "true");
|
||||
window.open(viewerUrl.toString(), "_blank");
|
||||
}, 250);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
} finally {
|
||||
setViewerState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
<Card slot='file-actions'>
|
||||
<div className='flex flex-col-reverse gap-3 p-6 tablet:flex-row tablet:items-center tablet:justify-between'>
|
||||
<div
|
||||
slot='raw-explanation'
|
||||
className={cn("flex gap-3", !showRaw && "invisible")}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<Dialog
|
||||
open={isRawExplanationOpen}
|
||||
onOpenChange={setIsRawExplanationOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<span className='cursor-pointer text-center text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400 tablet:text-start'>
|
||||
Difference between download link and raw link?
|
||||
</span>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className='flex h-fit flex-col gap-3'>
|
||||
<h4>Why?</h4>
|
||||
<p>
|
||||
Some services need the file extension to be present in the URL to properly embed the file.
|
||||
<br />
|
||||
- The download link only have encrypted file id.
|
||||
<br />
|
||||
- The raw link will have the whole path includes the file name and extension.
|
||||
<br />
|
||||
<br />
|
||||
So if you want to embed the file, it's recommended to use the raw link instead of the
|
||||
download link.
|
||||
</p>
|
||||
<p className='muted'>Note: This is only applicable for video, audio, and image files.</p>
|
||||
<h4>Ref</h4>
|
||||
<p>
|
||||
This information is based on the onedrive-vercel-index project documentation.
|
||||
<br />
|
||||
<Link
|
||||
href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
Customise Direct Link - onedrive-vercel-index
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer
|
||||
open={isRawExplanationOpen}
|
||||
onOpenChange={setIsRawExplanationOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<span className='cursor-pointer text-center text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400 tablet:text-start'>
|
||||
Difference between download link and raw link?
|
||||
</span>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader></DrawerHeader>
|
||||
<div className='flex h-fit flex-col gap-3 px-4'>
|
||||
<h4>Why?</h4>
|
||||
<p>
|
||||
Some services need the file extension to be present in the URL to properly embed the file.
|
||||
<br />
|
||||
- The download link only have encrypted file id.
|
||||
<br />
|
||||
- The raw link will have the whole path includes the file name and extension.
|
||||
<br />
|
||||
<br />
|
||||
So if you want to embed the file, it's recommended to use the raw link instead of the
|
||||
download link.
|
||||
</p>
|
||||
<h4>Ref</h4>
|
||||
<p>
|
||||
This information is based on the onedrive-vercel-index project documentation.
|
||||
<br />
|
||||
<Link
|
||||
href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
Customise Direct Link - onedrive-vercel-index
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col-reverse gap-3 tablet:flex-row tablet:items-center'>
|
||||
{showRaw ? (
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
icon='Copy'
|
||||
onClick={onCopyRaw}
|
||||
state={copyRawState}
|
||||
>
|
||||
Raw Link
|
||||
</ButtonLoading>
|
||||
) : null}
|
||||
{showViewDoc ? (
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
icon='ExternalLink'
|
||||
onClick={onOpenViewer}
|
||||
state={viewerState}
|
||||
>
|
||||
Open in Viewer
|
||||
</ButtonLoading>
|
||||
) : null}
|
||||
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
onClick={onCopy}
|
||||
state={copyDownloadState}
|
||||
icon={"Copy"}
|
||||
>
|
||||
Download Link
|
||||
</ButtonLoading>
|
||||
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
onClick={onDownload}
|
||||
state={downloadState}
|
||||
icon='Download'
|
||||
>
|
||||
Download
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card slot='file-info'>
|
||||
<CardHeader>
|
||||
<CardTitle>File Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{fileInfo.map((info) => (
|
||||
<div
|
||||
key={info.label}
|
||||
className='group flex flex-col'
|
||||
>
|
||||
<span className='text-sm font-semibold'>{info.label}:</span>
|
||||
<span className='col-span-3 whitespace-pre-wrap break-all'>{info.value}</span>
|
||||
<Separator className='my-1.5 group-last-of-type:hidden' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Status } from "~/components/Global";
|
||||
import {
|
||||
PreviewAudio,
|
||||
PreviewDocument,
|
||||
PreviewImage,
|
||||
PreviewInformation,
|
||||
PreviewManga,
|
||||
PreviewRich,
|
||||
PreviewUnknown,
|
||||
PreviewVideo,
|
||||
} from "~/components/Preview";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
fileType: "unknown" | ReturnType<typeof getFileType>;
|
||||
};
|
||||
export default function PreviewLayout({ data, fileType }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
return (
|
||||
<div
|
||||
slot='preview-container'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 overflow-hidden",
|
||||
"mobile:flex-row mobile:items-center mobile:justify-between",
|
||||
)}
|
||||
>
|
||||
<h3 className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>{data.name}</h3>
|
||||
{["text", "markdown", "code"].includes(fileType) && (
|
||||
<div className='flex w-full items-center mobile:w-fit'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "markdown" ? "default" : "outline"}
|
||||
onClick={() => setView("markdown")}
|
||||
className='w-full rounded-r-none mobile:w-fit'
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "raw" ? "default" : "outline"}
|
||||
onClick={() => setView("raw")}
|
||||
className='w-full rounded-l-none mobile:w-fit'
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
{config.apiConfig.streamMaxSize && Number(data.size || 0) > config.apiConfig.streamMaxSize ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='File is too large to preview'
|
||||
/>
|
||||
) : (
|
||||
<div className='px-3'>
|
||||
{fileType === "image" ? (
|
||||
<PreviewImage file={data} />
|
||||
) : fileType === "audio" ? (
|
||||
<PreviewAudio file={data} />
|
||||
) : fileType === "video" ? (
|
||||
<PreviewVideo file={data} />
|
||||
) : fileType === "code" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
code
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "text" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "markdown" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "document" ? (
|
||||
<PreviewDocument file={data} />
|
||||
) : fileType === "pdf" ? (
|
||||
<PreviewDocument file={data} />
|
||||
) : fileType === "manga" ? (
|
||||
<PreviewManga file={data} />
|
||||
) : (
|
||||
<PreviewUnknown />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<PreviewInformation file={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Loader, Status } from "~/components/Global";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
|
||||
const Plyr = dynamic(() => import("plyr-react"), {
|
||||
ssr: false,
|
||||
loading: () => <Loader message='Loading player...' />,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewVideo({ file }: Props) {
|
||||
const [videoSrc, setVideoSrc] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const loading = useLoading(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No video to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setVideoSrc(`/api/stream/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<Loader message='Loading video...' />
|
||||
) : error ? (
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='h-full w-full'>
|
||||
<Plyr
|
||||
source={{
|
||||
type: "video",
|
||||
sources: [
|
||||
{
|
||||
src: videoSrc,
|
||||
type: file.mimeType,
|
||||
size: file.size,
|
||||
},
|
||||
],
|
||||
poster: `/api/thumb/${file.encryptedId}?size=1000`,
|
||||
}}
|
||||
options={{
|
||||
toggleInvert: true,
|
||||
settings: ["quality", "speed"],
|
||||
ratio: "16:9",
|
||||
controls: [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"duration",
|
||||
"mute",
|
||||
"volume",
|
||||
"settings",
|
||||
"pip",
|
||||
"download",
|
||||
"fullscreen",
|
||||
],
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
loop: { active: false },
|
||||
speed: { selected: 1, options: [0.5, 1, 1.5, 2] },
|
||||
keyboard: {
|
||||
focused: true,
|
||||
global: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { Status } from "~/components/global";
|
||||
import { PageLoader } from "~/components/layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import Icon from "~/components/ui/icon";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import { useLayout } from "~/context/layoutContext";
|
||||
import { useResponsive } from "~/context/responsiveContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { getPreviewIcon } from "~/lib/previewHelper";
|
||||
import { bytesToReadable, cn, durationToReadable, formatDate } from "~/lib/utils";
|
||||
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import { GetSearchResultPath, SearchFiles } from "~/actions/search";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export default function FileActions() {
|
||||
const loading = useLoading();
|
||||
const { layout, setLayout, isPending } = useLayout();
|
||||
|
||||
const [layoutOpen, setLayoutOpen] = useState<boolean>(false);
|
||||
const [snap, setSnap] = useState<number | string | null>(0.3);
|
||||
const [searchOpen, setSearchOpen] = useState<boolean>(false);
|
||||
const [searchInput, setSearchInput] = useState<string>("");
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState<string>("");
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchError, setSearchError] = useState<string>("");
|
||||
const [searchResults, setSearchResults] = useState<z.infer<typeof Schema_File>[]>([]);
|
||||
|
||||
const { isDesktop } = useResponsive();
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInput) {
|
||||
setSearchLoading(true);
|
||||
setDebouncedSearchInput("");
|
||||
setSearchError("");
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearchInput(searchInput);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [searchInput]);
|
||||
useEffect(() => {
|
||||
if (!debouncedSearchInput) return;
|
||||
void onSearch();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const onSearch = async () => {
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const results = await SearchFiles(debouncedSearchInput);
|
||||
if (!results.success) throw new Error(results.error);
|
||||
setSearchResults(results.data);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[Search Error] ${e.message}`);
|
||||
setSearchError(e.message);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
<div className={cn("w-fit", "flex items-center justify-end gap-2")}>
|
||||
<Skeleton className='my-0.5 h-8 w-16 mobile:w-24' /> <Skeleton className='my-0.5 h-8 w-8' />
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='actions'
|
||||
className={cn("w-fit", "flex items-center justify-end gap-2")}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<>
|
||||
<DropdownMenu
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={"outline"}>
|
||||
<Icon name={layout === "grid" ? "LayoutGrid" : "LayoutList"} />
|
||||
{layout === "grid" ? "Grid" : "List"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
disabled={isPending ?? layout === "grid"}
|
||||
onClick={() => {
|
||||
setLayout("grid");
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
Grid
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
className='stroke-foreground'
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={isPending ?? layout === "list"}
|
||||
onClick={() => {
|
||||
setLayout("list");
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
List
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
className='stroke-foreground'
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dialog
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSnap(0.275);
|
||||
setTimeout(() => {
|
||||
setSearchInput("");
|
||||
}, 300);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>Search for files in your drive</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='grid gap-4'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-2'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-full max-h-[66dvh] flex-grow flex-col gap-2 overflow-y-auto'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<Status
|
||||
icon='LoaderCircle'
|
||||
iconClassName='animate-spin'
|
||||
message='Searching...'
|
||||
/>
|
||||
) : searchError ? (
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Drawer
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon name={layout === "grid" ? "LayoutGrid" : "LayoutList"} />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>File Layout</DrawerTitle>
|
||||
<DrawerDescription>Choose a layout for the files explorer</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-2 px-4 md:px-0'>
|
||||
<Button
|
||||
variant={layout === "grid" ? "secondary" : "outline"}
|
||||
size={"default"}
|
||||
className='w-full'
|
||||
disabled={isPending ?? layout === "grid"}
|
||||
onClick={() => {
|
||||
setLayout("grid");
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className='capitalize'>Grid</span>
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={layout === "list" ? "secondary" : "outline"}
|
||||
size={"default"}
|
||||
className='w-full'
|
||||
disabled={isPending ?? layout === "list"}
|
||||
onClick={() => {
|
||||
setLayout("list");
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className='capitalize'>List</span>
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSnap(0.275);
|
||||
setTimeout(() => {
|
||||
setSearchInput("");
|
||||
}, 300);
|
||||
}
|
||||
setSearchOpen(open);
|
||||
}}
|
||||
shouldScaleBackground
|
||||
fadeFromIndex={0}
|
||||
snapPoints={[0.275, 1]}
|
||||
activeSnapPoint={snap}
|
||||
setActiveSnapPoint={setSnap}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='flex items-center'
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
<DrawerContent className='h-full max-h-[95%]'>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Search</DrawerTitle>
|
||||
<DrawerDescription>Search for files in your drive</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-4 px-4 md:px-0'>
|
||||
<div
|
||||
slot='search-input'
|
||||
className='grid gap-2'
|
||||
>
|
||||
<Input
|
||||
placeholder='What are you looking for?'
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchError("");
|
||||
if (!e.target.value) {
|
||||
setSearchInput("");
|
||||
setSnap(0.3);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
setSearchInput(e.target.value);
|
||||
setSnap(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-fit flex-grow flex-col gap-2 overflow-y-auto'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<PageLoader message='Searching...' />
|
||||
) : searchError ? (
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-2'>
|
||||
{searchResults.map((result) => (
|
||||
<SearchResultItem
|
||||
key={result.encryptedId}
|
||||
data={result}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
const router = useRouter();
|
||||
|
||||
const useThumbnail = useMemo<boolean>(
|
||||
() => !!(data.thumbnailLink && (data.mimeType.includes("image") || data.mimeType.includes("video"))),
|
||||
[data],
|
||||
);
|
||||
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState<boolean>(true);
|
||||
|
||||
const onOpen = useCallback(async () => {
|
||||
toast.loading("Getting file path...", {
|
||||
description: "Might take a while for deep nested file",
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const paths = await GetSearchResultPath(data.encryptedId);
|
||||
if (!paths.success) throw new Error(paths.error);
|
||||
toast.success("Redirecting...", {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
router.push(`/${paths.data}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[Redirect Error] ${e.message}`);
|
||||
toast.error(e.message, {
|
||||
id: `open-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
}, [data.encryptedId, router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='file-search-result'
|
||||
title={data.name}
|
||||
className='group flex cursor-pointer items-center overflow-hidden rounded-lg border bg-card text-card-foreground shadow transition hover:border-accent-foreground'
|
||||
onClick={onOpen}
|
||||
>
|
||||
{/* Thumbnail / Icon */}
|
||||
<div className='relative grid aspect-square h-full w-14 shrink-0 place-items-center overflow-hidden mobile:w-16 tablet:w-20'>
|
||||
{useThumbnail ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={data.name}
|
||||
className='absolute -z-0 h-full w-full shrink-0 grow-0 object-cover object-center opacity-50 blur-sm'
|
||||
/>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={data.name}
|
||||
onLoad={() => {
|
||||
if (thumbnailUrl.endsWith("size=2")) {
|
||||
setThumbnailUrl(`/api/thumb/${data.encryptedId}`);
|
||||
setThumbnailLoading(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-0 w-full shrink-0 grow-0 object-contain object-center transition duration-500 ease-in-out group-hover:scale-105",
|
||||
thumbnailLoading ? "blur-lg" : "blur-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
{data.mimeType.includes("video") && (
|
||||
<div className='absolute inset-0 z-0 grid h-full w-full place-items-center'>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='rounded-full group-hover:bg-accent group-hover:stroke-accent-foreground group-hover:text-accent-foreground'
|
||||
>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='fill-current'
|
||||
/>
|
||||
</Button>
|
||||
<div className='absolute bottom-0.5 right-0.5 z-0 rounded-sm bg-muted px-1 py-0.5 text-[10px] text-muted-foreground shadow-sm mobile:bottom-1 mobile:right-1 mobile:text-xs'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension ?? "", data.mimeType)
|
||||
}
|
||||
className={"size-6 stroke-muted-foreground"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className='flex h-full grow flex-col justify-start p-2'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap text-pretty break-all text-base mobile:text-sm'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`\.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='inline-flex items-center gap-1 text-xs text-muted-foreground'>
|
||||
{/* Type */}
|
||||
{data.mimeType.includes("folder") ? (
|
||||
"Folder"
|
||||
) : config.siteConfig.showFileExtension ? (
|
||||
<></>
|
||||
) : (
|
||||
data.fileExtension ?? "Unknown format"
|
||||
)}
|
||||
{/* Slash */}
|
||||
{!data.mimeType.includes("folder") && !config.siteConfig.showFileExtension && (
|
||||
<Icon
|
||||
name='Slash'
|
||||
className='size-3'
|
||||
/>
|
||||
)}
|
||||
{/* Size */}
|
||||
{!data.mimeType.includes("folder") && bytesToReadable(data.size ?? 0)}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>{formatDate(data.modifiedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { type z } from "zod";
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import {
|
||||
ResponsiveDropdownMenu,
|
||||
ResponsiveDropdownMenuContent,
|
||||
ResponsiveDropdownMenuItem,
|
||||
ResponsiveDropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu.responsive";
|
||||
import Icon from "~/components/ui/icon";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { type Schema_Breadcrumb } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data?: z.infer<typeof Schema_Breadcrumb>[];
|
||||
};
|
||||
export default function FileBreadcrumb({ data }: Props) {
|
||||
const loading = useLoading();
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [path] = useState<z.infer<typeof Schema_Breadcrumb>[]>(data ?? []);
|
||||
|
||||
if (loading) return <Skeleton className='my-2 h-5 w-1/2' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='file-path'
|
||||
className='flex w-full flex-nowrap items-center px-3'
|
||||
>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{/* Root */}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={"/"}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon
|
||||
name='FolderRoot'
|
||||
size={"1rem"}
|
||||
className='stroke-foreground'
|
||||
/>
|
||||
~
|
||||
</div>
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{!!path.length ? (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
|
||||
{path.length >= config.siteConfig.breadcrumbMax && (
|
||||
<>
|
||||
<ResponsiveDropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<ResponsiveDropdownMenuTrigger asChild>
|
||||
<BreadcrumbLink asChild>
|
||||
<BreadcrumbEllipsis className='w-4 cursor-pointer' />
|
||||
</BreadcrumbLink>
|
||||
</ResponsiveDropdownMenuTrigger>
|
||||
<ResponsiveDropdownMenuContent
|
||||
header={{
|
||||
title: "Navigate",
|
||||
description: "Navigate to parent directories",
|
||||
}}
|
||||
>
|
||||
{path.slice(0, -config.siteConfig.breadcrumbMax + 1).map((item, idx, array) => (
|
||||
<ResponsiveDropdownMenuItem
|
||||
key={`breadcrumb-${item.label}/${item.href}`}
|
||||
closeOnSelect
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</ResponsiveDropdownMenuItem>
|
||||
))}
|
||||
</ResponsiveDropdownMenuContent>
|
||||
</ResponsiveDropdownMenu>
|
||||
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{path.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
|
||||
<Fragment key={`${item.label}/${item.href}`}>
|
||||
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.href ? (
|
||||
<>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${path
|
||||
.map((item) => item.href)
|
||||
.join("/")
|
||||
.replace(/\/\//g, "/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbPage className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.label}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{item.href && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "~/components/ui/context-menu";
|
||||
import Icon from "~/components/ui/icon";
|
||||
|
||||
import { type TLayout } from "~/context/layoutContext";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { getPreviewIcon } from "~/lib/previewHelper";
|
||||
import { bytesToReadable, cn, durationToReadable, formatDate } from "~/lib/utils";
|
||||
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
layout: TLayout;
|
||||
};
|
||||
export default function FileItem({ data, layout }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)].join("/").replace(/\/+/g, "/");
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data.name, pathname]);
|
||||
|
||||
const contextButtonRef = useRef<HTMLDivElement>(null);
|
||||
const copyTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const useThumbnail = useMemo<boolean>(
|
||||
() => !!(data.thumbnailLink && (data.mimeType.includes("image") || data.mimeType.includes("video"))),
|
||||
[data],
|
||||
);
|
||||
// Using proxied URL to avoid CORS issues
|
||||
// Also using state to load smaller thumbnail first
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [thumbnailLoading, setThumbnailLoading] = useState<boolean>(true);
|
||||
|
||||
// Unused for now
|
||||
// Might be added on next version (?)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [isShareDialogOpen, setShareDialogOpen] = useState<boolean>(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [copyStatus, setCopyStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
|
||||
const onOpenNewTab = useCallback(async () => {
|
||||
void 0;
|
||||
}, []);
|
||||
const onCopy = useCallback(async () => {
|
||||
if (copyTimeout.current) clearTimeout(copyTimeout.current);
|
||||
|
||||
setCopyStatus("loading");
|
||||
toast.loading("Copying link...", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(filePath, config.basePath).toString());
|
||||
toast.success(`Link to ${data.mimeType.includes("folder") ? "folder" : "file"} copied!`, {
|
||||
description: <span className='line-clamp-2 whitespace-pre-wrap break-all'>{data.name}</span>,
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
setCopyStatus("success");
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[onCopy] ${e.message}`);
|
||||
toast.error(e.message, {
|
||||
description: <span className='line-clamp-2 whitespace-pre-wrap break-all'>{data.name}</span>,
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
setCopyStatus("error");
|
||||
} finally {
|
||||
copyTimeout.current = setTimeout(() => {
|
||||
setCopyStatus("idle");
|
||||
}, 3000);
|
||||
}
|
||||
}, [data.encryptedId, data.mimeType, data.name, filePath]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const onShare = useCallback(async () => {
|
||||
setShareDialogOpen(true);
|
||||
}, []);
|
||||
const onDownload = useCallback(async () => {
|
||||
void 0;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu modal={false}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
slot='file-item'
|
||||
ref={contextButtonRef}
|
||||
title={data.name}
|
||||
onClick={() => {
|
||||
router.push(filePath);
|
||||
}}
|
||||
className={cn(
|
||||
"group grid h-full w-full cursor-pointer overflow-hidden rounded-lg border bg-card text-card-foreground shadow transition hover:border-primary/50",
|
||||
layout === "list" && "flex items-center",
|
||||
)}
|
||||
>
|
||||
{/* Thumbnail / Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative grid aspect-video h-24 w-full shrink-0 place-items-center overflow-hidden bg-background mobile:h-28 tablet:h-32",
|
||||
layout === "list" && "aspect-square h-20 w-20 mobile:h-24 mobile:w-24 tablet:h-28 tablet:w-28",
|
||||
)}
|
||||
>
|
||||
{useThumbnail ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={data.name}
|
||||
className={cn(
|
||||
"absolute -z-0 h-24 w-full shrink-0 grow-0 object-cover object-center opacity-50 blur-sm mobile:h-28 tablet:h-32",
|
||||
layout === "list" && "h-20 mobile:h-24 tablet:h-28",
|
||||
)}
|
||||
/>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={data.name}
|
||||
onLoad={() => {
|
||||
if (thumbnailUrl.endsWith("?size=2")) {
|
||||
setThumbnailUrl(`/api/thumb/${data.encryptedId}`);
|
||||
setThumbnailLoading(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-0 h-24 w-full shrink-0 grow-0 object-contain object-center transition duration-500 ease-in-out group-hover:scale-105 mobile:h-28 tablet:h-32",
|
||||
layout === "list" && "h-20 mobile:h-24 tablet:h-28",
|
||||
thumbnailLoading ? "blur-lg" : "blur-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Play Icon */}
|
||||
{data.mimeType.includes("video") && (
|
||||
<div className='absolute inset-0 z-0 grid h-full w-full place-items-center'>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='rounded-full group-hover:bg-accent group-hover:stroke-accent-foreground group-hover:text-accent-foreground'
|
||||
>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='fill-current'
|
||||
/>
|
||||
</Button>
|
||||
<div className='absolute bottom-0.5 right-0.5 z-0 rounded-sm bg-muted px-1 py-0.5 text-[10px] text-muted-foreground shadow-sm mobile:bottom-1 mobile:right-1 mobile:text-xs'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder")
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension ?? "", data.mimeType)
|
||||
}
|
||||
className={cn("size-10 stroke-muted-foreground", layout === "list" && "size-8")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full grow items-start justify-between gap-2 p-2",
|
||||
layout === "list" && "items-center",
|
||||
)}
|
||||
>
|
||||
<div className='flex h-full grow flex-col justify-between gap-2'>
|
||||
<span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all text-base mobile:text-sm'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='inline-flex items-center gap-1 text-xs text-muted-foreground'>
|
||||
{/* Type */}
|
||||
{data.mimeType.includes("folder") ? (
|
||||
"Folder"
|
||||
) : config.siteConfig.showFileExtension ? (
|
||||
<></>
|
||||
) : (
|
||||
data.fileExtension ?? "Unknown format"
|
||||
)}
|
||||
{/* Slash */}
|
||||
{!data.mimeType.includes("folder") && !config.siteConfig.showFileExtension && (
|
||||
<Icon
|
||||
name='Slash'
|
||||
className='size-3'
|
||||
/>
|
||||
)}
|
||||
{/* Size */}
|
||||
{!data.mimeType.includes("folder") && bytesToReadable(data.size ?? 0)}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>{formatDate(data.modifiedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"ghost"}
|
||||
className={cn("h-8 w-8 shrink-0 rounded-full", layout === "list" && "h-10 w-10")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const menuEvent = new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
button: 2,
|
||||
buttons: 2,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
contextButtonRef.current?.dispatchEvent(menuEvent);
|
||||
}}
|
||||
>
|
||||
<Icon name='EllipsisVertical' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={onOpenNewTab}>
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
className='mr-2'
|
||||
/>
|
||||
Open in new tab
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={onCopy}>
|
||||
<Icon
|
||||
name='Copy'
|
||||
className='mr-2'
|
||||
/>
|
||||
Copy link
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={onDownload}
|
||||
disabled={data.mimeType.includes("folder")}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-2'
|
||||
/>
|
||||
Download
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{/* <ResponsiveDialog
|
||||
open={isShareDialogOpen}
|
||||
onOpenChange={setShareDialogOpen}
|
||||
>
|
||||
<ResponsiveDialogContent>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>Share link</ResponsiveDialogTitle>
|
||||
<ResponsiveDialogDescription>
|
||||
Share the link with others to give them access to this file.
|
||||
</ResponsiveDialogDescription>
|
||||
</ResponsiveDialogHeader>
|
||||
|
||||
<ResponsiveDialogBody className='flex flex-col gap-4'>
|
||||
<pre className='flex w-full items-center justify-between gap-2 rounded-md bg-muted p-2 text-muted-foreground'>
|
||||
<code className='grow select-all text-sm'>{new URL(filePath, config.basePath).toString()}</code>
|
||||
<LoadingButton
|
||||
loading={copyStatus === "loading"}
|
||||
size={"icon"}
|
||||
variant={"ghost"}
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Icon
|
||||
name={copyStatus === "idle" ? "Copy" : copyStatus === "success" ? "Check" : "X"}
|
||||
className={cn(
|
||||
"transition",
|
||||
copyStatus === "success" && "stroke-green-600",
|
||||
copyStatus === "error" && "stroke-destructive",
|
||||
)}
|
||||
/>
|
||||
</LoadingButton>
|
||||
</pre>
|
||||
|
||||
<Separator className='bg-input' />
|
||||
|
||||
<div className='grid gap-2 tablet:grid-cols-4'>
|
||||
{["facebook", "twitter", "whatsapp"].map((platform) => (
|
||||
<Link
|
||||
key={platform}
|
||||
href={shareUrl[platform as keyof typeof shareUrl]!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={cn("flex items-center justify-center gap-2 rounded-md bg-muted p-2", "hover:bg-muted/50")}
|
||||
>
|
||||
<span>{platform}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</ResponsiveDialogBody>
|
||||
|
||||
<ResponsiveDialogFooter>
|
||||
<ResponsiveDialogClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</ResponsiveDialogClose>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { FileItem } from "~/components/explorer";
|
||||
import { Status } from "~/components/global";
|
||||
import { PageLoader } from "~/components/layout";
|
||||
import { Button, LoadingButton } from "~/components/ui/button";
|
||||
import Icon from "~/components/ui/icon";
|
||||
|
||||
import { useLayout } from "~/context/layoutContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import { ListFiles } from "~/actions/files";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
encryptedId: string;
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
showBackButton?: boolean;
|
||||
};
|
||||
export default function FileExplorerLayout({ encryptedId, files, nextPageToken, showBackButton }: Props) {
|
||||
const { layout, isPending } = useLayout();
|
||||
const loading = useLoading();
|
||||
const pathname = usePathname();
|
||||
const prevPath = useMemo<string>(() => {
|
||||
const path = pathname.split("/").slice(0, -1).join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const [filesList, setFilesList] = useState<z.infer<typeof Schema_File>[]>(files);
|
||||
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
|
||||
const [isLoadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
|
||||
const onLoadMore = async () => {
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
if (!nextToken) throw new Error("No more files to load");
|
||||
const data = await ListFiles({ id: encryptedId, pageToken: nextToken });
|
||||
if (!data.success) throw new Error(data.error);
|
||||
|
||||
const uniqueData = [...filesList, ...data.data.files].filter(
|
||||
(item, index, self) => index === self.findIndex((i) => i.encryptedId === item.encryptedId),
|
||||
);
|
||||
|
||||
setFilesList(uniqueData);
|
||||
setNextToken(data.data.nextPageToken ?? undefined);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(`[OnLoadMore] ${e.message}`);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || isPending) return <PageLoader message='Loading files...' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='explorer'
|
||||
className={cn("mx-auto w-full max-w-screen-desktop", "flex flex-grow-0 flex-col gap-4 px-2 py-4")}
|
||||
>
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
asChild
|
||||
>
|
||||
<Link href={prevPath}>
|
||||
<Icon
|
||||
name='CornerLeftUp'
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!filesList.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='There are no files here'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
slot='file-container'
|
||||
className={cn(
|
||||
"w-full",
|
||||
layout === "list"
|
||||
? "flex flex-col gap-2"
|
||||
: "grid grid-cols-1 gap-2 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{filesList.map((file) => (
|
||||
<div
|
||||
slot='item'
|
||||
key={file.encryptedId}
|
||||
className='group'
|
||||
>
|
||||
<FileItem
|
||||
data={file}
|
||||
layout={layout}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{nextToken && (
|
||||
<LoadingButton
|
||||
className='w-full'
|
||||
loading={isLoadingMore}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
Load more files
|
||||
</LoadingButton>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,17 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Markdown } from "~/components/Global";
|
||||
import { Markdown } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
title: string;
|
||||
};
|
||||
export default function Readme({ content, title }: Props) {
|
||||
export default function FileReadme({ content, title }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
|
||||
return (
|
||||
@@ -25,11 +24,11 @@ export default function Readme({ content, title }: Props) {
|
||||
<CardHeader className='pb-0'>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 overflow-hidden",
|
||||
"flex flex-col gap-4 overflow-hidden",
|
||||
"mobile:flex-row mobile:items-center mobile:justify-between",
|
||||
)}
|
||||
>
|
||||
<h3 className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>{title}</h3>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<div className='flex w-full items-center mobile:w-fit'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
@@ -49,9 +48,8 @@ export default function Readme({ content, title }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<CardContent className='p-1.5 px-3 pt-0 tablet:p-3 tablet:px-6 tablet:pt-0'>
|
||||
<Markdown
|
||||
content={content}
|
||||
view={view}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as FileActions } from "./FileActions";
|
||||
export { default as FileBreadcrumb } from "./FileBreadcrumbs";
|
||||
export { default as FileItem } from "./FileItem";
|
||||
export { default as FileExplorerLayout } from "./FileLayout";
|
||||
export { default as FileReadme } from "./FileReadme";
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { type SpecialComponents } from "react-markdown/lib/ast-to-react";
|
||||
import { type NormalComponents } from "react-markdown/lib/complex-types";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypePrism from "rehype-prism-plus";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkSlug from "remark-slug";
|
||||
import remarkToc from "remark-toc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import Icon from "~/components/ui/icon";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
view?: "markdown" | "raw";
|
||||
className?: string;
|
||||
customComponents?: Partial<Omit<NormalComponents, keyof SpecialComponents> & SpecialComponents>;
|
||||
};
|
||||
export default function Markdown({ content, view, className, customComponents }: Props) {
|
||||
const [viewState, setViewState] = useState<"markdown" | "raw">(view ?? "markdown");
|
||||
|
||||
useEffect(() => {
|
||||
if (view) setViewState(view);
|
||||
}, [view]);
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='markdown-container'
|
||||
className={cn("flex flex-col py-3", className)}
|
||||
>
|
||||
{viewState === "raw" ? (
|
||||
<div className={cn("markdown w-full rounded-lg bg-background")}>
|
||||
<pre className='w-full whitespace-pre-wrap break-words bg-transparent p-0 text-sm tablet:p-2'>{content}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("markdown w-full rounded-lg")}>
|
||||
<ReactMarkdown
|
||||
className='prose w-full !max-w-none px-0 dark:prose-invert tablet:px-2'
|
||||
disallowedElements={["script"]}
|
||||
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc, remarkBreaks]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw, [rehypePrism, { ignoreMissing: true }]]}
|
||||
components={{
|
||||
img: ({ src, alt, className, ...props }) => {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn("mx-auto max-w-screen-md cursor-pointer rounded-lg", className)}
|
||||
onClick={() => {
|
||||
window.open(src, "_blank");
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{alt && <figcaption className='mt-1 text-center text-sm text-muted-foreground'>{alt}</figcaption>}
|
||||
</>
|
||||
);
|
||||
},
|
||||
a: ({ href, target, rel, className, ...props }) => {
|
||||
if (!href) return null;
|
||||
const isRelative = href.startsWith("/");
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isRelative ? target : "_blank"}
|
||||
rel={isRelative ? rel : "noreferer"}
|
||||
className={cn("!whitespace-pre-wrap !break-words", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
p: ({ children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='paragraph text-pretty'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
pre: PreComponent,
|
||||
code: ({ inline: _inline, className, children, ...props }) => (
|
||||
<code
|
||||
className={cn(`!whitespace-pre-wrap break-words font-mono !text-sm`, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
...customComponents,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreComponent(props: React.HTMLAttributes<HTMLPreElement>) {
|
||||
const codeRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">("idle");
|
||||
const copyTimeout = useRef<NodeJS.Timeout>();
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!codeRef.current) return;
|
||||
if (copyStatus !== "idle") return;
|
||||
if (copyTimeout.current) clearTimeout(copyTimeout.current);
|
||||
|
||||
const toastId = `copy-${Math.random() * 1000}`;
|
||||
toast.loading("Copying content to clipboard...", {
|
||||
id: toastId,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!codeRef.current.textContent) throw new Error("No content to copy");
|
||||
await navigator.clipboard.writeText(codeRef.current.textContent);
|
||||
setCopyStatus("copied");
|
||||
copyTimeout.current = setTimeout(() => {
|
||||
setCopyStatus("idle");
|
||||
}, 2000);
|
||||
toast.success("Code copied to clipboard", {
|
||||
id: toastId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setCopyStatus("error");
|
||||
copyTimeout.current = setTimeout(() => {
|
||||
setCopyStatus("idle");
|
||||
}, 2000);
|
||||
toast.error("Failed to copy code to clipboard", {
|
||||
id: toastId,
|
||||
});
|
||||
}
|
||||
}, [copyStatus]);
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full`}>
|
||||
<div className={`transition-smooth absolute right-0 top-0 p-1.5`}>
|
||||
<div
|
||||
className='group cursor-pointer rounded-lg bg-background p-1.5 opacity-75 transition duration-300 ease-in-out hover:opacity-100'
|
||||
data-state={copyStatus}
|
||||
onClick={() => {
|
||||
handleCopy().catch(() => {
|
||||
toast.error("Failed to copy code to clipboard");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name='Copy'
|
||||
className='hidden size-4 group-data-[state=idle]:block'
|
||||
/>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='hidden size-4 stroke-green-600 group-data-[state=copied]:block dark:stroke-green-400'
|
||||
/>
|
||||
<Icon
|
||||
name='X'
|
||||
className='hidden size-4 stroke-destructive group-data-[state=error]:block'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
ref={codeRef}
|
||||
{...props}
|
||||
className='overflow-x-auto border border-border shadow shadow-background'
|
||||
style={{
|
||||
...props,
|
||||
backgroundColor: "var(--syntax-bg) !important",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { type icons } from "lucide-react";
|
||||
|
||||
import Icon from "~/components/ui/icon";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
icon: keyof typeof icons;
|
||||
iconClassName?: string;
|
||||
message?: string;
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
export default function Status({ icon, iconClassName, message, destructive }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-fit min-h-[33dvh] w-full",
|
||||
"flex flex-col items-center justify-center gap-2",
|
||||
destructive ? "stroke-destructive text-destructive" : "stroke-muted-foreground text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={icon}
|
||||
className={cn("size-10", iconClassName)}
|
||||
/>
|
||||
<p>{message ?? "Something went wrong"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Markdown } from "./Markdown";
|
||||
export { default as Status } from "./Status";
|
||||
@@ -0,0 +1,668 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import Icon from "~/components/ui/icon";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { FormColumn, type FormProps, FormSection } from "./ConfiguratorPage";
|
||||
|
||||
export default function ApiForm({ form, onResetField }: FormProps) {
|
||||
const [inputHiddenFile, setInputHiddenFile] = useState<string>("");
|
||||
function onHiddenFileSubmit() {
|
||||
if (!inputHiddenFile) {
|
||||
toast.error("File name cannot be empty");
|
||||
setInputHiddenFile("");
|
||||
return;
|
||||
}
|
||||
const prevValue = form.watch("api.hiddenFiles");
|
||||
const value = inputHiddenFile.trim();
|
||||
if (prevValue.some((v) => v.trim() === value)) {
|
||||
toast.error("File name already exists in the hidden files list");
|
||||
setInputHiddenFile("");
|
||||
return;
|
||||
}
|
||||
form.setValue("api.hiddenFiles", [...form.watch("api.hiddenFiles"), inputHiddenFile], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setInputHiddenFile("");
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection
|
||||
title='API Configuration'
|
||||
description='Configure how your index backend works'
|
||||
>
|
||||
<FormColumn>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.cache.public'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.cache.public");
|
||||
}}
|
||||
>
|
||||
Public Cache
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ? "true" : "false"}
|
||||
onValueChange={(value) => field.onChange(value === "true")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select option' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>Enable (Recommended)</SelectItem>
|
||||
<SelectItem value='false'>Disable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Enable public cache for the index.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.cache.staleWhileRevalidate'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.cache.staleWhileRevalidate");
|
||||
}}
|
||||
>
|
||||
Stale While Revalidate
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ? "true" : "false"}
|
||||
onValueChange={(value) => field.onChange(value === "true")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select option' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>Enable (Recommended)</SelectItem>
|
||||
<SelectItem value='false'>Disable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Return stale data before requesting fresh data.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.cache.maxAge'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.cache.maxAge");
|
||||
}}
|
||||
>
|
||||
Max Age
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>How long should the cache in browser last.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.cache.sMaxAge'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.cache.sMaxAge");
|
||||
}}
|
||||
>
|
||||
Shared Max Age
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>How long should the cache in server last.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormColumn>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.rootFolder'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.rootFolder");
|
||||
}}
|
||||
>
|
||||
Root Folder ID
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Unencrypted folder ID'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Google Drive folder ID to be used as the root folder.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.sharedDrive'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<div className='inline-flex w-full items-center justify-between gap-4 tablet:justify-start'>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.sharedDrive");
|
||||
form.setValue("api.isTeamDrive", false);
|
||||
}}
|
||||
>
|
||||
Shared Drive ID
|
||||
</FormLabel>
|
||||
<Switch
|
||||
checked={form.getValues("api.isTeamDrive")}
|
||||
onCheckedChange={(value) => {
|
||||
form.setValue("api.isTeamDrive", value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
form.watch("api.isTeamDrive") ? "Unencrypted shared drive ID" : "Switch to use shared drive"
|
||||
}
|
||||
disabled={!form.watch("api.isTeamDrive")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Google Drive Shared Drive ID.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormColumn>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.itemsPerPage'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.itemsPerPage");
|
||||
}}
|
||||
>
|
||||
Items Per Page
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Number of items to show before pagination.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.searchResult'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.searchResult");
|
||||
}}
|
||||
>
|
||||
Search Result Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Number of search results to show.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormColumn>
|
||||
<FormColumn column={3}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.specialFile.banner'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.specialFile.banner");
|
||||
}}
|
||||
>
|
||||
Banner File
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='.banner.jpg'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Will be used as the banner if found in the folder.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.specialFile.password'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.specialFile.password");
|
||||
}}
|
||||
>
|
||||
Password File
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='.password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Will be used to protect the folder.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.specialFile.readme'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.specialFile.readme");
|
||||
}}
|
||||
>
|
||||
Readme File
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='.readme.md'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Will be used as the description if found in the folder.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormColumn>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.hiddenFiles'
|
||||
render={({ fieldState }) => {
|
||||
const watch = form.watch("api.hiddenFiles");
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.hiddenFiles");
|
||||
}}
|
||||
>
|
||||
Hidden Files
|
||||
</FormLabel>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{watch.length ? (
|
||||
<>
|
||||
<div className='flex grow flex-wrap gap-1'>
|
||||
{watch.map((name, index) => (
|
||||
<Badge
|
||||
key={`name-${index}`}
|
||||
className='cursor-pointer'
|
||||
variant={"secondary"}
|
||||
onClick={() => {
|
||||
form.setValue(
|
||||
"api.hiddenFiles",
|
||||
watch.filter((_, i) => i !== index),
|
||||
{
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
<span className='text-[0.8rem] text-muted-foreground'>Click to remove hidden file</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className='text-[0.8rem] text-destructive'>
|
||||
All files including special files are visible.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col items-center gap-2 tablet:flex-row'>
|
||||
<Input
|
||||
placeholder='Press enter to add to hidden files'
|
||||
value={inputHiddenFile}
|
||||
onChange={(e) => setInputHiddenFile(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onHiddenFileSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
type='button'
|
||||
className='w-full tablet:w-fit'
|
||||
onClick={onHiddenFileSubmit}
|
||||
>
|
||||
<Icon name='Plus' />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge
|
||||
variant={watch.includes(form.watch("api.specialFile.banner")) ? "outline" : "destructive"}
|
||||
className={cn(
|
||||
"inline-flex gap-1",
|
||||
watch.includes(form.watch("api.specialFile.banner")) ? "cursor-not-allowed" : "cursor-pointer",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (watch.includes(form.watch("api.specialFile.banner"))) {
|
||||
return;
|
||||
}
|
||||
form.setValue("api.hiddenFiles", [...watch, form.watch("api.specialFile.banner")]);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={watch.includes(form.watch("api.specialFile.banner")) ? "Check" : "X"}
|
||||
className={
|
||||
watch.includes(form.watch("api.specialFile.banner"))
|
||||
? "stroke-green-600 dark:stroke-green-400"
|
||||
: "stroke-destructive-foreground"
|
||||
}
|
||||
/>
|
||||
{watch.includes(form.watch("api.specialFile.banner"))
|
||||
? "Banner file is hidden"
|
||||
: "Add banner file to the list"}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant={watch.includes(form.watch("api.specialFile.password")) ? "outline" : "destructive"}
|
||||
className={cn(
|
||||
"inline-flex gap-1",
|
||||
watch.includes(form.watch("api.specialFile.password")) ? "cursor-not-allowed" : "cursor-pointer",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (watch.includes(form.watch("api.specialFile.password"))) {
|
||||
return;
|
||||
}
|
||||
form.setValue("api.hiddenFiles", [...watch, form.watch("api.specialFile.password")]);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={watch.includes(form.watch("api.specialFile.password")) ? "Check" : "X"}
|
||||
className={
|
||||
watch.includes(form.watch("api.specialFile.password"))
|
||||
? "stroke-green-600 dark:stroke-green-400"
|
||||
: "stroke-destructive-foreground"
|
||||
}
|
||||
/>
|
||||
{watch.includes(form.watch("api.specialFile.password"))
|
||||
? "Password file is hidden"
|
||||
: "Add password file to the list"}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant={watch.includes(form.watch("api.specialFile.readme")) ? "outline" : "destructive"}
|
||||
className={cn(
|
||||
"inline-flex gap-1",
|
||||
watch.includes(form.watch("api.specialFile.readme")) ? "cursor-not-allowed" : "cursor-pointer",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (watch.includes(form.watch("api.specialFile.readme"))) {
|
||||
return;
|
||||
}
|
||||
form.setValue("api.hiddenFiles", [...watch, form.watch("api.specialFile.readme")]);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={watch.includes(form.watch("api.specialFile.readme")) ? "Check" : "X"}
|
||||
className={
|
||||
watch.includes(form.watch("api.specialFile.readme"))
|
||||
? "stroke-green-600 dark:stroke-green-400"
|
||||
: "stroke-destructive-foreground"
|
||||
}
|
||||
/>
|
||||
{watch.includes(form.watch("api.specialFile.readme"))
|
||||
? "Readme file is hidden"
|
||||
: "Add readme file to the list"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>Click the badge to add special files to the hidden files list.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.proxyThumbnail'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.proxyThumbnail");
|
||||
}}
|
||||
>
|
||||
Proxy Thumbnail
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ? "true" : "false"}
|
||||
onValueChange={(value) => field.onChange(value === "true")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select option' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>Proxy Thumbnail (Recommended)</SelectItem>
|
||||
<SelectItem value='false'>Use GDrive Thumbnail</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Serve thumbnail through API route to avoid CORS issue.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormColumn>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.streamMaxSize'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.streamMaxSize");
|
||||
}}
|
||||
>
|
||||
Preview Max Size
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
value={field.value / (1024 * 1024)}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value ?? "0");
|
||||
form.setValue("api.streamMaxSize", value * 1024 * 1024, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum file size to be previewed in the browser in MB. Larger file won't be previewed.
|
||||
<br />
|
||||
<span className='text-destructive'>Will count towards the deployment bandwidth usage.</span>{" "}
|
||||
<b>Set to 0 to disable the limit</b>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.maxFileSize'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.maxFileSize");
|
||||
}}
|
||||
>
|
||||
Max File Size
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
value={field.value / (1024 * 1024)}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value ?? "0");
|
||||
form.setValue("api.maxFileSize", value * 1024 * 1024, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum file size that can be downloaded via API route. Larger file will be using GDrive link.
|
||||
<br />
|
||||
<span className='text-destructive'>Will count towards the deployment bandwidth usage.</span>{" "}
|
||||
<b>Set to 0 to disable the limit</b>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.allowDownloadProtectedFile'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.allowDownloadProtectedFile");
|
||||
}}
|
||||
>
|
||||
Download Protected File
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ? "true" : "false"}
|
||||
onValueChange={(value) => field.onChange(value === "true")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select option' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>Allow Download</SelectItem>
|
||||
<SelectItem value='false'>Disallow Download (Recommended)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Allow download for file inside protected folder.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='api.temporaryTokenDuration'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("api.temporaryTokenDuration");
|
||||
}}
|
||||
>
|
||||
Temporary Token Duration
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Duration for temporary token in hours. Token will be used for protected folder download.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormColumn>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { type ChangeEvent, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { LoadingButton } from "~/components/ui/button";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
import { GenerateServiceAccountB64 } from "~/actions/configuration";
|
||||
|
||||
import { type FormProps, FormSection } from "./ConfiguratorPage";
|
||||
|
||||
export default function EnvironmentForm({ onResetField, form }: FormProps) {
|
||||
const serviceInputRef = useRef<HTMLInputElement>(null);
|
||||
const [serviceInputLoading, setServiceInputLoading] = useState<boolean>(false);
|
||||
|
||||
function onLoadServiceAccount(e: ChangeEvent<HTMLInputElement>) {
|
||||
setServiceInputLoading(true);
|
||||
toast.loading("Processing service account file", {
|
||||
id: "service-account",
|
||||
});
|
||||
|
||||
try {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) throw new Error("No file selected");
|
||||
|
||||
const fr = new FileReader();
|
||||
fr.onload = async () => {
|
||||
if (!fr.result) throw new Error("Failed to read file content");
|
||||
const stringResult = typeof fr.result === "object" ? JSON.stringify(fr.result) : String(fr.result);
|
||||
const b64 = await GenerateServiceAccountB64(stringResult);
|
||||
if (!b64.success) throw new Error(b64.error);
|
||||
|
||||
form.setValue("environment.GD_SERVICE_B64", b64.data);
|
||||
toast.success("Service account encoded", {
|
||||
id: "service-account",
|
||||
});
|
||||
};
|
||||
fr.readAsText(file);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error("Failed to load service account file", {
|
||||
description: e.message,
|
||||
id: "service-account",
|
||||
});
|
||||
} finally {
|
||||
e.target.value = ""; // Reset file input wether success or not
|
||||
setServiceInputLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection
|
||||
title='Environment'
|
||||
description='Configure your environment variables'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='environment.GD_SERVICE_B64'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("environment.GD_SERVICE_B64");
|
||||
}}
|
||||
>
|
||||
Service Account
|
||||
</FormLabel>
|
||||
<div className='flex flex-col gap-2 tablet:flex-row tablet:items-center tablet:justify-between'>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Encoded base64 will be here'
|
||||
readOnly
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<input
|
||||
ref={serviceInputRef}
|
||||
type='file'
|
||||
hidden
|
||||
accept='.json'
|
||||
onChange={onLoadServiceAccount}
|
||||
/>
|
||||
<LoadingButton
|
||||
loading={serviceInputLoading}
|
||||
size={"sm"}
|
||||
onClick={() => serviceInputRef.current?.click()}
|
||||
type='button'
|
||||
>
|
||||
Load JSON
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<FormDescription>Load your service account JSON file to get the base64 encoded string.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='environment.ENCRYPTION_KEY'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("environment.ENCRYPTION_KEY");
|
||||
}}
|
||||
>
|
||||
Encryption Key
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='jugemu-jugemu-gokō-no-surikire-kaijarisuigyo-no-suigyōmatsu-unraimatsu-fūraimatsu-kūneru-tokoro-ni-sumu-tokoro-yaburakōji-no-burakōji-paipopaipo-paiponoshūringan-shūringanno-gūrindai-gūrindaino-ponpokopīno-ponpokonāno-chōkyūmei-no-chōsuke'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Secret encryption key to protect sensitive data.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='environment.SITE_PASSWORD'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("environment.SITE_PASSWORD");
|
||||
}}
|
||||
>
|
||||
Private Index Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder="I swear it's not admin123"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Will be used if you set the index to private.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='environment.NEXT_PUBLIC_DOMAIN'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("environment.NEXT_PUBLIC_DOMAIN");
|
||||
}}
|
||||
>
|
||||
Site Domain
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='acme.com / hey.acme.com'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The domain for the site, without the protocol.
|
||||
<br />
|
||||
<b>Needed if you deploy outside of Vercel.</b>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useFieldArray } from "react-hook-form";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { VirtualizedCombobox } from "~/components/ui/combobox.virtualized";
|
||||
import {
|
||||
ResponsiveDialog,
|
||||
ResponsiveDialogBody,
|
||||
ResponsiveDialogClose,
|
||||
ResponsiveDialogContent,
|
||||
ResponsiveDialogDescription,
|
||||
ResponsiveDialogFooter,
|
||||
ResponsiveDialogHeader,
|
||||
ResponsiveDialogTitle,
|
||||
ResponsiveDialogTrigger,
|
||||
} from "~/components/ui/dialog.responsive";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import Icon, { IconNamesArray } from "~/components/ui/icon";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
import { useResponsive } from "~/context/responsiveContext";
|
||||
import { cn, formatFooterContent } from "~/lib/utils";
|
||||
|
||||
import { type Schema_App_Configuration } from "~/types/schema";
|
||||
|
||||
import { FormColumn, type FormProps, FormSection } from "./ConfiguratorPage";
|
||||
|
||||
export default function SiteForm({ form, onResetField }: FormProps) {
|
||||
return (
|
||||
<FormSection
|
||||
title='Site Configuration'
|
||||
description='Configure how your site looks and behaves'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.privateIndex'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.privateIndex");
|
||||
}}
|
||||
>
|
||||
Private Index
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ? "true" : "false"}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value === "true");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select option' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>Enable</SelectItem>
|
||||
<SelectItem value='false'>Disable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enable to require a password to access the site.{" "}
|
||||
<b>Make sure to set a password in the environment section.</b>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.guideButton'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.guideButton");
|
||||
}}
|
||||
>
|
||||
Internal Menu
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ? "true" : "false"}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value === "true");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select option' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>Show</SelectItem>
|
||||
<SelectItem value='false'>Hide (Recommended)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Show internal menu on the navbar.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormColumn>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.siteName'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.siteName");
|
||||
}}
|
||||
>
|
||||
Site Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='My Awesome Index'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Site name for meta and navbar.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.siteNameTemplate'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.siteNameTemplate");
|
||||
}}
|
||||
>
|
||||
Site Name Template
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='%s - %t'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Template for the site name. <code className='font-semibold'>%t</code> for site name and{" "}
|
||||
<code className='font-semibold'>%s</code> for page title.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='col-span-full rounded-lg border p-4 shadow'>
|
||||
<span className='w-full text-center text-base font-semibold'>
|
||||
{(form.watch("site.siteNameTemplate") ?? "No template")
|
||||
.replace("%t", form.watch("site.siteName") ?? "next-gdrive-index")
|
||||
.replace("%s", "Page Title Goes Here")}
|
||||
</span>
|
||||
</div>
|
||||
</FormColumn>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.siteDescription'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.siteDescription");
|
||||
}}
|
||||
>
|
||||
Site Description
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='A simple file browser for Google Drive with awesome features'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Site description to be displayed on the metadata.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormColumn>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.siteAuthor'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.siteAuthor");
|
||||
}}
|
||||
>
|
||||
Site Author
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='mbaharip'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Site author to be displayed on the metadata, and used for the footer.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.twitterHandle'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.twitterHandle");
|
||||
}}
|
||||
>
|
||||
X (Twitter) Handle
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='mbaharip'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>X (Twitter) handle to be used on the metadata, and for the footer.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormColumn>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.robots'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.robots");
|
||||
}}
|
||||
>
|
||||
Robots Meta
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Robots meta tag for search engine.{" "}
|
||||
<Link
|
||||
href={"https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag#directives"}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 dark:text-blue-400'
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.breadcrumbMax'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.breadcrumbMax");
|
||||
}}
|
||||
>
|
||||
Max Breadcrumbs Item
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Maximum number of breadcrumbs item before it's truncated.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormColumn>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.toaster.duration'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.toaster.duration");
|
||||
}}
|
||||
>
|
||||
Toaster Duration
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Duration in milliseconds for the toaster to be displayed.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='site.toaster.position'
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
className='grow tablet:grow-0'
|
||||
resetDisabled={!fieldState.isDirty}
|
||||
onFieldReset={() => {
|
||||
onResetField?.("site.toaster.position");
|
||||
}}
|
||||
>
|
||||
Toaster Position
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select option' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='top-right'>Top Right</SelectItem>
|
||||
<SelectItem value='top-left'>Top Left</SelectItem>
|
||||
<SelectItem value='bottom-right'>Bottom Right</SelectItem>
|
||||
<SelectItem value='bottom-left'>Bottom Left</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Position of the toaster.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
className='col-span-full w-full'
|
||||
variant={"secondary"}
|
||||
onClick={() => {
|
||||
toast.info("This is a test toaster", {
|
||||
position: form.watch("site.toaster.position"),
|
||||
duration: form.watch("site.toaster.duration"),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon name='Megaphone' />
|
||||
Test Toaster
|
||||
</Button>
|
||||
</FormColumn>
|
||||
<NavbarItemsField
|
||||
form={form}
|
||||
onResetField={onResetField}
|
||||
/>
|
||||
<SupportsField
|
||||
form={form}
|
||||
onResetField={onResetField}
|
||||
/>
|
||||
<FooterField
|
||||
form={form}
|
||||
onResetField={onResetField}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
function NavbarItemsField({ form, onResetField }: FormProps) {
|
||||
const { isDesktop } = useResponsive();
|
||||
const { append, fields, remove } = useFieldArray<z.infer<typeof Schema_App_Configuration>>({
|
||||
control: form.control,
|
||||
name: "site.navbarItems",
|
||||
});
|
||||
const iconItems = useMemo<{ label: React.ReactNode; value: string }[]>(
|
||||
() =>
|
||||
IconNamesArray.map((icon) => ({
|
||||
label: (
|
||||
<div className='inline-flex grow items-center justify-between gap-2'>
|
||||
<span className='line-clamp-1 break-all'>{icon}</span>
|
||||
<Icon
|
||||
name={icon}
|
||||
hideWrapper
|
||||
className='shrink-0'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
value: icon,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2 rounded-lg border px-4 py-2 shadow'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
|
||||
<Label>Navbar Items</Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant={"ghost"}
|
||||
disabled={!form.getFieldState("site.navbarItems").isDirty}
|
||||
onClick={() => {
|
||||
onResetField?.(`site.navbarItems`);
|
||||
}}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name='RefreshCcw'
|
||||
className='stroke-inherit'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{fields.length === 0 ? (
|
||||
<div className='w-full py-8 text-center text-sm font-semibold text-muted-foreground'>
|
||||
No extra navbar items, add one to show extra links on the navbar!
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
className='flex flex-col gap-4'
|
||||
key={field.id}
|
||||
>
|
||||
<div className='flex w-full flex-col gap-4 tablet:flex-row tablet:items-end'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`site.navbarItems.${index}.icon`}
|
||||
render={({ field }) => (
|
||||
<FormItem disableBorder>
|
||||
<FormLabel>Icon</FormLabel>
|
||||
<FormControl>
|
||||
<VirtualizedCombobox
|
||||
minWidth={isDesktop ? "300px" : "100%"}
|
||||
maxWidth={isDesktop ? "300px" : "100%"}
|
||||
options={iconItems}
|
||||
searchPlaceholder='Search icon...'
|
||||
selectedOption={field.value}
|
||||
onSelectOption={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`site.navbarItems.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
disableBorder
|
||||
className='grow'
|
||||
>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Navigation name'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='inline-flex w-full flex-col gap-4 tablet:flex-row tablet:items-end'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`site.navbarItems.${index}.href`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
disableBorder
|
||||
className='grow'
|
||||
>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='/path/to/page'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`site.navbarItems.${index}.external`}
|
||||
render={({ field }) => (
|
||||
<FormItem disableBorder>
|
||||
<FormControl>
|
||||
<Button
|
||||
type='button'
|
||||
variant={field.value ? "default" : "secondary"}
|
||||
name={field.name}
|
||||
disabled={field.disabled}
|
||||
onClick={() => {
|
||||
field.onChange(!field.value);
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
className={cn(
|
||||
"w-full transition tablet:w-fit",
|
||||
field.value ? "opacity-100" : "opacity-30",
|
||||
)}
|
||||
>
|
||||
External Link
|
||||
</Button>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className='w-full'
|
||||
type='button'
|
||||
variant='outline-destructive'
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Icon name='X' />
|
||||
Delete Item
|
||||
</Button>
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
className='w-full'
|
||||
onClick={() => append({ icon: "Link", name: "New Item", href: "/new-item", external: false })}
|
||||
>
|
||||
<Icon name='Plus' />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function SupportsField({ form, onResetField }: FormProps) {
|
||||
const { append, fields, remove } = useFieldArray<z.infer<typeof Schema_App_Configuration>>({
|
||||
control: form.control,
|
||||
name: "site.supports",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2 rounded-lg border px-4 py-2 shadow'>
|
||||
<div className='w-full space-y-2'>
|
||||
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
|
||||
<Label>Supports / Donations</Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant={"ghost"}
|
||||
disabled={!form.getFieldState("site.supports").isDirty}
|
||||
onClick={() => {
|
||||
onResetField?.(`site.supports`);
|
||||
}}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name='RefreshCcw'
|
||||
className='stroke-inherit'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{fields.length === 0 ? (
|
||||
<div className='w-full py-8 text-center text-sm font-semibold text-muted-foreground'>
|
||||
No support items, add one to show supports link on the navbar!
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
className='flex flex-col gap-4'
|
||||
key={field.id}
|
||||
>
|
||||
<div className='flex w-full flex-col gap-4 tablet:flex-row tablet:items-end'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`site.supports.${index}.currency`}
|
||||
render={({ field }) => (
|
||||
<FormItem disableBorder>
|
||||
<FormLabel>Currency</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Currency'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`site.supports.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
disableBorder
|
||||
className='grow'
|
||||
>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Service name'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='inline-flex w-full flex-col gap-4 tablet:flex-row '>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`site.supports.${index}.href`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
disableBorder
|
||||
className='grow'
|
||||
>
|
||||
<FormLabel>URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='/path/to/page'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className='w-full'
|
||||
type='button'
|
||||
variant='outline-destructive'
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Icon name='X' />
|
||||
Delete Item
|
||||
</Button>
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
type='button'
|
||||
onClick={() => append({ currency: "USD", name: "Paypal", href: "https://paypal.me/acme" })}
|
||||
>
|
||||
<Icon name='Plus' />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function FooterField({ form, onResetField }: FormProps) {
|
||||
const { append, fields, remove } = useFieldArray<z.infer<typeof Schema_App_Configuration>>({
|
||||
control: form.control,
|
||||
name: "site.footer",
|
||||
});
|
||||
const templates = useMemo(
|
||||
() => [
|
||||
{
|
||||
code: "version",
|
||||
description: "Show current version",
|
||||
},
|
||||
{
|
||||
code: "poweredBy",
|
||||
description: 'Show "Powered by next-gdrive-index", linked to the repository',
|
||||
},
|
||||
{
|
||||
code: "year",
|
||||
description: "Show current year",
|
||||
},
|
||||
{
|
||||
code: "repository",
|
||||
description: "Original repository (mbaharip/next-gdrive-index)",
|
||||
},
|
||||
{
|
||||
code: "creator",
|
||||
description: "mbaharip, the creator of next-gdrive-index",
|
||||
},
|
||||
{
|
||||
code: "author",
|
||||
description: "Site author from configuration",
|
||||
},
|
||||
{
|
||||
code: "siteName",
|
||||
description: "Site name from configuration",
|
||||
},
|
||||
{
|
||||
code: "handle",
|
||||
description: "Twitter handle from configuration",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const [content, setContent] = useState<string>(() => {
|
||||
return formatFooterContent(form.watch("site.footer"), form.getValues("site"));
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2 rounded-lg border px-4 py-2 shadow'>
|
||||
<div className='w-full space-y-4'>
|
||||
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
|
||||
<Label>Footer Items</Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant={"ghost"}
|
||||
disabled={!form.getFieldState("site.footer").isDirty}
|
||||
onClick={() => {
|
||||
onResetField?.(`site.footer`);
|
||||
}}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name='RefreshCcw'
|
||||
className='stroke-inherit'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{fields.length === 0 ? (
|
||||
<div className='w-full py-8 text-center text-sm font-semibold text-muted-foreground'>
|
||||
No support items, add one to show supports link on the navbar!
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={field.id}
|
||||
name={`site.footer.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem disableBorder>
|
||||
<div className='flex w-full items-center gap-4'>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='See description for templates'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type='button'
|
||||
variant={"outline-destructive"}
|
||||
size='icon'
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
<Icon name='X' />
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription className={cn(index !== fields.length - 1 && "sr-only")}>
|
||||
<span className='flex flex-col gap-2 tablet:flex-row tablet:items-center tablet:justify-between'>
|
||||
<span>
|
||||
Text to be displayed on the footer. <b>Markdown supported</b>
|
||||
</span>
|
||||
<ResponsiveDialog>
|
||||
<ResponsiveDialogTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
className='w-full tablet:ml-auto tablet:w-fit'
|
||||
>
|
||||
Click to see templates
|
||||
</Button>
|
||||
</ResponsiveDialogTrigger>
|
||||
<ResponsiveDialogContent>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>Template Guide</ResponsiveDialogTitle>
|
||||
<ResponsiveDialogDescription>
|
||||
Available templates for dynamic content
|
||||
</ResponsiveDialogDescription>
|
||||
</ResponsiveDialogHeader>
|
||||
<ResponsiveDialogBody>
|
||||
<ScrollArea
|
||||
className='h-fit w-full pr-4'
|
||||
type='always'
|
||||
>
|
||||
<div className='flex max-h-[50dvh] flex-col gap-2'>
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.code}
|
||||
className='flex flex-col rounded-lg border bg-background p-2 tablet:px-4'
|
||||
>
|
||||
<span className='font-semibold'>{`{{ ${template.code} }}`}</span>
|
||||
<span className='text-sm text-muted-foreground'>{template.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ResponsiveDialogBody>
|
||||
<ResponsiveDialogFooter>
|
||||
<ResponsiveDialogClose asChild>
|
||||
<Button>Close</Button>
|
||||
</ResponsiveDialogClose>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
</span>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
className='mt-2 w-full'
|
||||
onClick={() => {
|
||||
append({ value: "" });
|
||||
}}
|
||||
>
|
||||
<Icon name='Plus' />
|
||||
Add Item
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
<div className='space-y-4'>
|
||||
<div className='flex w-full flex-col items-center justify-center'>
|
||||
<ReactMarkdown
|
||||
className='flex w-full select-none flex-col items-center justify-center text-center'
|
||||
components={{
|
||||
p: ({ children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='muted text-balance text-sm'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ children, ...props }) => {
|
||||
const isExternal = props.href?.startsWith("http");
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={() => {
|
||||
setContent(formatFooterContent(form.watch("site.footer"), form.getValues("site")));
|
||||
}}
|
||||
>
|
||||
Reload Preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { type PropsWithChildren, useState } from "react";
|
||||
import { type FieldPath, type UseFormReturn, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button, LoadingButton } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Form } from "~/components/ui/form";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { type PickFileResponse, initialConfiguration, pickFile, versionExpectMap } from "~/lib/configurationHelper";
|
||||
|
||||
import { type ConfigurationCategory, Schema_App_Configuration } from "~/types/schema";
|
||||
|
||||
import { GenerateConfiguration, ProcessConfiguration, ProcessEnvironmentConfig } from "~/actions/configuration";
|
||||
|
||||
import {
|
||||
APIConfigurator as ApiForm,
|
||||
EnvironmentConfigurator as EnvironmentForm,
|
||||
SiteConfigurator as SiteForm,
|
||||
} from ".";
|
||||
|
||||
export default function ConfiguratorPage() {
|
||||
const [isLoadingEnv, setIsLoadingEnv] = useState<boolean>(false);
|
||||
const [isLoadingConfig, setIsLoadingConfig] = useState<boolean>(false);
|
||||
const form = useForm<z.infer<typeof Schema_App_Configuration>>({
|
||||
resolver: zodResolver(Schema_App_Configuration),
|
||||
defaultValues: initialConfiguration,
|
||||
});
|
||||
|
||||
function onReset(category: ConfigurationCategory | "all") {
|
||||
if (category === "all") {
|
||||
form.reset(initialConfiguration);
|
||||
} else {
|
||||
form.resetField(category);
|
||||
}
|
||||
toast.success("Form reverted to initial state");
|
||||
}
|
||||
async function onFormSubmit(values: z.infer<typeof Schema_App_Configuration>) {
|
||||
const id = `download-${Date.now()}`;
|
||||
toast.loading("Generating configuration...", {
|
||||
id,
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
const data = await GenerateConfiguration(values);
|
||||
if (!data.success) {
|
||||
toast.error(data.message, {
|
||||
id,
|
||||
description: data.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(data.data.zip);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${Date.now()}-gIndex.config.zip`;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
|
||||
toast.success("Configuration generated", {
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
async function onLoadEnv(response: PickFileResponse) {
|
||||
const id = `env-latest-${Date.now()}`;
|
||||
toast.loading("Waiting for file...", {
|
||||
id,
|
||||
duration: 0,
|
||||
});
|
||||
if (!response.success) {
|
||||
toast.error(response.message, {
|
||||
id,
|
||||
description: response.details.length ? (
|
||||
<pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>
|
||||
{response.details.join("\n")}
|
||||
</pre>
|
||||
) : undefined,
|
||||
});
|
||||
setIsLoadingEnv(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading("Processing environment file...", {
|
||||
id,
|
||||
duration: 0,
|
||||
});
|
||||
const data = await ProcessEnvironmentConfig(response.data);
|
||||
if (!data.success) {
|
||||
toast.error(data.message, {
|
||||
id,
|
||||
description: <pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>{data.error}</pre>,
|
||||
});
|
||||
setIsLoadingEnv(false);
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue("environment", data.data);
|
||||
|
||||
toast.success(data.message, {
|
||||
id,
|
||||
});
|
||||
setIsLoadingEnv(false);
|
||||
}
|
||||
async function onLoadConfig(response: PickFileResponse) {
|
||||
const id = `config-${Date.now()}`;
|
||||
toast.loading("Waiting for file...", {
|
||||
id,
|
||||
duration: 0,
|
||||
});
|
||||
if (!response.success) {
|
||||
toast.error(response.message, {
|
||||
id,
|
||||
description: response.details.length ? (
|
||||
<pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>
|
||||
{response.details.join("\n")}
|
||||
</pre>
|
||||
) : undefined,
|
||||
});
|
||||
setIsLoadingConfig(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedVersion = /version:\s*["']?(\d+\.\d+\.\d+)["']?/.exec(response.data)?.[1];
|
||||
if (!loadedVersion) {
|
||||
toast.error("Version not found in configuration file", {
|
||||
id,
|
||||
});
|
||||
setIsLoadingConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const versionGroup = Object.entries(versionExpectMap).find(([_, v]) => v.includes(loadedVersion))?.[0];
|
||||
if (!versionGroup) {
|
||||
toast.error("Version not recognized", {
|
||||
id,
|
||||
description: (
|
||||
<pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>
|
||||
{`Loaded version: ${loadedVersion}, not matching any known version`}
|
||||
</pre>
|
||||
),
|
||||
});
|
||||
setIsLoadingConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading(`Version ${loadedVersion} detected, processing...`, {
|
||||
id,
|
||||
duration: 0,
|
||||
});
|
||||
const data = await ProcessConfiguration(response.data, versionGroup as "v1" | "v2" | "latest");
|
||||
if (!data.success) {
|
||||
toast.error(data.message, {
|
||||
id,
|
||||
description: <pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>{data.error}</pre>,
|
||||
});
|
||||
setIsLoadingConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue("api", data.data.api);
|
||||
form.setValue("site", data.data.site);
|
||||
form.setValue("site.navbarItems", data.data.site.navbarItems);
|
||||
form.setValue("site.supports", data.data.site.supports);
|
||||
form.setValue("site.footer", data.data.site.footer);
|
||||
|
||||
toast.success(data.message, {
|
||||
id,
|
||||
});
|
||||
setIsLoadingConfig(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert variant={"primary"}>
|
||||
<AlertTitle>Theme customization is removed from the configurator.</AlertTitle>
|
||||
<AlertDescription>
|
||||
You can use website like{" "}
|
||||
<Link
|
||||
href={"https://themes.fkaya.dev/"}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-balance text-sm font-medium text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
themes.fkaya.dev
|
||||
</Link>
|
||||
,{" "}
|
||||
<Link
|
||||
href={"https://themeshadcn.com/"}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-balance text-sm font-medium text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
themeshadcn.com
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link
|
||||
href={"https://ui.jln.dev/"}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-balance text-sm font-medium text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
ui.jln.dev
|
||||
</Link>{" "}
|
||||
to generate theme configuration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='flex w-full flex-col gap-4 tablet:flex-row tablet:items-center tablet:justify-between'>
|
||||
<div className='flex grow flex-col space-y-1.5'>
|
||||
<CardTitle>Configurator</CardTitle>
|
||||
<CardDescription>Generate configurator for your index.</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-col items-center gap-2 tablet:flex-row-reverse'>
|
||||
<LoadingButton
|
||||
loading={isLoadingConfig}
|
||||
className='w-full tablet:w-fit'
|
||||
onClick={() => {
|
||||
setIsLoadingConfig(true);
|
||||
|
||||
pickFile({
|
||||
accept: ".ts",
|
||||
async onLoad(response) {
|
||||
await onLoadConfig(response);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Load Config
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
loading={isLoadingEnv}
|
||||
className='w-full tablet:w-fit'
|
||||
onClick={() => {
|
||||
setIsLoadingEnv(true);
|
||||
|
||||
pickFile({
|
||||
accept: ".env",
|
||||
async onLoad(response) {
|
||||
await onLoadEnv(response);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Load Env
|
||||
</LoadingButton>
|
||||
<Button
|
||||
className='w-full tablet:w-fit'
|
||||
variant={"destructive"}
|
||||
onClick={() => onReset("all")}
|
||||
>
|
||||
Reset All
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator className='mb-6' />
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
className='grid grid-cols-1'
|
||||
>
|
||||
<CardContent className='grid grid-cols-1 gap-8'>
|
||||
<EnvironmentForm
|
||||
form={form}
|
||||
onResetField={(field) => form.resetField(field)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ApiForm
|
||||
form={form}
|
||||
onResetField={(field) => form.resetField(field)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SiteForm
|
||||
form={form}
|
||||
onResetField={(field) => form.resetField(field)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<LoadingButton
|
||||
size={"lg"}
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={!form.formState.isValid || !form.formState.isDirty}
|
||||
type='submit'
|
||||
className='w-full'
|
||||
>
|
||||
Generate Configuration
|
||||
</LoadingButton>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type FormColumnProps = {
|
||||
column?: number;
|
||||
};
|
||||
export function FormColumn({ column = 2, children }: PropsWithChildren<FormColumnProps>) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-[--form-column]'
|
||||
style={
|
||||
{
|
||||
"--form-column": `repeat(${column}, minmax(0, 1fr))`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type FormSectionProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
export function FormSection({ title, description, children }: PropsWithChildren<FormSectionProps>) {
|
||||
return (
|
||||
<div
|
||||
id={title
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.replace(/-+/g, "-")}
|
||||
className='group grid grid-cols-1 gap-4'
|
||||
>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<h2 className='text-lg font-semibold'>{title}</h2>
|
||||
<p className='text-sm text-muted-foreground'>{description}</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type FormProps = {
|
||||
form: UseFormReturn<z.infer<typeof Schema_App_Configuration>>;
|
||||
onResetField?: (field: FieldPath<z.infer<typeof Schema_App_Configuration>>) => void;
|
||||
} & Omit<FormSectionProps, "title" | "description">;
|
||||
@@ -0,0 +1,731 @@
|
||||
"use client";
|
||||
|
||||
import "@vidstack/react/player/styles/base.css";
|
||||
import "@vidstack/react/player/styles/default/layouts/audio.css";
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css";
|
||||
import "@vidstack/react/player/styles/default/theme.css";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { type SpecialComponents } from "react-markdown/lib/ast-to-react";
|
||||
import { type NormalComponents } from "react-markdown/lib/complex-types";
|
||||
|
||||
import { Markdown } from "~/components/global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import Icon from "~/components/ui/icon";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { useResponsive } from "~/context/responsiveContext";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
type TocItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
};
|
||||
type GuideSection = "gettingStarted" | "newUser" | "migration" | "sharedDrive";
|
||||
|
||||
function generateToc(content: string): TocItem[] {
|
||||
const headings = content.match(/#{1,6}.+/g) ?? [];
|
||||
const tocItems = headings
|
||||
.map((heading) => {
|
||||
const match = /(#+)\s(.+)/.exec(heading);
|
||||
if (!match) return null;
|
||||
if (!match[1] || !match[2]) return null;
|
||||
const level = match[1].length;
|
||||
const title = match[2] ?? "";
|
||||
const id = title.toLowerCase().replace(/[^\w]+/g, "-");
|
||||
return { id, title, level };
|
||||
})
|
||||
.filter((item): item is TocItem => item !== null);
|
||||
|
||||
return tocItems;
|
||||
}
|
||||
const content: Readonly<Record<GuideSection, string>> = {
|
||||
gettingStarted: `Welcome to the deployment guide! This guide will assist you in deploying the project to Vercel or similar services.
|
||||
|
||||
If you are new, you can follow along from the beginning. However, if you have previously deployed the project and wish to upgrade from v1 or v2.x, you can proceed directly to the [Migration Guide](#migration) section.
|
||||
|
||||
You can also utilize the [next-gdrive-index configurator](/ngdi-internal/configurator) to generate configuration for your deployment!
|
||||
|
||||
This guide assumes that you have a fundamental understanding of how to deploy a Next.js application on Vercel or other platforms.
|
||||
|
||||
---
|
||||
|
||||
If you prefer a video tutorial, you can watch the video below to deploy the project.`,
|
||||
newUser: `For new user, this guide will provide step-by-step instructions on deploying the project to Vercel or similar services.
|
||||
Before you begin, you'll need to have the following:
|
||||
|
||||
- A Github account
|
||||
- A Google Cloud Platform account
|
||||
- A Vercel (or similar service) account
|
||||
- ~Basic understanding of Next.js and common sense~
|
||||
|
||||
---
|
||||
|
||||
### Fork and clone the repository
|
||||
|
||||
As a first step, you'll need to fork and clone the repository to your GitHub account.
|
||||
|
||||
**Forking the repository**
|
||||
- Click the **Fork** button or [click here](https://github.com/mbaharip/next-gdrive-index/fork)
|
||||
- Fill in the repository name, and description as you like
|
||||
|
||||
**Cloning the repository**
|
||||
- Click the **Code** button, and copy the repository URL
|
||||
- Open your terminal, and run the following command:
|
||||
\`\`\`bash
|
||||
git clone <repository_url>
|
||||
\`\`\`
|
||||
|
||||
> **Note**
|
||||
> You can also use **GitHub Codespaces** to edit the project if you prefer not to clone the repository.
|
||||
|
||||
---
|
||||
|
||||
### Creating Google Cloud Platform project
|
||||
|
||||
Before accessing files from Google Drive, you'll need to create a project in **Google Cloud Platform** and enable the **Google Drive API** (covered in the next step).
|
||||
You can use your Google account to login to Google Cloud Platform.
|
||||
|
||||
1. Go to [Google Cloud Platform](https://console.cloud.google.com/) (if it's your first time, you need to accept the terms and conditions before you can proceed)
|
||||
2. In the center of the page, click on **Create or select project**
|
||||
3. In the top right corner, click the **New Project** button
|
||||
4. Enter a desired name for your project, and click the **Create** button
|
||||
5. A notification confirming project creation will appear, click on the **Select project** button
|
||||
|
||||
Congratulations! You've successfully created a project in Google Cloud Platform.
|
||||
Next we'll enable the Google Drive API to access your Google Drive files.
|
||||
|
||||
---
|
||||
|
||||
### Enabling Google Drive API
|
||||
|
||||
To allow the project to access files stored in your Google Drive, you need to enable the Google Drive API within your Google Cloud Platform project.
|
||||
You can access your Google Cloud Platform project by selecting the project on the top left corner of the page. Or clicking **Select Project** button on notification after creating the project.
|
||||
|
||||
To enable the Google Drive API, follow these steps:
|
||||
|
||||
1. Go to **APIs & Services** product, and then select the **Enabled APIs & services** menu
|
||||
2. Click on the **+ Enable APIs and Services** button at the top of the page
|
||||
3. Search for "Google Drive API" on the search bar, and select the first result (It should be named **Google Drive API**)
|
||||
4. Click the **Enable** button and wait for the process to complete
|
||||
|
||||
Now that the Google Drive API is enabled, we need to create a service account to obtain the necessary credentials for accessing the API.
|
||||
|
||||
---
|
||||
|
||||
### Creating service account
|
||||
|
||||
Go to the **Credentials** page on the Google Cloud Platform to create a service account (tt can be found on the left sidebar), and follow these steps to create a service account:
|
||||
|
||||
1. Click on **Create Credentials** button, and choose **Service account**.
|
||||
2. Fill the service account name and description as you like, and then click the **Create and continue** button.
|
||||
3. You can skip both the **Grant this service account access to project** and **Grant users access to this service account** by clicking the **Done** button.
|
||||
4. You should see the service account you just created on the **Service Account** table, click on the service account name to open the details.
|
||||
5. Go to the **Keys** tab, and then click the **Add Key** button, choose the **Create new key**, and then pick the key type as **JSON**.
|
||||
6. A download prompt will appear, save the JSON file on easy-to-find location, and **keep it safe**. We will use it later on the configuration.
|
||||
|
||||
> **Note**
|
||||
> The JSON file contains sensitive information, don't share it with anyone.
|
||||
|
||||
---
|
||||
|
||||
### Preparing root folder in Google Drive
|
||||
|
||||
Since the service account can't access your Root folder, you need to create a new folder as the root folder for the index.
|
||||
You can either set the folder to public or share it with the service account.
|
||||
|
||||
1. Open your [Google Drive](https://drive.google.com/)
|
||||
2. Click the **New** button, and choose **Folder** to create a new folder, you can name it anything you want
|
||||
3. Right-click the folder you just created, and choose **Share**
|
||||
4. Enter the email address of the service account you just created (you can find it on the JSON file, or on the service account details page)
|
||||
5. To allow download files larger than deployment limit, you need to enable **Link sharing** and set it to **Anyone with the link** (read note below)
|
||||
6. Copy the folder ID from the URL, it's the part after **/folders/** in the URL (e.g: \`https://drive.google.com/drive/u/0/folders/<folder_id>\`)
|
||||
|
||||
Save the folder ID on notepad or somewhere safe, we will use it on the configuration.
|
||||
|
||||
> **Note**
|
||||
> People can't download files larger than the download limit (more information on [configuring section](#configuring-the-project)) if the folder is not set to public.
|
||||
> I'm still looking for a workaround for this.
|
||||
|
||||
---
|
||||
|
||||
### Configuring the project
|
||||
|
||||
There are 2 ways to configure the project, you can either use configurator or manually edit the configuration file (\`/src/config/gIndex.config.ts\`).
|
||||
While the configurator is easier, you can only configure the basic settings, so I recommend you to manually edit the configuration file.
|
||||
|
||||
#### Using the configurator
|
||||
|
||||
1. Open the configurator page [here](/ngdi-internal/configurator)
|
||||
2. Fill the form with the required information
|
||||
3. Click the **Generate Config** button to download the configuration file
|
||||
4. Replace the existing configuration file (\`/src/config/gIndex.config.ts\`) with the downloaded file
|
||||
5. Save the environment file (\`.env.local\`) to be used on the deployment
|
||||
|
||||
> **Important Note**
|
||||
> Make sure you don't push / upload the environment file to the repository, or share it with anyone.
|
||||
> Since it contains sensitive information, it should be kept secret.
|
||||
|
||||
#### Manually editing the configuration file
|
||||
|
||||
First, we need create a environment file to store the sensitive information.
|
||||
You can either store it temporarily on notepad, or create a new file named \`.env.local\` on the root of the project.
|
||||
The environment file should contain the following information:
|
||||
|
||||
- \`GD_SERVICE_B64\` - Base64 encoded service account JSON file, you can encode it using [base64encode.org](https://www.base64encode.org/)
|
||||
- \`ENCRYPTION_KEY\` - A secret key to encrypt sensitive information, make sure it's a long random string
|
||||
- \`SITE_PASSWORD\` - Will be used as the index password if you set **private index** to \`true\` on the configuration file
|
||||
- \`NEXT_PUBLIC_DOMAIN\` (optional) - Domain without protocol and trailing slash, e.g: \`example.com\`, default to your Vercel deployment domain / url (**If you're not using Vercel, you need to set this**)
|
||||
|
||||
> **Important Note**
|
||||
> Make sure to keep the environment file safe, and don't share it with anyone.
|
||||
> Since it contains sensitive information, it should be kept secret.
|
||||
|
||||
After creating the environment file, you can edit the configuration file (\`/src/config/gIndex.config.ts\`) to configure the project.
|
||||
Before changing the configuration, you need to replace the \`apiConfig.rootFolder\` with the encrypted value of the folder ID you just copied.
|
||||
To encrypt the value, you can use the following command:
|
||||
|
||||
1. Copy this command to your terminal
|
||||
\`curl "https://drive-demo.mbaharip.com/api/encrypt?key=<ENCRYPTION_KEY>&data=<FOLDER_ID>"\`
|
||||
2. Replace the \`<ENCRYPTION_KEY>\` with your encryption key from above, and the \`<FOLDER_ID>\` with the folder ID you copied.
|
||||
3. Run the command, if it's successful, you should get the \`encryptedValue\` and other information on the response.
|
||||
4. Make sure \`decryptedValue\` is the same as the folder ID you copied, and \`key\` is the same as the encryption key you used. If it's not, you need to recheck the encryption key and folder ID.
|
||||
5. Replace the \`apiConfig.rootFolder\` with the \`encryptedValue\` you got from the response.
|
||||
|
||||
If you're using **Shared Drive**, please follow the [Shared Drive Guide](#using-shared-drive) before deploying the project.
|
||||
Now you can change the other configuration as you like, each configuration has a description to help you understand the purpose of the configuration.
|
||||
Then save the configuration file, and you're ready to deploy the project.
|
||||
|
||||
---
|
||||
|
||||
### Deploying the project
|
||||
|
||||
Before deploying the project, you need to make sure these things:
|
||||
|
||||
- No environment file is pushed to the repository
|
||||
- Root folder ID and Shared Drive ID (if you're using Shared Drive) is encrypted
|
||||
- \`StreamMaxSize\` is set to the maximum file size you want to stream (To avoid excessive bandwidth usage)
|
||||
- \`maxFileSize\` is set to your deployment limit (Especially if you're using other platforms than Vercel)
|
||||
|
||||
After making sure everything is set, you can follow these steps to deploy the project:
|
||||
(Note: I'm using Vercel as the deployment platform, but other platforms should be similar)
|
||||
|
||||
- Go to your deployment platform dashboard (e.g: [Vercel](https://vercel.com/))
|
||||
- Click on the **Add** button, then select your \`next-gdrive-index\` repository
|
||||
- Wait for the build process to finish
|
||||
- Go to the project settings, and search for the **Environment Variables** section
|
||||
- Add each environment variable from the environment file to the key fields, and the value to the value fields
|
||||
- Redeploy the project, and wait for the deployment to finish
|
||||
|
||||
For other platforms, it's better to check their documentation on how to deploy Next.js project.
|
||||
It should be similar to Vercel. (New Project > Select Repository > Wait for build > Add Environment Variables > Redeploy)
|
||||
|
||||
---
|
||||
|
||||
### Done! 🎉
|
||||
|
||||
Congratulations! You've successfully deployed your own instance of **next-gdrive-index**`,
|
||||
migration: `If you've deployed the project before and want to upgrade to the latest version, you can follow this guide to migrate the project.
|
||||
|
||||
For v1, you need to refork the repository to get the latest changes.
|
||||
If you're using v2.3 or below, you can click on **Sync fork** to get the latest changes.
|
||||
But don't forget to backup the configuration file before syncing the repository.
|
||||
|
||||
### Migrating from v1
|
||||
|
||||
Here are the things you need to change to migrate from v1 to the latest version.
|
||||
|
||||
#### Environment file (v1.x)
|
||||
|
||||
- \`NEXT_PUBLIC_ENCRYPTION_KEY\` - Change to \`ENCRYPTION_KEY\`
|
||||
- \`NEXT_PUBLIC_SITE_PASSWORD\` - Change to \`SITE_PASSWORD\`
|
||||
- \`NEXT_PUBLIC_VERCEL_URL\` - Change to \`NEXT_PUBLIC_DOMAIN\`
|
||||
|
||||
#### Configuration file (v1.x)
|
||||
|
||||
- \`masterKey\` - Deprecated, please remove this configuration
|
||||
- \`apiConfig.rootFolder\` - Need to be encrypted, you can read [this section](#manually-editing-the-configuration-file) to encrypt the folder ID
|
||||
- \`siteConfig.navbarItems\` - You need to change the Icon value, since now the icon using [Lucide Icons](https://lucide.dev)
|
||||
|
||||
The other configuration are new, you might want to read each description to understand the purpose of the configuration before changing it.
|
||||
|
||||
---
|
||||
|
||||
### Migrating from v2.3 or below
|
||||
|
||||
For faster migration, you can use [the configurator](/ngdi-internal/configurator) to load your \`.env.local\` and \`gIndex.config.ts\` file, and generate the new configuration file.
|
||||
It will automatically set the new configuration based on your old configuration.
|
||||
|
||||
But if you want to manually change the configuration, you can follow the steps below
|
||||
|
||||
#### Environment file (v2.3 or below)
|
||||
|
||||
There are no changes on the environment file, you can use the old environment file without any changes.
|
||||
But if you want, you can change your \`ENCRYPTION_KEY\` to the new one. There are no limitations like the old version.
|
||||
|
||||
#### Configuration file (v2.3 or below)
|
||||
|
||||
- \`showGuideButton\` - Previously named as \`showDeployGuide\`
|
||||
- \`apiConfig.rootFolder\` - Need to be encrypted, you can read [this section](#manually-editing-the-configuration-file) to encrypt the folder ID
|
||||
- \`apiConfig.sharedDrive\` - Need to be encrypted, you can read [this section](#manually-editing-the-configuration-file) to encrypt the shared drive ID
|
||||
- \`siteConfig.footer\` - Format changed, it is now an array of object with \`value\` instead of array of string. Also add \`{{ poweredBy }}\` template
|
||||
- \`siteConfig.experimental_pageLoadTime\` - Currently WIP. you need to add this, but it's not used yet
|
||||
- \`siteConfig.previewSettings.manga\` - Currently only manga preview is available, you can set the max item / size to load on the preview
|
||||
|
||||
---
|
||||
|
||||
That's it! You've successfully migrated the project to the latest version.`,
|
||||
sharedDrive: `It's actually pretty simple to use Shared Drive, here are the steps:
|
||||
|
||||
- Open your [Google Drive](https://drive.google.com/)
|
||||
- On the left sidebar, select **Shared drives**, and then pick the Shared Drive you want to use
|
||||
- Copy the Shared Drive folder ID from the URL, it's the part after \`/drive/u/0/folders/\` in the URL (e.g: \`https://drive.google.com/drive/u/0/folders/<folder_id>\`) and save it somewhere easy to access (e.g notepad)
|
||||
- Create a new folder inside the Shared Drive, and share it with the service account email address (you can find it on the JSON file, or on the service account details page)
|
||||
- Share the folder with **Anyone with the link** to allow download files larger than deployment limit
|
||||
- Copy the folder ID from the URL, it's the part after \`/folders/\` in the URL (e.g: \`https://drive.google.com/drive/u/0/folders/<folder_id>\` ), and save it somewhere easy to access (e.g notepad)
|
||||
|
||||
Now we need to encrypt both the Root folder ID and Shared Drive ID before we can use it on the configuration file.
|
||||
To encrypt the value, you can use the following command:
|
||||
|
||||
1. Copy this command to your terminal
|
||||
\`curl "https://drive-demo.mbaharip.com/api/encrypt?key=<ENCRYPTION_KEY>&data=<ID>"\`
|
||||
2. Replace the \`<ENCRYPTION_KEY>\` with your encryption key from above, and the \`<ID>\` with the ID you copied.
|
||||
3. Run the command, if it's successful, you should get the \`encryptedValue\` and other information on the response.
|
||||
4. Make sure \`decryptedValue\` is the same as the ID you copied, and \`key\` is the same as the encryption key you used. If it's not, you need to recheck the encryption key and ID.
|
||||
|
||||
Do this for both the Root folder ID and Shared Drive ID, and replace the configuration file with the new encrypted value.
|
||||
And make sure to set \`apiConfig.isTeamDrive\` to \`true\` to use Shared Drive.`,
|
||||
} as const;
|
||||
|
||||
const guide: Readonly<
|
||||
Record<
|
||||
GuideSection,
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
toc: TocItem[];
|
||||
}
|
||||
>
|
||||
> = {
|
||||
gettingStarted: {
|
||||
id: "getting-started",
|
||||
title: "Getting started",
|
||||
content: content.gettingStarted,
|
||||
toc: generateToc(content.gettingStarted),
|
||||
},
|
||||
newUser: {
|
||||
id: "new-user",
|
||||
title: "New deployment guide",
|
||||
content: content.newUser,
|
||||
toc: generateToc(content.newUser),
|
||||
},
|
||||
migration: {
|
||||
id: "migration",
|
||||
title: "Migration guide",
|
||||
content: content.migration,
|
||||
toc: generateToc(content.migration),
|
||||
},
|
||||
sharedDrive: {
|
||||
id: "using-shared-drive",
|
||||
title: "Using shared drive",
|
||||
content: content.sharedDrive,
|
||||
toc: generateToc(content.sharedDrive),
|
||||
},
|
||||
} as const;
|
||||
|
||||
const customComponents: Partial<Omit<NormalComponents, keyof SpecialComponents> & SpecialComponents> = {
|
||||
h1: ({ className, id, children, ...props }) => (
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
className='group block w-fit !text-card-foreground !opacity-100'
|
||||
>
|
||||
<h1
|
||||
id={id}
|
||||
className={cn("inline-flex items-center gap-2 !border-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</h1>
|
||||
</Link>
|
||||
),
|
||||
h2: ({ className, id, children, ...props }) => (
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
className='group block w-fit !text-card-foreground !opacity-100'
|
||||
>
|
||||
<h2
|
||||
id={id}
|
||||
className={cn("inline-flex items-center gap-2 !border-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</h2>
|
||||
</Link>
|
||||
),
|
||||
h3: ({ className, id, children, ...props }) => (
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
className='group block w-fit !text-card-foreground !opacity-100'
|
||||
>
|
||||
<h3
|
||||
id={id}
|
||||
className={cn("inline-flex items-center gap-2 !border-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</h3>
|
||||
</Link>
|
||||
),
|
||||
h4: ({ className, id, children, ...props }) => (
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
className='group block w-fit !text-card-foreground !opacity-100'
|
||||
>
|
||||
<h4
|
||||
id={id}
|
||||
className={cn("inline-flex items-center gap-2 !border-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</h4>
|
||||
</Link>
|
||||
),
|
||||
h5: ({ className, id, children, ...props }) => (
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
className='group block w-fit !text-card-foreground !opacity-100'
|
||||
>
|
||||
<h5
|
||||
id={id}
|
||||
className={cn("inline-flex items-center gap-2 !border-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</h5>
|
||||
</Link>
|
||||
),
|
||||
h6: ({ className, id, children, ...props }) => (
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
className='group block w-fit !text-card-foreground !opacity-100'
|
||||
>
|
||||
<h6
|
||||
id={id}
|
||||
className={cn("inline-flex items-center gap-2 !border-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</h6>
|
||||
</Link>
|
||||
),
|
||||
};
|
||||
|
||||
export default function DeployPage() {
|
||||
return (
|
||||
<>
|
||||
<Alert variant={"warning"}>
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>It is recommended to use desktop / laptop to follow along the guide.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<TableOfContents guide={guide} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Link
|
||||
href={`#${guide.gettingStarted.id}`}
|
||||
className='group'
|
||||
>
|
||||
<CardTitle
|
||||
id={guide.gettingStarted.id}
|
||||
className='inline-flex scroll-m-20 items-center gap-2 text-3xl font-semibold tracking-tight'
|
||||
>
|
||||
{guide.gettingStarted.title}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='stroke size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</CardTitle>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className='flex w-full flex-col items-center justify-center gap-2'>
|
||||
<Markdown
|
||||
className='w-full'
|
||||
customComponents={customComponents}
|
||||
content={guide.gettingStarted.content}
|
||||
/>
|
||||
<iframe
|
||||
src='https://www.youtube-nocookie.com/embed/Wt-w5zWyOlk?si=z6j1Htb_YsaLswGW'
|
||||
title='next-gdrive-index deployment guide'
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
referrerPolicy='strict-origin-when-cross-origin'
|
||||
className='aspect-video h-full w-full max-w-[640px] rounded-lg'
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Link
|
||||
href={`#${guide.newUser.id}`}
|
||||
className='group'
|
||||
>
|
||||
<CardTitle
|
||||
id={guide.newUser.id}
|
||||
className='inline-flex scroll-m-20 items-center gap-2 text-3xl font-semibold tracking-tight'
|
||||
>
|
||||
{guide.newUser.title}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='stroke size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</CardTitle>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className='flex w-full flex-col items-center justify-center gap-2'>
|
||||
<Markdown
|
||||
className='w-full'
|
||||
customComponents={customComponents}
|
||||
content={guide.newUser.content}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Link
|
||||
href={`#${guide.migration.id}`}
|
||||
className='group'
|
||||
>
|
||||
<CardTitle
|
||||
id={guide.migration.id}
|
||||
className='inline-flex scroll-m-20 items-center gap-2 text-3xl font-semibold tracking-tight'
|
||||
>
|
||||
{guide.migration.title}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='stroke size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</CardTitle>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className='flex w-full flex-col items-center justify-center gap-2'>
|
||||
<Markdown
|
||||
className='w-full'
|
||||
customComponents={customComponents}
|
||||
content={guide.migration.content}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Link
|
||||
href={`#${guide.sharedDrive.id}`}
|
||||
className='group'
|
||||
>
|
||||
<CardTitle
|
||||
id={guide.sharedDrive.id}
|
||||
className='inline-flex scroll-m-20 items-center gap-2 text-3xl font-semibold tracking-tight'
|
||||
>
|
||||
{guide.sharedDrive.title}
|
||||
<Icon
|
||||
name='Hash'
|
||||
className='stroke size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
|
||||
/>
|
||||
</CardTitle>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className='flex w-full flex-col items-center justify-center gap-2'>
|
||||
<Markdown
|
||||
className='w-full'
|
||||
customComponents={customComponents}
|
||||
content={guide.sharedDrive.content}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type TableOfContentsProps = {
|
||||
guide: Record<GuideSection, { id: string; title: string; content: string; toc: TocItem[] }>;
|
||||
};
|
||||
function TableOfContents({ guide }: TableOfContentsProps) {
|
||||
const { isDesktop } = useResponsive();
|
||||
const [tocShow, setTocShow] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDesktop ? (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-24 z-50 flex items-stretch gap-6 rounded-lg border bg-card p-4 text-card-foreground opacity-100 shadow-md transition-all duration-500 ease-in-out",
|
||||
tocShow ? "right-3" : "-right-72",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='group flex cursor-pointer flex-col items-center gap-4'
|
||||
onClick={() => {
|
||||
setTocShow((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name='ChevronsLeft'
|
||||
hideWrapper
|
||||
className={cn(
|
||||
"size-5 transition-all duration-500 ease-in-out group-hover:scale-110",
|
||||
tocShow ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className='font-semibold tracking-wider'
|
||||
style={{
|
||||
writingMode: "vertical-lr",
|
||||
}}
|
||||
>
|
||||
Table of Contents
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea className='h-full max-w-64 pr-4'>
|
||||
<div className='h-fit max-h-[50dvh] space-y-2'>
|
||||
{Object.entries(guide).map(([k, v], index, self) => (
|
||||
<Fragment key={k}>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Link
|
||||
href={`#${v.id}`}
|
||||
className='my-0.5 text-lg text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
{v.title}
|
||||
</Link>
|
||||
{!!v.toc.length && (
|
||||
<div className='flex flex-col gap-1'>
|
||||
{v.toc.map((menu) => (
|
||||
<Link
|
||||
key={menu.id}
|
||||
href={`#${menu.id}`}
|
||||
className='my-0.5 text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
style={{
|
||||
marginLeft: `${(menu.level - 2) * 1}rem`,
|
||||
}}
|
||||
>
|
||||
{menu.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{index !== self.length - 1 && <Separator />}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<div className={cn("fixed bottom-16 right-6 z-50 transition")}>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
className='h-10 w-10 rounded-full p-0 shadow'
|
||||
>
|
||||
<Icon
|
||||
name='TableOfContents'
|
||||
className='size-6'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Table of Contents</DrawerTitle>
|
||||
<DrawerDescription>Jump to the section you want to read</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='space-y-2 p-4'>
|
||||
{Object.entries(guide).map(([k, v], index, self) => (
|
||||
<Fragment key={`mobile-${k}`}>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<DrawerClose asChild>
|
||||
<Link
|
||||
href={`#${v.id}`}
|
||||
className='my-0.5 text-lg text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
{v.title}
|
||||
</Link>
|
||||
</DrawerClose>
|
||||
{!!v.toc.length && (
|
||||
<div className='flex flex-col gap-1'>
|
||||
{v.toc.map((menu) => (
|
||||
<DrawerClose
|
||||
key={`mobile-${menu.id}`}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`#${menu.id}`}
|
||||
className='my-0.5 text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
style={{
|
||||
marginLeft: `${(menu.level - 2) * 0.75}rem`,
|
||||
}}
|
||||
>
|
||||
{menu.title}
|
||||
</Link>
|
||||
</DrawerClose>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{index !== self.length - 1 && <Separator />}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type AudioMimeType,
|
||||
MediaPlayer,
|
||||
type MediaPlayerInstance,
|
||||
type MediaPlayerQuery,
|
||||
MediaProvider,
|
||||
Menu,
|
||||
PlayButton,
|
||||
SeekButton,
|
||||
Time,
|
||||
TimeSlider,
|
||||
type VideoMimeType,
|
||||
useAudioGainOptions,
|
||||
usePlaybackRateOptions,
|
||||
} from "@vidstack/react";
|
||||
import {
|
||||
DefaultAudioLayout,
|
||||
type DefaultLayoutIcons,
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default";
|
||||
import "@vidstack/react/player/styles/base.css";
|
||||
import "@vidstack/react/player/styles/default/layouts/audio.css";
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css";
|
||||
import "@vidstack/react/player/styles/default/theme.css";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { type z } from "zod";
|
||||
|
||||
import { PageLoader } from "~/components/layout";
|
||||
import Icon from "~/components/ui/icon";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { MediaPlayerIcons } from "~/lib/previewHelper";
|
||||
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import "~/styles/vidstack.css";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
type: "audio" | "video";
|
||||
};
|
||||
export default function EmbedPage({ file, type }: Props) {
|
||||
const loading = useLoading();
|
||||
const player = useRef<MediaPlayerInstance>(null);
|
||||
const [canPlay, setCanPlay] = useState<boolean>(false);
|
||||
|
||||
const [isLoop, setIsLoop] = useState<boolean>(false);
|
||||
|
||||
const icons = useMemo<DefaultLayoutIcons>(
|
||||
() => ({
|
||||
...defaultLayoutIcons,
|
||||
AirPlayButton: {
|
||||
Default: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Airplay'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Connecting: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='LoaderCircle'
|
||||
className='vds-icon size-5 animate-spin'
|
||||
/>
|
||||
),
|
||||
Connected: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Airplay'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
},
|
||||
GoogleCastButton: {
|
||||
Default: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Cast'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Connecting: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='LoaderCircle'
|
||||
className='vds-icon size-5 animate-spin'
|
||||
/>
|
||||
),
|
||||
Connected: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Cast'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
},
|
||||
PlayButton: {
|
||||
Play: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Play'
|
||||
className='vds-icon size-5 fill-current media-ended:hidden media-playing:hidden'
|
||||
/>
|
||||
),
|
||||
Pause: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Pause'
|
||||
className='vds-icon size-5 fill-current media-paused:hidden'
|
||||
/>
|
||||
),
|
||||
Replay: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='RotateCcw'
|
||||
className='vds-icon hidden size-5 media-ended:block'
|
||||
/>
|
||||
),
|
||||
},
|
||||
MuteButton: {
|
||||
Mute: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='VolumeX'
|
||||
className='vds-icon mute-icon size-5'
|
||||
/>
|
||||
),
|
||||
VolumeLow: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Volume1'
|
||||
className='vds-icon volume-low-icon size-5'
|
||||
/>
|
||||
),
|
||||
VolumeHigh: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Volume2'
|
||||
className='vds-icon volume-high-icon size-5'
|
||||
/>
|
||||
),
|
||||
},
|
||||
CaptionButton: {
|
||||
On: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Captions'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Off: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='CaptionsOff'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
},
|
||||
PIPButton: {
|
||||
Enter: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='PictureInPicture'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Exit: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='X'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
},
|
||||
FullscreenButton: {
|
||||
Enter: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Maximize2'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Exit: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Minimize2'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
},
|
||||
SeekButton: {
|
||||
Backward: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='ChevronsLeft'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Forward: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='ChevronsRight'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
},
|
||||
DownloadButton: {
|
||||
Default: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Download'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
},
|
||||
Menu: {
|
||||
Accessibility: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='PersonStanding'
|
||||
className='vds-icon mr-2 size-5'
|
||||
/>
|
||||
),
|
||||
ArrowLeft: () => null,
|
||||
ArrowRight: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='ChevronRight'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Audio: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Volume2'
|
||||
className='vds-icon mr-2 size-5'
|
||||
/>
|
||||
),
|
||||
AudioBoostUp: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Volume2'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
AudioBoostDown: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Volume'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Chapters: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='TableOfContents'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Captions: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Captions'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
Playback: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='ListVideo'
|
||||
className='size5 mr-2 '
|
||||
/>
|
||||
),
|
||||
Settings: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Settings'
|
||||
className='vds-icon vds-rotate-icon size-5'
|
||||
/>
|
||||
),
|
||||
SpeedUp: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='ChevronsUp'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
SpeedDown: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='ChevronsDown'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
QualityUp: () => null,
|
||||
QualityDown: () => null,
|
||||
FontSizeUp: () => null,
|
||||
FontSizeDown: () => null,
|
||||
OpacityUp: () => null,
|
||||
OpacityDown: () => null,
|
||||
RadioCheck: () => null,
|
||||
},
|
||||
KeyboardDisplay: {
|
||||
Play: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Play'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Pause: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Pause'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Mute: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='VolumeX'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
VolumeUp: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Volume2'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
VolumeDown: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Volume1'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
EnterFullscreen: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Maximize2'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
ExitFullscreen: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Minimize2'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
// EnterPiP: () => null,
|
||||
// ExitPiP: () => null,
|
||||
CaptionsOn: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='Captions'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
CaptionsOff: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='CaptionsOff'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
SeekForward: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='ChevronsRight'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
SeekBackward: () => (
|
||||
<div className='grid h-full w-full place-items-center'>
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='ChevronsLeft'
|
||||
className='size-6'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const smallAudioLayoutQuery = useCallback<MediaPlayerQuery>(({ width }) => {
|
||||
return width < 576;
|
||||
}, []);
|
||||
const smallVideoLayoutQuery = useCallback<MediaPlayerQuery>(({ width, height }) => {
|
||||
return width < 576 || height < 380;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? (
|
||||
<PageLoader message='Preparing media embed...' />
|
||||
) : (
|
||||
<MediaPlayer
|
||||
ref={player}
|
||||
key={file.encryptedId}
|
||||
src={{
|
||||
src: `/api/preview/${file.encryptedId}`,
|
||||
type: file.mimeType as AudioMimeType | VideoMimeType,
|
||||
}}
|
||||
loop={isLoop}
|
||||
autoPlay
|
||||
playsInline
|
||||
crossOrigin
|
||||
viewType={type === "audio" ? "audio" : "video"}
|
||||
className='media-player h-fit max-h-screen w-full rounded-xl'
|
||||
preload='auto'
|
||||
onLoadedMetadata={() => {
|
||||
setCanPlay(true);
|
||||
}}
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultAudioLayout
|
||||
icons={MediaPlayerIcons}
|
||||
colorScheme='default'
|
||||
smallLayoutWhen={smallAudioLayoutQuery}
|
||||
showTooltipDelay={150}
|
||||
className='h-fit !px-2 !py-2 shadow-md [&.vds-button>svg]:stroke-[--media-controls-color] [&.vds-play-button>svg]:fill-[--audio-play-button-color] [&.vds-play-button>svg]:stroke-[--audio-play-button-color] [&>.vds-captions]:hidden [&>.vds-controls>.vds-controls-group]:inline-flex [&>.vds-controls>.vds-controls-group]:gap-1 [&>.vds-controls>.vds-controls-group]:md:inline-flex [&>.vds-controls>.vds-controls-group]:md:flex-row'
|
||||
slots={{
|
||||
seekBackwardButton: null,
|
||||
seekForwardButton: null,
|
||||
playButton: (
|
||||
<div className='flex w-fit items-center justify-center'>
|
||||
<SeekButton
|
||||
className='vds-button'
|
||||
seconds={-10}
|
||||
>
|
||||
<icons.SeekButton.Backward />
|
||||
</SeekButton>
|
||||
<PlayButton
|
||||
className='vds-button vds-play-button aspect-square h-10 w-10'
|
||||
disabled={canPlay === false}
|
||||
>
|
||||
{canPlay ? (
|
||||
<>
|
||||
<icons.PlayButton.Play />
|
||||
<icons.PlayButton.Pause />
|
||||
<icons.PlayButton.Replay />
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='LoaderCircle'
|
||||
className='vds-icon size-5 animate-spin'
|
||||
/>
|
||||
)}
|
||||
</PlayButton>
|
||||
<SeekButton
|
||||
className='vds-button'
|
||||
seconds={10}
|
||||
>
|
||||
<icons.SeekButton.Forward />
|
||||
</SeekButton>
|
||||
</div>
|
||||
),
|
||||
timeSlider: (
|
||||
<div className='grid w-full grow place-items-center'>
|
||||
<TimeSlider.Root className='vds-time-slider vds-slider !opacity-100'>
|
||||
<TimeSlider.Track className='vds-slider-track'>
|
||||
<TimeSlider.Progress className='vds-slider-progress' />
|
||||
<TimeSlider.TrackFill className='vds-slider-track-fill vds-slider-track' />
|
||||
</TimeSlider.Track>
|
||||
</TimeSlider.Root>
|
||||
</div>
|
||||
),
|
||||
beforeEndTime: <div className='vds-controls-spacer' />,
|
||||
endTime: (
|
||||
<div className='inline-flex w-fit items-center justify-between gap-2 md:w-fit'>
|
||||
<div className='inline-flex items-center text-sm'>
|
||||
<Time
|
||||
className='time'
|
||||
type='current'
|
||||
/>
|
||||
<span className='mx-1'>/</span>
|
||||
<Time
|
||||
className='time'
|
||||
type='duration'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center'>
|
||||
<Menu.Root className='vds-menu'>
|
||||
<Menu.Button
|
||||
className='vds-menu-button vds-button'
|
||||
aria-label='Settings'
|
||||
>
|
||||
<icons.Menu.Settings />
|
||||
</Menu.Button>
|
||||
<Menu.Items
|
||||
className='vds-menu-items'
|
||||
placement={"bottom"}
|
||||
offset={0}
|
||||
>
|
||||
{/* Loop */}
|
||||
<Menu.Root>
|
||||
<Menu.Button className='vds-menu-item'>
|
||||
<ChevronLeft className='vds-menu-close-icon' />
|
||||
<Icon
|
||||
name='Repeat'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-menu-item-label'>Loop</span>
|
||||
<span className='vds-menu-item-hint'>{isLoop ? "On" : "Off"}</span>
|
||||
<ChevronRight className='vds-menu-open-icon' />
|
||||
</Menu.Button>
|
||||
<Menu.Content className='vds-menu-items'>
|
||||
<Menu.RadioGroup
|
||||
className='vds-radio-group'
|
||||
value={String(isLoop)}
|
||||
>
|
||||
<Menu.Radio
|
||||
className='vds-radio'
|
||||
value='true'
|
||||
onSelect={() => setIsLoop(true)}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label'>On</span>
|
||||
</Menu.Radio>
|
||||
<Menu.Radio
|
||||
className='vds-radio'
|
||||
value='false'
|
||||
onSelect={() => setIsLoop(false)}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label'>Off</span>
|
||||
</Menu.Radio>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
|
||||
{/* Playback Speed */}
|
||||
<PlaybackMenu />
|
||||
|
||||
{/* Audio Gain */}
|
||||
<AudioGain />
|
||||
</Menu.Items>
|
||||
</Menu.Root>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
settingsMenu: null,
|
||||
}}
|
||||
/>
|
||||
<DefaultVideoLayout
|
||||
icons={MediaPlayerIcons}
|
||||
colorScheme='default'
|
||||
smallLayoutWhen={smallVideoLayoutQuery}
|
||||
showTooltipDelay={150}
|
||||
slots={{
|
||||
currentTime: (
|
||||
<Time
|
||||
className='text-sm'
|
||||
type='current'
|
||||
/>
|
||||
),
|
||||
endTime: (
|
||||
<Time
|
||||
className='text-sm'
|
||||
type='duration'
|
||||
/>
|
||||
),
|
||||
settingsMenu: (
|
||||
<Menu.Root className='vds-menu'>
|
||||
<Menu.Button
|
||||
className='vds-menu-button vds-button'
|
||||
aria-label='Settings'
|
||||
>
|
||||
<icons.Menu.Settings />
|
||||
</Menu.Button>
|
||||
<Menu.Items
|
||||
className='vds-menu-items'
|
||||
placement={"top"}
|
||||
offset={0}
|
||||
>
|
||||
{/* Loop */}
|
||||
<Menu.Root>
|
||||
<Menu.Button className='vds-menu-item'>
|
||||
<ChevronLeft className='vds-menu-close-icon' />
|
||||
<Icon
|
||||
name='Repeat'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-menu-item-label'>Loop</span>
|
||||
<span className='vds-menu-item-hint'>{isLoop ? "On" : "Off"}</span>
|
||||
<ChevronRight className='vds-menu-open-icon' />
|
||||
</Menu.Button>
|
||||
<Menu.Content className='vds-menu-items'>
|
||||
<Menu.RadioGroup
|
||||
className='vds-radio-group'
|
||||
value={String(isLoop)}
|
||||
>
|
||||
<Menu.Radio
|
||||
className='vds-radio'
|
||||
value='true'
|
||||
onSelect={() => setIsLoop(true)}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label'>On</span>
|
||||
</Menu.Radio>
|
||||
<Menu.Radio
|
||||
className='vds-radio'
|
||||
value='false'
|
||||
onSelect={() => setIsLoop(false)}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label'>Off</span>
|
||||
</Menu.Radio>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
|
||||
{/* Playback Speed */}
|
||||
<PlaybackMenu />
|
||||
|
||||
{/* Audio Gain */}
|
||||
<AudioGain />
|
||||
</Menu.Items>
|
||||
</Menu.Root>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</MediaPlayer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaybackMenu() {
|
||||
const options = usePlaybackRateOptions();
|
||||
const hint = options.selectedValue === "1" ? "Normal" : `${options.selectedValue}x`;
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Button
|
||||
className='vds-menu-item'
|
||||
disabled={options.disabled}
|
||||
>
|
||||
<ChevronLeft className='vds-menu-close-icon' />
|
||||
<Icon
|
||||
name='Gauge'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-menu-item-label'>Playback Speed</span>
|
||||
<span className='vds-menu-item-hint'>{hint}</span>
|
||||
<ChevronRight className='vds-menu-open-icon' />
|
||||
</Menu.Button>
|
||||
<Menu.Content className='vds-menu-items'>
|
||||
<Menu.RadioGroup
|
||||
className='vds-radio-group'
|
||||
value={options.selectedValue}
|
||||
>
|
||||
{/* eslint-disable-next-line @typescript-eslint/unbound-method */}
|
||||
{options.map(({ value, select, label }) => (
|
||||
<Menu.Radio
|
||||
key={value}
|
||||
className='vds-radio'
|
||||
value={value}
|
||||
onSelect={select}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label'>{label}</span>
|
||||
</Menu.Radio>
|
||||
))}
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
);
|
||||
}
|
||||
function AudioGain() {
|
||||
const options = useAudioGainOptions({
|
||||
disabledLabel: "100%",
|
||||
});
|
||||
const hint = options.selectedValue ? `${Number(options.selectedValue) * 100}%` : "100%";
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Button
|
||||
className='vds-menu-item'
|
||||
disabled={options.disabled}
|
||||
>
|
||||
<ChevronLeft className='vds-menu-close-icon' />
|
||||
<Icon
|
||||
name='Volume2'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-menu-item-label'>Audio Boost</span>
|
||||
<span className='vds-menu-item-hint'>{hint}</span>
|
||||
<ChevronRight className='vds-menu-open-icon' />
|
||||
</Menu.Button>
|
||||
<Menu.Content className='vds-menu-items'>
|
||||
<Menu.RadioGroup
|
||||
className='vds-radio-group'
|
||||
value={options.selectedValue}
|
||||
>
|
||||
{/* eslint-disable-next-line @typescript-eslint/unbound-method */}
|
||||
{options.map(({ value, select, label }) => (
|
||||
<Menu.Radio
|
||||
key={value}
|
||||
className='vds-radio'
|
||||
value={value}
|
||||
onSelect={select}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label capitalize'>{label}</span>
|
||||
</Menu.Radio>
|
||||
))}
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as ConfiguratorPage } from "./ConfiguratorPage";
|
||||
export { default as DeployPage } from "./DeployPage";
|
||||
export { default as EmbedPage } from "./EmbedPage";
|
||||
|
||||
export { default as APIConfigurator } from "./ConfiguratorPage.Api";
|
||||
export { default as EnvironmentConfigurator } from "./ConfiguratorPage.Environment";
|
||||
export { default as SiteConfigurator } from "./ConfiguratorPage.Site";
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import Icon from "~/components/ui/icon";
|
||||
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
error: Error & { digest?: string };
|
||||
reset?: () => void;
|
||||
};
|
||||
|
||||
export default function ErrorComponent({ error, reset }: Props) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-screen-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-2",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
className='size-10 stroke-destructive'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-center text-destructive'>Something went wrong</span>
|
||||
<span className='text-center text-sm text-destructive'>More details can be found in the console</span>
|
||||
</div>
|
||||
<code className='my-4 w-full whitespace-pre-wrap rounded-[var(--radius)] bg-muted px-3 py-1.5 text-sm text-muted-foreground'>
|
||||
{error.message}
|
||||
</code>
|
||||
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
if (reset) reset();
|
||||
else router.refresh();
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Back to previous page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import { NO_LAYOUT_PATHS } from "~/constant";
|
||||
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
content?: string;
|
||||
};
|
||||
export default function Footer({ content }: Props) {
|
||||
const pathname = usePathname();
|
||||
const loading = useLoading();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [loadTime, setLoadTime] = useState<number>(0);
|
||||
|
||||
if (NO_LAYOUT_PATHS.some((path) => new RegExp(path).test(pathname))) return null;
|
||||
|
||||
return (
|
||||
<footer className='flex w-full flex-col items-center justify-center pb-3'>
|
||||
{config.siteConfig.experimental_pageLoadTime &&
|
||||
(loading ? (
|
||||
<Skeleton className='h-4 w-40 max-w-96' />
|
||||
) : (
|
||||
<span className='text-center text-sm text-muted-foreground'>
|
||||
Page loaded in{" "}
|
||||
{config.siteConfig.experimental_pageLoadTime === "ms"
|
||||
? `${Math.round(loadTime ?? 0)}`
|
||||
: `${loadTime?.toFixed(2)}`}
|
||||
{config.siteConfig.experimental_pageLoadTime}
|
||||
</span>
|
||||
))}
|
||||
{content && (
|
||||
<ReactMarkdown
|
||||
className='flex w-full select-none flex-col items-center justify-center text-center'
|
||||
components={{
|
||||
p: ({ children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='muted text-balance text-sm'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ children, ...props }) => {
|
||||
const isExternal = props.href?.startsWith("http");
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import pkg from "~/../package.json";
|
||||
import { NO_LAYOUT_PATHS } from "~/constant";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import Icon from "~/components/ui/icon";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { useConfirmDialog } from "~/context/confirmProvider";
|
||||
import { useResponsive } from "~/context/responsiveContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { ClearSavedPasswords } from "~/actions/password";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const { theme, themes, setTheme } = useTheme();
|
||||
const { isDesktop } = useResponsive();
|
||||
const isLoading = useLoading();
|
||||
const confirm = useConfirmDialog();
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [themeOpen, setThemeOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop) setOpen(false);
|
||||
}, [isDesktop]);
|
||||
|
||||
async function onClearPassword() {
|
||||
toast.loading("Clearing all saved password...", {
|
||||
id: "clear-password",
|
||||
});
|
||||
|
||||
const clear = await ClearSavedPasswords();
|
||||
if (!clear.success) return toast.error(clear.error, { id: "clear-password" });
|
||||
toast.success(clear.message, { id: "clear-password" });
|
||||
}
|
||||
|
||||
if (NO_LAYOUT_PATHS.some((path) => new RegExp(path).test(pathname))) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("sticky left-0 top-0 z-[48]", "h-12 w-full")}>
|
||||
<nav
|
||||
slot='nav'
|
||||
className={cn(
|
||||
"bg-card shadow-md",
|
||||
"h-12 w-full",
|
||||
"px-2 py-2 md:px-4",
|
||||
"flex flex-grow flex-row items-center justify-between",
|
||||
"border-b border-border",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-row items-center justify-between", "mx-auto w-full max-w-screen-desktop")}>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={"/"}
|
||||
onClick={() => setOpen(false)}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<img
|
||||
src={config.siteConfig.siteIcon}
|
||||
alt={config.siteConfig.siteName}
|
||||
className={cn("h-10 w-10")}
|
||||
loading='eager'
|
||||
/>
|
||||
<div className='inline-flex items-center gap-1'>
|
||||
<span className={cn("large", "hidden", "tablet:block")}>{config.siteConfig.siteName}</span>
|
||||
<span className='text-xs text-muted-foreground'>({pkg.version})</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-8 w-1/3' />
|
||||
) : (
|
||||
<>
|
||||
{isDesktop ? (
|
||||
<div className='flex items-center gap-1'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<Tooltip key={item.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
pathname === item.href ? "cursor-default opacity-100" : "cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>{item.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<Separator
|
||||
orientation='vertical'
|
||||
className='mx-2 my-auto h-6'
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={"SunMoon"}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Site theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
{themes.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item}
|
||||
disabled={item === theme}
|
||||
className='w-full'
|
||||
onClick={() => setTheme(item)}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className={cn("capitalize")}>{item}</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
className='stroke-foreground'
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{config.siteConfig.supports.length && (
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
"cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Heart'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>Support</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<DropdownMenuItem key={item.name}>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={item.href}
|
||||
target={"_blank"}
|
||||
rel={"noopener noreferrer"}
|
||||
className='flex w-full items-center justify-between gap-4'
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
<span className='muted'>{item.currency}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<Separator
|
||||
orientation='vertical'
|
||||
className='mx-2 my-auto h-6'
|
||||
/>
|
||||
|
||||
{config.showGuideButton && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
className={`text-sm`}
|
||||
>
|
||||
Internal Links
|
||||
<Icon name={"ChevronDown"} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={"/ngdi-internal/deploy"}
|
||||
className='flex w-full items-center justify-start gap-2'
|
||||
>
|
||||
<Icon name={"Book"} />
|
||||
Deploy Guide
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={"/ngdi-internal/configurator"}
|
||||
className='flex w-full items-center justify-start gap-2'
|
||||
>
|
||||
<Icon name={"Wrench"} />
|
||||
Configurator
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={"ghost-destructive"}
|
||||
size={"icon"}
|
||||
onClick={async () => {
|
||||
await confirm({
|
||||
title: "Clear all saved password?",
|
||||
description: "You will need to re-enter the password to access the protected content.",
|
||||
confirmText: "Clear all",
|
||||
confirmProps: {
|
||||
variant: "destructive",
|
||||
},
|
||||
cancelText: "Cancel",
|
||||
async onConfirm() {
|
||||
await onClearPassword();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon name='LogOut' />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Drawer
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name={"SunMoon"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Theme</DrawerTitle>
|
||||
<DrawerDescription>Choose your preferred theme</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-2 px-4'>
|
||||
{themes.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={theme === item ? "secondary" : "outline"}
|
||||
size={"default"}
|
||||
className='w-full'
|
||||
disabled={item === theme}
|
||||
onClick={() => {
|
||||
setTheme(item);
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span className={cn("capitalize")}>{item}</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
? "Check"
|
||||
: item === "system"
|
||||
? "LaptopMinimal"
|
||||
: item === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
className='w-full'
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name='Menu'
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Menu</DrawerTitle>
|
||||
<DrawerDescription>Explore the site</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-2 px-4'>
|
||||
{config.siteConfig.navbarItems.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"outline"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
className='flex w-full items-center justify-between gap-4'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.external && (
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
size={"0.75rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{config.showGuideButton && (
|
||||
<>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={"/ngdi-internal/deploy"}
|
||||
className='flex w-full items-center justify-between gap-4'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Icon
|
||||
name={"Book"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
Deploy Guide
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={"/ngdi-internal/configurator"}
|
||||
className='flex w-full items-center justify-between gap-4'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Icon
|
||||
name={"Wrench"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
Configurator
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.siteConfig.supports.length && (
|
||||
<>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Support</DrawerTitle>
|
||||
<DrawerDescription>Support the site</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='grid gap-2 px-4'>
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<Button
|
||||
key={item.name}
|
||||
variant={"outline"}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={"_blank"}
|
||||
rel={"noopener noreferrer"}
|
||||
className='flex w-full items-center justify-between gap-4'
|
||||
>
|
||||
<small>{item.name}</small>
|
||||
<small className='muted'>{item.currency}</small>
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DrawerFooter className='w-full'>
|
||||
<Separator className='my-3' />
|
||||
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
className='w-full gap-4'
|
||||
onClick={async () => {
|
||||
await confirm({
|
||||
title: "Clear all saved password?",
|
||||
description: "You will need to re-enter the password to access the protected content.",
|
||||
confirmText: "Clear all",
|
||||
confirmProps: {
|
||||
variant: "destructive",
|
||||
},
|
||||
cancelText: "Cancel",
|
||||
async onConfirm() {
|
||||
await onClearPassword();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Status } from "~/components/global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
export default function NotFoundClient() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className='mx-auto flex h-full w-full max-w-screen-md flex-grow flex-col items-center justify-center gap-12'>
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="Can't find the file or folder you're looking for"
|
||||
/>
|
||||
|
||||
<div className='flex w-full items-center gap-2'>
|
||||
<Button
|
||||
className='grow'
|
||||
variant={"outline"}
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
<Button
|
||||
className='grow'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import Icon from "~/components/ui/icon";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
message?: string;
|
||||
extra?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function Loader({ message, extra }: Props) {
|
||||
export default function PageLoader({ message, extra }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
"flex flex-grow flex-col items-center justify-center gap-2",
|
||||
"text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={"2rem"}
|
||||
className='animate-spin'
|
||||
className='size-10 animate-spin stroke-primary'
|
||||
/>
|
||||
<p>{message || "Loading..."}</p>
|
||||
{extra}
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<p>{message ?? "Loading..."}</p>
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PageLoader } from "~/components/layout";
|
||||
import { LoadingButton } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import { cn, toUrlPath } from "~/lib/utils";
|
||||
|
||||
import { CheckIndexPassword, CheckPagePassword, SetIndexPassword, SetPagePassword } from "~/actions/password";
|
||||
|
||||
type Props =
|
||||
| {
|
||||
type: "global";
|
||||
errorMessage?: string;
|
||||
}
|
||||
| {
|
||||
type: "path";
|
||||
paths: { path: string; id: string }[];
|
||||
errorMessage?: string;
|
||||
};
|
||||
export default function Password(props: Props) {
|
||||
const router = useRouter();
|
||||
const loading = useLoading();
|
||||
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [submitLoading, setSubmitLoading] = useState<boolean>(false);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!input) return toast.error("Password is required", { id: "password" });
|
||||
setSubmitLoading(true);
|
||||
toast.loading("Validating password...", { id: "password" });
|
||||
|
||||
try {
|
||||
if (props.type === "global") {
|
||||
await SetIndexPassword(input);
|
||||
const res = await CheckIndexPassword();
|
||||
if (!res.success) return toast.error(res.error, { id: "password" });
|
||||
} else {
|
||||
await SetPagePassword(toUrlPath(props.paths), input);
|
||||
const res = await CheckPagePassword(props.paths);
|
||||
if (!res.success) return toast.error(res.error, { id: "password" });
|
||||
}
|
||||
|
||||
toast.success("Password accepted! Refreshing...", { id: "password" });
|
||||
router.refresh();
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
setInput("");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <PageLoader message='Checking password...' />;
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-4")}>
|
||||
<img
|
||||
src='/images/undraw_vault.svg'
|
||||
alt='Password illustration'
|
||||
loading='eager'
|
||||
className={cn("h-48 w-64 object-contain")}
|
||||
/>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<h3 className='text-balance text-center'>
|
||||
{props.type === "global"
|
||||
? "The site is password protected"
|
||||
: "The folder or file you are trying to access is password protected"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={cn("w-full", "grid grid-cols-1 gap-2", "tablet:grid-cols-4")}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
name='password'
|
||||
type='password'
|
||||
placeholder='Password'
|
||||
className={cn("tablet:col-span-3")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<LoadingButton
|
||||
loading={submitLoading}
|
||||
type='submit'
|
||||
disabled={!input}
|
||||
>
|
||||
Submit
|
||||
</LoadingButton>
|
||||
</form>
|
||||
<span className={cn("text-pretty text-center text-destructive", props.errorMessage ? "visible" : "invisible")}>
|
||||
{props.errorMessage ?? "Nothing here, just a password wall"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { type TooltipProviderProps } from "@radix-ui/react-tooltip";
|
||||
import { ThemeProvider, type ThemeProviderProps } from "next-themes";
|
||||
import { usePathname } from "next/navigation";
|
||||
import NextTopLoader, { type NextTopLoaderProps } from "nextjs-toploader";
|
||||
import { type ToasterProps } from "sonner";
|
||||
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import { TooltipProvider } from "~/components/ui/tooltip";
|
||||
|
||||
import UseConfirmDialogProvider from "~/context/confirmProvider";
|
||||
import { LayoutProvider } from "~/context/layoutContext";
|
||||
import { ResponsiveProvider } from "~/context/responsiveContext";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
loader?: NextTopLoaderProps;
|
||||
theme?: ThemeProviderProps;
|
||||
tooltip?: Omit<TooltipProviderProps, "children">;
|
||||
toaster?: ToasterProps;
|
||||
};
|
||||
export default function Provider(props: React.PropsWithChildren<Props>) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<ThemeProvider {...props.theme}>
|
||||
<LayoutProvider>
|
||||
<ResponsiveProvider>
|
||||
<NextTopLoader
|
||||
color='hsl(var(--primary))'
|
||||
{...props.loader}
|
||||
/>
|
||||
<UseConfirmDialogProvider>
|
||||
<TooltipProvider {...props.tooltip}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col items-start font-sans text-foreground",
|
||||
pathname.startsWith("/ngdi-internal/embed/") ? "h-fit" : "h-full min-h-screen",
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<Toaster {...props.toaster} />
|
||||
</UseConfirmDialogProvider>
|
||||
</ResponsiveProvider>
|
||||
</LayoutProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user