Version 2

This commit is contained in:
mbaharip
2024-04-06 22:09:03 +07:00
parent 3be80bc9fb
commit 4c0ba8288a
99 changed files with 5381 additions and 2342 deletions
+6 -3
View File
@@ -1,5 +1,8 @@
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file" GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
NEXT_PUBLIC_ENCRYPTION_KEY="Rj3wRGB9nUElQ7OA" NEXT_PUBLIC_ENCRYPTION_KEY=
NEXT_PUBLIC_SITE_PASSWORD="mbaharip" NEXT_PUBLIC_SITE_PASSWORD=
NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
# Only fill with the domain name, without the protocol and trailing slash
# Example: https://www.mbaharip.com -> mbaharip.com
NEXT_PUBLIC_DOMAIN=
+2 -1
View File
@@ -2,5 +2,6 @@
"extends": "next/core-web-vitals", "extends": "next/core-web-vitals",
"rules": { "rules": {
"@next/next/no-img-element": "off" "@next/next/no-img-element": "off"
} },
"ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_pages/**/*"]
} }
+4 -2
View File
@@ -34,8 +34,10 @@ next-env.d.ts
# personal docs # personal docs
/docs /docs
/src/pages/api/legacy/ /src/pages/api/legacy/
/**/*/_legacy/ /**/*/_legacy/
/data
/.*/ /.*/
/src/_app /src/_app
/src/_pages
/src/utils/_legacy
/src/components/_legacy
+13 -6
View File
@@ -1,6 +1,9 @@
"use strict"; "use strict";
module.exports = { module.exports = {
plugins: [require("@trivago/prettier-plugin-sort-imports"), require("prettier-plugin-tailwindcss")], plugins: [
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss",
],
printWidth: 80, printWidth: 80,
tabWidth: 2, tabWidth: 2,
useTabs: false, useTabs: false,
@@ -17,15 +20,19 @@ module.exports = {
endOfLine: "lf", endOfLine: "lf",
embeddedLanguageFormatting: "auto", embeddedLanguageFormatting: "auto",
singleAttributePerLine: true, singleAttributePerLine: true,
tailwindAttributes: ["classNames", "wrapperClassName", "rootClassName"],
tailwindFunctions: ["twsx", "cn"],
tailwindConfig: "./tailwind.config.ts",
importOrder: [ importOrder: [
"<THIRD_PARTY_MODULES>", "<THIRD_PARTY_MODULES>",
"^components/(.*)$", "^~/components/(.*)$",
"^(utils|hooks|context)/(.*)$", "^~/(utils|hooks|context)/(.*)$",
"^types/(.*)$", "^~/types/(.*)$",
"^(config|styles)/(.*)$", "^~/(config|styles)/(.*)$",
"^[./]", "^[./]",
], ],
importOrderSeparation: true, importOrderSeparation: true,
importOrderSortSpecifiers: true, importOrderSortSpecifiers: true,
tailwindFunctions: ["twsx", "cn"],
}; };
+114 -16
View File
@@ -8,15 +8,64 @@
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ'>FAQ</a> <a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ'>FAQ</a>
</p> </p>
## Version 2 ## Version 2 is here!
It seems there are some bugs on mobile devices that prevent the files list from rendering properly. So I will use this chance to upgrade the project to `Next.js 14`, use `shadcn/ui`, and also add some new features. It seems there are some bugs on mobile devices that prevent the files list from rendering properly.
So I will use this chance to upgrade the project to `Next.js 14`, use `shadcn/ui`, and also add some new features.
There also some changes on the configuration file, so make sure to read the [Migration Guide](#migration-guide) if you're upgrading from version 1.x.
Here's what changed in version 2: Here's what changed in version 2:
- Upgrade to `Next.js 14` app directory structure. ### New Features
- Use `shadcn/ui` for the UI components.
- Add light and dark mode. - **Theme Select**, light mode? dark mode? you choose.
- **[shadcn/ui](https://ui.shadcn.com/)**, change the UI library to `shadcn/ui`.
- **Faster Response Time**, added cache to improve the response time.
- **Revalidate Data**, data fetching now utilizing revalidate. (can be changed from config file)
> You can manually revalidate the data by sending POST request to `/api/revalidate` with `Authorization` header set to `{{ masterKey }}`.
### Changed
- **General Layout**, New fresh look with `shadcn/ui`.
- **Download Link**, Added token that will be expired after x hour(s). (can be changed from config file)
- **Raw Link**, Raw link only available for image, video, and audio files. Raw link are recommended for hosting web project assets or comments on forum.
- **Footer**, now you can customize the footer from config file, also added support for Markdown.
- **Data Fetching**, now using `Server Actions` instead of `API Routes`.
- **Encryption**, moved to `Server Actions`.
- **Typescript Config**, target changed from `ES5` to `ES2021`.
- **Environment Variable**, now focus on Server Side Environment Variable.
- `NEXT_PUBLIC_ENCRYPTION_KEY` changed to `ENCRYPTION_KEY`.
- `NEXT_PUBLIC_SITE_PASSWORD` changed to `SITE_PASSWORD`.
- `MASTER_KEY` added.
- **Configuration File**, things are changed:
- `version` bumped to `2.0`.
- `masterKey` moved to `Environment Variable`.
- `apiConfig.revalidate` added.
- `siteConfig.siteNameTemplate` added.
- `siteConfig.siteAuthor` added.
- `siteConfig.robots` added.
- `siteConfig.footer` added.
- `siteConfig.defaultAccentColor` deprecated.
- `siteConfig.breadcrumbMax` added.
- `siteConfig.toaster` added.
- `siteConfig.navbarItems[number].icons` changed to `lucide icons`.
- `siteConfig.supports` added.
### Fixed
- Rendering issue on mobile devices.
### Dependency Update
- `@iconify/react` switched to `lucide-react`.
- `@mdx-js/loader` removed.
- `@mdx-js/react` removed.
- `@next/mdx` removed.
- `@popperjs/core` removed.
- `next` updated to `^14.1.4`.
- `next-seo` removed.
- `tailwind-merge` updated to `^2.2.2`.
## What is this? ## What is this?
@@ -54,30 +103,43 @@ I know there are a lot of people selling cheap edu account for Google Drive and
## File Security ## File Security
All files fetched from Google Drive **NEED TO BE SHARED** with `Anyone with the link can view` permission. > This only apply if `maxFileSize` is enabled.
This is because Google Drive API can only access files that are shared with `Anyone with the link can view` permission.
But, every files `id` and `webContentLink` are encrypted with `AES-256-CBC` using your own key, so no one can access your files without the key. You need to set the file sharing permission to `Anyone with the link can view` for the files that you want to share.
Except, if the files are larger than the `fileSizeLimit` (default: 4MB for vercel), then the download will be redirected to the raw file link.
**Why?** because the download link will be redirected to the Google Drive download link, and it can only be accessed if the file is shared with `Anyone with the link can view` permission.
If not, the download link will be redirected to the Google Drive web content link, and it can be accessed without sharing the file.
But this might expose the file ID, so people might be able to access the files directly from Google Drive. (But I'm sure they can't access the folder itself, only the file that are being downloaded)
## Known Issue ## Known Issue
### File size limit ### File size limit
> This only apply if you're using platform that have file size limit, like Vercel.
> If you're using VPS or other platform that doesn't have file size limit, you can disable `maxFileSize` inside config file.
File size limit causing some files can't be previewed, and the download will be redirected to the raw file link. File size limit causing some files can't be previewed, and the download will be redirected to the raw file link.
**This won't be fixed**, because it's a limitation from the deployment platform itself.
### Long Response Time ### Long Response Time
The flow of the project are: From version 2.0, We are using Cache to improve the response time.
It might take a long time for deep nested folders, but once the data is fetched, it will be cached and the response time will be faster.
- Validate the path is valid or not. (Only applied for `/...[path]`) I also add [revalidate](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#revalidating-data) config on page file to 5 minutes. You can change the value inside `src/app/page.tsx` & `src/app/[...rest]/page.tsx`.
- Fetch the password, readme, and files from Google Drive.
- Show to the user.
So, if you have a lot of files in your Google Drive, it might take a long time to fetch all the files. ~~The flow of the project are:~~
To improve the response time, I added a cache to the response, so the next time you access the same path, it will be faster.
ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive. - ~~Validate the path is valid or not. (Only applied for `/...[path]`)~~
- ~~Fetch the password, readme, and files from Google Drive.~~
- ~~Show to the user.~~
~~So, if you have a lot of files in your Google Drive, it might take a long time to fetch all the files.
To improve the response time, I added a cache to the response, so the next time you access the same path, it will be faster.~~
~~ATM, it roughly take around 600 - 2 seconds to fetch all the data on my Google Drive.~~
### Shared Drive is now supported ### Shared Drive is now supported
@@ -88,3 +150,39 @@ Implemented by [@loadingthedev](https://github.com/loadingthedev) [(PR #4)](http
For now, I don't have any plan to implement this, because I think it's not necessary. For now, I don't have any plan to implement this, because I think it's not necessary.
All Google Drive files like Docs, Sheets, and Slides are hidden from the list. All Google Drive files like Docs, Sheets, and Slides are hidden from the list.
**PR are welcome if you want to implement this.**
## Migration Guide
If you're upgrading from version 1.x to version 2, there are some changes that you need to do:
### Configuration File
> You can check the new Configuration Schema on `/src/schema.ts`
There are some changes on the configuration file, here's the list of changes:
- `masterKey` **REMOVED**, we don't need this anymore
- `apiConfig.rootFolder` **CHANGED**, you need to encrypt the folder ID first
`https://drive-demo.mbaharip.com/api/internal/encrypt?q={{ folderId }}`
- `apiConfig.proxyThumbnail` **ADDED**, default value is `true`
- `siteConfig.siteNameTemplate` **ADDED**, default value is `%s - next-gdrive-index`
- `siteConfig.siteAuthor` **ADDED**, default value is `mbahArip`
- `siteConfig.robots` **ADDED**, default value is `noindex, nofollow`
- `siteConfig.footer` **ADDED**, default value is `Powered by next-gdrive-index`
- `siteConfig.defaultAccentColor` **REMOVED**, you can set the theme from `/src/app/globals.css` (more information about [shadcn/ui theme](https://ui.shadcn.com/docs/theming))
- `siteConfig.breadcrumbMax` **ADDED**, default value is `3`
- `siteConfig.toaster` **ADDED**, default value is `{ position: "bottom-right', duration: 3000 }`
- `siteConfig.navbarItems[number].icons` **CHANGED**, you need to use [Lucide Icons](https://lucide.dev/icons) now
- `siteConfig.supports` **ADDED**, you can leave it empty if you don't want to use it
### Environment Variable
Now all the environment variable are on the server side, so you need to change the environment variable:
- `NEXT_PUBLIC_ENCRYPTION_KEY` **CHANGED** to `ENCRYPTION_KEY`
- `NEXT_PUBLIC_SITE_PASSWORD` **CHANGED** to `SITE_PASSWORD`
- `NEXT_PUBLIC_VERCEL_URL` **CHANGED** to `NEXT_PUBLIC_DOMAIN`
-41
View File
@@ -1,41 +0,0 @@
- Redo how to handle error
# /
- ~~Override opengraph using banner image~~
- ~~Render readme file~~
- ~~Copy folder url~~
- ~~Copy file direct url~~
- ~~Download file url~~
- ~~Index password~~
# /:path
- ~~Override opengraph using banner image / thumbnail image~~
- ~~Render readme file~~
- ~~Copy folder url~~
- ~~Copy file direct url~~
- ~~Download file url~~
- ~~Password protected~~
- Issues: Return 500 error if it's wrong password
- File Preview
- 3D > 3DPreview
- Audio > AudioPreview
- Archive > UnknownPreview
- RichText > MarkdownPreview
- OfficeWord > OfficePreview
- OfficeExcel > OfficePreview
- OfficePowerPoint > OfficePreview
- PDF > PDFPreview
- Database > UnknownPreview
- Image > ImagePreview
- Code > CodePreview
- Text > TextPreview
- Video > VideoPreview
- Font > UnknownPreview
- Default > UnknownPreview
- Binary > UnknownPreview
# /download/:path
- ~~Download file based on path~~
- ~~Validate path~~
- Protected file
- Token based for protected file
+5 -17
View File
@@ -1,21 +1,10 @@
/** @type {import('next').NextConfig} */
// const withMDX = require("@next/mdx")({
// extension: /\.mdx?$/,
// options: {
// // If you use remark-gfm, you'll need to use next.config.mjs
// // as the package is ESM only
// // https://github.com/remarkjs/remark-gfm#install
// remarkPlugins: [],
// rehypePlugins: [],
// // If you use `MDXProvider`, uncomment the following line.
// providerImportSource: "@mdx-js/react",
// },
// });
const nextConfig = { const nextConfig = {
// pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
reactStrictMode: true, reactStrictMode: true,
webpack: (config) => {
config.resolve.alias.canvas = false;
return config;
},
// async headers() { // async headers() {
// return [ // return [
// { // {
@@ -31,5 +20,4 @@ const nextConfig = {
// }, // },
}; };
// module.exports = withMDX(nextConfig);
module.exports = nextConfig; module.exports = nextConfig;
+3 -3
View File
@@ -45,7 +45,7 @@
"clsx": "^2.1.0", "clsx": "^2.1.0",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.0.0", "embla-carousel-react": "^8.0.1",
"googleapis": "^118.0.0", "googleapis": "^118.0.0",
"input-otp": "^1.2.3", "input-otp": "^1.2.3",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
@@ -95,8 +95,8 @@
"eslint": "8.38.0", "eslint": "8.38.0",
"eslint-config-next": "^14.1.4", "eslint-config-next": "^14.1.4",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^2.8.7", "prettier": "3.0.0",
"prettier-plugin-tailwindcss": "^0.2.7", "prettier-plugin-tailwindcss": "0.5.12",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "5.0.4" "typescript": "5.0.4"
} }
+1 -1
View File
@@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };
+6 -9
View File
@@ -1,7 +1,6 @@
import Link from "next/link";
import Button from "components/Button"; import Button from "components/Button";
import LoaderLayout from "components/Layout/Loader"; import LoaderLayout from "components/Layout/Loader";
import Link from "next/link";
export default function NotFoundPage() { export default function NotFoundPage() {
return ( return (
@@ -10,24 +9,22 @@ export default function NotFoundPage() {
title: "Not Found", title: "Not Found",
}} }}
> >
<div className='w-full flex h-full flex-col gap-4 relative items-center justify-center'> <div className='relative flex h-full w-full flex-col items-center justify-center gap-4'>
<figure> <figure>
<img <img
src='/images/404.png' src='/images/404.png'
alt='Page not found' alt='Page not found'
className='w-64 h-64 tablet:w-96 tablet:h-96 object-contain' className='h-64 w-64 object-contain tablet:h-96 tablet:w-96'
/> />
</figure> </figure>
<div className='w-full flex items-center justify-center flex-col my-8'> <div className='my-8 flex w-full flex-col items-center justify-center'>
<span className='text-lg font-medium'> <span className='text-lg font-medium'>
The page you are trying to access is not found. The page you are trying to access is not found.
</span> </span>
<span> <span>Please check the URL or go back to the homepage.</span>
Please check the URL or go back to the homepage.
</span>
<div className='flex items-center justify-between flex-col tablet:flex-row gap-2 tablet:gap-4 my-4 tablet:my-12'> <div className='my-4 flex flex-col items-center justify-between gap-2 tablet:my-12 tablet:flex-row tablet:gap-4'>
<Link href='/'> <Link href='/'>
<Button <Button
variant='accent' variant='accent'
+63 -30
View File
@@ -1,23 +1,24 @@
import axios from "axios"; import axios from "axios";
import gIndexConfig from "config";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import ExplorerLayout from "components/Layout/Explorer"; import ExplorerLayout from "components/Layout/Explorer";
import LoaderLayout from "components/Layout/Loader"; import LoaderLayout from "components/Layout/Loader";
import PasswordLayout from "components/Layout/Password"; import PasswordLayout from "components/Layout/Password";
import PreviewLayout from "components/Layout/Preview"; import PreviewLayout from "components/Layout/Preview";
import gIndexConfig from "config";
import { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { IGDriveFiles } from "types/api/files";
import {
APIGetFileResponse,
APIGetPasswordResponse,
APIGetReadmeResponse,
} from "types/api/response";
import { Constant } from "types/constant";
import { decryptData, encryptData } from "utils/encryptionHelper/hash"; import { decryptData, encryptData } from "utils/encryptionHelper/hash";
import { gdriveFilesList } from "utils/gdrive"; import { gdriveFilesList } from "utils/gdrive";
import gdrive from "utils/gdriveInstance"; import gdrive from "utils/gdriveInstance";
import { addNewPassword, checkPathPassword } from "utils/passwordHelper"; import { addNewPassword, checkPathPassword } from "utils/passwordHelper";
import { IGDriveFiles } from "types/api/files";
import { APIGetFileResponse, APIGetPasswordResponse, APIGetReadmeResponse } from "types/api/response";
import { Constant } from "types/constant";
interface FilePathPageProps { interface FilePathPageProps {
mappedEncryptedPath: Record<"name" | "id" | "mimeType", string>[]; mappedEncryptedPath: Record<"name" | "id" | "mimeType", string>[];
} }
@@ -44,7 +45,9 @@ export default function FilePathPage(props: FilePathPageProps) {
useEffect(() => { useEffect(() => {
setIsLoadingData(true); setIsLoadingData(true);
const lastPathMimeType = props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].mimeType; const lastPathMimeType =
props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1]
.mimeType;
let isLastPathFile = false; let isLastPathFile = false;
if (lastPathMimeType === "application/vnd.google-apps.folder") { if (lastPathMimeType === "application/vnd.google-apps.folder") {
isLastPathFile = false; isLastPathFile = false;
@@ -61,13 +64,15 @@ export default function FilePathPage(props: FilePathPageProps) {
}); });
const _getData = axios.get<APIGetFileResponse>("/api/getData", { const _getData = axios.get<APIGetFileResponse>("/api/getData", {
params: { params: {
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id, encryptedId:
props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
isFile: isLastPathFile ? "1" : undefined, isFile: isLastPathFile ? "1" : undefined,
}, },
}); });
const _getReadme = axios.get<APIGetReadmeResponse>("/api/getReadme", { const _getReadme = axios.get<APIGetReadmeResponse>("/api/getReadme", {
params: { params: {
encryptedId: props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id, encryptedId:
props.mappedEncryptedPath[props.mappedEncryptedPath.length - 1].id,
}, },
}); });
@@ -77,13 +82,18 @@ export default function FilePathPage(props: FilePathPageProps) {
if (passwordData.data.data && passwordData.data.data.length) { if (passwordData.data.data && passwordData.data.data.length) {
let isPathProtected = true; let isPathProtected = true;
const savedPasswordCookie = const savedPasswordCookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ?? document.cookie
undefined; .split(";")
const savedPasswordValue = savedPasswordCookie?.split("=")[1] ?? undefined; .find((cookie) =>
cookie.startsWith(`${Constant.cookies_SitePassword}=`),
) ?? undefined;
const savedPasswordValue =
savedPasswordCookie?.split("=")[1] ?? undefined;
if (savedPasswordValue) { if (savedPasswordValue) {
// Check only nearest path // Check only nearest path
const nearestPassword = passwordData.data.data[passwordData.data.data.length - 1]; const nearestPassword =
passwordData.data.data[passwordData.data.data.length - 1];
setNearestProtected(nearestPassword.relativePath); setNearestProtected(nearestPassword.relativePath);
const isPasswordValid = checkPathPassword( const isPasswordValid = checkPathPassword(
nearestPassword.relativePath, nearestPassword.relativePath,
@@ -119,10 +129,15 @@ export default function FilePathPage(props: FilePathPageProps) {
return ( return (
<LoaderLayout <LoaderLayout
seo={{ seo={{
title: props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].name, title:
props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1]
.name,
openGraph: { openGraph: {
type: "website", type: "website",
title: props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].name, title:
props.mappedEncryptedPath[
(props.mappedEncryptedPath.length ?? 1) - 1
].name,
description: gIndexConfig.siteConfig.siteDescription, description: gIndexConfig.siteConfig.siteDescription,
url: process.env.NEXT_PUBLIC_VERCEL_URL, url: process.env.NEXT_PUBLIC_VERCEL_URL,
siteName: gIndexConfig.siteConfig.siteName, siteName: gIndexConfig.siteConfig.siteName,
@@ -137,15 +152,15 @@ export default function FilePathPage(props: FilePathPageProps) {
}} }}
> >
{isLoadingData ? ( {isLoadingData ? (
<div className='w-full h-full flex items-center justify-center'> <div className='flex h-full w-full items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'> <div className='flex animate-pulse flex-col items-center justify-center gap-4'>
<div className='w-fit h-fit relative grid place-items-center'> <div className='relative grid h-fit w-fit place-items-center'>
<img <img
src={gIndexConfig.siteConfig.siteIcon} src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName} alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative' className='relative -top-1 w-12'
/> />
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' /> <div className='border-primary-50 absolute h-12 w-12 animate-ping rounded-full border bg-transparent' />
</div> </div>
<span>Fetching {isFile ? "file info" : "folder contents"}...</span> <span>Fetching {isFile ? "file info" : "folder contents"}...</span>
</div> </div>
@@ -157,9 +172,16 @@ export default function FilePathPage(props: FilePathPageProps) {
path={nearestProtected} path={nearestProtected}
onSubmitted={(password) => { onSubmitted={(password) => {
const cookie = const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ?? document.cookie
undefined; .split(";")
addNewPassword(decodeURIComponent(nearestProtected), password, cookie?.split("=")[1] ?? undefined); .find((cookie) =>
cookie.startsWith(`${Constant.cookies_SitePassword}=`),
) ?? undefined;
addNewPassword(
decodeURIComponent(nearestProtected),
password,
cookie?.split("=")[1] ?? undefined,
);
router.reload(); router.reload();
}} }}
/> />
@@ -171,7 +193,11 @@ export default function FilePathPage(props: FilePathPageProps) {
data={data} data={data}
isProtected={false} isProtected={false}
isFileProtected={isFileProtected} isFileProtected={isFileProtected}
encryptedId={props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].id} encryptedId={
props.mappedEncryptedPath[
(props.mappedEncryptedPath.length ?? 1) - 1
].id
}
/> />
) : ( ) : (
<ExplorerLayout <ExplorerLayout
@@ -180,7 +206,11 @@ export default function FilePathPage(props: FilePathPageProps) {
readmeFile={readmeFile} readmeFile={readmeFile}
isProtected={false} isProtected={false}
isFileProtected={isFileProtected} isFileProtected={isFileProtected}
encryptedId={props.mappedEncryptedPath[(props.mappedEncryptedPath.length ?? 1) - 1].id} encryptedId={
props.mappedEncryptedPath[
(props.mappedEncryptedPath.length ?? 1) - 1
].id
}
/> />
)} )}
</> </>
@@ -223,7 +253,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}; };
}); });
const [rootId, pathId] = await Promise.all([fetchRootId, Promise.all(fetchPathId)]); const [rootId, pathId] = await Promise.all([
fetchRootId,
Promise.all(fetchPathId),
]);
if (pathId.some((path) => !path.data.length)) { if (pathId.some((path) => !path.data.length)) {
return { return {
+7 -10
View File
@@ -1,4 +1,10 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Button from "components/Button";
import ButtonGroup from "components/ButtonGroup";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Modal from "components/Modal";
import Tooltip from "components/Tooltip";
import gIndexConfig from "config"; import gIndexConfig from "config";
import { DefaultSeo } from "next-seo"; import { DefaultSeo } from "next-seo";
import { AppProps } from "next/app"; import { AppProps } from "next/app";
@@ -8,21 +14,12 @@ import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import "styles/globals.css";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import twColor from "tailwindcss/colors"; import twColor from "tailwindcss/colors";
import Button from "components/Button";
import ButtonGroup from "components/ButtonGroup";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Modal from "components/Modal";
import Tooltip from "components/Tooltip";
import hexToRgb from "utils/hexToRGB"; import hexToRgb from "utils/hexToRGB";
import { removeAllPassword } from "utils/passwordHelper"; import { removeAllPassword } from "utils/passwordHelper";
import "styles/globals.css";
const kanit = Kanit({ const kanit = Kanit({
display: "auto", display: "auto",
weight: ["300", "400", "500", "600", "700", "800", "900"], weight: ["300", "400", "500", "600", "700", "800", "900"],
+12 -23
View File
@@ -1,16 +1,11 @@
import axios from "axios"; import axios from "axios";
import ExplorerLayout from "components/Layout/Explorer";
import LoaderLayout from "components/Layout/Loader";
import gIndexConfig from "config"; import gIndexConfig from "config";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ExplorerLayout from "components/Layout/Explorer";
import LoaderLayout from "components/Layout/Loader";
import { IGDriveFiles } from "types/api/files"; import { IGDriveFiles } from "types/api/files";
import { import { APIGetFileResponse, APIGetReadmeResponse } from "types/api/response";
APIGetFileResponse,
APIGetReadmeResponse,
} from "types/api/response";
interface StateDataProps { interface StateDataProps {
file: IGDriveFiles | null; file: IGDriveFiles | null;
@@ -26,19 +21,13 @@ export default function RootPage() {
folders: [], folders: [],
pageToken: null, pageToken: null,
}); });
const [readmeFile, setReadmeFile] = useState< const [readmeFile, setReadmeFile] = useState<string | null>(null);
string | null const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
>(null);
const [isLoadingData, setIsLoadingData] =
useState<boolean>(true);
useEffect(() => { useEffect(() => {
setIsLoadingData(true); setIsLoadingData(true);
const _getData = const _getData = axios.get<APIGetFileResponse>("/api/getData");
axios.get<APIGetFileResponse>("/api/getData"); const _getReadme = axios.get<APIGetReadmeResponse>("/api/getReadme");
const _getReadme = axios.get<APIGetReadmeResponse>(
"/api/getReadme",
);
Promise.all([_getData, _getReadme]) Promise.all([_getData, _getReadme])
.then(([fileData, readmeData]) => { .then(([fileData, readmeData]) => {
@@ -60,15 +49,15 @@ export default function RootPage() {
return ( return (
<LoaderLayout> <LoaderLayout>
{isLoadingData ? ( {isLoadingData ? (
<div className='w-full h-full flex items-center justify-center'> <div className='flex h-full w-full items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'> <div className='flex animate-pulse flex-col items-center justify-center gap-4'>
<div className='w-fit h-fit relative grid place-items-center'> <div className='relative grid h-fit w-fit place-items-center'>
<img <img
src={gIndexConfig.siteConfig.siteIcon} src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName} alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative' className='relative -top-1 w-12'
/> />
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' /> <div className='border-primary-50 absolute h-12 w-12 animate-ping rounded-full border bg-transparent' />
</div> </div>
<span>Fetching folder contents...</span> <span>Fetching folder contents...</span>
</div> </div>
+51 -13
View File
@@ -1,14 +1,20 @@
"use client"; "use client";
import { useContext, useEffect, useState } from "react"; import Link from "next/link";
import { usePathname } from "next/navigation";
import { useContext, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon"; import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { LayoutContext } from "~/context/layoutContext"; import { LayoutContext } from "~/context/layoutContext";
import { Schema_File } from "~/schema";
import { cn } from "~/utils"; import config from "~/config/gIndex.config";
import FileGrid from "./@file.grid"; import FileGrid from "./@file.grid";
import FileList from "./@file.list"; import FileList from "./@file.list";
@@ -17,9 +23,21 @@ import { GetFiles } from "./actions";
type Props = { type Props = {
files: z.infer<typeof Schema_File>[]; files: z.infer<typeof Schema_File>[];
nextPageToken?: string; nextPageToken?: string;
root?: boolean;
}; };
export default function FileBrowser({ files, nextPageToken }: Props) { export default function FileBrowser({ files, nextPageToken, root }: Props) {
const { layout } = useContext(LayoutContext); const { layout } = useContext(LayoutContext);
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] = const [fileList, setFileList] =
useState<z.infer<typeof Schema_File>[]>(files); useState<z.infer<typeof Schema_File>[]>(files);
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken); const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
@@ -36,8 +54,13 @@ export default function FileBrowser({ files, nextPageToken }: Props) {
try { try {
if (!nextToken) throw new Error("No more files to load"); if (!nextToken) throw new Error("No more files to load");
const data = await GetFiles({ pageToken: nextToken }); const data = await GetFiles({ pageToken: nextToken });
const uniqueData = new Set([...fileList, ...data.files]); const uniqueData = [...fileList, ...data.files].filter(
setFileList(Array.from(uniqueData)); (item, index, array) =>
index === array.findIndex((i) => i.encryptedId === item.encryptedId),
);
setFileList(uniqueData);
// const uniqueData = new Set([...fileList, ...data.files]);
// setFileList([...uniqueData]);
setNextToken(data.nextPageToken); setNextToken(data.nextPageToken);
} catch (error) { } catch (error) {
const e = error as Error; const e = error as Error;
@@ -76,20 +99,35 @@ export default function FileBrowser({ files, nextPageToken }: Props) {
"gap-3 p-3", "gap-3 p-3",
"flex-grow-0", "flex-grow-0",
"flex flex-col", "flex flex-col",
// layout === "grid"
// ? "grid grid-cols-1 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4"
// : "flex flex-col",
)} )}
> >
{!root && (
<Button
size={"sm"}
variant={"ghost"}
className='justify-start'
asChild
>
<Link href={prevPath}>
<Icon
name='CornerLeftUp'
size={14}
className='mr-3'
/>
Back
</Link>
</Button>
)}
{!fileList.length && ( {!fileList.length && (
<div className='col-span-full flex flex-col items-center justify-center py-6'> <div className='col-span-full flex h-full min-h-[25vh] flex-col items-center justify-center gap-3'>
<p className='large'>
<Icon <Icon
name='Frown' name='Frown'
size={32} size={32}
className='text-muted-foreground'
/> />
</p> <span className='text-center text-muted-foreground'>
<p className='muted'>No files found</p> There are no files in this folder
</span>
</div> </div>
)} )}
{layout === "list" && ( {layout === "list" && (
+197 -19
View File
@@ -2,34 +2,197 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import Icon from "~/components/Icon";
import { Separator } from "~/components/ui/separator";
import { Schema_File } from "~/schema"; import { Schema_File } from "~/schema";
import { cn } from "~/utils"; import { cn } from "~/utils";
import Icon from "~/components/Icon";
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 useMediaQuery from "~/hooks/useMediaQuery";
import bytesToReadable from "~/utils/bytesFormat"; import bytesToReadable from "~/utils/bytesFormat";
import { durationToReadable } from "~/utils/durationFormat"; import { durationToReadable } from "~/utils/durationFormat";
import { getPreviewIcon } from "~/utils/previewHelper"; import { getPreviewIcon } from "~/utils/previewHelper";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
type Props = { type Props = {
data: z.infer<typeof Schema_File>; data: z.infer<typeof Schema_File>;
}; };
export default function FileGrid({ data }: Props) { export default function FileGrid({ data }: Props) {
const pathname = usePathname(); const pathname = usePathname();
const filePath = useMemo<string>(() => { const filePath = useMemo<string>(() => {
const path = [pathname, encodeURIComponent(data.name)].join("/"); const path = [pathname, encodeURIComponent(data.name)]
.join("/")
.replace(/\/+/g, "/");
// If start with /, remove it return new URL(path, config.basePath).pathname;
if (path.startsWith("/")) {
return path.slice(1);
} else {
return path;
}
}, [data, pathname]); }, [data, pathname]);
const [actionOpen, setActionOpen] = useState<boolean>(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const onCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
toast.promise(
navigator.clipboard.writeText(
new URL(filePath, config.basePath).toString(),
),
{
loading: "Copying link...",
success: "Link copied!",
error: "Failed to copy link",
},
);
} catch (error) {
const e = error as Error;
console.error(e.message);
}
};
const onDownload = async (e: React.MouseEvent) => {
e.stopPropagation();
toast.loading("Creating download token...", {
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}`);
}, 1000);
} catch (error) {
const e = error as Error;
console.error(e.message);
toast.error(e.message, {
id: `download-${data.encryptedId}`,
});
}
};
return ( return (
<> <div className='relative h-full w-full'>
{isDesktop ? (
<DropdownMenu
open={actionOpen}
onOpenChange={setActionOpen}
>
<DropdownMenuTrigger asChild>
<Button
size={"icon"}
variant={"secondary"}
className='absolute right-1 top-1 z-20 size-8'
>
<Icon
name='EllipsisVertical'
size={16}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={onCopy}>
<Icon
name='Link'
className='mr-3'
size={"1rem"}
/>
Copy link
</DropdownMenuItem>
{data.mimeType.includes("folder") ? null : (
<DropdownMenuItem onClick={onDownload}>
<Icon
name='Download'
className='mr-3'
size={"1rem"}
/>
Download
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Drawer
open={actionOpen}
onOpenChange={setActionOpen}
shouldScaleBackground
>
<DrawerTrigger asChild>
<Button
size={"icon"}
variant={"secondary"}
className='absolute right-1 top-1 z-20 size-8'
>
<Icon
name='EllipsisVertical'
size={16}
/>
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className='text-left'>
<DrawerTitle>Actions</DrawerTitle>
<DrawerDescription>
What would you like to do with this file?
</DrawerDescription>
</DrawerHeader>
<div className='grid gap-1.5 px-4'>
<DrawerClose asChild>
<Button onClick={onCopy}>
<Icon
name='Link'
className='mr-3'
size={"1rem"}
/>
Copy link
</Button>
</DrawerClose>
{data.mimeType.includes("folder") ? null : (
<DrawerClose asChild>
<Button onClick={onDownload}>
<Icon
name='Download'
className='mr-3'
size={"1rem"}
/>
Download
</Button>
</DrawerClose>
)}
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant={"secondary"}>Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)}
<Link <Link
href={filePath} href={filePath}
className={cn( className={cn(
@@ -49,7 +212,7 @@ export default function FileGrid({ data }: Props) {
)} )}
> >
{/* If it's media, show thumbnail */} {/* If it's media, show thumbnail */}
<div className='relative grid h-32 w-full flex-shrink-0 place-items-center bg-muted/25'> <div className='relative grid h-32 w-full flex-shrink-0 place-items-center overflow-hidden bg-muted/25'>
{data.thumbnailLink && {data.thumbnailLink &&
(data.mimeType.startsWith("video") || (data.mimeType.startsWith("video") ||
data.mimeType.startsWith("image")) ? ( data.mimeType.startsWith("image")) ? (
@@ -57,7 +220,12 @@ export default function FileGrid({ data }: Props) {
<img <img
src={`/api/thumb/${data.encryptedId}`} src={`/api/thumb/${data.encryptedId}`}
alt={data.name} alt={data.name}
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain' className='rounded-top-[var(--radius)] absolute -z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-cover opacity-50'
/>
<img
src={`/api/thumb/${data.encryptedId}`}
alt={data.name}
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain backdrop-blur'
/> />
{data.mimeType.startsWith("video") && ( {data.mimeType.startsWith("video") && (
@@ -89,27 +257,37 @@ export default function FileGrid({ data }: Props) {
{/* File data */} {/* File data */}
<div className='flex h-full w-full flex-col justify-between gap-1.5 px-3 py-1.5'> <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-balance break-all'> <span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all'>
{data.fileExtension {data.fileExtension
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "") ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
: data.name} : data.name}
</span> </span>
<Separator /> <div className='muted flex items-center gap-1'>
<div className='muted flex items-center justify-between gap-1'> <span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
<span className='whitespace-nowrap text-sm'> {data.mimeType.includes("folder")
{new Date(data.modifiedTime).toLocaleDateString()} ? "folder"
: data.fileExtension}
</span> </span>
{!data.mimeType.includes("folder") && ( {!data.mimeType.includes("folder") && (
<> <>
<Icon
name='Slash'
size={"0.875rem"}
/>
<span className='whitespace-nowrap text-sm'> <span className='whitespace-nowrap text-sm'>
{bytesToReadable(data.size || 0)} {bytesToReadable(data.size || 0)}
</span> </span>
</> </>
)} )}
</div> </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> </div>
</Link> </Link>
</> </div>
); );
} }
+184 -95
View File
@@ -2,23 +2,37 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon"; import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "~/components/ui/drawer";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import config from "~/config/gIndex.config";
import { Schema_File } from "~/schema"; import useMediaQuery from "~/hooks/useMediaQuery";
import { cn } from "~/utils";
import bytesToReadable from "~/utils/bytesFormat"; import bytesToReadable from "~/utils/bytesFormat";
import { getPreviewIcon } from "~/utils/previewHelper"; import { getPreviewIcon } from "~/utils/previewHelper";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions"; import { CreateDownloadToken } from "./actions";
type Props = { type Props = {
@@ -26,26 +40,170 @@ type Props = {
}; };
export default function FileList({ data }: Props) { export default function FileList({ data }: Props) {
const pathname = usePathname(); const pathname = usePathname();
const filePath = useMemo<string>(() => {
const path = [pathname, encodeURIComponent(data.name)].join("/");
// If start with /, remove it const filePath = useMemo<string>(() => {
if (path.startsWith("/")) { const path = [pathname, encodeURIComponent(data.name)]
return path.slice(1); .join("/")
} else { .replace(/\/+/g, "/");
return path;
} return new URL(path, config.basePath).pathname;
}, [data, pathname]); }, [data, pathname]);
const [actionOpen, setActionOpen] = useState<boolean>(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const onCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
toast.promise(
navigator.clipboard.writeText(
new URL(filePath, config.basePath).toString(),
),
{
loading: "Copying link...",
success: "Link copied!",
error: "Failed to copy link",
},
);
} catch (error) {
const e = error as Error;
console.error(e.message);
}
};
const onDownload = async (e: React.MouseEvent) => {
e.stopPropagation();
toast.loading("Creating download token...", {
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}`);
}, 1000);
} catch (error) {
const e = error as Error;
console.error(e.message);
toast.error(e.message, {
id: `download-${data.encryptedId}`,
});
}
};
return ( return (
<> <div className='relative flex h-full w-full flex-row-reverse items-center'>
<div className='absolute bottom-auto right-0 top-auto grid flex-grow place-items-center'>
{isDesktop ? (
<DropdownMenu
open={actionOpen}
onOpenChange={setActionOpen}
>
<DropdownMenuTrigger asChild>
<Button
size={"icon"}
variant={"ghost"}
// className='size-8'
className='absolute right-1 z-20 size-8'
>
<Icon
name='EllipsisVertical'
size={16}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={onCopy}>
<Icon
name='Link'
className='mr-3'
size={"1rem"}
/>
Copy link
</DropdownMenuItem>
{data.mimeType.includes("folder") ? null : (
<DropdownMenuItem onClick={onDownload}>
<Icon
name='Download'
className='mr-3'
size={"1rem"}
/>
Download
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Drawer
open={actionOpen}
onOpenChange={setActionOpen}
shouldScaleBackground
>
<DrawerTrigger asChild>
<Button
size={"icon"}
variant={"ghost"}
// className='size-8'
className='z-20 size-8'
>
<Icon
name='EllipsisVertical'
size={16}
/>
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className='text-left'>
<DrawerTitle>Actions</DrawerTitle>
<DrawerDescription>
What would you like to do with this file?
</DrawerDescription>
</DrawerHeader>
<div className='grid gap-1.5 px-4'>
<DrawerClose asChild>
<Button onClick={onCopy}>
<Icon
name='Link'
className='mr-3'
size={"1rem"}
/>
Copy link
</Button>
</DrawerClose>
{data.mimeType.includes("folder") ? null : (
<DrawerClose asChild>
<Button onClick={onDownload}>
<Icon
name='Download'
className='mr-3'
size={"1rem"}
/>
Download
</Button>
</DrawerClose>
)}
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant={"secondary"}>Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)}
</div>
<Link <Link
href={filePath} href={filePath}
className={cn( className={cn(
"w-full", "relative",
"px-1.5 py-1", // "w-full",
"px-1.5 py-1 pr-10", // since action button is size-8
"rounded-[var(--radius)]", "rounded-[var(--radius)]",
"flex items-center justify-between gap-3", "flex flex-grow items-center justify-between gap-3",
"hover:bg-muted/50", "hover:bg-muted/50",
"transition", "transition",
)} )}
@@ -94,18 +252,15 @@ export default function FileList({ data }: Props) {
</span> </span>
<div className='muted flex items-center gap-1'> <div className='muted flex items-center gap-1'>
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'> <span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
{data.mimeType.includes("folder") ? null : data.mimeType} {data.mimeType.includes("folder")
</span> ? "folder"
</div> : data.fileExtension}
<div className='muted flex items-center gap-1'>
<span className='whitespace-nowrap text-sm'>
{new Date(data.modifiedTime).toLocaleDateString()}
</span> </span>
{!data.mimeType.includes("folder") && ( {!data.mimeType.includes("folder") && (
<> <>
<Icon <Icon
name='Slash' name='Slash'
size={14} size={"0.875rem"}
/> />
<span className='whitespace-nowrap text-sm'> <span className='whitespace-nowrap text-sm'>
{bytesToReadable(data.size || 0)} {bytesToReadable(data.size || 0)}
@@ -113,80 +268,14 @@ export default function FileList({ data }: Props) {
</> </>
)} )}
</div> </div>
<div className='muted flex items-center gap-1'>
<span className='whitespace-nowrap text-sm'>
{new Date(data.modifiedTime).toLocaleDateString()}
</span>
</div>
</div> </div>
{/* Actions */}
<DropdownMenu>
<DropdownMenuTrigger>
<Button
size={"icon"}
variant={"ghost"}
>
<Icon
name='EllipsisVertical'
size={16}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={async (e) => {
e.stopPropagation();
try {
toast.promise(
navigator.clipboard.writeText(
new URL(filePath, config.basePath).toString(),
),
{
loading: "Copying link...",
success: "Link copied!",
error: "Failed to copy link",
},
);
} catch (error) {
const e = error as Error;
console.error(e.message);
}
}}
>
Copy link
</DropdownMenuItem>
<DropdownMenuItem
disabled={data.mimeType.includes("folder")}
onClick={async (e) => {
e.stopPropagation();
toast.loading("Creating download token...", {
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}`,
);
}, 1000);
} catch (error) {
const e = error as Error;
console.error(e.message);
toast.error(e.message, {
id: `download-${data.encryptedId}`,
});
}
}}
>
Download
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</Link> </Link>
</> </div>
); );
} }
+28 -24
View File
@@ -4,6 +4,8 @@ import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import Link from "next/link"; import Link from "next/link";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { z } from "zod"; import { z } from "zod";
import { Schema_Breadcrumb } from "~/schema";
import Icon from "~/components/Icon"; import Icon from "~/components/Icon";
import { import {
Breadcrumb, Breadcrumb,
@@ -31,9 +33,10 @@ import {
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import config from "~/config/gIndex.config";
import useMediaQuery from "~/hooks/useMediaQuery"; import useMediaQuery from "~/hooks/useMediaQuery";
import { Schema_Breadcrumb } from "~/schema";
import config from "~/config/gIndex.config";
type Props = { type Props = {
data: z.infer<typeof Schema_Breadcrumb>[]; data: z.infer<typeof Schema_Breadcrumb>[];
@@ -43,14 +46,15 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
if (loading) return <Skeleton className='my-1.5 h-6 w-1/2' />; if (loading) return <Skeleton className='h-5 w-1/2' />;
return ( return (
<div className='flex w-full flex-nowrap items-center'> <div className='flex w-full flex-nowrap items-center'>
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink href={"/"}> <BreadcrumbLink asChild>
<Link href={"/"}>
<div className='flex items-center gap-1'> <div className='flex items-center gap-1'>
<Icon <Icon
name='FolderRoot' name='FolderRoot'
@@ -58,8 +62,9 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
/> />
~ ~
</div> </div>
</Link>
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem>
{!!data.length ? ( {!!data.length ? (
<> <>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
@@ -81,14 +86,16 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
<DropdownMenuItem <DropdownMenuItem
key={`${item.label}/${item.href}`} key={`${item.label}/${item.href}`}
> >
<BreadcrumbLink <BreadcrumbLink asChild>
href={array <Link
href={`/${array
.slice(0, array.indexOf(item) + 1) .slice(0, array.indexOf(item) + 1)
.map((item) => item.href) .map((item) => item.href)
.join("/")} .join("/")}`}
className='w-full' className='w-full'
> >
{item.label} {item.label}
</Link>
</BreadcrumbLink> </BreadcrumbLink>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -116,26 +123,24 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
<BreadcrumbItem <BreadcrumbItem
key={`${item.label}/${item.href}`} key={`${item.label}/${item.href}`}
> >
<BreadcrumbLink asChild>
<Link <Link
href={array href={`/${array
.slice(0, array.indexOf(item) + 1) .slice(0, array.indexOf(item) + 1)
.map((item) => item.href) .map((item) => item.href)
.join("/")} .join("/")}`}
className='w-full py-1.5' className='w-full py-1.5'
> >
{item.label} {item.label}
</Link> </Link>
</BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
))} ))}
</div> </div>
<DrawerFooter className='pt-4'>
<DrawerFooter>
<DrawerClose asChild> <DrawerClose asChild>
<Button <Button variant={"secondary"}>Close</Button>
size={"sm"}
variant={"outline"}
>
Close
</Button>
</DrawerClose> </DrawerClose>
</DrawerFooter> </DrawerFooter>
</DrawerContent> </DrawerContent>
@@ -146,20 +151,20 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
</> </>
) : null} ) : null}
{data {data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
.slice(-config.siteConfig.breadcrumbMax + 1)
.map((item) => (
<Fragment key={`${item.label}/${item.href}`}> <Fragment key={`${item.label}/${item.href}`}>
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'> <BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
{item.href ? ( {item.href ? (
<> <>
<BreadcrumbLink <BreadcrumbLink asChild>
href={data <Link
href={`/${data
.map((item) => item.href) .map((item) => item.href)
.join("/") .join("/")
.replace(/\/\//g, "/")} .replace(/\/\//g, "/")}`}
> >
{item.label} {item.label}
</Link>
</BreadcrumbLink> </BreadcrumbLink>
</> </>
) : ( ) : (
@@ -173,7 +178,6 @@ export default function HeaderBreadcrumb({ data, loading }: Props) {
))} ))}
</> </>
) : null} ) : null}
</BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
</div> </div>
+542 -35
View File
@@ -1,38 +1,115 @@
"use client"; "use client";
import { PropsWithChildren, useContext } from "react"; import { PropsWithChildren, useContext, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon"; import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { import {
Select, Drawer,
SelectContent, DrawerClose,
SelectItem, DrawerContent,
SelectTrigger, DrawerDescription,
SelectValue, DrawerFooter,
} from "~/components/ui/select"; DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "~/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Input } from "~/components/ui/input";
import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { LayoutContext, TLayout } from "~/context/layoutContext";
import { cn } from "~/utils";
export default function HeaderButton({ import { LayoutContext } from "~/context/layoutContext";
children, import useMediaQuery from "~/hooks/useMediaQuery";
loading, import useRouter from "~/hooks/usePRouter";
}: PropsWithChildren<{ loading?: boolean }>) { import bytesToReadable from "~/utils/bytesFormat";
import { getPreviewIcon } from "~/utils/previewHelper";
import { RedirectSearchFile, SearchFile } from "./actions";
export default function HeaderButton({ children }: PropsWithChildren) {
const { layout, setLayout } = useContext(LayoutContext); const { layout, setLayout } = useContext(LayoutContext);
const [layoutOpen, setLayoutOpen] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
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(() => {
setLoading(false);
}, []);
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) { if (loading) {
return ( return (
<div className={cn("w-full", "flex items-center justify-end gap-1.5")}> <div className={cn("w-fit", "flex items-center justify-end gap-1.5")}>
<Skeleton className='my-0.5 h-8 w-16 tablet:w-24' />{" "} <Skeleton className='my-0.5 h-8 w-16 mobile:w-24' />{" "}
<Skeleton className='my-0.5 h-8 w-8' /> <Skeleton className='my-0.5 h-8 w-8' />
</div> </div>
); );
@@ -41,33 +118,147 @@ export default function HeaderButton({
return ( return (
<div <div
className={cn( className={cn(
"mx-auto max-w-screen-desktop flex-grow", // "mx-auto max-w-screen-desktop flex-grow",
"w-fit",
"flex items-center justify-end gap-1.5", "flex items-center justify-end gap-1.5",
)} )}
> >
<Select {isDesktop ? (
value={layout} <DropdownMenu
onValueChange={(value) => setLayout(value as TLayout)} open={layoutOpen}
onOpenChange={setLayoutOpen}
>
<DropdownMenuTrigger asChild>
<Button
variant={"outline"}
className='flex w-24 items-center justify-between gap-1.5'
> >
<SelectTrigger className='w-16 tablet:w-24'>
<SelectValue placeholder={"Layout"}>
<div className='flex items-center gap-1.5'> <div className='flex items-center gap-1.5'>
<Icon <Icon
name={layout === "grid" ? "LayoutGrid" : "LayoutList"} name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
size={16} size={16}
/> />
<span className={cn("hidden", "tablet:block")}> <span>{layout === "grid" ? "Grid" : "List"}</span>
{layout === "grid" ? "Grid" : "List"}
</span>
</div> </div>
</SelectValue> <Icon
</SelectTrigger> name='ChevronDown'
<SelectContent align='end'> className={cn("transition", layoutOpen && "rotate-180")}
<SelectItem value='grid'>Grid</SelectItem> size={14}
<SelectItem value='list'>List</SelectItem> />
</SelectContent> </Button>
</Select> </DropdownMenuTrigger>
<Dialog> <DropdownMenuContent align='end'>
<DropdownMenuItem
className={cn(
"gap-1.5",
layout === "grid" && "bg-primary text-primary-foreground",
)}
onClick={() => setLayout("grid")}
>
<Icon
name={layout === "grid" ? "Check" : "LayoutGrid"}
size={16}
/>
<span>Grid</span>
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
"gap-1.5",
layout === "list" && "bg-primary text-primary-foreground",
)}
onClick={() => setLayout("list")}
>
<Icon
name={layout === "list" ? "Check" : "LayoutList"}
size={16}
/>
<span>List</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Drawer
open={layoutOpen}
onOpenChange={setLayoutOpen}
shouldScaleBackground
>
<DrawerTrigger asChild>
<Button
size={"icon"}
variant={"outline"}
>
<Icon
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
size={16}
/>
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className='text-start'>
<DrawerTitle>Layout</DrawerTitle>
<DrawerDescription>
Choose a layout for the files explorer
</DrawerDescription>
</DrawerHeader>
<div className='flex h-full flex-col gap-3 px-4'>
<DrawerClose asChild>
<Button
variant={"ghost"}
className={cn(
"justify-start gap-3",
layout === "grid" && "bg-primary text-primary-foreground",
)}
onClick={() => {
setLayout("grid");
setLayoutOpen(false);
}}
>
<Icon
name={layout === "grid" ? "Check" : "LayoutGrid"}
size={16}
/>
<span>Grid</span>
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button
variant={"ghost"}
className={cn(
"justify-start gap-3",
layout === "list" && "bg-primary text-primary-foreground",
)}
onClick={() => {
setLayout("list");
setLayoutOpen(false);
}}
>
<Icon
name={layout === "list" ? "Check" : "LayoutList"}
size={16}
/>
<span>List</span>
</Button>
</DrawerClose>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant={"secondary"}>Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)}
{isDesktop ? (
<Dialog
open={searchOpen}
onOpenChange={(open) => {
if (!open) {
setSearchInput("");
setSnap(0.3);
}
setSearchOpen(open);
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
size={"icon"} size={"icon"}
@@ -87,11 +278,327 @@ export default function HeaderButton({
Search for files in your drive Search for files in your drive
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className='flex flex-col'> <div className='flex h-full flex-col gap-6'>
<Input placeholder='Input your query...' /> <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-96 flex-grow flex-col gap-1.5 overflow-y-auto'
>
{!debouncedSearchInput ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='Search'
size={24}
className='text-muted-foreground'
/>
<span className='text-center text-muted-foreground'>
Start typing to search
</span>
</div>
) : searchLoading ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='LoaderCircle'
size={24}
className='animate-spin text-muted-foreground'
/>
<span className='text-center text-muted-foreground'>
Searching...
</span>
</div>
) : searchError ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>
{searchError}
</span>
</div>
) : !searchResults.length ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='Frown'
size={24}
className='text-muted-foreground'
/>
<span className='text-center text-muted-foreground'>
We couldn&apos;t find any results
</span>
</div>
) : (
<div className='flex w-full flex-col gap-1.5'>
{searchResults.map((result) => (
<SearchResultItem
key={result.encryptedId}
data={result}
/>
))}
</div>
)}
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </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={16}
/>
</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 ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='Search'
size={32}
className='text-muted-foreground'
/>
<span className='text-center text-muted-foreground'>
Start typing to search
</span>
</div>
) : searchLoading ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-muted-foreground'
/>
<span className='text-center text-muted-foreground'>
Searching...
</span>
</div>
) : searchError ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={32}
className='text-destructive'
/>
<span className='text-center text-destructive'>
{searchError}
</span>
</div>
) : !searchResults.length ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='Frown'
size={32}
className='text-muted-foreground'
/>
<span className='text-center text-muted-foreground'>
We couldn&apos;t find any results
</span>
</div>
) : (
<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 router = useRouter();
return (
<div
className={cn(
"relative",
// "w-full",
"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",
"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={`/api/thumb/${data.encryptedId}`}
alt={data.name}
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-12'
/>
{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={24}
/>
</>
)}
</>
) : (
<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-12'
/>
)}
</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> </div>
); );
} }
+1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { import {
Tooltip, Tooltip,
+1 -2
View File
@@ -6,7 +6,6 @@ import { Schema_Breadcrumb } from "~/schema";
import { cn } from "~/utils"; import { cn } from "~/utils";
import HeaderBreadcrumb from "./@header.breadcrumb"; import HeaderBreadcrumb from "./@header.breadcrumb";
import HeaderButton from "./@header.button";
type Props = { type Props = {
name: string; name: string;
@@ -38,7 +37,7 @@ export default function Header({ name, breadcrumb }: Props) {
data={breadcrumb || []} data={breadcrumb || []}
loading={loading} loading={loading}
/> />
<HeaderButton loading={loading} /> {/* <HeaderButton loading={loading} /> */}
</div> </div>
</div> </div>
); );
+1
View File
@@ -10,6 +10,7 @@ import remarkGfm from "remark-gfm";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import remarkSlug from "remark-slug"; import remarkSlug from "remark-slug";
import remarkToc from "remark-toc"; import remarkToc from "remark-toc";
import Icon from "~/components/Icon"; import Icon from "~/components/Icon";
import "./highlight.css"; import "./highlight.css";
+138 -56
View File
@@ -6,6 +6,8 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { cn } from "~/utils";
import Icon from "~/components/Icon"; import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
@@ -17,6 +19,16 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "~/components/ui/drawer";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -31,9 +43,10 @@ import {
SheetTrigger, SheetTrigger,
} from "~/components/ui/sheet"; } from "~/components/ui/sheet";
import { Tooltip, TooltipContent } from "~/components/ui/tooltip"; import { Tooltip, TooltipContent } from "~/components/ui/tooltip";
import config from "~/config/gIndex.config";
import useMediaQuery from "~/hooks/useMediaQuery"; import useMediaQuery from "~/hooks/useMediaQuery";
import { cn } from "~/utils";
import config from "~/config/gIndex.config";
import { ClearPassword } from "./actions"; import { ClearPassword } from "./actions";
@@ -43,6 +56,7 @@ export default function Navbar() {
const { theme, themes, setTheme } = useTheme(); const { theme, themes, setTheme } = useTheme();
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
const [themeOpen, setThemeOpen] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (isDesktop) setOpen(false); if (isDesktop) setOpen(false);
@@ -65,27 +79,16 @@ export default function Navbar() {
} }
return ( return (
<div <div className={cn("sticky left-0 top-0 z-50", "h-12 w-full")}>
className={cn(
"relative left-0 top-0 z-50",
"h-12 w-full",
// "tablet:h-screen tablet:w-20",
"flex-shrink-0",
)}
>
<nav <nav
slot='nav' slot='nav'
className={cn( className={cn(
"bg-background", "bg-background",
"fixed left-0 top-0", // "sticky left-0 top-0",
"h-12 w-full", "h-12 w-full",
// "tablet:h-screen tablet:w-20",
"px-6 py-3", "px-6 py-3",
// "tablet:px-3 tablet:py-6", "flex flex-grow flex-row items-center justify-between",
"flex flex-row items-center justify-between",
// "tablet:flex-col",
"border-b border-border", "border-b border-border",
// "tablet:border-b-0 tablet:border-r",
)} )}
> >
<div <div
@@ -94,8 +97,6 @@ export default function Navbar() {
"mx-auto w-full max-w-screen-desktop", "mx-auto w-full max-w-screen-desktop",
)} )}
> >
<Tooltip>
<TooltipTrigger>
<Link <Link
href={"/"} href={"/"}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
@@ -104,20 +105,16 @@ export default function Navbar() {
<img <img
src={config.siteConfig.siteIcon} src={config.siteConfig.siteIcon}
alt={config.siteConfig.siteName} alt={config.siteConfig.siteName}
className={cn("h-6 w-6 invert dark:invert-0")} className={cn("h-10 w-10")}
loading='eager' loading='eager'
/> />
<span className={cn("large", "hidden", "tablet:block")}> <span className={cn("large", "hidden", "tablet:block")}>
{config.siteConfig.siteName} {config.siteConfig.siteName}
</span> </span>
</Link> </Link>
</TooltipTrigger>
<TooltipContent>
<p>Back to Root folder</p>
</TooltipContent>
</Tooltip>
<div className={cn("hidden", "tablet:flex")}> {isDesktop ? (
<div className='flex'>
{config.siteConfig.navbarItems.map((item) => ( {config.siteConfig.navbarItems.map((item) => (
<Tooltip key={item.name}> <Tooltip key={item.name}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -153,7 +150,11 @@ export default function Navbar() {
className='mx-3' className='mx-3'
/> />
<DropdownMenu modal={false}> <DropdownMenu
modal={false}
open={themeOpen}
onOpenChange={setThemeOpen}
>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -294,11 +295,8 @@ export default function Navbar() {
<DialogContent> <DialogContent>
<DialogTitle>Clear all saved password?</DialogTitle> <DialogTitle>Clear all saved password?</DialogTitle>
<DialogDescription> <DialogDescription>
This action will clear all saved password from your browser. You will need to re-enter the password to access the
<br /> protected content.
You will need to re-enter the password for each
password-protected folder.
<br />
<br /> <br />
<b>Are you sure you want to continue?</b> <b>Are you sure you want to continue?</b>
</DialogDescription> </DialogDescription>
@@ -318,8 +316,92 @@ export default function Navbar() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) : (
<div className='flex items-center gap-1.5'>
<Drawer
open={themeOpen}
onOpenChange={setThemeOpen}
>
<DrawerTrigger asChild>
<Button
variant={"ghost"}
size={"icon"}
>
<Icon
name={
theme === "system"
? "LaptopMinimal"
: theme === "light"
? "Sun"
: "Moon"
}
size={18}
className='text-foreground'
/>
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className='text-start'>
<DrawerTitle>Theme</DrawerTitle>
<DrawerDescription>
Choose your preferred theme
</DrawerDescription>
</DrawerHeader>
<div className={cn("flex", "tablet:hidden")}> <div className='flex flex-col px-4'>
{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'>
<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={16}
/>
</div>
</Button>
))}
</div>
<DrawerFooter>
<DrawerClose>
<Button
className='w-full'
variant={"secondary"}
>
Close
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Sheet <Sheet
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
@@ -331,6 +413,7 @@ export default function Navbar() {
> >
<Icon <Icon
name='Menu' name='Menu'
size={18}
className='text-foreground' className='text-foreground'
/> />
</Button> </Button>
@@ -446,48 +529,47 @@ export default function Navbar() {
</div> </div>
<SheetFooter className='w-full'> <SheetFooter className='w-full'>
<Dialog> <Drawer>
<DialogTrigger asChild> <DrawerTrigger asChild>
<Button <Button
variant={"destructive"} variant={"destructive"}
className='gap-3' className='w-full gap-3'
size={"sm"} size={"sm"}
> >
Clear all saved password Clear all saved password
</Button> </Button>
</DialogTrigger> </DrawerTrigger>
<DrawerContent>
<DialogContent> <DrawerHeader className='text-left'>
<DialogTitle>Clear all saved password?</DialogTitle> <DrawerTitle>Clear all saved password?</DrawerTitle>
<DialogDescription> <DrawerDescription>
This action will clear all saved password from your You will need to re-enter the password to access the
browser. protected content.
<br />
You will need to re-enter the password for each
password-protected folder.
<br />
<br /> <br />
<b>Are you sure you want to continue?</b> <b>Are you sure you want to continue?</b>
</DialogDescription> </DrawerDescription>
<DialogFooter className='gap-1.5'> </DrawerHeader>
<DialogClose asChild>
<Button variant={"secondary"}>Cancel</Button> <DrawerFooter className='gap-1.5'>
</DialogClose> <DrawerClose asChild>
<DialogClose asChild>
<Button <Button
variant={"destructive"} variant={"destructive"}
onClick={onClearPassword} onClick={onClearPassword}
> >
Clear all saved password Clear all saved password
</Button> </Button>
</DialogClose> </DrawerClose>
</DialogFooter> <DrawerClose asChild>
</DialogContent> <Button variant={"secondary"}>Cancel</Button>
</Dialog> </DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</div> </div>
)}
</div> </div>
</nav> </nav>
</div> </div>
+8 -2
View File
@@ -3,12 +3,13 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { cn } from "~/utils";
import Icon from "~/components/Icon"; import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { cn } from "~/utils";
import { CheckSitePassword, SetSitePassword } from "./actions"; import { CheckSitePassword, SetPassword, SetSitePassword } from "./actions";
type Props = { type Props = {
path: string; path: string;
@@ -29,11 +30,16 @@ export default function Password({ path, errorMessage }: Props) {
try { try {
if (!input) throw new Error("Password is required"); if (!input) throw new Error("Password is required");
if (path === "global") {
const set = await SetSitePassword(input); const set = await SetSitePassword(input);
if (!set.success) throw new Error(set.message); if (!set.success) throw new Error(set.message);
const check = await CheckSitePassword(); const check = await CheckSitePassword();
if (!check.success) throw new Error(check.message); if (!check.success) throw new Error(check.message);
} else {
const set = await SetPassword(path, input);
if (!set.success) throw new Error(set.message);
}
toast.success("Password accepted! Refreshing...", { toast.success("Password accepted! Refreshing...", {
id: "password", id: "password",
+333
View File
@@ -0,0 +1,333 @@
"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 { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
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 { durationToReadable } from "~/utils/durationFormat";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewAction({ file }: Props) {
const pathname = usePathname();
const showRaw = useMemo<boolean>(() => {
return (
file.mimeType.startsWith("image") ||
file.mimeType.startsWith("video") ||
file.mimeType.startsWith("audio")
);
}, [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),
},
{
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 [downloading, setDownloading] = useState<boolean>(false);
const [diffOpen, setDiffOpen] = useState<boolean>(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const onCopyRaw = async () => {
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: "Link copied!",
error: "Failed to copy link",
});
} catch (error) {
const e = error as Error;
console.error(e.message);
}
};
const onCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
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("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}`,
});
}
};
const onDownload = async (e: React.MouseEvent) => {
e.stopPropagation();
setDownloading(true);
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}`);
setDownloading(false);
}, 1000);
} catch (error) {
const e = error as Error;
console.error(e.message);
toast.error(e.message, {
id: `download-${file.encryptedId}`,
});
setDownloading(false);
}
};
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 className={cn("flex gap-3", !showRaw && "invisible")}>
{isDesktop ? (
<Dialog
open={diffOpen}
onOpenChange={setDiffOpen}
>
<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&apos;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={diffOpen}
onOpenChange={setDiffOpen}
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&apos;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 ? (
<Button
size={"sm"}
variant={"outline"}
className='gap-3'
onClick={onCopyRaw}
>
<Icon
name='Copy'
size={16}
/>
Raw Link
</Button>
) : null}
<Button
size={"sm"}
variant={"outline"}
className='gap-3'
onClick={onCopy}
>
<Icon
name='Copy'
size={16}
/>
Download Link
</Button>
<Button
size={"sm"}
className='gap-3'
onClick={onDownload}
disabled={downloading}
>
<Icon
name={downloading ? "LoaderCircle" : "Download"}
size={16}
className={cn(downloading && "animate-spin")}
/>
Download
</Button>
</div>
</div>
</Card>
<Card slot='file-info'>
<CardHeader>
<CardTitle>File Information</CardTitle>
</CardHeader>
<CardContent>
<div className='flex flex-col'>
{fileInfo.map((info) => (
<div
key={info.label}
className='group flex flex-col'
>
<span className='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>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import AudioPlayer from "react-h5-audio-player";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { CreateDownloadToken } from "./actions";
import "./r5-style.css";
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewAudio({ file }: Props) {
const [audioSrc, setAudioSrc] = useState<string>("");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
useEffect(() => {
(async () => {
try {
if (!file.encryptedWebContentLink) {
setError("No audio to preview");
return;
}
const token = await CreateDownloadToken();
setAudioSrc(`/api/download/${file.encryptedId}?token=${token}`);
} catch (error) {
const e = error as Error;
console.error(e);
setError(e.message);
} finally {
setLoading(false);
}
})();
}, [file]);
return (
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading video...</p>
</div>
) : error ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<AudioPlayer
autoPlay={false}
layout='stacked-reverse'
src={audioSrc}
showJumpControls={false}
showFilledVolume
style={{
borderRadius: "var(--radius)",
}}
onError={(e) => {
console.error(e);
setError("Could not preview this audio, try downloading the file");
}}
/>
)}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
import { useEffect, useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { CreateDownloadToken } from "./actions";
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewDoc({ file }: Props) {
const [docSrc, setDocSrc] = useState<string>("");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
useEffect(() => {
(async () => {
try {
if (!file.encryptedWebContentLink) {
setError("Nothing to preview");
return;
}
const token = await CreateDownloadToken();
setDocSrc(`/api/download/${file.encryptedId}?token=${token}`);
} catch (error) {
const e = error as Error;
console.error(e);
setError(e.message);
} finally {
setLoading(false);
}
})();
}, [file]);
return (
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading document...</p>
</div>
) : error ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<DocViewer
key={file.encryptedId}
documents={[
{
uri: docSrc,
},
]}
pluginRenderers={DocViewerRenderers}
config={{
header: {
disableHeader: true,
},
loadingRenderer: {
overrideComponent: () => (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading document...</p>
</div>
),
showLoadingTimeout: 10000,
},
noRenderer: {
overrideComponent: () => (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>
Error loading document
</span>
</div>
),
},
}}
className={cn(
"h-full max-h-[70dvh] min-h-[70dvh] w-full rounded-[var(--radius)] border border-border !text-black",
)}
theme={{
disableThemeScrollbar: true,
}}
/>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { CreateDownloadToken } from "./actions";
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewImage({ file }: Props) {
const [imgSrc, setImgSrc] = useState<string>("");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
useEffect(() => {
(async () => {
try {
if (!file.encryptedWebContentLink) {
setError("No image to preview");
return;
}
const token = await CreateDownloadToken();
await fetch(`/api/download/${file.encryptedId}?token=${token}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch image");
return res.blob();
})
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
setImgSrc(reader.result as string);
};
reader.onerror = (e) => {
console.error(e);
setError(
"Could not preview this image, try downloading the file",
);
};
reader.readAsDataURL(blob);
})
.catch((e) => {
console.error(e.message);
setError(e.message);
});
} catch (error) {
const e = error as Error;
console.error(e);
setError(e.message);
} finally {
setLoading(false);
}
})();
}, [file]);
return (
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading image...</p>
</div>
) : error ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<img
src={imgSrc}
alt={file.name}
className='max-h-[70dvh] w-full rounded-[var(--radius)] bg-muted object-contain object-center'
/>
)}
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import JSZip from "jszip";
import { useEffect, useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import {
Carousel,
CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "~/components/ui/carousel";
import useMediaQuery from "~/hooks/useMediaQuery";
import { CreateDownloadToken } from "./actions";
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewManga({ file }: Props) {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [images, setImages] = useState<{ name: string; blob: string }[]>([]);
const [currentImage, setCurrentImage] = useState<number>(1);
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
const [api, setApi] = useState<CarouselApi>();
const isDesktop = useMediaQuery("(min-width: 768px)");
useEffect(() => {
(async () => {
try {
if (!file.encryptedWebContentLink) {
setError("No video to preview");
return;
}
const token = await CreateDownloadToken();
const manga = await fetch(
`/api/download/${file.encryptedId}?token=${token}`,
);
const archiveBlob = await manga.blob();
const zipData = await JSZip.loadAsync(archiveBlob);
const tempArray: { name: string; blob: string }[] = [];
const files = Object.values(zipData.files);
for (const file of files) {
const f = zipData.file(file.name);
if (!f) continue;
const blob = await f.async("blob");
const reader = new FileReader();
reader.onload = () => {
setImages((prev) => {
const exist = prev.find((p) => p.name === file.name);
if (exist) return prev;
return [
...prev,
{ name: file.name, blob: reader.result as string },
];
});
};
reader.readAsDataURL(blob);
}
} catch (error) {
const e = error as Error;
console.error(e);
setError(e.message);
} finally {
setLoading(false);
}
})();
}, [file]);
useEffect(() => {
if (!api) return;
api.on("select", (embla, e) => {
const index = embla.selectedScrollSnap();
setCurrentImage(index + 1);
});
}, [api]);
return (
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading manga content...</p>
</div>
) : error ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<div className='flex flex-col gap-3'>
<div className='h-full w-full tablet:px-8'>
<Carousel
className={cn(
"w-full",
viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full",
)}
opts={{
loop: false,
}}
setApi={setApi}
>
<CarouselContent className='h-full w-full'>
{images.map((image, index) => (
<CarouselItem
key={index}
className='flex h-full flex-col items-center justify-center'
>
<img
src={image.blob}
alt={`${image.name} - Page ${index + 1}`}
className={cn(
"w-full object-contain",
viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full",
)}
/>
<span className='muted text-center text-xs'>
{image.name}
</span>
</CarouselItem>
))}
</CarouselContent>
{isDesktop ? (
<>
<CarouselPrevious />
<CarouselNext />
</>
) : null}
</Carousel>
</div>
<div className='flex w-full items-center justify-end gap-1.5'>
<span className='muted text-sm'>
{currentImage}/{images.length}
</span>
<div
className='relative z-10 cursor-pointer p-0.5 text-foreground/80 transition hover:text-foreground'
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
>
<Icon
name={viewSize === "fit" ? "Maximize" : "Minimize"}
size={14}
/>
</div>
</div>
</div>
)}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button";
import Markdown from "./@markdown";
import { GetContent } from "./actions";
type Props = {
file: z.infer<typeof Schema_File>;
code?: boolean;
};
export default function PreviewRich({ file, code }: Props) {
const [content, setContent] = useState<string>("");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [expand, setExpand] = useState<boolean>(false);
useEffect(() => {
(async () => {
try {
const text = await GetContent(file.encryptedId);
if (!text) {
setError("Looks like there is no content to preview");
return;
}
if (code) {
setContent(`\`\`\`${file.fileExtension}\n${text}\`\`\``);
} else {
setContent(text);
}
} catch (error) {
const e = error as Error;
console.error(e);
setError(e.message);
} finally {
setLoading(false);
}
})();
}, [file, code]);
return (
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading content...</p>
</div>
) : error ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<div
className={cn("relative w-full", expand ? "h-full" : "max-h-[50dvh]")}
>
<div
className={cn(
"h-full w-full",
expand
? " [mask-image:linear-gradient(180deg,white_65%,white]"
: "max-h-[50dvh] [mask-image:linear-gradient(180deg,white_65%,rgba(255,255,255,0))]",
)}
>
<Markdown content={content} />
</div>
<div
className={cn(
"absolute bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
expand
? "pointer-events-none opacity-0"
: "pointer-events-auto opacity-100",
)}
>
<Button
size={"sm"}
onClick={() => {
setExpand(true);
}}
>
<Icon
name='ChevronDown'
size={16}
/>
Expand
</Button>
</div>
</div>
)}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
export default function PreviewUnknown() {
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
setLoading(false);
}, []);
return (
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<div
className={cn(
"h-auto min-h-[33dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading image...</p>
</div>
) : (
<div
className={cn(
"h-auto min-h-[33dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='Frown'
size={32}
className='text-muted-foreground'
/>
<h4 className='text-muted-foreground'>Preview not available</h4>
<p className='text-center text-muted-foreground tablet:text-sm'>
This file type is not supported for preview, try downloading the
file instead.
</p>
</div>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useState } from "react";
import ReactPlayer from "react-player";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { CreateDownloadToken } from "./actions";
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewVideo({ file }: Props) {
const [videoSrc, setVideoSrc] = useState<string>("");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
useEffect(() => {
(async () => {
try {
if (!file.encryptedWebContentLink) {
setError("No video to preview");
return;
}
const token = await CreateDownloadToken();
setVideoSrc(`/api/download/${file.encryptedId}?token=${token}`);
} catch (error) {
const e = error as Error;
console.error(e);
setError(e.message);
} finally {
setLoading(false);
}
})();
}, [file]);
return (
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading video...</p>
</div>
) : error ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
<Icon
name='CircleX'
size={24}
className='text-destructive'
/>
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<ReactPlayer
key={file.encryptedId}
url={videoSrc}
controls
pip
wrapper={({ children }) => (
<div className='h-[60dvh] w-full overflow-hidden rounded-[var(--radius)] bg-muted'>
{children}
</div>
)}
style={{
width: "100%",
height: "100%",
maxHeight: "60vh",
}}
onError={(error) => {
console.error(error.message);
if (error instanceof Error) {
setError(error.message);
} else {
setError("An unknown error occurred");
}
}}
/>
)}
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
import { Metadata, ResolvedMetadata } from "next";
import { notFound } from "next/navigation";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { decryptData } from "~/utils/encryptionHelper/hash";
import gdrive from "~/utils/gdriveInstance";
import { getFileType } from "~/utils/previewHelper";
import FileBrowser from "../@explorer";
import Header from "../@header";
import HeaderButton from "../@header.button";
import Markdown from "../@markdown";
import Password from "../@password";
import PreviewAction from "../@preview.action";
import PreviewAudio from "../@preview.audio";
import PreviewDoc from "../@preview.doc";
import PreviewImage from "../@preview.image";
import PreviewManga from "../@preview.manga";
import PreviewRich from "../@preview.rich";
import PreviewUnknown from "../@preview.unknown";
import PreviewVideo from "../@preview.video";
import {
CheckPassword,
CheckPaths,
GetBanner,
GetFile,
GetFiles,
GetReadme,
} from "../actions";
export const revalidate = 300;
type Props = {
params: {
rest: string[];
};
};
export async function generateMetadata(
{ params: { rest } }: Props,
parent: ResolvedMetadata,
): Promise<Metadata> {
const paths = await CheckPaths(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 banner = await GetBanner(encryptedId);
return {
title: data.name,
description: data.mimeType?.includes("folder")
? `Browse ${data.name} files`
: `View ${data.name}`,
openGraph: {
images: banner
? [
{
url: `/api/og/${banner}`,
// url: `/api/og/${
// data.mimeType.startsWith("image") ? data.encryptedId : banner
// }`,
width: 1200,
height: 630,
},
]
: parent.openGraph?.images,
},
};
}
export default async function RestPage({ params: { rest } }: Props) {
const paths = await CheckPaths(rest);
if (!paths.success) notFound();
const unlocked = await CheckPassword(paths.data);
if (!unlocked.success) {
if (!unlocked.path)
throw new Error("No path returned from password checking");
return (
<Password
path={unlocked.path}
errorMessage={unlocked.message}
/>
);
}
const encryptedId = paths.data.pop()?.id;
if (!encryptedId)
throw new Error("Failed to get encrypted ID, try to refresh the page.");
const promise = [];
const { data: file } = await gdrive.files.get({
fileId: await decryptData(encryptedId),
fields: "mimeType, fileExtension",
});
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,
];
}
});
let fileType;
if (file.fileExtension && file.mimeType) {
fileType = getFileType(file.fileExtension, file.mimeType);
}
const isFile = !("files" in data);
return (
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
<Header
name='Root'
breadcrumb={rest.map((item, index, array) => ({
label: decodeURIComponent(item),
href: index === array.length - 1 ? undefined : `${item}`,
}))}
/>
<div
slot='content'
className='w-full'
>
<Card>
<CardHeader className='pb-0'>
{isFile ? (
<div className='flex w-full gap-3'>
<CardTitle className='line-clamp-2 flex-grow whitespace-pre-wrap break-all'>
{data.name}
</CardTitle>
</div>
) : (
<div className='flex w-full items-center justify-between gap-3'>
<CardTitle className='flex-grow'>Browse files</CardTitle>
<HeaderButton />
</div>
)}
<Separator />
</CardHeader>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
{isFile ? (
<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
/>
) : fileType === "text" ? (
<PreviewRich file={data} />
) : fileType === "markdown" ? (
<PreviewRich file={data} />
) : fileType === "document" ? (
<PreviewDoc file={data} />
) : fileType === "pdf" ? (
<PreviewDoc file={data} />
) : fileType === "manga" ? (
<PreviewManga file={data} />
) : (
<PreviewUnknown />
)}
</div>
) : (
<FileBrowser
files={data.files}
nextPageToken={data.nextPageToken}
/>
)}
</CardContent>
</Card>
</div>
{readme && (
<div
slot='readme'
className='w-full'
>
<Card>
<CardHeader className='pb-0'>
<CardTitle>README.md</CardTitle>
<Separator />
</CardHeader>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
<Markdown content={readme} />
</CardContent>
</Card>
</div>
)}
{isFile && <PreviewAction file={data} />}
</div>
);
}
@@ -1,10 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ encryptedId }: { encryptedId: string },
) {
return NextResponse.json({
encryptedId,
});
}
+445 -14
View File
@@ -3,12 +3,15 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { z } from "zod"; import { z } from "zod";
import config from "~/config/gIndex.config";
import { Schema_File } from "~/schema"; import { Schema_File } from "~/schema";
import { Constant } from "~/types/constant";
import { decryptData, encryptData } from "~/utils/encryptionHelper/hash"; import { decryptData, encryptData } from "~/utils/encryptionHelper/hash";
import gdrive from "~/utils/gdriveInstance"; import gdrive from "~/utils/gdriveInstance";
import { Constant } from "~/types/constant";
import config from "~/config/gIndex.config";
export async function CheckSitePassword(): Promise<{ export async function CheckSitePassword(): Promise<{
success: boolean; success: boolean;
message?: string; message?: string;
@@ -60,6 +63,8 @@ export async function SetSitePassword(password: string): Promise<{
secure: true, secure: true,
sameSite: "strict", sameSite: "strict",
httpOnly: true, httpOnly: true,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
maxAge: 1000 * 60 * 60 * 24 * 365,
}); });
revalidatePath("/", "layout"); revalidatePath("/", "layout");
@@ -103,6 +108,220 @@ export async function ClearPassword(): Promise<{
} }
} }
export async function CheckPaths(paths: string[]): Promise<
| {
success: false;
message?: string;
}
| {
success: true;
data: { path: string; id: string }[];
message?: string;
}
> {
try {
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)",
supportsAllDrives: config.apiConfig.isTeamDrive,
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
})
.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);
for (const [index, path] of data.entries()) {
if (!valid) break;
// if first path, check if it's in root
if (index === 0) {
if (path?.data[0].parents === decryptedRootId) break;
} else {
if (path?.data[0].parents === data[index - 1]?.data[0].id) break;
}
valid = false;
invalidPath = data[index]?.path;
}
if (!valid) throw new Error(`Invalid path: ${invalidPath}`);
const ids: { path: string; id: string }[] = [];
for (const item of data) {
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));
}
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 gdrive.files.list({
q: query.join(" and "),
fields: "files(id, name, mimeType, parents)",
pageSize: 1000,
supportsAllDrives: config.apiConfig.isTeamDrive,
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
});
// 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`,
path: currentFolder.id,
};
const savedPassword = await decryptData(cookiesValue[currentFolder.id]);
const { data: passwordFile } = await gdrive.files.get(
{
fileId: protectedFolder.id as string,
alt: "media",
},
{
responseType: "text",
},
);
if (savedPassword !== passwordFile)
throw new Error(
`Password for '${currentFolder.path}' is incorrect, please re-enter the password`,
);
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({ export async function GetFiles({
id, id,
pageToken, pageToken,
@@ -116,15 +335,13 @@ export async function GetFiles({
try { try {
let decryptedId: string | undefined; let decryptedId: string | undefined;
if (id) decryptedId = await decryptData(id); if (id) decryptedId = await decryptData(id);
const filterName: string = config.apiConfig.hiddenFiles else decryptedId = await decryptData(config.apiConfig.rootFolder);
.map((item) => `name != '${item}'`) const filterName = config.apiConfig.hiddenFiles
.map((item) => `not name = '${item}'`)
.join(" and "); .join(" and ");
const query: string = [ const query: string = [
...config.apiConfig.defaultQuery, ...config.apiConfig.defaultQuery,
// `'1owGkP0NsJ4am8vVYebpjnoxzY5VOc7Vh' in parents`, `'${decryptedId}' in parents`,
decryptedId
? `'${decryptedId}' in parents`
: `'${config.apiConfig.rootFolder}' in parents`,
`${filterName}`, `${filterName}`,
].join(" and "); ].join(" and ");
@@ -160,7 +377,7 @@ export async function GetFiles({
? { ? {
width: Number(file.imageMediaMetadata.width), width: Number(file.imageMediaMetadata.width),
height: Number(file.imageMediaMetadata.height), height: Number(file.imageMediaMetadata.height),
rotation: Number(file.imageMediaMetadata.rotation), rotation: Number(file.imageMediaMetadata.rotation || 0),
} }
: undefined, : undefined,
videoMediaMetadata: file.videoMediaMetadata videoMediaMetadata: file.videoMediaMetadata
@@ -184,13 +401,184 @@ export async function GetFiles({
throw new 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 {
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,
supportsAllDrives: config.apiConfig.isTeamDrive,
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
});
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( export async function GetReadme(
encryptedId: string | undefined, encryptedId: string | undefined,
): Promise<string | null> { ): Promise<string | null> {
try { try {
let decryptedId: string | undefined; let decryptedId;
if (encryptedId) decryptedId = await decryptData(encryptedId); if (encryptedId) decryptedId = await decryptData(encryptedId);
else decryptedId = config.apiConfig.rootFolder; else decryptedId = await decryptData(config.apiConfig.rootFolder);
const query: string[] = [ const query: string[] = [
...config.apiConfig.defaultQuery, ...config.apiConfig.defaultQuery,
@@ -199,10 +587,12 @@ export async function GetReadme(
]; ];
const { data } = await gdrive.files.list({ const { data } = await gdrive.files.list({
q: query.join(" and "), q: query.join(" and "),
fields: `files(${config.apiConfig.defaultField}), nextPageToken`, fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
orderBy: config.apiConfig.defaultOrder, orderBy: config.apiConfig.defaultOrder,
pageSize: config.apiConfig.itemsPerPage, pageSize: config.apiConfig.itemsPerPage,
pageToken: undefined, pageToken: undefined,
supportsAllDrives: config.apiConfig.isTeamDrive,
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
}); });
if (!data.files?.length) return null; if (!data.files?.length) return null;
@@ -223,13 +613,55 @@ export async function GetReadme(
throw new 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);
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,
supportsAllDrives: config.apiConfig.isTeamDrive,
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
});
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( export async function CreateDownloadToken(
duration: number = config.apiConfig.temporaryTokenDuration, duration: number = config.apiConfig.temporaryTokenDuration,
): Promise<string> { ): Promise<string> {
try { try {
const data = { const data = {
expiredAt: Date.now() + duration * 60 * 60 * 1000, expiredAt:
Date.now() +
duration * 60 * 60 * 1000 * config.apiConfig.temporaryTokenDuration,
}; };
const token = await encryptData(JSON.stringify(data)); const token = await encryptData(JSON.stringify(data));
return token; return token;
@@ -239,7 +671,6 @@ export async function CreateDownloadToken(
throw new Error(e.message); throw new Error(e.message);
} }
} }
export async function CheckDownloadToken(token: string): Promise<{ export async function CheckDownloadToken(token: string): Promise<{
success: boolean; success: boolean;
message?: string; message?: string;
@@ -1,96 +0,0 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import { decryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import { isDownloadTokenValid } from "utils/tokenHelper";
import { ErrorResponse } from "types/api/response";
export async function GET(
request: NextRequest,
{
params,
}: {
params: {
encryptedId: string;
fileName: string;
};
},
) {
const reqStart = Date.now();
const { encryptedId, fileName } = params;
try {
const data = JSON.parse(decryptData(encryptedId)) as {
id: string;
isProtected: boolean;
};
const { token, preview, disableLimit } = getSearchParams(request.url, ["token", "preview", "disableLimit"]);
if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && !token)
throw new ExtendedError("Missing download token", 403, "You are not authorized to download this file");
if (!gIndexConfig.apiConfig.allowDownloadProtectedFile && data.isProtected && token && !isDownloadTokenValid(token))
throw new ExtendedError("Invalid download token", 403, "You are not authorized to download this file");
const _fileMeta = gdrive.files.get({
fileId: decryptData(data.id),
fields: "id, name, size, mimeType, webContentLink",
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
});
const _fileContent = gdrive.files.get(
{
fileId: decryptData(data.id),
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
alt: "media",
},
{ responseType: "stream" },
);
const [fileMeta, fileContent] = await Promise.all([_fileMeta, _fileContent]);
const fileSize = Number(fileMeta.data.size) ?? 0;
if (fileSize > gIndexConfig.apiConfig.maxFileSize && !disableLimit) {
return NextResponse.redirect(fileMeta.data.webContentLink!, {
status: 302,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
}
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
fileContent.data.on("data", (chunk) => {
chunks.push(chunk);
});
fileContent.data.on("end", () => {
resolve(Buffer.concat(chunks));
});
fileContent.data.on("error", (err) => {
reject(err);
});
});
return new NextResponse(fileBuffer, {
status: 200,
headers: {
"Content-Type": fileMeta.data.mimeType ?? "application/octet-stream",
"Content-Disposition": `${preview ? "inline;" : "attachment;"} filename="${encodeURIComponent(fileName)}"`,
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+162
View File
@@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from "next/server";
import {
CheckDownloadToken,
CheckPassword,
CheckPaths,
CheckSitePassword,
RedirectSearchFile,
} from "~/app/actions";
import { decryptData } from "~/utils/encryptionHelper/hash";
import gdrive from "~/utils/gdriveInstance";
import config from "~/config/gIndex.config";
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) {
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, fileExtension, webContentLink",
supportsAllDrives: config.apiConfig.isTeamDrive,
});
const _fileContent = gdrive.files.get(
{
fileId: decryptedId,
alt: "media",
supportsAllDrives: config.apiConfig.isTeamDrive,
},
{
responseType: "stream",
},
);
const [fileMeta, fileContent, filePaths] = await Promise.all([
_fileMeta,
_fileContent,
_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
) {
return NextResponse.redirect(fileMeta.data.webContentLink, {
status: 302,
headers: {
...request.headers,
"Cache-Control": config.cacheControl,
},
});
}
const fileBuffer = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
fileContent.data.on("data", (chunk) => {
chunks.push(chunk);
});
fileContent.data.on("end", () => {
resolve(Buffer.concat(chunks));
});
fileContent.data.on("error", (err) => {
reject(err);
});
});
return new NextResponse(fileBuffer, {
status: 200,
headers: {
...request.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,
},
});
// const data = await GetFile(encryptedId);
// if (data.mimeType?.includes("folder"))
// throw new Error("Can't download folder");
// if (!data.encryptedWebContentLink)
// throw new Error("No download link found");
// const decryptedWebContent = await decryptData(data.encryptedWebContentLink);
// return new NextResponse(null, {
// status: 302,
// headers: {
// Location: decryptedWebContent,
// },
// });
} catch (error) {
const e = error as Error;
console.error(e.message);
return new NextResponse(e.message, {
status: 500,
});
}
}
-141
View File
@@ -1,141 +0,0 @@
// import gIndexConfig from "config";
// import { drive_v3 } from "googleapis";
// import { NextRequest, NextResponse } from "next/server";
// import getSearchParams from "utils/apiHelper/getSearchParams";
// import { decryptData, encryptData } from "utils/encryptionHelper/hash";
// import ExtendedError from "utils/extendedError";
// import { gdriveFilesList } from "utils/gdrive";
// import gdrive from "utils/gdriveInstance";
// import { IGDriveFiles } from "types/api/files";
// import { APIGetFileResponse, ErrorResponse } from "types/api/response";
// function isHiddenFile(name: string) {
// return gIndexConfig.apiConfig.hiddenFiles.find((item) => name.startsWith(item)) ? true : false;
// }
// function generateFileObject(data: drive_v3.Schema$File): IGDriveFiles {
// return {
// mimeType: data.mimeType as string,
// fileExtension: (data.fileExtension as string) ?? null,
// encryptedId: encryptData(data.id as string),
// name: data.name as string,
// trashed: (data.trashed as boolean) ?? false,
// modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(),
// encryptedWebContentLink: encryptData(data.webContentLink as string),
// size: Number(data.size ?? 0),
// thumbnailLink: (data.thumbnailLink as string) ?? null,
// imageMediaMetadata: data.imageMediaMetadata
// ? {
// width: Number(data.imageMediaMetadata.width ?? 0),
// height: Number(data.imageMediaMetadata.height ?? 0),
// rotation: Number(data.imageMediaMetadata.rotation ?? 0),
// }
// : null,
// videoMediaMetadata: data.videoMediaMetadata
// ? {
// width: Number(data.videoMediaMetadata.width ?? 0),
// height: Number(data.videoMediaMetadata.height ?? 0),
// durationMillis: Number(data.videoMediaMetadata.durationMillis ?? 0),
// }
// : null,
// };
// }
// function generateFolderObject(data: drive_v3.Schema$File): IGDriveFiles {
// return {
// mimeType: data.mimeType as string,
// encryptedId: encryptData(data.id as string),
// name: data.name as string,
// trashed: (data.trashed as boolean) ?? false,
// modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(),
// };
// }
// export async function GET(request: NextRequest) {
// const reqStart = Date.now();
// try {
// const { encryptedId, isFile, pageToken } = getSearchParams(request.url, ["encryptedId", "isFile", "pageToken"]);
// let data: {
// file: IGDriveFiles | null;
// files: IGDriveFiles[];
// folders: IGDriveFiles[];
// pageToken: string | null;
// } = {
// file: null,
// files: [],
// folders: [],
// pageToken: null,
// };
// if (isFile && encryptedId) {
// const fileContent = await gdrive.files.get({
// fileId: decryptData(encryptedId),
// fields: gIndexConfig.apiConfig.defaultField,
// supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
// });
// if (isHiddenFile(fileContent.data.name as string))
// throw new ExtendedError("File not found", 404, "File you are looking for is not found");
// const payload: IGDriveFiles = generateFileObject(fileContent.data);
// data.file = payload;
// } else {
// const filterName = gIndexConfig.apiConfig.hiddenFiles.map((item) => `name != '${item}'`).join(" and ");
// const query: string[] = [
// ...gIndexConfig.apiConfig.defaultQuery,
// `'${encryptedId ? decryptData(encryptedId) : gIndexConfig.apiConfig.rootFolder}' in parents`,
// `${filterName}`,
// ];
// const folderContent = await gdriveFilesList({
// q: query.join(" and "),
// fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
// orderBy: gIndexConfig.apiConfig.defaultOrder,
// pageSize: gIndexConfig.apiConfig.itemsPerPage,
// pageToken: pageToken ?? undefined,
// supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
// includeItemsFromAllDrives: gIndexConfig.apiConfig.isTeamDrive,
// });
// const listFolders: IGDriveFiles[] =
// folderContent.data.files
// ?.filter((item) => item.mimeType === "application/vnd.google-apps.folder")
// .map((item) => generateFolderObject(item)) ?? [];
// const listFiles: IGDriveFiles[] =
// folderContent.data.files
// ?.filter((item) => item.mimeType !== "application/vnd.google-apps.folder")
// .map((item) => generateFileObject(item)) ?? [];
// data = {
// ...data,
// files: listFiles,
// folders: listFolders,
// pageToken: folderContent.data.nextPageToken ?? null,
// };
// }
// const payload: APIGetFileResponse = {
// timestamp: Date.now(),
// responseTime: Date.now() - reqStart,
// data,
// };
// return NextResponse.json(payload, {
// status: 200,
// headers: {
// "Cache-Control": gIndexConfig.cacheControl,
// },
// });
// } catch (error: any) {
// const res: ErrorResponse = {
// timestamp: Date.now(),
// responseTime: Date.now() - reqStart,
// error: {
// code: error.code || 500,
// message: error.message,
// reason: error.errors?.[0].reason,
// },
// };
// return NextResponse.json(res, {
// status: error.code || 500,
// });
// }
// }
-103
View File
@@ -1,103 +0,0 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import { decryptData, encryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import { gdriveFilesList } from "utils/gdrive";
import gdrive from "utils/gdriveInstance";
import { APIGetPasswordResponse, ErrorResponse } from "types/api/response";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const { path } = getSearchParams(request.url, ["path"]);
if (!path) throw new ExtendedError("Path is required", 400, "Can't find path in query, please check your query");
const mappedPath: Record<"name" | "id" | "mimeType", string>[] = JSON.parse(decryptData(path));
const passwordParents = mappedPath.map((path) => `'${decryptData(path.id)}' in parents`);
const query: string[] = [
"trashed = false",
`name = '${gIndexConfig.apiConfig.specialFile.password}' and (${passwordParents.join(" or ")})`,
];
const passwordData = await gdriveFilesList({
q: query.join(" and "),
fields: "files(id, name, parents)",
});
const protectedPaths = mappedPath.map((path) => {
const isPasswordExist = passwordData.data.files?.find((file) => file.parents?.[0] === decryptData(path.id));
if (isPasswordExist)
return {
name: path.name,
passwordId: isPasswordExist.id,
};
});
const _passwordContent: Promise<{
path: string;
password: string;
}>[] = [];
protectedPaths.forEach((path) => {
if (!path) return;
_passwordContent.push(
gdrive.files
.get(
{
fileId: path.passwordId as string,
alt: "media",
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
},
{ responseType: "text" },
)
.then((res) => ({
path: path.name as string,
password: res.data as string,
})),
);
});
const passwordContent = await Promise.all(_passwordContent);
let prevPath = [""];
let password: Record<"relativePath" | "password", string>[] = [];
mappedPath.forEach((path) => {
prevPath.push(path.name);
const isPasswordExist = passwordContent.find((password) => password.path === path.name);
if (isPasswordExist) {
password.push({
relativePath: prevPath.join("/"),
password: encryptData(isPasswordExist.password),
});
}
});
const payload: APIGetPasswordResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
data: password,
};
return NextResponse.json(payload, {
status: 200,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
-69
View File
@@ -1,69 +0,0 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import { decryptData } from "utils/encryptionHelper/hash";
import { gdriveFilesList } from "utils/gdrive";
import gdrive from "utils/gdriveInstance";
import { APIGetReadmeResponse, ErrorResponse } from "types/api/response";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const { encryptedId } = getSearchParams(request.url, ["encryptedId"]);
const query: string[] = [
...gIndexConfig.apiConfig.defaultQuery,
`'${encryptedId ? decryptData(encryptedId) : gIndexConfig.apiConfig.rootFolder}' in parents`,
];
const folderContent = await gdriveFilesList({
q: query.join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: gIndexConfig.apiConfig.defaultOrder,
pageSize: gIndexConfig.apiConfig.itemsPerPage,
pageToken: undefined,
});
const isReadmeExist = folderContent.data.files?.find(
(file) => file.name === gIndexConfig.apiConfig.specialFile.readme,
);
let data: string | null = null;
if (isReadmeExist) {
const fileContent = await gdrive.files.get(
{
fileId: isReadmeExist.id as string,
alt: "media",
},
{ responseType: "text" },
);
data = fileContent.data as string;
}
const payload: APIGetReadmeResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
data,
};
return NextResponse.json(payload, {
status: 200,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { encryptData } from "~/utils/encryptionHelper/hash";
export async function GET(request: NextRequest) {
try {
const sp = new URL(request.nextUrl).searchParams;
const query = sp.get("q");
if (!query)
return new NextResponse(
"Add query parameter 'q' with the value to encrypt",
{ status: 400 },
);
const encrypted = await encryptData(query);
return NextResponse.json(
{
value: encrypted,
},
{ status: 200 },
);
} catch (error) {
const e = error as Error;
console.error(e);
return new Response(e.message, { status: 500 });
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { decryptData } from "~/utils/encryptionHelper/hash";
import gdrive from "~/utils/gdriveInstance";
import config from "~/config/gIndex.config";
type Props = {
params: {
encryptedId: string;
};
};
export async function GET(
request: NextRequest,
{ params: { encryptedId } }: Props,
) {
try {
const decryptedId = await decryptData(encryptedId);
const { data } = await gdrive.files.get({
fileId: decryptedId,
fields: "id, name, mimeType, webContentLink",
supportsAllDrives: config.apiConfig.isTeamDrive,
});
if (!data.webContentLink) throw new Error("No thumbnail for this file");
const downloadThumb = await fetch(data.webContentLink, {
cache: "force-cache",
});
const buffer = await downloadThumb.arrayBuffer();
const bufferData = Buffer.from(buffer);
return new NextResponse(bufferData, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": data.mimeType || "application/octet-stream",
"Content-Length": bufferData.length.toString(),
"Content-Disposition": `inline; filename="${data.name}"`,
},
});
} catch (error) {
const e = error as Error;
console.error(e.message);
return NextResponse.json(
{
error: e.message,
},
{
status: 500,
},
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { CheckPassword, CheckPaths, GetFile } from "~/app/actions";
import { decryptData } from "~/utils/encryptionHelper/hash";
import config from "~/config/gIndex.config";
export async function GET(
request: NextRequest,
{ params: { rest } }: { params: { rest: string[] } },
) {
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 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 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 decryptedWebContent = await decryptData(data.encryptedWebContentLink);
return new NextResponse(null, {
status: 302,
headers: {
Location: decryptedWebContent,
},
});
} catch (error) {
const e = error as Error;
console.error(e.message);
return new NextResponse(e.message, {
status: 500,
});
}
}
@@ -1,81 +0,0 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import { decryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import gdrive from "utils/gdriveInstance";
import { ErrorResponse } from "types/api/response";
export async function GET(request: NextRequest, { params }: { params: { encryptedId: string } }) {
const reqStart = Date.now();
const { encryptedId } = params;
try {
const id = decryptData(encryptedId);
if (!id) throw new ExtendedError("Invalid encryptedId", 400, "EncryptedId provided is invalid");
console.log(id);
const path: string[] = [];
let lastId = id;
const fileContent = await gdrive.files.get({
fileId: lastId,
fields: "id, name, mimeType, parents",
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
});
if (fileContent) {
path.push(fileContent.data.name as string);
if (fileContent.data.parents) {
lastId = fileContent.data.parents[0] as string;
while (lastId) {
const folderContent = await gdrive.files.get({
fileId: lastId,
fields: "id, name, mimeType, parents",
supportsAllDrives: gIndexConfig.apiConfig.isTeamDrive,
});
if (folderContent.data.id === gIndexConfig.apiConfig.rootFolder) {
lastId = "";
break;
}
if (folderContent) {
path.push(folderContent.data.name as string);
if (folderContent.data.parents) {
if (folderContent.data.parents[0] === gIndexConfig.apiConfig.rootFolder) {
lastId = "";
break;
} else {
lastId = folderContent.data.parents[0] as string;
}
} else {
lastId = "";
}
} else {
lastId = "";
}
}
}
}
const redirectURL = new URL(path.reverse().join("/"), request.nextUrl.origin ?? process.env.VERCEL_URL);
console.log(redirectURL);
return NextResponse.redirect(redirectURL, {
status: 302,
headers: {
"Cache-Control": gIndexConfig.cacheControl,
},
});
} catch (error: any) {
const errorCode = isNaN(Number(error.code)) ? 500 : Number(error.code);
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: errorCode,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: errorCode,
});
}
}
-65
View File
@@ -1,65 +0,0 @@
import gIndexConfig from "config";
import { NextRequest, NextResponse } from "next/server";
import getSearchParams from "utils/apiHelper/getSearchParams";
import { encryptData } from "utils/encryptionHelper/hash";
import ExtendedError from "utils/extendedError";
import { gdriveFilesList } from "utils/gdrive";
import { IGDriveFiles } from "types/api/files";
import { APISearchResponse, ErrorResponse } from "types/api/response";
export async function GET(request: NextRequest) {
const reqStart = Date.now();
try {
const { query } = getSearchParams(request.url, ["query"]);
if (!query) throw new ExtendedError("Missing query", 400, "Search query is required");
const searchFiles = await gdriveFilesList({
q: [...gIndexConfig.apiConfig.defaultQuery, `name contains '${query}'`].join(" and "),
fields: `files(${gIndexConfig.apiConfig.defaultField}), nextPageToken`,
orderBy: "name_natural desc",
pageSize: gIndexConfig.apiConfig.searchResult,
pageToken: undefined,
});
const files: (IGDriveFiles & { redirect: string })[] =
searchFiles.data.files?.map((data) => ({
mimeType: data.mimeType ?? "Unknown",
encryptedId: encryptData(data.id as string),
name: data.name as string,
trashed: data.trashed ?? false,
modifiedTime: new Date(data.modifiedTime as string).toLocaleDateString(),
fileExtension: (data.fileExtension as string) ?? null,
encryptedWebContentLink: undefined,
size: Number(data.size) ?? 0,
thumbnailLink: (data.thumbnailLink as string) ?? null,
imageMediaMetadata: null,
videoMediaMetadata: null,
redirect: `/api/search/redirect/${encryptData(data.id as string)}`,
})) ?? [];
const payload: APISearchResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
files: files,
};
return NextResponse.json(payload, {
status: 200,
});
} catch (error: any) {
const res: ErrorResponse = {
timestamp: Date.now(),
responseTime: Date.now() - reqStart,
error: {
code: error.code || 500,
message: error.message,
reason: error.errors?.[0].reason,
},
};
return NextResponse.json(res, {
status: error.code || 500,
});
}
}
+86 -5
View File
@@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import config from "~/config/gIndex.config";
import { decryptData } from "~/utils/encryptionHelper/hash"; import { decryptData } from "~/utils/encryptionHelper/hash";
import gdrive from "~/utils/gdriveInstance"; import gdrive from "~/utils/gdriveInstance";
import config from "~/config/gIndex.config";
type Props = { type Props = {
params: { params: {
encryptedId: string; encryptedId: string;
@@ -14,14 +16,93 @@ export async function GET(
{ params: { encryptedId } }: Props, { params: { encryptedId } }: Props,
) { ) {
try { try {
const defaultImage = NextResponse.redirect(
new URL("/og.png", config.basePath),
{
status: 302,
},
);
const decryptedId = await decryptData(encryptedId); const decryptedId = await decryptData(encryptedId);
const { data } = await gdrive.files.get({ const _fileMeta = gdrive.files.get({
fileId: decryptedId, fileId: decryptedId,
fields: "id, name, mimeType, thumbnailLink", fields:
"id, name, mimeType, fileExtension, webContentLink, thumbnailLink",
supportsAllDrives: config.apiConfig.isTeamDrive, supportsAllDrives: config.apiConfig.isTeamDrive,
}); });
if (!data.thumbnailLink) throw new Error("No thumbnail for this file"); const _fileContent = gdrive.files.get(
return NextResponse.redirect(data.thumbnailLink); {
fileId: decryptedId,
alt: "media",
supportsAllDrives: config.apiConfig.isTeamDrive,
},
{
responseType: "stream",
},
);
const [fileMeta, fileContent] = await Promise.all([
_fileMeta,
_fileContent,
]);
const fileSize = Number(fileMeta.data.size || 0);
if (!fileMeta.data.webContentLink) return defaultImage;
if (!fileMeta.data.thumbnailLink) return defaultImage;
if (
!fileMeta.data.mimeType?.startsWith("image") &&
!fileMeta.data.mimeType?.startsWith("video")
)
return defaultImage;
// If svg, return actual image since there is no thumbnail for svg
if (
fileMeta.data.mimeType?.includes("svg") &&
fileSize <= config.apiConfig.maxFileSize
) {
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, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"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}`,
)}"`,
},
});
}
if (config.apiConfig.proxyThumbnail) {
const downloadThumb = await fetch(fileMeta.data.thumbnailLink, {
cache: "force-cache",
});
const buffer = await downloadThumb.arrayBuffer();
return new NextResponse(buffer, {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": fileMeta.data.mimeType || "application/octet-stream",
"Content-Length": buffer.byteLength.toString(),
"Content-Disposition": `attachment; filename="${encodeURIComponent(
fileMeta.data.name || `Untitled.${fileMeta.data.fileExtension}`,
)}"`,
},
});
}
return NextResponse.redirect(fileMeta.data.thumbnailLink);
} catch (error) { } catch (error) {
const e = error as Error; const e = error as Error;
console.error(e.message); console.error(e.message);
-7
View File
@@ -1,7 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
return NextResponse.json({
message: "Hello World!",
});
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.error(error);
}, [error]);
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={32}
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>
);
}
+1 -1
View File
@@ -77,7 +77,7 @@ body {
@apply bg-background; @apply bg-background;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-primary/50 hover:bg-primary/75; @apply rounded-full bg-primary/25 hover:bg-primary/50;
} }
/* Typography */ /* Typography */
+5 -12
View File
@@ -1,8 +1,9 @@
import { Metadata } from "next"; import { Metadata } from "next";
import { JetBrains_Mono, Source_Sans_3 } from "next/font/google"; import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
import config from "~/config/gIndex.config";
import { cn } from "~/utils"; import { cn } from "~/utils";
import config from "~/config/gIndex.config";
import Footer from "./@footer"; import Footer from "./@footer";
import Navbar from "./@navbar"; import Navbar from "./@navbar";
import Password from "./@password"; import Password from "./@password";
@@ -41,7 +42,7 @@ export const metadata: Metadata = {
creator: "mbaharip", creator: "mbaharip",
icons: [ icons: [
{ {
url: config.siteConfig.siteIcon, url: config.siteConfig.favIcon,
}, },
], ],
keywords: ["gdrive", "index", "nextjs", "reactjs"], keywords: ["gdrive", "index", "nextjs", "reactjs"],
@@ -90,7 +91,7 @@ export default async function RootLayout({
<html lang='en'> <html lang='en'>
<body <body
className={cn( className={cn(
"bg-background font-sans text-foreground", "h-full bg-background font-sans text-foreground",
jetbrainsMono.variable, jetbrainsMono.variable,
sourceSans3.variable, sourceSans3.variable,
)} )}
@@ -100,19 +101,12 @@ export default async function RootLayout({
defaultTheme='system' defaultTheme='system'
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
>
<div
className={cn(
"h-full min-h-screen w-full overflow-x-hidden",
"flex flex-col items-start",
// "tablet:flex-row",
)}
> >
<Navbar /> <Navbar />
<main <main
slot='content' slot='content'
className={cn( className={cn(
"mx-auto h-full min-h-screen w-full max-w-screen-desktop", "mx-auto h-full w-full max-w-screen-desktop",
"relative left-0 top-0", "relative left-0 top-0",
"flex flex-grow flex-col gap-3 p-6", "flex flex-grow flex-col gap-3 p-6",
"tablet:gap-6", "tablet:gap-6",
@@ -130,7 +124,6 @@ export default async function RootLayout({
{config.siteConfig.footer && ( {config.siteConfig.footer && (
<Footer content={formatFooter(config.siteConfig.footer)} /> <Footer content={formatFooter(config.siteConfig.footer)} />
)} )}
</div>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+2 -1
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import Icon from "~/components/Icon";
import { cn } from "~/utils"; import { cn } from "~/utils";
import Icon from "~/components/Icon";
export default function RootLoading() { export default function RootLoading() {
return ( return (
<div <div
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button";
import useRouter from "~/hooks/usePRouter";
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-3",
)}
>
<Icon
name='Frown'
size={32}
className='text-muted-foreground'
/>
<span className='text-center text-muted-foreground'>
The file you are looking for does not exist
</span>
<div className='flex w-full flex-col items-center gap-3 pt-8'>
<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>
);
}
+9 -3
View File
@@ -1,13 +1,15 @@
import { cn } from "~/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { cn } from "~/utils";
import FileBrowser from "./@explorer"; import FileBrowser from "./@explorer";
import Header from "./@header"; import Header from "./@header";
import HeaderButton from "./@header.button";
import Markdown from "./@markdown"; import Markdown from "./@markdown";
import { GetFiles, GetReadme } from "./actions"; import { GetFiles, GetReadme } from "./actions";
export const revalidate = 3600; export const revalidate = 300;
export default async function RootPage() { export default async function RootPage() {
const [data, readme] = await Promise.all([ const [data, readme] = await Promise.all([
@@ -24,13 +26,17 @@ export default async function RootPage() {
> >
<Card> <Card>
<CardHeader className='pb-0'> <CardHeader className='pb-0'>
<CardTitle>Browse files</CardTitle> <div className='flex w-full items-center justify-between gap-3'>
<CardTitle className='flex-grow'>Browse files</CardTitle>
<HeaderButton />
</div>
<Separator /> <Separator />
</CardHeader> </CardHeader>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'> <CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
<FileBrowser <FileBrowser
files={data.files} files={data.files}
nextPageToken={data.nextPageToken} nextPageToken={data.nextPageToken}
root
/> />
</CardContent> </CardContent>
</Card> </Card>
+274
View File
@@ -0,0 +1,274 @@
.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;
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
/**
* We don't actually need RSS
* But console always show "Path not found" error on rss.xml
* since we are using [...rest] route
*
* So this is just a empty route to prevent that
*/
export async function GET() {
return new NextResponse();
}
+21 -2
View File
@@ -2,19 +2,38 @@
import { ThemeProvider as NextThemeProvider } from "next-themes"; import { ThemeProvider as NextThemeProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types"; import { type ThemeProviderProps } from "next-themes/dist/types";
import NextTopLoader from "nextjs-toploader";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { cn } from "~/utils";
import { TooltipProvider } from "~/components/ui/tooltip"; import { TooltipProvider } from "~/components/ui/tooltip";
import config from "~/config/gIndex.config";
import LayoutProvider from "~/context/layoutContext"; import LayoutProvider from "~/context/layoutContext";
import config from "~/config/gIndex.config";
export default function ThemeProvider({ export default function ThemeProvider({
children, children,
...props ...props
}: ThemeProviderProps) { }: ThemeProviderProps) {
return ( return (
<LayoutProvider> <LayoutProvider>
<NextTopLoader color='hsl(var(--primary))' />
<NextThemeProvider {...props}> <NextThemeProvider {...props}>
<TooltipProvider delayDuration={250}>{children}</TooltipProvider> <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 <Toaster
position={config.siteConfig.toaster?.position} position={config.siteConfig.toaster?.position}
+44 -36
View File
@@ -1,16 +1,21 @@
import * as React from "react" import { Slot } from "@radix-ui/react-slot";
import { Slot } from "@radix-ui/react-slot" import { ChevronRight, MoreHorizontal } from "lucide-react";
import { ChevronRight, MoreHorizontal } from "lucide-react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils"
const Breadcrumb = React.forwardRef< const Breadcrumb = React.forwardRef<
HTMLElement, HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & { React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode separator?: React.ReactNode;
} }
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />) >(({ ...props }, ref) => (
Breadcrumb.displayName = "Breadcrumb" <nav
ref={ref}
aria-label='breadcrumb'
{...props}
/>
));
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef< const BreadcrumbList = React.forwardRef<
HTMLOListElement, HTMLOListElement,
@@ -19,13 +24,13 @@ const BreadcrumbList = React.forwardRef<
<ol <ol
ref={ref} ref={ref}
className={cn( className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-zinc-500 sm:gap-2.5 dark:text-zinc-400", "flex flex-wrap items-center gap-1.5 break-words text-sm text-zinc-500 dark:text-zinc-400 sm:gap-2.5",
className className,
)} )}
{...props} {...props}
/> />
)) ));
BreadcrumbList.displayName = "BreadcrumbList" BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef< const BreadcrumbItem = React.forwardRef<
HTMLLIElement, HTMLLIElement,
@@ -36,26 +41,29 @@ const BreadcrumbItem = React.forwardRef<
className={cn("inline-flex items-center gap-1.5", className)} className={cn("inline-flex items-center gap-1.5", className)}
{...props} {...props}
/> />
)) ));
BreadcrumbItem.displayName = "BreadcrumbItem" BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef< const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement, HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & { React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean asChild?: boolean;
} }
>(({ asChild, className, ...props }, ref) => { >(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
ref={ref} ref={ref}
className={cn("transition-colors hover:text-zinc-950 dark:hover:text-zinc-50", className)} className={cn(
"transition-colors hover:text-zinc-950 dark:hover:text-zinc-50",
className,
)}
{...props} {...props}
/> />
) );
}) });
BreadcrumbLink.displayName = "BreadcrumbLink" BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef< const BreadcrumbPage = React.forwardRef<
HTMLSpanElement, HTMLSpanElement,
@@ -63,14 +71,14 @@ const BreadcrumbPage = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<span <span
ref={ref} ref={ref}
role="link" role='link'
aria-disabled="true" aria-disabled='true'
aria-current="page" aria-current='page'
className={cn("font-normal text-zinc-950 dark:text-zinc-50", className)} className={cn("font-normal text-zinc-950 dark:text-zinc-50", className)}
{...props} {...props}
/> />
)) ));
BreadcrumbPage.displayName = "BreadcrumbPage" BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ const BreadcrumbSeparator = ({
children, children,
@@ -78,31 +86,31 @@ const BreadcrumbSeparator = ({
...props ...props
}: React.ComponentProps<"li">) => ( }: React.ComponentProps<"li">) => (
<li <li
role="presentation" role='presentation'
aria-hidden="true" aria-hidden='true'
className={cn("[&>svg]:size-3.5", className)} className={cn("[&>svg]:size-3.5", className)}
{...props} {...props}
> >
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
) );
BreadcrumbSeparator.displayName = "BreadcrumbSeparator" BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ const BreadcrumbEllipsis = ({
className, className,
...props ...props
}: React.ComponentProps<"span">) => ( }: React.ComponentProps<"span">) => (
<span <span
role="presentation" role='presentation'
aria-hidden="true" aria-hidden='true'
className={cn("flex h-9 w-9 items-center justify-center", className)} className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props} {...props}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className='h-4 w-4' />
<span className="sr-only">More</span> <span className='sr-only'>More</span>
</span> </span>
) );
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export { export {
Breadcrumb, Breadcrumb,
@@ -112,4 +120,4 @@ export {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbEllipsis, BreadcrumbEllipsis,
} };
+17 -16
View File
@@ -1,22 +1,23 @@
import * as React from "react" import { Slot } from "@radix-ui/react-slot";
import { Slot } from "@radix-ui/react-slot" import { type VariantProps, cva } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-zinc-900 text-zinc-50 hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90", default:
"bg-zinc-900 text-zinc-50 hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90",
destructive: destructive:
"bg-red-500 text-zinc-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90", "bg-red-500 text-zinc-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90",
outline: outline:
"border border-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50", "border border-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
secondary: secondary:
"bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80", "bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
ghost: "hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50", ghost:
"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50", link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
}, },
size: { size: {
@@ -30,27 +31,27 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Button.displayName = "Button" Button.displayName = "Button";
export { Button, buttonVariants } export { Button, buttonVariants };
+29 -19
View File
@@ -1,6 +1,5 @@
import * as React from "react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils"
const Card = React.forwardRef< const Card = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -10,12 +9,12 @@ const Card = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-lg border bg-card text-card-foreground shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Card.displayName = "Card" Card.displayName = "Card";
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -26,8 +25,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
)) ));
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@@ -37,12 +36,12 @@ const CardTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-2xl font-semibold leading-none tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
)) ));
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@@ -53,16 +52,20 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
CardDescription.displayName = "CardDescription" CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef< const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div
)) ref={ref}
CardContent.displayName = "CardContent" className={cn("p-6 pt-0", className)}
{...props}
/>
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -73,7 +76,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} {...props}
/> />
)) ));
CardFooter.displayName = "CardFooter" CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
+265
View File
@@ -0,0 +1,265 @@
"use client";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
import { Button } from "~/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref,
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role='region'
aria-roledescription='carousel'
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className='overflow-hidden'
>
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role='group'
aria-roledescription='slide'
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className='h-4 w-4' />
<span className='sr-only'>Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className='h-4 w-4' />
<span className='sr-only'>Next slide</span>
</Button>
);
});
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
+30 -31
View File
@@ -1,18 +1,17 @@
"use client" "use client";
import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react";
import { X } from "lucide-react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils" const Dialog = DialogPrimitive.Root;
const Dialog = DialogPrimitive.Root const DialogTrigger = DialogPrimitive.Trigger;
const DialogTrigger = DialogPrimitive.Trigger const DialogPortal = DialogPrimitive.Portal;
const DialogPortal = DialogPrimitive.Portal const DialogClose = DialogPrimitive.Close;
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
@@ -22,12 +21,12 @@ const DialogOverlay = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@@ -39,19 +38,19 @@ const DialogContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className='absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'>
<X className="h-4 w-4" /> <X className='h-4 w-4' />
<span className="sr-only">Close</span> <span className='sr-only'>Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({
className, className,
@@ -60,12 +59,12 @@ const DialogHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center sm:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ const DialogFooter = ({
className, className,
@@ -74,12 +73,12 @@ const DialogFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className,
)} )}
{...props} {...props}
/> />
) );
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@@ -89,12 +88,12 @@ const DialogTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
@@ -105,8 +104,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
@@ -119,4 +118,4 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} };
+25 -26
View File
@@ -1,9 +1,8 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul" import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "~/utils";
import { cn } from "~/utils"
const Drawer = ({ const Drawer = ({
shouldScaleBackground = true, shouldScaleBackground = true,
@@ -13,14 +12,14 @@ const Drawer = ({
shouldScaleBackground={shouldScaleBackground} shouldScaleBackground={shouldScaleBackground}
{...props} {...props}
/> />
) );
Drawer.displayName = "Drawer" Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef< const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>, React.ElementRef<typeof DrawerPrimitive.Overlay>,
@@ -31,8 +30,8 @@ const DrawerOverlay = React.forwardRef<
className={cn("fixed inset-0 z-50 bg-black/80", className)} className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props} {...props}
/> />
)) ));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef< const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>, React.ElementRef<typeof DrawerPrimitive.Content>,
@@ -44,16 +43,16 @@ const DrawerContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className className,
)} )}
{...props} {...props}
> >
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> <div className='mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted' />
{children} {children}
</DrawerPrimitive.Content> </DrawerPrimitive.Content>
</DrawerPortal> </DrawerPortal>
)) ));
DrawerContent.displayName = "DrawerContent" DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ const DrawerHeader = ({
className, className,
@@ -63,8 +62,8 @@ const DrawerHeader = ({
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
DrawerHeader.displayName = "DrawerHeader" DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ const DrawerFooter = ({
className, className,
@@ -74,8 +73,8 @@ const DrawerFooter = ({
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
DrawerFooter.displayName = "DrawerFooter" DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef< const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>, React.ElementRef<typeof DrawerPrimitive.Title>,
@@ -85,12 +84,12 @@ const DrawerTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef< const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>, React.ElementRef<typeof DrawerPrimitive.Description>,
@@ -101,8 +100,8 @@ const DrawerDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export { export {
Drawer, Drawer,
@@ -115,4 +114,4 @@ export {
DrawerFooter, DrawerFooter,
DrawerTitle, DrawerTitle,
DrawerDescription, DrawerDescription,
} };
+46 -47
View File
@@ -1,27 +1,26 @@
"use client" "use client";
import * as React from "react" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import { Check, ChevronRight, Circle } from "lucide-react";
import { Check, ChevronRight, Circle } from "lucide-react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils" const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuGroup = DropdownMenuPrimitive.Group const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -29,16 +28,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className='ml-auto h-4 w-4' />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ));
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -48,13 +47,13 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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", "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -66,18 +65,18 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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", "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)) ));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -85,12 +84,12 @@ const DropdownMenuItem = React.forwardRef<
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@@ -100,21 +99,21 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className='h-4 w-4' />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)) ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -124,24 +123,24 @@ const DropdownMenuRadioItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" /> <Circle className='h-2 w-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)) ));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@@ -149,12 +148,12 @@ const DropdownMenuLabel = React.forwardRef<
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", "px-2 py-1.5 text-sm font-semibold",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@@ -165,8 +164,8 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({
className, className,
@@ -177,9 +176,9 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} {...props}
/> />
) );
} };
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export { export {
DropdownMenu, DropdownMenu,
@@ -197,4 +196,4 @@ export {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} };
+8 -9
View File
@@ -1,6 +1,5 @@
import * as React from "react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils"
export interface InputProps export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {} extends React.InputHTMLAttributes<HTMLInputElement> {}
@@ -12,14 +11,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Input.displayName = "Input" Input.displayName = "Input";
export { Input } export { Input };
+25
View File
@@ -0,0 +1,25 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+54 -55
View File
@@ -1,20 +1,19 @@
"use client" "use client";
import * as React from "react" import * as MenubarPrimitive from "@radix-ui/react-menubar";
import * as MenubarPrimitive from "@radix-ui/react-menubar" import { Check, ChevronRight, Circle } from "lucide-react";
import { Check, ChevronRight, Circle } from "lucide-react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils" const MenubarMenu = MenubarPrimitive.Menu;
const MenubarMenu = MenubarPrimitive.Menu const MenubarGroup = MenubarPrimitive.Group;
const MenubarGroup = MenubarPrimitive.Group const MenubarPortal = MenubarPrimitive.Portal;
const MenubarPortal = MenubarPrimitive.Portal const MenubarSub = MenubarPrimitive.Sub;
const MenubarSub = MenubarPrimitive.Sub const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef< const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>, React.ElementRef<typeof MenubarPrimitive.Root>,
@@ -24,12 +23,12 @@ const Menubar = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1", "flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Menubar.displayName = MenubarPrimitive.Root.displayName Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef< const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>, React.ElementRef<typeof MenubarPrimitive.Trigger>,
@@ -39,17 +38,17 @@ const MenubarTrigger = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", "flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className className,
)} )}
{...props} {...props}
/> />
)) ));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef< const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>, React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger <MenubarPrimitive.SubTrigger
@@ -57,15 +56,15 @@ const MenubarSubTrigger = React.forwardRef<
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className='ml-auto h-4 w-4' />
</MenubarPrimitive.SubTrigger> </MenubarPrimitive.SubTrigger>
)) ));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef< const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>, React.ElementRef<typeof MenubarPrimitive.SubContent>,
@@ -75,12 +74,12 @@ const MenubarSubContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground 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", "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground 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",
className className,
)} )}
{...props} {...props}
/> />
)) ));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef< const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>, React.ElementRef<typeof MenubarPrimitive.Content>,
@@ -88,7 +87,7 @@ const MenubarContent = React.forwardRef<
>( >(
( (
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, { className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref ref,
) => ( ) => (
<MenubarPrimitive.Portal> <MenubarPrimitive.Portal>
<MenubarPrimitive.Content <MenubarPrimitive.Content
@@ -98,19 +97,19 @@ const MenubarContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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", "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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",
className className,
)} )}
{...props} {...props}
/> />
</MenubarPrimitive.Portal> </MenubarPrimitive.Portal>
) ),
) );
MenubarContent.displayName = MenubarPrimitive.Content.displayName MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef< const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>, React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item <MenubarPrimitive.Item
@@ -118,12 +117,12 @@ const MenubarItem = React.forwardRef<
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
MenubarItem.displayName = MenubarPrimitive.Item.displayName MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef< const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
@@ -133,20 +132,20 @@ const MenubarCheckboxItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator> <MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className='h-4 w-4' />
</MenubarPrimitive.ItemIndicator> </MenubarPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</MenubarPrimitive.CheckboxItem> </MenubarPrimitive.CheckboxItem>
)) ));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef< const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>, React.ElementRef<typeof MenubarPrimitive.RadioItem>,
@@ -156,24 +155,24 @@ const MenubarRadioItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator> <MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" /> <Circle className='h-2 w-2 fill-current' />
</MenubarPrimitive.ItemIndicator> </MenubarPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</MenubarPrimitive.RadioItem> </MenubarPrimitive.RadioItem>
)) ));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef< const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>, React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label <MenubarPrimitive.Label
@@ -181,12 +180,12 @@ const MenubarLabel = React.forwardRef<
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", "px-2 py-1.5 text-sm font-semibold",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef< const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>, React.ElementRef<typeof MenubarPrimitive.Separator>,
@@ -197,8 +196,8 @@ const MenubarSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ const MenubarShortcut = ({
className, className,
@@ -208,13 +207,13 @@ const MenubarShortcut = ({
<span <span
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", "ml-auto text-xs tracking-widest text-muted-foreground",
className className,
)} )}
{...props} {...props}
/> />
) );
} };
MenubarShortcut.displayname = "MenubarShortcut" MenubarShortcut.displayname = "MenubarShortcut";
export { export {
Menubar, Menubar,
@@ -233,4 +232,4 @@ export {
MenubarGroup, MenubarGroup,
MenubarSub, MenubarSub,
MenubarShortcut, MenubarShortcut,
} };
+34 -35
View File
@@ -1,16 +1,15 @@
"use client" "use client";
import * as React from "react" import * as SelectPrimitive from "@radix-ui/react-select";
import * as SelectPrimitive from "@radix-ui/react-select" import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { Check, ChevronDown, ChevronUp } from "lucide-react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils" const Select = SelectPrimitive.Root;
const Select = SelectPrimitive.Root const SelectGroup = SelectPrimitive.Group;
const SelectGroup = SelectPrimitive.Group const SelectValue = SelectPrimitive.Value;
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef< const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>, React.ElementRef<typeof SelectPrimitive.Trigger>,
@@ -20,17 +19,17 @@ const SelectTrigger = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className='h-4 w-4 opacity-50' />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)) ));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef< const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@@ -40,14 +39,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className='h-4 w-4' />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
)) ));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef< const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@@ -57,15 +56,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className='h-4 w-4' />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
)) ));
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
@@ -78,7 +77,7 @@ const SelectContent = React.forwardRef<
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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", "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
{...props} {...props}
@@ -88,7 +87,7 @@ const SelectContent = React.forwardRef<
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)} )}
> >
{children} {children}
@@ -96,8 +95,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
)) ));
SelectContent.displayName = SelectPrimitive.Content.displayName SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef< const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>, React.ElementRef<typeof SelectPrimitive.Label>,
@@ -108,8 +107,8 @@ const SelectLabel = React.forwardRef<
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ));
SelectLabel.displayName = SelectPrimitive.Label.displayName SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef< const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>, React.ElementRef<typeof SelectPrimitive.Item>,
@@ -119,20 +118,20 @@ const SelectItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className='h-4 w-4' />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ));
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef< const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>, React.ElementRef<typeof SelectPrimitive.Separator>,
@@ -143,8 +142,8 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export { export {
Select, Select,
@@ -157,4 +156,4 @@ export {
SelectSeparator, SelectSeparator,
SelectScrollUpButton, SelectScrollUpButton,
SelectScrollDownButton, SelectScrollDownButton,
} };
+10 -11
View File
@@ -1,9 +1,8 @@
"use client" "use client";
import * as React from "react" import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils"
const Separator = React.forwardRef< const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>, React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -11,7 +10,7 @@ const Separator = React.forwardRef<
>( >(
( (
{ className, orientation = "horizontal", decorative = true, ...props }, { className, orientation = "horizontal", decorative = true, ...props },
ref ref,
) => ( ) => (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
ref={ref} ref={ref}
@@ -20,12 +19,12 @@ const Separator = React.forwardRef<
className={cn( className={cn(
"shrink-0 bg-border", "shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className className,
)} )}
{...props} {...props}
/> />
) ),
) );
Separator.displayName = SeparatorPrimitive.Root.displayName Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator } export { Separator };
+31 -32
View File
@@ -1,19 +1,18 @@
"use client" "use client";
import * as React from "react" import * as SheetPrimitive from "@radix-ui/react-dialog";
import * as SheetPrimitive from "@radix-ui/react-dialog" import { type VariantProps, cva } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import { X } from "lucide-react";
import { X } from "lucide-react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils" const Sheet = SheetPrimitive.Root;
const Sheet = SheetPrimitive.Root const SheetTrigger = SheetPrimitive.Trigger;
const SheetTrigger = SheetPrimitive.Trigger const SheetClose = SheetPrimitive.Close;
const SheetClose = SheetPrimitive.Close const SheetPortal = SheetPrimitive.Portal;
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef< const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>, React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -22,13 +21,13 @@ const SheetOverlay = React.forwardRef<
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className,
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)) ));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -46,8 +45,8 @@ const sheetVariants = cva(
defaultVariants: { defaultVariants: {
side: "right", side: "right",
}, },
} },
) );
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
@@ -65,14 +64,14 @@ const SheetContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetPrimitive.Close className='absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary'>
<X className="h-4 w-4" /> <X className='h-4 w-4' />
<span className="sr-only">Close</span> <span className='sr-only'>Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
)) ));
SheetContent.displayName = SheetPrimitive.Content.displayName SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ const SheetHeader = ({
className, className,
@@ -81,12 +80,12 @@ const SheetHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-2 text-center sm:text-left", "flex flex-col space-y-2 text-center sm:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
SheetHeader.displayName = "SheetHeader" SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ const SheetFooter = ({
className, className,
@@ -95,12 +94,12 @@ const SheetFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className,
)} )}
{...props} {...props}
/> />
) );
SheetFooter.displayName = "SheetFooter" SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
@@ -111,8 +110,8 @@ const SheetTitle = React.forwardRef<
className={cn("text-lg font-semibold text-foreground", className)} className={cn("text-lg font-semibold text-foreground", className)}
{...props} {...props}
/> />
)) ));
SheetTitle.displayName = SheetPrimitive.Title.displayName SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef< const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>, React.ElementRef<typeof SheetPrimitive.Description>,
@@ -123,8 +122,8 @@ const SheetDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
SheetDescription.displayName = SheetPrimitive.Description.displayName SheetDescription.displayName = SheetPrimitive.Description.displayName;
export { export {
Sheet, Sheet,
@@ -137,4 +136,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };
+3 -3
View File
@@ -1,4 +1,4 @@
import { cn } from "~/utils" import { cn } from "~/utils";
function Skeleton({ function Skeleton({
className, className,
@@ -9,7 +9,7 @@ function Skeleton({
className={cn("animate-pulse rounded-md bg-muted", className)} className={cn("animate-pulse rounded-md bg-muted", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };
+28 -25
View File
@@ -1,28 +1,31 @@
import * as React from "react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils"
const Table = React.forwardRef< const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto"> <div className='relative w-full overflow-auto'>
<table <table
ref={ref} ref={ref}
className={cn("w-full caption-bottom text-sm", className)} className={cn("w-full caption-bottom text-sm", className)}
{...props} {...props}
/> />
</div> </div>
)) ));
Table.displayName = "Table" Table.displayName = "Table";
const TableHeader = React.forwardRef< const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead
)) ref={ref}
TableHeader.displayName = "TableHeader" className={cn("[&_tr]:border-b", className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef< const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@@ -33,8 +36,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
)) ));
TableBody.displayName = "TableBody" TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef< const TableFooter = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@@ -44,12 +47,12 @@ const TableFooter = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableFooter.displayName = "TableFooter" TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef< const TableRow = React.forwardRef<
HTMLTableRowElement, HTMLTableRowElement,
@@ -59,12 +62,12 @@ const TableRow = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableRow.displayName = "TableRow" TableRow.displayName = "TableRow";
const TableHead = React.forwardRef< const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@@ -74,12 +77,12 @@ const TableHead = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableHead.displayName = "TableHead" TableHead.displayName = "TableHead";
const TableCell = React.forwardRef< const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@@ -90,8 +93,8 @@ const TableCell = React.forwardRef<
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props} {...props}
/> />
)) ));
TableCell.displayName = "TableCell" TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef< const TableCaption = React.forwardRef<
HTMLTableCaptionElement, HTMLTableCaptionElement,
@@ -102,8 +105,8 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
TableCaption.displayName = "TableCaption" TableCaption.displayName = "TableCaption";
export { export {
Table, Table,
@@ -114,4 +117,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "~/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
+30 -31
View File
@@ -1,13 +1,12 @@
"use client" "use client";
import * as React from "react" import * as ToastPrimitives from "@radix-ui/react-toast";
import * as ToastPrimitives from "@radix-ui/react-toast" import { type VariantProps, cva } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import { X } from "lucide-react";
import { X } from "lucide-react" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils" const ToastProvider = ToastPrimitives.Provider;
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
@@ -17,12 +16,12 @@ const ToastViewport = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className className,
)} )}
{...props} {...props}
/> />
)) ));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
@@ -37,8 +36,8 @@ const toastVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
@@ -51,9 +50,9 @@ const Toast = React.forwardRef<
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
) );
}) });
Toast.displayName = ToastPrimitives.Root.displayName Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
@@ -63,12 +62,12 @@ const ToastAction = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className className,
)} )}
{...props} {...props}
/> />
)) ));
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
@@ -78,15 +77,15 @@ const ToastClose = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className className,
)} )}
toast-close="" toast-close=''
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className='h-4 w-4' />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) ));
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
@@ -97,8 +96,8 @@ const ToastTitle = React.forwardRef<
className={cn("text-sm font-semibold", className)} className={cn("text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ));
ToastTitle.displayName = ToastPrimitives.Title.displayName ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
@@ -109,12 +108,12 @@ const ToastDescription = React.forwardRef<
className={cn("text-sm opacity-90", className)} className={cn("text-sm opacity-90", className)}
{...props} {...props}
/> />
)) ));
ToastDescription.displayName = ToastPrimitives.Description.displayName ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction> type ToastActionElement = React.ReactElement<typeof ToastAction>;
export { export {
type ToastProps, type ToastProps,
@@ -126,4 +125,4 @@ export {
ToastDescription, ToastDescription,
ToastClose, ToastClose,
ToastAction, ToastAction,
} };
+11 -8
View File
@@ -1,4 +1,4 @@
"use client" "use client";
import { import {
Toast, Toast,
@@ -7,18 +7,21 @@ import {
ToastProvider, ToastProvider,
ToastTitle, ToastTitle,
ToastViewport, ToastViewport,
} from "~/components/ui/toast" } from "~/components/ui/toast";
import { useToast } from "~/components/ui/use-toast" import { useToast } from "~/components/ui/use-toast";
export function Toaster() { export function Toaster() {
const { toasts } = useToast() const { toasts } = useToast();
return ( return (
<ToastProvider> <ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(function ({ id, title, description, action, ...props }) {
return ( return (
<Toast key={id} {...props}> <Toast
<div className="grid gap-1"> key={id}
{...props}
>
<div className='grid gap-1'>
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && ( {description && (
<ToastDescription>{description}</ToastDescription> <ToastDescription>{description}</ToastDescription>
@@ -27,9 +30,9 @@ export function Toaster() {
{action} {action}
<ToastClose /> <ToastClose />
</Toast> </Toast>
) );
})} })}
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
) );
} }
+11 -12
View File
@@ -1,15 +1,14 @@
"use client" "use client";
import * as React from "react" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as React from "react";
import { cn } from "~/utils";
import { cn } from "~/utils" const TooltipProvider = TooltipPrimitive.Provider;
const TooltipProvider = TooltipPrimitive.Provider const Tooltip = TooltipPrimitive.Root;
const Tooltip = TooltipPrimitive.Root const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef< const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>, React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -20,11 +19,11 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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", "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TooltipContent.displayName = TooltipPrimitive.Content.displayName TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+1
View File
@@ -2,6 +2,7 @@
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from "react"; import * as React from "react";
import type { ToastActionElement, ToastProps } from "~/components/ui/toast"; import type { ToastActionElement, ToastProps } from "~/components/ui/toast";
const TOAST_LIMIT = 3; const TOAST_LIMIT = 3;
+107 -74
View File
@@ -1,9 +1,7 @@
import { icons } from "lucide-react"; import { z } from "zod";
import { Metadata } from "next"; import { Schema_Config } from "~/schema";
import { ToastPosition } from "react-hot-toast";
import colors from "tailwindcss/colors";
const config: gIndexConfig = { const config: z.input<typeof Schema_Config> = {
/** /**
* If possible, please don't change this value * If possible, please don't change this value
* Even if you're creating a PR, just let me change it myself * Even if you're creating a PR, just let me change it myself
@@ -11,19 +9,19 @@ const config: gIndexConfig = {
version: "2.0", version: "2.0",
/** /**
* Base path of the app, used for generating links * Base path of the app, used for generating links
* If you're not using Vercel, you need to change this to your domain name
* *
* 2023/09/10: This is not used anymore, edit via env variable instead * If you're using another port for development, you can set it here
* *
* @default process.env.NEXT_PUBLIC_VERCEL_URL * @default process.env.NEXT_PUBLIC_DOMAIN
*/ */
basePath: basePath:
process.env.NODE_ENV === "development" process.env.NODE_ENV === "development"
? "http://localhost:3000" ? "http://localhost:3000"
: `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`, : `https://${process.env.NEXT_PUBLIC_DOMAIN}`,
/** /**
* DEPRECATED * DEPRECATED
* Since in 2.0 we're using server side data fetching, this is not needed anymore.
* *
* Hashed key for fetching protected files / folders from the server * Hashed key for fetching protected files / folders from the server
* This key will bypass the file / folder password * This key will bypass the file / folder password
@@ -35,9 +33,9 @@ const config: gIndexConfig = {
* Used for all pages and api routes * Used for all pages and api routes
* Default is 5 minutes (300/60 = 5min) * Default is 5 minutes (300/60 = 5min)
* *
* @default "max-age=300, s-maxage=300, stale-while-revalidate, public" * @default "max-age=0, s-maxage=60, stale-while-revalidate"
*/ */
cacheControl: "max-age=300, s-maxage=300, stale-while-revalidate, public", cacheControl: "max-age=0, s-maxage=60, stale-while-revalidate",
apiConfig: { apiConfig: {
/** /**
@@ -48,7 +46,8 @@ const config: gIndexConfig = {
* You need to create a new folder and share it with the service account * You need to create a new folder and share it with the service account
* Then, copy the folder id and paste it here * Then, copy the folder id and paste it here
*/ */
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j", rootFolder:
"49b52b93b109c0f8157af45a6a265fbd33f5e45293a70774780b53c96fee9ee1a6e78b077bea3476c13b9603af47d762",
isTeamDrive: false, // Set this to true if you're using Team Drive isTeamDrive: false, // Set this to true if you're using Team Drive
defaultQuery: [ defaultQuery: [
"trashed = false", "trashed = false",
@@ -57,8 +56,23 @@ const config: gIndexConfig = {
defaultField: defaultField:
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed", "id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed",
defaultOrder: "folder, name asc, modifiedTime desc", defaultOrder: "folder, name asc, modifiedTime desc",
itemsPerPage: 12, itemsPerPage: 50,
searchResult: 10, searchResult: 5,
/**
* By default, the app will use the thumbnail URL from Google Drive
*
* Sometimes, the thumbnail can't be accessed because of CORS policy
* If you're having this issue, you can set this to true
*
* This will make the api fetch the thumbnail and serve it from the server
* instead of using the Google Drive thumbnail
*
* This will increase the server load, so use it wisely
*
* Default: true
*/
proxyThumbnail: true,
/** /**
* Special file name that will be used for certain purposes * Special file name that will be used for certain purposes
@@ -74,7 +88,22 @@ const config: gIndexConfig = {
*/ */
banner: ".banner", banner: ".banner",
}, },
hiddenFiles: [".password", ".readme.md", ".banner"], /**
* Reason why banner has multiple extensions:
* - If I use contains query, it will also match the file or folder that contains the word.
* (e.g: File / folder with the name of "Test Password" will be matched)
* - If I use = query, it will only match the exact name, hence the multiple extensions
*
* You can add more extensions if you want
*/
hiddenFiles: [
".password",
".readme.md",
".banner",
".banner.jpg",
".banner.png",
".banner.webp",
],
/** /**
* Allow user to download protected file without password. * Allow user to download protected file without password.
@@ -86,6 +115,13 @@ const config: gIndexConfig = {
allowDownloadProtectedFile: false, allowDownloadProtectedFile: false,
/** /**
* Duration in hours. * Duration in hours.
* In version 2, this will be used for download link expiration.
* If you need it under 1 hour, you can use math expression. (e.g: (5 / 60) * 1 = 5 minutes)
*
* This only affect when the user download the file
* For example if you set it for example 30 minutes (0.5)
* After 30 minutes, and the user still downloading the file, the download will NOT be interrupted
* But if the user refresh the page / trying to download again, the download link will be expired
* *
* Default: 6 hours * Default: 6 hours
*/ */
@@ -116,15 +152,15 @@ const config: gIndexConfig = {
siteName: "next-gdrive-index", siteName: "next-gdrive-index",
siteNameTemplate: "%s - next-gdrive-index", siteNameTemplate: "%s - next-gdrive-index",
siteDescription: "A simple file browser for Google Drive", siteDescription: "A simple file browser for Google Drive",
siteIcon: "/flaticon.svg", siteIcon: "/logo.svg",
siteAuthor: "mbaharip", siteAuthor: "mbaharip",
favIcon: "/favicon.svg", favIcon: "/favicon.png",
/** /**
* Next.js Metadata robots object * Next.js Metadata robots object
* *
* ref: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#robots * ref: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#robots
*/ */
robots: undefined, robots: "noindex, nofollow",
twitterHandle: "@mbaharip_", twitterHandle: "@mbaharip_",
/** /**
@@ -159,7 +195,7 @@ const config: gIndexConfig = {
* Tailwind color name. * Tailwind color name.
* Ref: https://tailwindcss.com/docs/customizing-colors * Ref: https://tailwindcss.com/docs/customizing-colors
*/ */
defaultAccentColor: "teal", // defaultAccentColor: "teal",
/** /**
* Site wide password protection * Site wide password protection
@@ -246,65 +282,62 @@ const config: gIndexConfig = {
}; };
export default config; export default config;
// export interface gIndexConfig {
// version: string;
// basePath: string;
// cacheControl: string;
interface gIndexConfig { // apiConfig: {
version: string; // rootFolder: string;
basePath: string; // isTeamDrive: boolean;
// masterKey: string; // defaultQuery: string[];
cacheControl: string; // defaultField: string;
// defaultOrder: string;
// itemsPerPage: number;
// searchResult: number;
// proxyThumbnail: boolean;
apiConfig: { // specialFile: {
rootFolder: string; // password: string;
isTeamDrive: boolean; // readme: string;
defaultQuery: string[]; // banner: string;
defaultField: string; // };
defaultOrder: string; // hiddenFiles: string[];
itemsPerPage: number;
searchResult: number;
specialFile: { // allowDownloadProtectedFile: boolean;
password: string; // temporaryTokenDuration: number;
readme: string; // maxFileSize: number;
banner: string; // };
}; // siteConfig: {
hiddenFiles: string[]; // siteName: string;
// siteNameTemplate?: string;
// siteDescription: string;
// siteIcon: string;
// siteAuthor?: string;
// favIcon: string;
// robots?: Metadata["robots"];
// twitterHandle?: string;
allowDownloadProtectedFile: boolean; // footer: string | string[];
temporaryTokenDuration: number;
maxFileSize: number;
};
siteConfig: {
siteName: string;
siteNameTemplate?: string;
siteDescription: string;
siteIcon: string;
siteAuthor?: string;
favIcon: string;
robots?: Metadata["robots"];
twitterHandle?: string;
footer: string | string[]; // privateIndex: boolean;
// breadcrumbMax: number;
defaultAccentColor?: keyof typeof colors; // toaster?: {
// position?: ToastPosition;
// duration?: number;
// };
privateIndex: boolean; // navbarItems: {
breadcrumbMax: number; // icon: keyof typeof icons;
// name: string;
toaster?: { // href: string;
position?: ToastPosition; // external?: boolean;
duration?: number; // }[];
}; // supports: {
// name: string;
navbarItems: { // currency: string;
icon: keyof typeof icons; // href: string;
name: string; // }[];
href: string; // };
external?: boolean; // }
}[];
supports: {
name: string;
currency: string;
href: string;
}[];
};
}
+1 -5
View File
@@ -1,10 +1,6 @@
"use client"; "use client";
import React, { import React, { createContext, useEffect, useState } from "react";
createContext,
useEffect,
useState,
} from "react";
export type TTheme = "dark" | "light"; export type TTheme = "dark" | "light";
+1 -3
View File
@@ -3,9 +3,7 @@ import { toast } from "react-toastify";
export default function useCopyText() { export default function useCopyText() {
return (text: string) => { return (text: string) => {
if (!navigator.clipboard) { if (!navigator.clipboard) {
toast.error( toast.error("Your browser does not support copying to clipboard.");
"Your browser does not support copying to clipboard.",
);
return; return;
} }
if (!text) { if (!text) {
+7 -26
View File
@@ -2,10 +2,7 @@ import { useEffect, useState } from "react";
type SetValue<T> = (value: T | ((val: T) => T)) => void; type SetValue<T> = (value: T | ((val: T) => T)) => void;
export default function useLocalStorage<T>( export default function useLocalStorage<T>(key: string, initialValue: T) {
key: string,
initialValue: T,
) {
// First check if there is a value in localStorage // First check if there is a value in localStorage
function readLocalStorage() { function readLocalStorage() {
if (typeof window === "undefined") return initialValue; if (typeof window === "undefined") return initialValue;
@@ -14,30 +11,20 @@ export default function useLocalStorage<T>(
const item = window.localStorage.getItem(key); const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue; return item ? JSON.parse(item) : initialValue;
} catch (error) { } catch (error) {
console.warn( console.warn(`Error reading localStorage key “${key}”:`, error);
`Error reading localStorage key “${key}”:`,
error,
);
return initialValue; return initialValue;
} }
} }
const [storedValue, setStoredValue] = useState<T>( const [storedValue, setStoredValue] = useState<T>(readLocalStorage);
readLocalStorage,
);
// Create dispatch function to set or remove localStorage // Create dispatch function to set or remove localStorage
const setValue: SetValue<T> = (value) => { const setValue: SetValue<T> = (value) => {
try { try {
const valueToStore = const valueToStore =
value instanceof Function value instanceof Function ? value(storedValue) : value;
? value(storedValue)
: value;
setStoredValue(valueToStore); setStoredValue(valueToStore);
window.localStorage.setItem( window.localStorage.setItem(key, JSON.stringify(valueToStore));
key,
JSON.stringify(valueToStore),
);
} catch (error) { } catch (error) {
console.error("Error setting localStorage:", error); console.error("Error setting localStorage:", error);
} }
@@ -54,10 +41,7 @@ export default function useLocalStorage<T>(
const newValue = JSON.parse(event.newValue); const newValue = JSON.parse(event.newValue);
setStoredValue(newValue); setStoredValue(newValue);
} catch (error) { } catch (error) {
console.error( console.error("Error setting localStorage:", error);
"Error setting localStorage:",
error,
);
} }
} }
}; };
@@ -65,10 +49,7 @@ export default function useLocalStorage<T>(
window.addEventListener("storage", handleStorageChange); window.addEventListener("storage", handleStorageChange);
return () => { return () => {
window.removeEventListener( window.removeEventListener("storage", handleStorageChange);
"storage",
handleStorageChange,
);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]); }, [key]);
+19
View File
@@ -0,0 +1,19 @@
import { useRouter as useNextRouter } from "next/navigation";
import NProgress from "nprogress";
/**
* Custom useRouter hook that add NProgress to the router
* Why? Because NProgress is not working with Next.js 14 router
* So we need to add it manually
*/
export default function useRouter() {
const router = useNextRouter();
const { push } = router;
router.push = (...args: Parameters<typeof push>) => {
NProgress.start();
return push(...args);
};
return router;
}
+89 -1
View File
@@ -1,3 +1,4 @@
import { icons } from "lucide-react";
import { z } from "zod"; import { z } from "zod";
export const Schema_Breadcrumb = z.object({ export const Schema_Breadcrumb = z.object({
@@ -19,7 +20,7 @@ export const Schema_File = z.object({
.object({ .object({
width: z.coerce.number(), width: z.coerce.number(),
height: z.coerce.number(), height: z.coerce.number(),
rotation: z.coerce.number(), rotation: z.coerce.number().optional(),
}) })
.optional() .optional()
.nullable(), .nullable(),
@@ -32,3 +33,90 @@ export const Schema_File = z.object({
.optional() .optional()
.nullable(), .nullable(),
}); });
export const Schema_Config = z.object({
version: z.string(),
basePath: z.string(),
cacheControl: z.string(),
apiConfig: z.object({
rootFolder: z.string(),
isTeamDrive: z.boolean(),
defaultQuery: z.array(z.string()),
defaultField: z.string(),
defaultOrder: z.string(),
itemsPerPage: z.number().positive(),
searchResult: z.number().positive(),
proxyThumbnail: z.boolean(),
specialFile: z.object({
password: z.string(),
readme: z.string(),
banner: z.string(),
}),
hiddenFiles: z.array(z.string()),
allowDownloadProtectedFile: z.boolean(),
temporaryTokenDuration: z.number().positive(),
maxFileSize: z.number().positive(),
}),
siteConfig: z.object({
siteName: z.string(),
siteNameTemplate: z.string().optional().default("%s"),
siteDescription: z.string(),
siteIcon: z.string(),
siteAuthor: z.string().optional().default("mbaharip"),
favIcon: z.string(),
robots: z.string().optional().default("noindex, nofollow"),
twitterHandle: z.string().optional().default("@__mbaharip__"),
footer: z
.string()
.or(z.array(z.string()))
.optional()
.default([
"{{ year }}",
"{{ repository }}",
"{{ author }}",
"{{ version }}",
"{{ siteName }}",
"{{ creator }}",
]),
privateIndex: z.boolean().optional().default(false),
breadcrumbMax: z.number(),
toaster: z
.object({
position: z.enum([
"top-left",
"top-right",
"bottom-left",
"bottom-right",
]),
duration: z.number().positive(),
})
.optional()
.default({
position: "top-right",
duration: 5000,
}),
navbarItems: z.array(
z.object({
icon: z.enum(Object.keys(icons) as [keyof typeof icons]),
name: z.string(),
href: z.string(),
external: z.boolean().optional().default(false),
}),
),
supports: z.array(
z.object({
name: z.string(),
currency: z.string(),
href: z.string(),
}),
),
}),
});
+1 -1
View File
@@ -12,7 +12,7 @@ body {
@layer base { @layer base {
* { * {
@apply outline-none; @apply outline-none;
@apply focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500; @apply focus-visible:outline-accent-500 focus-visible:outline-2 focus-visible:-outline-offset-1;
} }
html, html,
+32 -115
View File
@@ -22,11 +22,7 @@
--syntax-accent: hsl(230, 100%, 66%); --syntax-accent: hsl(230, 100%, 66%);
/* From syntax-variables.less */ /* From syntax-variables.less */
--syntax-selection-color: hsl(230, 1%, 90%); --syntax-selection-color: hsl(230, 1%, 90%);
--syntax-gutter-background-color-selected: hsl( --syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
230,
1%,
90%
);
--syntax-cursor-line: hsla(230, 8%, 24%, 0.05); --syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
} }
@@ -54,11 +50,7 @@ body {
--syntax-accent: hsl(220, 100%, 66%); --syntax-accent: hsl(220, 100%, 66%);
/* From syntax-variables.less */ /* From syntax-variables.less */
--syntax-selection-color: hsl(220, 13%, 28%); --syntax-selection-color: hsl(220, 13%, 28%);
--syntax-gutter-background-color-selected: hsl( --syntax-gutter-background-color-selected: hsl(220, 13%, 26%);
220,
13%,
26%
);
--syntax-cursor-line: hsla(220, 100%, 80%, 0.04); --syntax-cursor-line: hsla(220, 100%, 80%, 0.04);
} }
@@ -231,9 +223,7 @@ pre {
/* MD overrides */ /* MD overrides */
.language-markdown .token.url, .language-markdown .token.url,
.language-markdown .token.url > .token.operator, .language-markdown .token.url > .token.operator,
.language-markdown .language-markdown .token.url-reference.url > .token.string {
.token.url-reference.url
> .token.string {
color: var(--syntax-fg); color: var(--syntax-fg);
} }
@@ -267,9 +257,7 @@ pre {
.language-markdown .token.strike .token.content, .language-markdown .token.strike .token.content,
.language-markdown .token.strike .token.punctuation, .language-markdown .token.strike .token.punctuation,
.language-markdown .token.list.punctuation, .language-markdown .token.list.punctuation,
.language-markdown .language-markdown .token.title.important > .token.punctuation {
.token.title.important
> .token.punctuation {
color: var(--hue-5); color: var(--hue-5);
} }
@@ -310,44 +298,21 @@ div.code-toolbar > .toolbar.toolbar > .toolbar-item {
} }
/* Styling the buttons */ /* Styling the buttons */
div.code-toolbar div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
> .toolbar.toolbar
> .toolbar-item
> button,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a, div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span { div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
background: var( background: var(--syntax-gutter-background-color-selected);
--syntax-gutter-background-color-selected
);
color: var(--mono-2); color: var(--mono-2);
padding: 0.1em 0.4em; padding: 0.1em 0.4em;
border-radius: 0.3em; border-radius: 0.3em;
} }
div.code-toolbar div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
> .toolbar.toolbar div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
> .toolbar-item div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
> button:hover, div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
div.code-toolbar div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
> .toolbar.toolbar div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
> .toolbar-item
> button:focus,
div.code-toolbar
> .toolbar.toolbar
> .toolbar-item
> a:hover,
div.code-toolbar
> .toolbar.toolbar
> .toolbar-item
> a:focus,
div.code-toolbar
> .toolbar.toolbar
> .toolbar-item
> span:hover,
div.code-toolbar
> .toolbar.toolbar
> .toolbar-item
> span:focus {
background: var(--syntax-selection-color); background: var(--syntax-selection-color);
color: var(--syntax-fg); color: var(--syntax-fg);
} }
@@ -361,9 +326,7 @@ div.code-toolbar
/* Default line numbers in Line Highlight plugin */ /* Default line numbers in Line Highlight plugin */
.line-highlight.line-highlight:before, .line-highlight.line-highlight:before,
.line-highlight.line-highlight[data-end]:after { .line-highlight.line-highlight[data-end]:after {
background: var( background: var(--syntax-gutter-background-color-selected);
--syntax-gutter-background-color-selected
);
color: var(--syntax-fg); color: var(--syntax-fg);
padding: 0.1em 0.6em; padding: 0.1em 0.6em;
border-radius: 0.3em; border-radius: 0.3em;
@@ -420,86 +383,40 @@ pre[id].linkable-line-numbers.linkable-line-numbers
/* Diff Highlight plugin overrides */ /* Diff Highlight plugin overrides */
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */ /* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
pre.diff-highlight > code .token.token.deleted:not(.prefix), pre.diff-highlight > code .token.token.deleted:not(.prefix),
pre pre > code.diff-highlight .token.token.deleted:not(.prefix) {
> code.diff-highlight
.token.token.deleted:not(.prefix) {
background-color: hsla(353, 100%, 66%, 0.15); background-color: hsla(353, 100%, 66%, 0.15);
} }
pre.diff-highlight pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
> code pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection,
.token.token.deleted:not(.prefix)::-moz-selection, pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
pre.diff-highlight pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection {
> code
.token.token.deleted:not(.prefix)
*::-moz-selection,
pre
> code.diff-highlight
.token.token.deleted:not(.prefix)::-moz-selection,
pre
> code.diff-highlight
.token.token.deleted:not(.prefix)
*::-moz-selection {
background-color: hsla(353, 95%, 66%, 0.25); background-color: hsla(353, 95%, 66%, 0.25);
} }
pre.diff-highlight pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
> code pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
.token.token.deleted:not(.prefix)::selection, pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
pre.diff-highlight pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
> code
.token.token.deleted:not(.prefix)
*::selection,
pre
> code.diff-highlight
.token.token.deleted:not(.prefix)::selection,
pre
> code.diff-highlight
.token.token.deleted:not(.prefix)
*::selection {
background-color: hsla(353, 95%, 66%, 0.25); background-color: hsla(353, 95%, 66%, 0.25);
} }
pre.diff-highlight pre.diff-highlight > code .token.token.inserted:not(.prefix),
> code pre > code.diff-highlight .token.token.inserted:not(.prefix) {
.token.token.inserted:not(.prefix),
pre
> code.diff-highlight
.token.token.inserted:not(.prefix) {
background-color: hsla(137, 100%, 55%, 0.15); background-color: hsla(137, 100%, 55%, 0.15);
} }
pre.diff-highlight pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection,
> code pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection,
.token.token.inserted:not(.prefix)::-moz-selection, pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection,
pre.diff-highlight pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection {
> code
.token.token.inserted:not(.prefix)
*::-moz-selection,
pre
> code.diff-highlight
.token.token.inserted:not(.prefix)::-moz-selection,
pre
> code.diff-highlight
.token.token.inserted:not(.prefix)
*::-moz-selection {
background-color: hsla(135, 73%, 55%, 0.25); background-color: hsla(135, 73%, 55%, 0.25);
} }
pre.diff-highlight pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
> code pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
.token.token.inserted:not(.prefix)::selection, pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
pre.diff-highlight pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
> code
.token.token.inserted:not(.prefix)
*::selection,
pre
> code.diff-highlight
.token.token.inserted:not(.prefix)::selection,
pre
> code.diff-highlight
.token.token.inserted:not(.prefix)
*::selection {
background-color: hsla(135, 73%, 55%, 0.25); background-color: hsla(135, 73%, 55%, 0.25);
} }
+7 -7
View File
@@ -31,15 +31,15 @@
.markdown h1, .markdown h1,
.markdown h2, .markdown h2,
.markdown h3 { .markdown h3 {
@apply my-4 border-b-2 border-b-primary-700 first-letter:capitalize; @apply border-b-primary-700 my-4 border-b-2 first-letter:capitalize;
} }
.markdown code:not(pre code) { .markdown code:not(pre code) {
@apply rounded-md bg-primary-800 px-2 py-0.5 leading-normal; @apply bg-primary-800 rounded-md px-2 py-0.5 leading-normal;
} }
.markdown a { .markdown a {
@apply text-accent-500 opacity-80 transition-smooth hover:opacity-100; @apply text-accent-500 transition-smooth opacity-80 hover:opacity-100;
} }
.markdown p { .markdown p {
@@ -68,7 +68,7 @@
.markdown table th, .markdown table th,
.markdown table td { .markdown table td {
@apply border border-primary-700 px-2 py-1; @apply border-primary-700 border px-2 py-1;
} }
.markdown table th { .markdown table th {
@@ -93,15 +93,15 @@
.markdown input[type="checkbox"] { .markdown input[type="checkbox"] {
@apply mr-2 aspect-square h-4 w-4 rounded-full p-2; @apply mr-2 aspect-square h-4 w-4 rounded-full p-2;
@apply appearance-none border border-primary-500; @apply border-primary-500 appearance-none border;
@apply disabled:bg-primary-600 disabled:checked:bg-accent-400; @apply disabled:bg-primary-600 disabled:checked:bg-accent-400;
} }
.markdown blockquote { .markdown blockquote {
@apply my-2 rounded-r-md border-l-4 border-l-info-500 bg-primary-800 py-4 pl-2; @apply border-l-info-500 bg-primary-800 my-2 rounded-r-md border-l-4 py-4 pl-2;
} }
.markdown hr { .markdown hr {
@apply my-4 border-b border-t-0 border-b-primary-500; @apply border-b-primary-500 my-4 border-b border-t-0;
} }
} }
+3 -3
View File
@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx" import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
-15
View File
@@ -1,15 +0,0 @@
export default function getSearchParams(
url: string,
key: string | string[],
): Record<string, string | null> {
const { searchParams } = new URL(url);
if (typeof key === "string") {
return { [key]: searchParams.get(key) };
} else {
const result: Record<string, string | null> = {};
for (const k of key) {
result[k] = searchParams.get(k);
}
return result;
}
}
@@ -1,45 +0,0 @@
import { NextRequest } from "next/server";
import ExtendedError from "utils/extendedError";
import { checkPathPassword } from "utils/passwordHelper";
import { Constant } from "types/constant";
export default async function privateIndexProtection(
request: NextRequest,
) {
const cookies = request.cookies.get(
Constant.cookies_SitePassword,
);
if (!cookies) {
throw new ExtendedError(
"Unauthorized",
401,
"No password found",
);
}
const password = cookies.value;
const sitePassword =
process.env.NEXT_PUBLIC_SITE_PASSWORD;
console.log(sitePassword);
if (!sitePassword) {
throw new ExtendedError(
"Internal Server Error",
500,
"Can't find site password. Please check your environment variable",
);
}
const valid = checkPathPassword(
"site-wide",
password,
sitePassword,
);
if (!valid) {
throw new ExtendedError(
"Unauthorized",
401,
"Invalid password",
);
}
}
+1 -3
View File
@@ -2,7 +2,5 @@ export default function bytesToReadable(bytes: number) {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte"; if (bytes === 0) return "0 Byte";
const i = Math.floor(Math.log(bytes) / Math.log(1024)); const i = Math.floor(Math.log(bytes) / Math.log(1024));
return ( return Math.round(bytes / Math.pow(1024, i)) + " " + sizes[i];
Math.round(bytes / Math.pow(1024, i)) + " " + sizes[i]
);
} }
-14
View File
@@ -1,14 +0,0 @@
class ExtendedError extends Error {
public extendedMessage?: string;
public code?: number;
public reason?: string;
constructor(msg: string, code: number, reason: string) {
super(msg);
this.extendedMessage = msg;
this.code = code;
this.reason = reason;
}
}
export default ExtendedError;
-10
View File
@@ -1,10 +0,0 @@
export function stripExtension(
filename: string,
ext: string,
): string {
// Make sure the extension is lowercase and also remove the dot
const extension = ext.toLowerCase().replace(/^\./, "");
return filename.toLowerCase().endsWith(extension)
? filename.slice(0, -extension.length - 1)
: filename;
}
-30
View File
@@ -1,30 +0,0 @@
import gIndexConfig from "config";
import { GaxiosResponse } from "gaxios";
import type { drive_v3 } from "googleapis";
import gdrive from "utils/gdriveInstance";
interface ListFiles extends drive_v3.Params$Resource$Files$List {}
export async function gdriveFilesList({
q,
fields,
orderBy,
pageSize,
pageToken,
}: ListFiles): Promise<GaxiosResponse<drive_v3.Schema$FileList>> {
const result = await gdrive.files.list({
q,
fields,
orderBy,
pageSize,
pageToken,
...(gIndexConfig.apiConfig.isTeamDrive && {
supportsAllDrives: true,
includeItemsFromAllDrives: true,
driveId: gIndexConfig.apiConfig.rootFolder,
corpora: "drive",
}),
});
return result;
}
+7 -7
View File
@@ -53,13 +53,13 @@ if (!gdrive) {
gdrive = google.drive({ gdrive = google.drive({
version: "v3", version: "v3",
auth: serviceAccountAuth, auth: serviceAccountAuth,
fetchImplementation: (url, init) => // fetchImplementation: (url, init) =>
fetch(url, { // fetch(url, {
...init, // ...init,
next: { // next: {
revalidate: 3600, // revalidate: 3600,
}, // },
}), // }),
}); });
} }
-39
View File
@@ -1,39 +0,0 @@
import { IGDriveFiles } from "types/api/files";
import { encryptData } from "./encryptionHelper/hash";
/**
* Generate download link for the file, so I don't have to repeat the code.
* Since it's contains encrypted object, I hope this function will make it easier to read and maintain.
*
* @param {IGDriveFiles} file - File object from GDrive API response.
* @param {boolean} [isProtected = false] - Is the file inside password protected folder or not.
* @param {boolean} [includeHost = false] - Include host name or not.
* @param {string} [token] - Download token for protected file.
* @param {string} [disableLimit] - Disable file size limit.
* @returns {string} URL to download the file.
*/
export function generatedDownloadLink(
file: IGDriveFiles,
isProtected: boolean = false,
includeHost: boolean = false,
token?: string | null,
disableLimit?: boolean,
): string {
const data: Record<"id" | "isProtected", string | boolean> = {
id: file.encryptedId,
isProtected,
};
const encryptedData = encryptData(JSON.stringify(data, null, 0));
// let url = `/api/download/${encryptedData}/${file.name}`;
const url = new URL(`/api/download/${encryptedData}/${file.name}`, window.location.origin);
if (token) url.searchParams.append("token", token);
if (disableLimit) url.searchParams.append("disableLimit", "true");
// if (includeHost) url = `${window.location.origin}${url}`;
// if (token) url = `${url}?token=${token}`;
// if (disableLimit) url = `${url}&disableLimit=true`;
return url.toString();
}
-18
View File
@@ -1,18 +0,0 @@
/**
* Converts a hex color code to an RGB value.
*
* @param {string} hex - The hex color code to convert.
* @returns {string} The RGB value of the hex color code.
*/
export default function hexToRgb(hex: string): string {
// Remove the # character from the beginning of the hex code
hex = hex.replace("#", "");
// Convert the hex code to RGB values
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// Return the RGB value as a string
return `${r} ${g} ${b}`;
}
-77
View File
@@ -1,77 +0,0 @@
import { Constant } from "types/constant";
import {
decryptData,
encryptData,
} from "./encryptionHelper/hash";
/**
* Check if the password is correct for the given path
* @param cookies - cookies value
* @param path - The path to check
* @param password - The password to check
* @returns true if the password is correct, false otherwise
*/
export function checkPathPassword(
path: string,
cookies: string,
password: string,
): boolean {
try {
const validCookieToken = decryptData(cookies);
const cookieTokenJSON = JSON.parse(validCookieToken);
if (!cookieTokenJSON[path]) return false;
return cookieTokenJSON[path] === password;
} catch (error: any) {
console.error(error.message);
return false;
}
}
/**
* Create a new password for the given path
* @param cookies - cookies value
* @param path - The path to add
* @param password - The password to add
* @returns The new cookies token
*/
export function addNewPassword(
path: string,
password: string,
cookies?: string,
server: boolean = false,
): string | void {
try {
let cookieTokenJSON: any = {};
if (cookies) {
const validCookieToken = decryptData(cookies);
cookieTokenJSON = JSON.parse(validCookieToken);
}
cookieTokenJSON[path] = password;
const encryptedCookieToken = encryptData(
JSON.stringify(cookieTokenJSON),
);
// Set cookie for 5 years
const expires = new Date(
Date.now() + 5 * 365 * 24 * 60 * 60 * 1000,
).toUTCString();
if (!server) {
document.cookie = `${Constant.cookies_SitePassword}=${encryptedCookieToken}; expires=${expires}; path=/; SameSite=Strict;`;
} else {
return `${Constant.cookies_SitePassword}=${encryptedCookieToken}; expires=${expires}; path=/; SameSite=Strict;`;
}
} catch (error: any) {
removeAllPassword();
throw new Error(
"Failed to add new password, it's likely because the encryption key are changed. Please refresh the page and try again.",
);
}
}
/**
* Remove all password
*/
export function removeAllPassword() {
document.cookie = `${Constant.cookies_SitePassword}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
+19 -1
View File
@@ -29,7 +29,7 @@ const extensionsMap: Record<FileTypes, string[]> = {
audio: ["mp3", "ogg", "wav", "flac", "m4a", "wma", "aac"], audio: ["mp3", "ogg", "wav", "flac", "m4a", "wma", "aac"],
markdown: ["md"], markdown: ["md"],
pdf: ["pdf"], pdf: ["pdf"],
document: ["doc", "docx", "xls", "xlsx", "ppt", "pptx"], document: ["odt", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv"],
executable: ["exe", "msi"], executable: ["exe", "msi"],
code: [ code: [
"html", "html",
@@ -110,6 +110,24 @@ const iconMap: Record<FileTypes | "unknown", keyof typeof icons> = {
// return previewMap[type as keyof typeof previewMap] ?? previewMap["unknown"]; // return previewMap[type as keyof typeof previewMap] ?? previewMap["unknown"];
// } // }
export function getFileType(
fileExtension: string,
mimeType: string,
): FileTypes | "unknown" {
if (fileExtension === "ts") {
if (mimeType.includes("video")) {
return "video";
} else {
return "code";
}
}
const type = Object.keys(extensionsMap).find((key) =>
extensionsMap[key as keyof typeof extensionsMap].includes(fileExtension),
);
return type ? (type as FileTypes) : "unknown";
}
export function getPreviewIcon(fileExtension: string, mimeType: string) { export function getPreviewIcon(fileExtension: string, mimeType: string) {
if (fileExtension === "ts") { if (fileExtension === "ts") {
if (mimeType.includes("video")) { if (mimeType.includes("video")) {
-18
View File
@@ -1,18 +0,0 @@
import gIndexConfig from "config";
import { decryptData, encryptData } from "./encryptionHelper/hash";
export function createDownloadToken(durationH: number = gIndexConfig.apiConfig.temporaryTokenDuration) {
const duration = durationH * 60 * 60 * 1000;
const expiredAt = Date.now() + duration;
return encryptData(expiredAt.toString());
}
export function isDownloadTokenValid(token: string): boolean {
try {
const expiredAt = Number(decryptData(token));
return expiredAt > Date.now();
} catch (e) {
return false;
}
}
+15 -3
View File
@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es2021",
"lib": ["dom", "dom.iterable", "esnext", "es2019"], "lib": ["dom", "dom.iterable", "esnext", "es2019"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -35,6 +35,18 @@
} }
] ]
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules", ".next", "**/*/_legacy/**/*", "**/*/_app/**/*"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": [
"node_modules",
".next",
"**/*/_legacy/**/*",
"**/*/_app/**/*",
"**/*/_pages/**/*"
]
} }
+34 -32
View File
@@ -3290,31 +3290,31 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"embla-carousel-react@npm:^8.0.0": "embla-carousel-react@npm:^8.0.1":
version: 8.0.0 version: 8.0.1
resolution: "embla-carousel-react@npm:8.0.0" resolution: "embla-carousel-react@npm:8.0.1"
dependencies: dependencies:
embla-carousel: "npm:8.0.0" embla-carousel: "npm:8.0.1"
embla-carousel-reactive-utils: "npm:8.0.0" embla-carousel-reactive-utils: "npm:8.0.1"
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0 react: ^16.8.0 || ^17.0.1 || ^18.0.0
checksum: 10c0/43d1f551be6119ba62f29fc281b5f89fabfc731fa7a8a008cb5520639be2775377580ad218c23171877f5812167b2a3bb72fac82918ad78b40ebf6f9214dfb4a checksum: 10c0/a16af76be911133f00ff38491b0ed12f09571949234b22bfe83cbd2d8b0d3cf43b666ffc4fc6aaf17e04691495fdad69fc1bc33db3eeec9ba7f67fe8db056a25
languageName: node languageName: node
linkType: hard linkType: hard
"embla-carousel-reactive-utils@npm:8.0.0": "embla-carousel-reactive-utils@npm:8.0.1":
version: 8.0.0 version: 8.0.1
resolution: "embla-carousel-reactive-utils@npm:8.0.0" resolution: "embla-carousel-reactive-utils@npm:8.0.1"
peerDependencies: peerDependencies:
embla-carousel: 8.0.0 embla-carousel: 8.0.1
checksum: 10c0/d88fb25a59be6bbbb11a9b012bdc42bc1570ae82fd363bda0224cc3131852d469855bc9068bd5aa85e4c8546311de2dd1eae43ea0556dd1443d9b12ff138034c checksum: 10c0/c511dbbcd869f11f102e826aea600f1668f8097792b4e185678f44240466fe6a224b68833c408bd9eb6c9b26e40321b6f18f70f45bd19df3cd403d964e1fba95
languageName: node languageName: node
linkType: hard linkType: hard
"embla-carousel@npm:8.0.0": "embla-carousel@npm:8.0.1":
version: 8.0.0 version: 8.0.1
resolution: "embla-carousel@npm:8.0.0" resolution: "embla-carousel@npm:8.0.1"
checksum: 10c0/51634c07816a5eef142b6bdfa1104da8232015de3260ff7c4b21a952abeac4035d2d02ea87bb5cec06290ec4429e606543e898b50aa9ccf6e636e74baf2951a6 checksum: 10c0/9ce30759a77e75ff4ce490102c429794fd46f03bbcc4a6af4ecefbe55a5de5289b7ac0f7607d1774a9b23c241d5781bf3d45459590768b15679a9da5b56ef6df
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6516,7 +6516,7 @@ __metadata:
clsx: "npm:^2.1.0" clsx: "npm:^2.1.0"
cmdk: "npm:^1.0.0" cmdk: "npm:^1.0.0"
date-fns: "npm:^3.6.0" date-fns: "npm:^3.6.0"
embla-carousel-react: "npm:^8.0.0" embla-carousel-react: "npm:^8.0.1"
encoding: "npm:^0.1.13" encoding: "npm:^0.1.13"
eslint: "npm:8.38.0" eslint: "npm:8.38.0"
eslint-config-next: "npm:^14.1.4" eslint-config-next: "npm:^14.1.4"
@@ -6529,8 +6529,8 @@ __metadata:
next-themes: "npm:^0.3.0" next-themes: "npm:^0.3.0"
nextjs-toploader: "npm:^1.6.11" nextjs-toploader: "npm:^1.6.11"
postcss: "npm:^8.4.38" postcss: "npm:^8.4.38"
prettier: "npm:^2.8.7" prettier: "npm:3.0.0"
prettier-plugin-tailwindcss: "npm:^0.2.7" prettier-plugin-tailwindcss: "npm:0.5.12"
react: "npm:^18.2.0" react: "npm:^18.2.0"
react-day-picker: "npm:^8.10.0" react-day-picker: "npm:^8.10.0"
react-dom: "npm:^18.2.0" react-dom: "npm:^18.2.0"
@@ -7159,25 +7159,25 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"prettier-plugin-tailwindcss@npm:^0.2.7": "prettier-plugin-tailwindcss@npm:0.5.12":
version: 0.2.8 version: 0.5.12
resolution: "prettier-plugin-tailwindcss@npm:0.2.8" resolution: "prettier-plugin-tailwindcss@npm:0.5.12"
peerDependencies: peerDependencies:
"@ianvs/prettier-plugin-sort-imports": "*" "@ianvs/prettier-plugin-sort-imports": "*"
"@prettier/plugin-pug": "*" "@prettier/plugin-pug": "*"
"@shopify/prettier-plugin-liquid": "*" "@shopify/prettier-plugin-liquid": "*"
"@shufo/prettier-plugin-blade": "*"
"@trivago/prettier-plugin-sort-imports": "*" "@trivago/prettier-plugin-sort-imports": "*"
prettier: ">=2.2.0" prettier: ^3.0
prettier-plugin-astro: "*" prettier-plugin-astro: "*"
prettier-plugin-css-order: "*" prettier-plugin-css-order: "*"
prettier-plugin-import-sort: "*" prettier-plugin-import-sort: "*"
prettier-plugin-jsdoc: "*" prettier-plugin-jsdoc: "*"
prettier-plugin-marko: "*"
prettier-plugin-organize-attributes: "*" prettier-plugin-organize-attributes: "*"
prettier-plugin-organize-imports: "*" prettier-plugin-organize-imports: "*"
prettier-plugin-sort-imports: "*"
prettier-plugin-style-order: "*" prettier-plugin-style-order: "*"
prettier-plugin-svelte: "*" prettier-plugin-svelte: "*"
prettier-plugin-twig-melody: "*"
peerDependenciesMeta: peerDependenciesMeta:
"@ianvs/prettier-plugin-sort-imports": "@ianvs/prettier-plugin-sort-imports":
optional: true optional: true
@@ -7185,8 +7185,6 @@ __metadata:
optional: true optional: true
"@shopify/prettier-plugin-liquid": "@shopify/prettier-plugin-liquid":
optional: true optional: true
"@shufo/prettier-plugin-blade":
optional: true
"@trivago/prettier-plugin-sort-imports": "@trivago/prettier-plugin-sort-imports":
optional: true optional: true
prettier-plugin-astro: prettier-plugin-astro:
@@ -7197,26 +7195,30 @@ __metadata:
optional: true optional: true
prettier-plugin-jsdoc: prettier-plugin-jsdoc:
optional: true optional: true
prettier-plugin-marko:
optional: true
prettier-plugin-organize-attributes: prettier-plugin-organize-attributes:
optional: true optional: true
prettier-plugin-organize-imports: prettier-plugin-organize-imports:
optional: true optional: true
prettier-plugin-sort-imports:
optional: true
prettier-plugin-style-order: prettier-plugin-style-order:
optional: true optional: true
prettier-plugin-svelte: prettier-plugin-svelte:
optional: true optional: true
prettier-plugin-twig-melody: prettier-plugin-twig-melody:
optional: true optional: true
checksum: 10c0/b025983a88ecfd53e8d7cf601b54feb2583c62e8f8b87533200f5c4291fee4a60801e1463caa639fd56e3c7a0cf28fea00feed4e93c1fe27a1136f1385907ba6 checksum: 10c0/984b79fd33aed89f09bb19c3659079eb19dad6c38a78150f8b1a3082d7331f4faf46e6c561e0e54f47954679c105ef8e674e9c56e22db318e1493b468aa29f5b
languageName: node languageName: node
linkType: hard linkType: hard
"prettier@npm:^2.8.7": "prettier@npm:3.0.0":
version: 2.8.8 version: 3.0.0
resolution: "prettier@npm:2.8.8" resolution: "prettier@npm:3.0.0"
bin: bin:
prettier: bin-prettier.js prettier: bin/prettier.cjs
checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a checksum: 10c0/113e2828c6487f10e19e7c30598e8810684b827f2a1b7747f113728d1049da15583b77cc5e907f37b371b08c49cfdd900aebe5c2a2077be3f0cccb73cca1133b
languageName: node languageName: node
linkType: hard linkType: hard