Merge pull request #7 from mbahArip/v2

Update to version 2.0
This commit is contained in:
Arief Rachmawan
2024-04-07 00:40:50 +07:00
committed by GitHub
136 changed files with 17971 additions and 10019 deletions
+7 -3
View File
@@ -1,5 +1,9 @@
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
NEXT_PUBLIC_ENCRYPTION_KEY="Rj3wRGB9nUElQ7OA"
NEXT_PUBLIC_SITE_PASSWORD="mbaharip"
NEXT_PUBLIC_VERCEL_URL="http://localhost:3000"
NEXT_PUBLIC_ENCRYPTION_KEY=
NEXT_PUBLIC_SITE_PASSWORD=
# Only fill with the domain name, without the protocol and trailing slash
# This will be used as a fallback if VERCEL_URL is not available
# Example: https://drive-demo.mbaharip.com -> drive.mbaharip.com
NEXT_PUBLIC_DOMAIN=
+2 -1
View File
@@ -2,5 +2,6 @@
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": "off"
}
},
"ignorePatterns": ["**/*/_legacy/**/*", "**/*/_app/**/*", "**/*/_page/**/*"]
}
+6 -3
View File
@@ -34,8 +34,11 @@ next-env.d.ts
# personal docs
/docs
/src/pages/api/legacy/
/**/*/_legacy/
/data
/.*/
/src/_app
/src/_app
/src/_page
/src/utils/_legacy
/src/components/_legacy
.env.production
+12 -7
View File
@@ -1,10 +1,10 @@
"use strict";
module.exports = {
plugins: [
require("prettier-plugin-tailwindcss"),
require("@trivago/prettier-plugin-sort-imports"),
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss",
],
printWidth: 120,
printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: true,
@@ -20,12 +20,17 @@ module.exports = {
endOfLine: "lf",
embeddedLanguageFormatting: "auto",
singleAttributePerLine: true,
tailwindAttributes: ["classNames", "wrapperClassName", "rootClassName"],
tailwindFunctions: ["twsx", "cn"],
tailwindConfig: "./tailwind.config.ts",
importOrder: [
"<THIRD_PARTY_MODULES>",
"^components/(.*)$",
"^(utils|hooks|context)/(.*)$",
"^types/(.*)$",
"^(config|styles)/(.*)$",
"^~/(app|components)/(.*)$",
"^~/(utils|hooks|context)/(.*)$",
"^~/types/(.*)$",
"^~/(config|styles)/(.*)$",
"^[./]",
],
importOrderSeparation: true,
+134 -13
View File
@@ -8,6 +8,65 @@
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ'>FAQ</a>
</p>
## 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.
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:
### New Features
- **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 `{{ process.env.SITE_PASSWORD }}`.
### 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?
`next-gdrive-index` is an indexer for Google Drive, it's a simple project that I made to index my files in Google Drive.
@@ -44,32 +103,50 @@ I know there are a lot of people selling cheap edu account for Google Drive and
## File Security
All files fetched from Google Drive **NEED TO BE SHARED** with `Anyone with the link can view` permission.
This is because Google Drive API can only access files that are shared with `Anyone with the link can view` permission.
> This only apply if `maxFileSize` is enabled.
But, every files `id` and `webContentLink` are encrypted with `AES-256-CBC` using your own key, so no one can access your files without the key.
Except, if the files are larger than the `fileSizeLimit` (default: 4MB for vercel), then the download will be redirected to the raw file link.
You need to set the file sharing permission to `Anyone with the link can view` for the files that you want to share.
**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
### File size limit
File size limit causing some files can't be previewed, and the download will be redirected to the raw file link.
> 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.
**This won't be fixed**, because it's a limitation from the deployment platform itself.
### 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]`)
- Fetch the password, readme, and files from Google Drive.
- Show to the user.
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`.
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.
~~The flow of the project are:~~
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.~~
### Shared Drive is now supported
~~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.~~
### Can't seek on audio and video preview
It looks like the audio and video preview can't seek, so you need to listen/watch from the beginning.
I'm still looking for a solution for this.
### ~~Shared Drive is now supported~~
~~I don't have Google Shared Drive, so I can't test it and implement it.~~
Implemented by [@loadingthedev](https://github.com/loadingthedev) [(PR #4)](https://github.com/mbahArip/next-gdrive-index/pull/4)
@@ -78,3 +155,47 @@ 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.
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`
## Sponsors and Donations
If you think I deserve it, you can support me by:
- [Paypal (USD)](https://paypal.me/mbaharip)
- [Ko-fi (USD)](https://ko-fi.com/mbaharip)
- [Saweria (IDR)](https://saweria.co/mbaharip)
-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
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/utils"
}
}
+18 -29
View File
@@ -1,34 +1,23 @@
/** @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 = {
pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
reactStrictMode: true,
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Cache-Control",
value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
},
],
},
];
webpack: (config) => {
config.resolve.alias.canvas = false;
return config;
},
// async headers() {
// return [
// {
// source: "/:path*",
// headers: [
// {
// key: "Cache-Control",
// value: "max-age=300, s-maxage=300, stale-while-revalidate, public",
// },
// ],
// },
// ];
// },
};
module.exports = withMDX(nextConfig);
module.exports = nextConfig;
+63 -24
View File
@@ -1,6 +1,6 @@
{
"name": "drive-manager",
"version": "0.1.0",
"name": "next-gdrive-index",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
@@ -12,24 +12,59 @@
},
"dependencies": {
"@cyntler/react-doc-viewer": "^1.13.0",
"@iconify/react": "^4.1.1",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "^13.4.19",
"@popperjs/core": "^2.11.8",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-menubar": "^1.0.4",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"axios": "^1.3.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.0.1",
"googleapis": "^118.0.0",
"input-otp": "^1.2.3",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
"next": "^13.4.13",
"next-seo": "^6.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-h5-audio-player": "^3.8.6",
"lucide-react": "^0.363.0",
"next": "^14.1.4",
"next-themes": "^0.3.0",
"nextjs-toploader": "^1.6.11",
"react": "^18",
"react-day-picker": "^8.10.0",
"react-dom": "^18",
"react-h5-audio-player": "^3.9.1",
"react-hook-form": "^7.51.2",
"react-hot-toast": "^2.4.1",
"react-is": "^18.2.0",
"react-markdown": "^8.0.7",
"react-player": "^2.13.0",
"react-popper": "^2.3.0",
"react-resizable-panels": "^2.0.16",
"react-toastify": "^9.1.3",
"rehype-katex": "^6.0.3",
"rehype-prism-plus": "^1.6.3",
@@ -37,7 +72,12 @@
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"remark-slug": "^7.0.1",
"remark-toc": "^8.0.1"
"remark-toc": "^8.0.1",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
@@ -46,19 +86,18 @@
"@types/cors": "^2.8.13",
"@types/jsonwebtoken": "^9.0.1",
"@types/mime-types": "^2.1.1",
"@types/node": "18.15.11",
"@types/react": "18.0.35",
"@types/react-dom": "18.0.11",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/three": "^0.150.2",
"autoprefixer": "10.4.14",
"autoprefixer": "^10.4.19",
"encoding": "^0.1.13",
"eslint": "8.38.0",
"eslint-config-next": "^13.4.3",
"postcss": "8.4.22",
"prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.7",
"tailwind-merge": "^1.14.0",
"tailwindcss": "3.3.1",
"typescript": "5.0.4"
"eslint-config-next": "^14.1.4",
"postcss": "^8.4.38",
"prettier": "3.0.0",
"prettier-plugin-tailwindcss": "0.5.12",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
+1 -1
View File
@@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
}
};
+189
View File
@@ -0,0 +1,189 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useContext, useEffect, 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 { Separator } from "~/components/ui/separator";
import { LayoutContext } from "~/context/layoutContext";
import config from "~/config/gIndex.config";
import FileGrid from "./@file.grid";
import FileList from "./@file.list";
import { GetFiles } from "./actions";
type Props = {
files: z.infer<typeof Schema_File>[];
nextPageToken?: string;
root?: boolean;
};
export default function FileBrowser({ files, nextPageToken, root }: Props) {
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] =
useState<z.infer<typeof Schema_File>[]>(files);
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
const [loadMoreLoading, setLoadMoreLoading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
setLoading(false);
}, []);
const onLoadMore = async () => {
setLoadMoreLoading(true);
try {
if (!nextToken) throw new Error("No more files to load");
const data = await GetFiles({ pageToken: nextToken });
const uniqueData = [...fileList, ...data.files].filter(
(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);
} catch (error) {
const e = error as Error;
console.error(e.message);
toast.error(e.message);
} finally {
setLoadMoreLoading(false);
}
};
if (loading) {
return (
<div
className={cn(
"mx-auto h-[50dvh] w-full max-w-screen-desktop",
"rounded-[var(--radius)] bg-card",
"gap-3 p-3",
"flex-grow-0",
"flex flex-col items-center justify-center",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin'
/>
<p>Wait a moment while we load your files...</p>
</div>
);
}
return (
<div
className={cn(
"mx-auto w-full max-w-screen-desktop",
"gap-3 p-3",
"flex-grow-0",
"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 && (
<div className='col-span-full flex h-full min-h-[25vh] flex-col items-center justify-center gap-3'>
<Icon
name='Frown'
size={32}
className='text-muted-foreground'
/>
<span className='text-center text-muted-foreground'>
There are no files in this folder
</span>
</div>
)}
{layout === "list" && (
<div className='flex w-full flex-col gap-1.5'>
{fileList.map((file) => (
<div
key={file.encryptedId}
className='group'
>
<FileList
key={file.encryptedId}
data={file}
/>
<Separator className='mt-1.5 group-last:hidden' />
</div>
))}
</div>
)}
{layout === "grid" && (
<div className='grid grid-cols-1 gap-3 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4'>
{fileList.map((file) => (
<div
key={file.encryptedId}
className='group'
>
<FileGrid
key={file.encryptedId}
data={file}
/>
</div>
))}
</div>
)}
{nextToken && (
<Button
className='col-span-full gap-3'
size={"sm"}
variant={"secondary"}
disabled={loadMoreLoading}
onClick={onLoadMore}
>
{loadMoreLoading ? (
<>
<Icon
name='LoaderCircle'
size={16}
className='animate-spin'
/>
Loading...
</>
) : (
<>Load More</>
)}
</Button>
)}
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
"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 {
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 { durationToReadable } from "~/utils/durationFormat";
import { getPreviewIcon } from "~/utils/previewHelper";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
type Props = {
data: z.infer<typeof Schema_File>;
};
export default function FileGrid({ data }: Props) {
const pathname = usePathname();
const filePath = useMemo<string>(() => {
// const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
// Set to pathname to remove the /e prefix
const path = [pathname, encodeURIComponent(data.name)]
.join("/")
.replace(/\/+/g, "/");
return new URL(path, config.basePath).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 (
<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
href={filePath}
className={cn(
"h-full w-full",
"rounded-[var(--radius)]",
"flex flex-col content-stretch justify-stretch gap-3",
"hover:bg-muted/50",
"transition",
"border border-border",
)}
>
<div
className={cn(
"flex-shrink flex-grow",
"flex flex-col items-center gap-3",
"relative",
)}
>
{/* If it's media, show thumbnail */}
<div className='relative grid h-32 w-full flex-shrink-0 place-items-center overflow-hidden bg-muted/25'>
{data.thumbnailLink &&
(data.mimeType.startsWith("video") ||
data.mimeType.startsWith("image")) ? (
<>
<img
src={`/api/thumb/${data.encryptedId}`}
alt={data.name}
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") && (
<>
<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={32}
/>
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-xs text-foreground'>
{durationToReadable(
data.videoMediaMetadata?.durationMillis || 0,
)}
</div>
</>
)}
</>
) : (
<Icon
name={
data.mimeType.includes("folder")
? "Folder"
: getPreviewIcon(data.fileExtension || "", data.mimeType)
}
className='size-16 flex-shrink-0 flex-grow-0 p-3'
/>
)}
</div>
{/* File data */}
<div className='flex h-full w-full flex-col justify-between gap-1.5 px-3 py-1.5'>
<span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all'>
{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>
</Link>
</div>
);
}
+283
View File
@@ -0,0 +1,283 @@
"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 {
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 { getPreviewIcon } from "~/utils/previewHelper";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
type Props = {
data: z.infer<typeof Schema_File>;
};
export default function FileList({ data }: Props) {
const pathname = usePathname();
const filePath = useMemo<string>(() => {
// const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
// Set to pathname to remove the /e prefix
const path = [pathname, encodeURIComponent(data.name)]
.join("/")
.replace(/\/+/g, "/");
return new URL(path, config.basePath).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 (
<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
href={filePath}
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",
)}
>
<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>
</Link>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import ReactMarkdown from "react-markdown";
type Props = {
content: string;
};
export default function Footer({ content }: Props) {
return (
<footer className='w-full pb-3'>
<ReactMarkdown
className='flex w-full select-none flex-col items-center justify-center'
components={{
p: ({ node, children, ...props }) => (
<p
{...props}
className='muted text-balance text-sm'
>
{children}
</p>
),
a: ({ node, children, ...props }) => {
const isExternal = props.href?.startsWith("http");
return (
<a
{...props}
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
>
{children}
</a>
);
},
}}
>
{content}
</ReactMarkdown>
</footer>
);
}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import Link from "next/link";
import { Fragment, useState } from "react";
import { z } from "zod";
import { Schema_Breadcrumb } from "~/schema";
import Icon from "~/components/Icon";
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/components/ui/breadcrumb";
import { Button } from "~/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "~/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
} from "~/components/ui/dropdown-menu";
import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton";
import useMediaQuery from "~/hooks/useMediaQuery";
import config from "~/config/gIndex.config";
type Props = {
data: z.infer<typeof Schema_Breadcrumb>[];
loading?: boolean;
};
export default function HeaderBreadcrumb({ data, loading }: Props) {
const [open, setOpen] = useState<boolean>(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
if (loading) return <Skeleton className='h-5 w-1/2' />;
return (
<div className='flex w-full flex-nowrap items-center'>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={"/"}>
<div className='flex items-center gap-1'>
<Icon
name='FolderRoot'
size={16}
/>
~
</div>
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{!!data.length ? (
<>
<BreadcrumbSeparator />
{data.length > config.siteConfig.breadcrumbMax ? (
<>
{isDesktop ? (
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenuTrigger>
<BreadcrumbEllipsis className='w-4' />
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{data
.slice(0, -config.siteConfig.breadcrumbMax + 1)
.map((item, _, array) => (
<DropdownMenuItem
key={`${item.label}/${item.href}`}
>
<BreadcrumbLink asChild>
<Link
href={`/${array
.slice(0, array.indexOf(item) + 1)
.map((item) => item.href)
.join("/")}`}
className='w-full'
>
{item.label}
</Link>
</BreadcrumbLink>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Drawer
open={open}
onOpenChange={setOpen}
>
<DrawerTrigger>
<BreadcrumbEllipsis className='w-4' />
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className='text-left'>
<DrawerTitle>
Navigate to parent directories
</DrawerTitle>
<Separator />
</DrawerHeader>
<div className='grid gap-1.5 px-4'>
{data
.slice(0, -config.siteConfig.breadcrumbMax + 1)
.map((item, _, array) => (
<BreadcrumbItem
key={`${item.label}/${item.href}`}
>
<BreadcrumbLink asChild>
<Link
href={`/${array
.slice(0, array.indexOf(item) + 1)
.map((item) => item.href)
.join("/")}`}
className='w-full py-1.5'
>
{item.label}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
))}
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant={"secondary"}>Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)}
<BreadcrumbSeparator />
</>
) : null}
{data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
<Fragment key={`${item.label}/${item.href}`}>
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
{item.href ? (
<>
<BreadcrumbLink asChild>
<Link
href={`/${data
.map((item) => item.href)
.join("/")
.replace(/\/\//g, "/")}`}
>
{item.label}
</Link>
</BreadcrumbLink>
</>
) : (
<BreadcrumbPage className='line-clamp-1 whitespace-pre-wrap break-all'>
{item.label}
</BreadcrumbPage>
)}
</BreadcrumbItem>
{item.href && <BreadcrumbSeparator />}
</Fragment>
))}
</>
) : null}
</BreadcrumbList>
</Breadcrumb>
</div>
);
}
+604
View File
@@ -0,0 +1,604 @@
"use client";
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 { Button } from "~/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "~/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Input } from "~/components/ui/input";
import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton";
import { LayoutContext } from "~/context/layoutContext";
import useMediaQuery from "~/hooks/useMediaQuery";
import useRouter from "~/hooks/usePRouter";
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 [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) {
return (
<div className={cn("w-fit", "flex items-center justify-end gap-1.5")}>
<Skeleton className='my-0.5 h-8 w-16 mobile:w-24' />{" "}
<Skeleton className='my-0.5 h-8 w-8' />
</div>
);
}
return (
<div
className={cn(
// "mx-auto max-w-screen-desktop flex-grow",
"w-fit",
"flex items-center justify-end gap-1.5",
)}
>
{isDesktop ? (
<DropdownMenu
open={layoutOpen}
onOpenChange={setLayoutOpen}
>
<DropdownMenuTrigger asChild>
<Button
variant={"outline"}
className='flex w-24 items-center justify-between gap-1.5'
>
<div className='flex items-center gap-1.5'>
<Icon
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
size={16}
/>
<span>{layout === "grid" ? "Grid" : "List"}</span>
</div>
<Icon
name='ChevronDown'
className={cn("transition", layoutOpen && "rotate-180")}
size={14}
/>
</Button>
</DropdownMenuTrigger>
<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>
<Button
size={"icon"}
variant={"outline"}
className='flex items-center'
>
<Icon
name='Search'
size={16}
/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Search</DialogTitle>
<DialogDescription>
Search for files in your drive
</DialogDescription>
</DialogHeader>
<div className='flex h-full flex-col gap-6'>
<div
slot='search-input'
className='grid gap-1.5'
>
<Input
placeholder='What are you looking for?'
value={searchInput}
onChange={(e) => {
setSearchError("");
if (!e.target.value) {
setSearchInput("");
setSnap(0.3);
} else {
setSearchLoading(true);
setSearchInput(e.target.value);
setSnap(1);
}
}}
/>
<DialogClose asChild>
<Button
size={"sm"}
variant={"secondary"}
>
Close
</Button>
</DialogClose>
</div>
<Separator />
<div
slot='search-result'
className='flex h-full max-h-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>
</DialogContent>
</Dialog>
) : (
<Drawer
open={searchOpen}
onOpenChange={(open) => {
if (!open) {
setSearchInput("");
setSnap(0.3);
}
setSearchOpen(open);
}}
shouldScaleBackground
fadeFromIndex={0}
snapPoints={[0.3, 1]}
activeSnapPoint={snap}
setActiveSnapPoint={setSnap}
>
<DrawerTrigger asChild>
<Button
size={"icon"}
variant={"outline"}
className='flex items-center'
>
<Icon
name='Search'
size={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",
"cursor-pointer transition",
)}
onClick={async () => {
toast.loading("Getting file path...", {
id: `open-${data.encryptedId}`,
});
try {
const paths = await RedirectSearchFile(data.encryptedId);
router.push(`/${paths}`);
toast.success("Redirecting...", {
id: `open-${data.encryptedId}`,
});
} catch (error) {
const e = error as Error;
console.error(e);
toast.error(e.message, {
id: `open-${data.encryptedId}`,
});
}
}}
>
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
{/* If it's media, show thumbnail */}
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
{data.thumbnailLink &&
(data.mimeType.startsWith("video") ||
data.mimeType.startsWith("image")) ? (
<>
<img
src={`/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>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { PropsWithChildren } from "react";
import { Skeleton } from "~/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
export default function HeaderTitle({
children,
loading,
}: PropsWithChildren<{ loading?: boolean }>) {
if (loading) return <Skeleton className='my-0.5 h-8 w-full' />;
return (
<Tooltip>
<TooltipTrigger asChild>
<h2 className='line-clamp-1'>{children}</h2>
</TooltipTrigger>
<TooltipContent>{children}</TooltipContent>
</Tooltip>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useEffect, useState } from "react";
import { z } from "zod";
import { Schema_Breadcrumb } from "~/schema";
import { cn } from "~/utils";
import HeaderBreadcrumb from "./@header.breadcrumb";
type Props = {
name: string;
breadcrumb?: z.infer<typeof Schema_Breadcrumb>[];
};
export default function Header({ name, breadcrumb }: Props) {
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
setLoading(false);
}, []);
return (
<div
className={cn(
"w-full",
"flex flex-col items-center justify-between gap-3",
)}
>
<div
className={cn(
"w-full",
"flex flex-grow items-center justify-between gap-3",
)}
>
{/* <HeaderTitle loading={loading}>{name}</HeaderTitle> */}
<HeaderBreadcrumb
data={breadcrumb || []}
loading={loading}
/>
{/* <HeaderButton loading={loading} /> */}
</div>
</div>
);
}
@@ -1,7 +1,8 @@
import { jetbrainsMono } from "pages/_app";
"use client";
import { useRef, useState } from "react";
import toast from "react-hot-toast";
import ReactMarkdown from "react-markdown";
import { toast } from "react-toastify";
import rehypeKatex from "rehype-katex";
import rehypePrism from "rehype-prism-plus";
import rehypeRaw from "rehype-raw";
@@ -10,10 +11,50 @@ import remarkMath from "remark-math";
import remarkSlug from "remark-slug";
import remarkToc from "remark-toc";
import Button from "components/Button";
import Icon from "~/components/Icon";
import "styles/highlight.css";
import "styles/markdown.css";
import "./highlight.css";
type Props = {
content: string;
};
export default function Markdown({ content }: Props) {
return (
<div className='markdown w-full rounded-[var(--radius)] bg-muted p-3'>
<ReactMarkdown
className='w-full'
disallowedElements={["script"]}
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
rehypePlugins={[
rehypeKatex,
rehypeRaw,
[rehypePrism, { ignoreMissing: true }],
]}
components={{
p: ({ node, children, ...props }) => (
<p
{...props}
className='paragraph text-balance'
>
{children}
</p>
),
pre: PreComponent,
code: ({ node, inline, className, children, ...props }) => (
<code
className={`${className} font-mono !text-sm`}
{...props}
>
{children}
</code>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
}
interface PreProps extends React.HTMLAttributes<HTMLPreElement> {}
function PreComponent(props: PreProps) {
@@ -25,7 +66,12 @@ function PreComponent(props: PreProps) {
const handleCopy = () => {
if (!codeRef.current) return;
if (isCopied || isError) return;
if (copyTimeout) clearTimeout(copyTimeout);
const toastId = `copy-${Math.random() * 1000}`;
toast.loading("Copying code to clipboard...", {
id: toastId,
});
try {
navigator.clipboard.writeText(codeRef.current.textContent as string);
setIsError(false);
@@ -35,7 +81,9 @@ function PreComponent(props: PreProps) {
setIsCopied(false);
}, 2000),
);
toast.success("Code copied to clipboard");
toast.success("Code copied to clipboard", {
id: toastId,
});
} catch (error: any) {
setIsCopied(false);
setIsError(true);
@@ -44,13 +92,15 @@ function PreComponent(props: PreProps) {
setIsError(false);
}, 2000),
);
toast.error("Failed to copy code to clipboard");
toast.error("Failed to copy code to clipboard", {
id: toastId,
});
}
};
return (
<div
className={`w-full h-full relative`}
className={`relative h-full w-full`}
onMouseEnter={() => {
setShowCopy(true);
}}
@@ -60,54 +110,33 @@ function PreComponent(props: PreProps) {
setIsError(false);
}}
>
<div className={`absolute top-0 right-0 p-2 ${showCopy ? "opacity-100" : "opacity-0"} transition-smooth`}>
<Button
size='small'
rounded='medium'
variant={isError ? "danger" : isCopied ? "success" : "primary"}
startIcon={isCopied ? "ion:checkmark" : isError ? "ion:close" : "ion:copy"}
disabled={isCopied || isError}
<div className={`transition-smooth absolute right-0 top-0 p-1.5`}>
<div
className='cursor-pointer rounded-[var(--radius)] bg-background p-1.5'
onClick={() => {
handleCopy();
}}
>
{isCopied ? "Copied!" : isError ? "Failed to copy" : "Copy"}
</Button>
<Icon
name={isCopied ? "Check" : isError ? "X" : "Copy"}
className={
isCopied
? "text-green-500"
: isError
? "text-red-500"
: "text-foreground"
}
size={16}
/>
</div>
</div>
<pre
ref={codeRef}
{...props}
className='overflow-x-auto'
className='overflow-x-auto border border-border shadow shadow-background'
>
{props.children}
</pre>
</div>
);
}
interface MarkdownProps {
content: string;
}
export default function Markdown(props: MarkdownProps) {
return (
<div className='markdown w-full'>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
rehypePlugins={[rehypeKatex, rehypeRaw, [rehypePrism, { ignoreMissing: true }]]}
components={{
pre: PreComponent,
code: ({ node, inline, className, children, ...props }) => (
<code
className={`${className} ${jetbrainsMono.className}`}
{...props}
>
{children}
</code>
),
}}
>
{props.content}
</ReactMarkdown>
</div>
);
}
+579
View File
@@ -0,0 +1,579 @@
"use client";
import { TooltipTrigger } from "@radix-ui/react-tooltip";
import { useTheme } from "next-themes";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "~/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Separator } from "~/components/ui/separator";
import {
Sheet,
SheetContent,
SheetFooter,
SheetTrigger,
} from "~/components/ui/sheet";
import { Tooltip, TooltipContent } from "~/components/ui/tooltip";
import useMediaQuery from "~/hooks/useMediaQuery";
import config from "~/config/gIndex.config";
import { ClearPassword } from "./actions";
export default function Navbar() {
const pathname = usePathname();
const router = useRouter();
const isDesktop = useMediaQuery("(min-width: 768px)");
const { theme, themes, setTheme } = useTheme();
const [open, setOpen] = useState<boolean>(false);
const [themeOpen, setThemeOpen] = useState<boolean>(false);
useEffect(() => {
if (isDesktop) setOpen(false);
}, [isDesktop]);
async function onClearPassword() {
const promise = new Promise(async (resolve, reject) => {
const clear = await ClearPassword();
if (!clear.success) reject(new Error(clear.message));
resolve(clear.message);
});
toast.promise(promise, {
loading: "Clearing all saved password...",
success: () => {
setOpen(false);
router.refresh();
return "All saved password cleared successfully!";
},
error: (error) => error.message,
});
}
return (
<div className={cn("sticky left-0 top-0 z-50", "h-12 w-full")}>
<nav
slot='nav'
className={cn(
"bg-background",
// "sticky left-0 top-0",
"h-12 w-full",
"px-6 py-3",
"flex flex-grow flex-row items-center justify-between",
"border-b border-border",
)}
>
<div
className={cn(
"flex flex-row items-center justify-between",
"mx-auto w-full max-w-screen-desktop",
)}
>
<Link
href={"/"}
onClick={() => setOpen(false)}
className='flex items-center gap-3'
>
<img
src={config.siteConfig.siteIcon}
alt={config.siteConfig.siteName}
className={cn("h-10 w-10")}
loading='eager'
/>
<span className={cn("large", "hidden", "tablet:block")}>
{config.siteConfig.siteName}
</span>
</Link>
{isDesktop ? (
<div className='flex'>
{config.siteConfig.navbarItems.map((item) => (
<Tooltip key={item.name}>
<TooltipTrigger asChild>
<Link
href={item.href}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noopener noreferrer" : undefined}
className={cn(
"flex flex-col items-center justify-center",
"opacity-80",
"hover:opacity-100",
pathname === item.href
? "cursor-default opacity-100"
: "cursor-pointer",
"p-1.5",
)}
>
<Icon
name={item.icon}
className='text-foreground'
size={20}
/>
</Link>
</TooltipTrigger>
<TooltipContent side='bottom'>
<p>{item.name}</p>
</TooltipContent>
</Tooltip>
))}
<Separator
orientation='vertical'
className='mx-3'
/>
<DropdownMenu
modal={false}
open={themeOpen}
onOpenChange={setThemeOpen}
>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div
className={cn(
"flex flex-col items-center justify-center",
"opacity-80",
"hover:opacity-100",
"cursor-pointer",
"p-1.5",
)}
>
<Icon
name={
theme === "system"
? "LaptopMinimal"
: theme === "light"
? "Sun"
: "Moon"
}
size={20}
/>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side='bottom'>
<p>Site theme</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align='end'>
{themes.map((item) => (
<DropdownMenuItem
key={item}
disabled={item === theme}
className='w-full'
onClick={() => setTheme(item)}
>
<div className='flex w-full items-center justify-between'>
<span
className={cn(
"capitalize",
// item === 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>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{config.siteConfig.supports.length && (
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div
className={cn(
"flex flex-col items-center justify-center",
"opacity-80",
"hover:opacity-100",
"cursor-pointer",
"p-1.5",
)}
>
<Icon
name='Heart'
size={20}
/>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side='bottom'>
<p>Support</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align='end'>
{config.siteConfig.supports.map((item) => (
<DropdownMenuItem key={item.name}>
<Link
href={item.href}
target={"_blank"}
rel={"noopener noreferrer"}
className='flex w-full items-center justify-between gap-3'
>
<span>{item.name}</span>
<span className='muted'>{item.currency}</span>
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<div
className={cn(
"flex flex-col items-center justify-center",
"opacity-80",
"hover:opacity-100",
"cursor-pointer",
"p-1.5",
)}
>
<Icon
name='LogOut'
className='text-red-500'
size={20}
/>
</div>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side='bottom'>
<p>Clear all saved password</p>
</TooltipContent>
</Tooltip>
<DialogContent>
<DialogTitle>Clear all saved password?</DialogTitle>
<DialogDescription>
You will need to re-enter the password to access the
protected content.
<br />
<b>Are you sure you want to continue?</b>
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant={"secondary"}>Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant={"destructive"}
onClick={onClearPassword}
>
Clear all saved password
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
) : (
<div className='flex items-center gap-1.5'>
<Drawer
open={themeOpen}
onOpenChange={setThemeOpen}
>
<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='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
open={open}
onOpenChange={setOpen}
>
<SheetTrigger asChild>
<Button
variant={"ghost"}
size={"icon"}
>
<Icon
name='Menu'
size={18}
className='text-foreground'
/>
</Button>
</SheetTrigger>
<SheetContent className='flex flex-col items-center gap-3 pt-12'>
<div className='flex w-full flex-grow flex-col gap-6 overflow-y-scroll'>
<div className='flex flex-col'>
{config.siteConfig.navbarItems.map((item) => (
<Button
key={item.name}
variant={"ghost"}
size={"sm"}
asChild
>
<Link
href={item.href}
target={item.external ? "_blank" : undefined}
rel={
item.external ? "noopener noreferrer" : undefined
}
className='flex w-full items-center justify-between gap-3'
onClick={() => setOpen(false)}
>
<div className='flex items-center gap-3'>
<Icon
name={item.icon}
className='text-foreground'
size={18}
/>
{item.name}
</div>
{item.external && (
<Icon
name='ExternalLink'
size={12}
className='text-muted-foreground'
/>
)}
</Link>
</Button>
))}
</div>
<div className='flex flex-col'>
<p className='large'>Theme</p>
<Separator className='my-1.5' />
{themes.map((item) => (
<Button
key={item}
variant={"ghost"}
size={"sm"}
className='w-full'
disabled={item === theme}
onClick={() => {
setTheme(item);
}}
>
<div className='flex w-full items-center justify-between gap-3'>
<span
className={cn(
"capitalize",
// item === theme && "text-muted-foreground",
)}
>
{item}
</span>
<Icon
name={
item === theme
? "Check"
: item === "system"
? "LaptopMinimal"
: item === "light"
? "Sun"
: "Moon"
}
className={
cn()
// item === theme && "text-muted-foreground",
}
size={18}
/>
</div>
</Button>
))}
</div>
{config.siteConfig.supports.length && (
<div className='flex flex-col'>
<p className='large'>Support & Donation</p>
<Separator className='my-1.5' />
{config.siteConfig.supports.map((item) => (
<Button
key={item.name}
variant={"ghost"}
size={"sm"}
asChild
>
<Link
key={item.name}
href={item.href}
target={"_blank"}
rel={"noopener noreferrer"}
className='flex w-full items-center justify-between gap-3'
>
<small>{item.name}</small>
<small className='muted'>{item.currency}</small>
</Link>
</Button>
))}
</div>
)}
</div>
<SheetFooter className='w-full'>
<Drawer>
<DrawerTrigger asChild>
<Button
variant={"destructive"}
className='w-full gap-3'
size={"sm"}
>
Clear all saved password
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className='text-left'>
<DrawerTitle>Clear all saved password?</DrawerTitle>
<DrawerDescription>
You will need to re-enter the password to access the
protected content.
<br />
<b>Are you sure you want to continue?</b>
</DrawerDescription>
</DrawerHeader>
<DrawerFooter className='gap-1.5'>
<DrawerClose asChild>
<Button
variant={"destructive"}
onClick={onClearPassword}
>
Clear all saved password
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant={"secondary"}>Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)}
</div>
</nav>
</div>
);
}
+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 NotFoundComponent() {
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>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import {
CheckPassword,
CheckSitePassword,
SetPassword,
SetSitePassword,
} from "./actions";
type Props = {
path: string;
checkPaths?: { path: string; id: string }[];
errorMessage?: string;
};
export default function Password({ path, checkPaths, errorMessage }: Props) {
const router = useRouter();
const [input, setInput] = useState<string>("");
const [submitLoading, setSubmitLoading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSubmitLoading(true);
toast.loading("Checking password...", {
id: "password",
});
try {
if (!input) throw new Error("Password is required");
if (path === "global") {
const set = await SetSitePassword(input);
if (!set.success) throw new Error(set.message);
const check = await CheckSitePassword();
if (!check.success) throw new Error(check.message);
} else {
if (!checkPaths)
throw new Error("No path found, try to refresh the page.");
const set = await SetPassword(path, input);
if (!set.success) throw new Error(set.message);
const check = await CheckPassword(checkPaths);
if (!check.success) throw new Error(check.message);
}
toast.success("Password accepted! Refreshing...", {
id: "password",
});
console.log("Password accepted! Refreshing...");
router.refresh();
} catch (error) {
const e = error as Error;
console.error(e.message);
toast.error(e.message, {
id: "password",
});
setSubmitLoading(false);
} finally {
setInput("");
}
};
useEffect(() => {
setLoading(false);
}, []);
if (loading)
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='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Checking password...</p>
</div>
);
return (
<div
className={cn(
"mx-auto h-full w-full max-w-md",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<img
src='/images/undraw_vault.svg'
alt='Password illustration'
loading='eager'
className={cn("h-48 w-64 object-contain")}
/>
<div className='flex flex-col items-center justify-center'>
<h3 className='text-pretty text-center'>
{path === "global"
? "This site are password protected"
: "The folder or file you are trying to access is password protected"}
</h3>
<span className='muted text-pretty text-center'>
Please enter the password to access the content
</span>
</div>
<form
className={cn("w-full", "grid grid-cols-1 gap-3", "tablet:grid-cols-4")}
onSubmit={onSubmit}
>
<Input
name='password'
type='password'
placeholder='Password'
className={cn("tablet:col-span-3")}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<Button
className='gap-3'
type='submit'
disabled={submitLoading}
>
{submitLoading ? (
<>
<Icon
name='LoaderCircle'
size={16}
className='animate-spin'
/>
Loading...
</>
) : (
<>Submit</>
)}
</Button>
</form>
<span
className={cn(
"muted text-pretty text-center text-red-500",
errorMessage ? "visible" : "invisible",
)}
>
{errorMessage || "Nothing wrong here, just a password wall"}
</span>
</div>
);
}
+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>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
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 player...</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='w-full'>
<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>
)}
</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>
);
}
+216
View File
@@ -0,0 +1,216 @@
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;
export const dynamic = "force-dynamic";
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}
checkPaths={paths.data}
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>
);
}
+701
View File
@@ -0,0 +1,701 @@
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { decryptData, encryptData } from "~/utils/encryptionHelper/hash";
import gdrive from "~/utils/gdriveInstance";
import { Constant } from "~/types/constant";
import config from "~/config/gIndex.config";
export async function CheckSitePassword(): Promise<{
success: boolean;
message?: string;
}> {
try {
// Skip if the index is public
if (!config.siteConfig.privateIndex)
return {
success: true,
};
if (!process.env.SITE_PASSWORD)
throw new Error(
"Index password not set, please set the SITE_PASSWORD environment variable or disable privateIndex in the config file",
);
const store = cookies();
if (!store.has(Constant.cookies_SitePassword))
return {
success: false,
};
const password = store.get(Constant.cookies_SitePassword)?.value || "";
const decryptedPassword = await decryptData(password);
if (decryptedPassword !== process.env.SITE_PASSWORD)
throw new Error(
"Saved password is incorrect, please re-enter the password",
);
return {
success: true,
};
} catch (error) {
const e = error as Error;
console.error(e.message);
return {
success: false,
message: e.message,
};
}
}
export async function SetSitePassword(password: string): Promise<{
success: boolean;
message: string;
}> {
try {
const store = cookies();
const encryptedPassword = await encryptData(password);
store.set(Constant.cookies_SitePassword, encryptedPassword, {
path: "/",
secure: true,
sameSite: "strict",
httpOnly: true,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
maxAge: 1000 * 60 * 60 * 24 * 365,
});
revalidatePath("/", "layout");
return {
success: true,
message: "Password set",
};
} catch (error) {
const e = error as Error;
console.error(e.message);
return {
success: false,
message: e.message,
};
}
}
export async function ClearPassword(): Promise<{
success: boolean;
message: string;
}> {
try {
const store = cookies();
if (store.has(Constant.cookies_FolderPassword)) {
store.delete(Constant.cookies_FolderPassword);
}
if (store.has(Constant.cookies_SitePassword)) {
store.delete(Constant.cookies_SitePassword);
}
return {
success: true,
message: "Password cleared",
};
} catch (error) {
const e = error as Error;
console.error(e.message);
return {
success: false,
message: e.message,
};
}
}
export async function CheckPaths(paths: string[]): Promise<
| {
success: false;
message?: string;
}
| {
success: true;
data: { path: string; id: string }[];
message?: string;
}
> {
try {
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 {
message: `Password for '${currentFolder.path}' is incorrect, please re-enter the password`,
path: currentFolder.id,
};
return {
success: true,
};
} catch (error) {
if (error instanceof Error) {
const e = error as Error;
console.error(e.message);
return {
success: false,
message: e.message,
};
} else {
const e = error as { message: string; path: string };
console.error(e.message);
return {
success: false,
message: e.message,
path: e.path,
};
}
}
}
export async function SetPassword(
path: string,
password: string,
): Promise<{
success: boolean;
message: string;
}> {
try {
const store = cookies();
const cookiesValue = JSON.parse(
store.get(Constant.cookies_FolderPassword)?.value || "{}",
);
const updatedValue = {
...cookiesValue,
[path]: await encryptData(password),
};
store.set(Constant.cookies_FolderPassword, JSON.stringify(updatedValue), {
path: "/",
secure: true,
sameSite: "strict",
httpOnly: true,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
maxAge: 1000 * 60 * 60 * 24 * 365,
});
revalidatePath("/", "layout");
return {
success: true,
message: "Password set",
};
} catch (error) {
const e = error as Error;
console.error(e.message);
return {
success: false,
message: e.message,
};
}
}
export async function GetFiles({
id,
pageToken,
}: {
id?: string;
pageToken?: string;
}): Promise<{
files: z.infer<typeof Schema_File>[];
nextPageToken?: string;
}> {
try {
let decryptedId: string | undefined;
if (id) decryptedId = await decryptData(id);
else decryptedId = await decryptData(config.apiConfig.rootFolder);
const filterName = config.apiConfig.hiddenFiles
.map((item) => `not name = '${item}'`)
.join(" and ");
const query: string = [
...config.apiConfig.defaultQuery,
`'${decryptedId}' in parents`,
`${filterName}`,
].join(" and ");
const data = await gdrive.files.list({
q: query,
fields: `files(${config.apiConfig.defaultField}), nextPageToken`,
orderBy: config.apiConfig.defaultOrder,
pageSize: config.apiConfig.itemsPerPage,
pageToken: pageToken,
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 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(
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 = '${config.apiConfig.specialFile.readme}'`,
`'${decryptedId}' in parents`,
];
const { data } = await gdrive.files.list({
q: query.join(" and "),
fields: `files(${config.apiConfig.defaultField}, parents), nextPageToken`,
orderBy: config.apiConfig.defaultOrder,
pageSize: config.apiConfig.itemsPerPage,
pageToken: undefined,
supportsAllDrives: config.apiConfig.isTeamDrive,
includeItemsFromAllDrives: config.apiConfig.isTeamDrive,
});
if (!data.files?.length) return null;
const { data: content } = await gdrive.files.get(
{
fileId: data.files[0].id as string,
alt: "media",
},
{
responseType: "text",
},
);
return content as string;
} catch (error) {
const e = error as Error;
console.error(e.message);
throw new Error(e.message);
}
}
export async function GetBanner(
encryptedId: string | undefined,
): Promise<string | null> {
try {
let decryptedId;
if (encryptedId) decryptedId = await decryptData(encryptedId);
else decryptedId = await decryptData(config.apiConfig.rootFolder);
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(
duration: number = config.apiConfig.temporaryTokenDuration,
): Promise<string> {
try {
const data = {
expiredAt:
Date.now() +
duration * 60 * 60 * 1000 * config.apiConfig.temporaryTokenDuration,
};
const token = await encryptData(JSON.stringify(data));
return token;
} catch (error) {
const e = error as Error;
console.error(e.message);
throw new Error(e.message);
}
}
export async function CheckDownloadToken(token: string): Promise<{
success: boolean;
message?: string;
}> {
try {
const decryptToken = JSON.parse(await decryptData(token));
if (decryptToken.expiredAt < Date.now()) {
return {
success: false,
message:
"Download token expired, please click the download button again to get a new token",
};
}
return {
success: true,
};
} catch (error) {
const e = error as Error;
console.error(e.message);
return {
success: false,
message: e.message,
};
}
}
@@ -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,
});
}
}
+164
View File
@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from "next/server";
import {
CheckDownloadToken,
CheckPassword,
CheckPaths,
CheckSitePassword,
RedirectSearchFile,
} from "~/app/actions";
import { decryptData } from "~/utils/encryptionHelper/hash";
import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
import config from "~/config/gIndex.config";
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{
params: { encryptedId },
}: {
params: {
encryptedId: string;
};
},
) {
try {
const sp = new URL(request.nextUrl).searchParams;
const token = sp.get("token");
if (!token) throw new Error("Token not found");
const tokenValidity = await CheckDownloadToken(token);
if (!tokenValidity.success) throw new Error(tokenValidity.message);
if (config.siteConfig.privateIndex) {
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,
});
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { encryptData } from "~/utils/encryptionHelper/hash";
export const dynamic = "force-dynamic";
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,
},
);
}
}
+65
View File
@@ -0,0 +1,65 @@
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 const dynamic = "force-dynamic";
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,
});
}
}
+118
View File
@@ -0,0 +1,118 @@
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 defaultImage = NextResponse.redirect(
new URL("/og.png", config.basePath),
{
status: 302,
},
);
const decryptedId = await decryptData(encryptedId);
const _fileMeta = gdrive.files.get({
fileId: decryptedId,
fields:
"id, name, mimeType, fileExtension, webContentLink, thumbnailLink",
supportsAllDrives: config.apiConfig.isTeamDrive,
});
const _fileContent = gdrive.files.get(
{
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) {
const e = error as Error;
console.error(e.message);
return NextResponse.json(
{
error: e.message,
},
{
status: 500,
},
);
}
}
+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>
);
}
+117
View File
@@ -0,0 +1,117 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Shadcn/ui theme */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 89.8%;
/* --ring: 0 0% 3.9%; */
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 14.9%;
/* --ring: 0 0% 83.1%; */
}
}
html,
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@layer base {
:root {
@apply text-[14px] tablet:text-[16px];
}
* {
@apply border-border;
/* @apply outline outline-1 outline-red-500; */
}
body {
@apply bg-background text-foreground;
}
::-webkit-scrollbar {
@apply h-1.5 w-1.5;
}
::-webkit-scrollbar-track {
@apply bg-background;
}
::-webkit-scrollbar-thumb {
@apply rounded-full bg-primary/25 hover:bg-primary/50;
}
/* Typography */
h1 {
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
}
h2 {
@apply scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0;
}
h3 {
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
}
h4 {
@apply scroll-m-20 text-xl font-semibold tracking-tight;
}
.paragraph {
@apply leading-7 [&:not(:first-child)]:mt-6;
}
blockquote {
@apply mt-6 border-l-2 pl-6 italic;
}
ul {
@apply my-6 ml-6 list-disc [&>li]:mt-2;
}
.lead {
@apply text-xl text-muted-foreground;
}
.large {
@apply text-lg font-semibold;
}
.muted {
@apply text-sm text-muted-foreground;
}
small {
@apply text-sm font-medium leading-none;
}
}
+472
View File
@@ -0,0 +1,472 @@
:root {
/**
* One Light theme for prism.js
* Based on Atom's One Light theme: https://github.com/atom/atom/tree/master/packages/one-light-syntax
*/
/* From colors.less */
--mono-1: hsl(230, 8%, 24%);
--mono-2: hsl(230, 6%, 44%);
--mono-3: hsl(230, 4%, 64%);
--hue-1: hsl(198, 99%, 37%);
--hue-2: hsl(221, 87%, 60%);
--hue-3: hsl(301, 63%, 40%);
--hue-4: hsl(119, 34%, 47%);
--hue-5: hsl(5, 74%, 59%);
--hue-5-2: hsl(344, 84%, 43%);
--hue-6: hsl(35, 99%, 36%);
--hue-6-2: hsl(35, 99%, 40%);
--syntax-fg: hsl(230, 8%, 24%);
--syntax-bg: hsl(230, 1%, 98%);
--syntax-gutter: hsl(230, 1%, 62%);
--syntax-guide: hsla(230, 8%, 24%, 0.2);
--syntax-accent: hsl(230, 100%, 66%);
/* From syntax-variables.less */
--syntax-selection-color: hsl(230, 1%, 90%);
--syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
--syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
}
.dark {
/**
* One Dark theme for prism.js
* Based on Atom's One Dark theme: https://github.com/atom/atom/tree/master/packages/one-dark-syntax
*/
/* From colors.less */
--mono-1: hsl(220, 14%, 71%);
--mono-2: hsl(220, 9%, 55%);
--mono-3: hsl(220, 10%, 40%);
--hue-1: hsl(187, 47%, 55%);
--hue-2: hsl(207, 82%, 66%);
--hue-3: hsl(286, 60%, 67%);
--hue-4: hsl(95, 38%, 62%);
--hue-5: hsl(355, 65%, 65%);
--hue-5-2: hsl(5, 48%, 51%);
--hue-6: hsl(29, 54%, 61%);
--hue-6-2: hsl(39, 67%, 69%);
--syntax-fg: hsl(220, 14%, 71%);
--syntax-bg: hsl(220, 13%, 18%);
--syntax-gutter: hsl(220, 14%, 45%);
--syntax-guide: hsla(220, 14%, 71%, 0.15);
--syntax-accent: hsl(220, 100%, 66%);
/* From syntax-variables.less */
--syntax-selection-color: hsl(220, 13%, 28%);
--syntax-gutter-background-color-selected: hsl(220, 13%, 26%);
--syntax-cursor-line: hsla(220, 100%, 80%, 0.04);
}
code[class*="language-"],
pre[class*="language-"],
pre {
background: var(--syntax-bg);
color: var(--syntax-fg);
text-shadow: 0 1px rgba(0, 0, 0, 0);
font-size: inherit;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
line-height: 1.5;
-moz-tab-size: 2;
tab-size: 2;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Selection */
code[class*="language-"]::-moz-selection,
code[class*="language-"] *::-moz-selection,
pre[class*="language-"] *::-moz-selection {
background: var(--syntax-selection-color);
color: inherit;
text-shadow: none;
}
code[class*="language-"]::selection,
code[class*="language-"] *::selection,
pre[class*="language-"] *::selection {
background: var(--syntax-selection-color);
color: inherit;
text-shadow: none;
}
/* Code blocks */
pre[class*="language-"],
pre {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: 0.2em 0.3em;
border-radius: 0.3em;
white-space: normal;
}
/* Print */
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
.token.comment,
.token.prolog,
.token.cdata {
color: var(--mono-3);
}
.token.doctype,
.token.punctuation,
.token.entity {
color: var(--syntax-fg);
}
.token.attr-name,
.token.class-name,
.token.boolean,
.token.constant,
.token.number,
.token.atrule {
color: var(--hue-6);
}
.token.keyword {
color: var(--hue-3);
}
.token.property,
.token.tag,
.token.symbol,
.token.deleted,
.token.important {
color: var(--hue-5);
}
.token.selector,
.token.string,
.token.char,
.token.builtin,
.token.inserted,
.token.regex,
.token.attr-value,
.token.attr-value > .token.punctuation {
color: var(--hue-4);
}
.token.variable,
.token.operator,
.token.function {
color: var(--hue-2);
}
.token.url {
color: var(--hue-1);
}
/* HTML overrides */
.token.attr-value > .token.punctuation.attr-equals,
.token.special-attr > .token.attr-value > .token.value.css {
color: var(--syntax-fg);
}
/* CSS overrides */
.language-css .token.selector {
color: var(--hue-5);
}
.language-css .token.property {
color: var(--syntax-fg);
}
.language-css .token.function,
.language-css .token.url > .token.function {
color: var(--hue-1);
}
.language-css .token.url > .token.string.url {
color: var(--hue-4);
}
.language-css .token.important,
.language-css .token.atrule .token.rule {
color: var(--hue-3);
}
/* JS overrides */
.language-javascript .token.operator {
color: var(--hue-3);
}
.language-javascript
.token.template-string
> .token.interpolation
> .token.interpolation-punctuation.punctuation {
color: var(--hue-5-2);
}
/* JSON overrides */
.language-json .token.operator {
color: var(--syntax-fg);
}
.language-json .token.null.keyword {
color: var(--hue-6);
}
/* MD overrides */
.language-markdown .token.url,
.language-markdown .token.url > .token.operator,
.language-markdown .token.url-reference.url > .token.string {
color: var(--syntax-fg);
}
.language-markdown .token.url > .token.content {
color: var(--hue-2);
}
.language-markdown .token.url > .token.url,
.language-markdown .token.url-reference.url {
color: var(--hue-1);
}
.language-markdown .token.blockquote.punctuation,
.language-markdown .token.hr.punctuation {
color: var(--mono-3);
font-style: italic;
}
.language-markdown .token.code-snippet {
color: var(--hue-4);
}
.language-markdown .token.bold .token.content {
color: var(--hue-6);
}
.language-markdown .token.italic .token.content {
color: var(--hue-3);
}
.language-markdown .token.strike .token.content,
.language-markdown .token.strike .token.punctuation,
.language-markdown .token.list.punctuation,
.language-markdown .token.title.important > .token.punctuation {
color: var(--hue-5);
}
/* General */
.token.bold {
font-weight: bold;
}
.token.comment,
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.namespace {
opacity: 0.8;
}
/* Plugin overrides */
/* Selectors should have higher specificity than those in the plugins' default stylesheets */
/* Show Invisible plugin overrides */
.token.token.tab:not(:empty):before,
.token.token.cr:before,
.token.token.lf:before,
.token.token.space:before {
color: var(--syntax-guide);
text-shadow: none;
}
/* Toolbar plugin overrides */
/* Space out all buttons and move them away from the right edge of the code block */
div.code-toolbar > .toolbar.toolbar > .toolbar-item {
margin-right: 0.4em;
}
/* Styling the buttons */
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
background: var(--syntax-gutter-background-color-selected);
color: var(--mono-2);
padding: 0.1em 0.4em;
border-radius: 0.3em;
}
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
div.code-toolbar > .toolbar.toolbar > .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);
color: var(--syntax-fg);
}
/* Line Highlight plugin overrides */
/* The highlighted line itself */
.line-highlight.line-highlight {
background: var(--syntax-cursor-line);
}
/* Default line numbers in Line Highlight plugin */
.line-highlight.line-highlight:before,
.line-highlight.line-highlight[data-end]:after {
background: var(--syntax-gutter-background-color-selected);
color: var(--syntax-fg);
padding: 0.1em 0.6em;
border-radius: 0.3em;
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */
}
/* Hovering over a linkable line number (in the gutter area) */
/* Requires Line Numbers plugin as well */
pre[id].linkable-line-numbers.linkable-line-numbers
span.line-numbers-rows
> span:hover:before {
background-color: var(--syntax-cursor-line);
}
/* Line Numbers and Command Line plugins overrides */
/* Line separating gutter from coding area */
.line-numbers.line-numbers .line-numbers-rows,
.command-line .command-line-prompt {
border-right-color: var(--syntax-guide);
}
/* Stuff in the gutter */
.line-numbers .line-numbers-rows > span:before,
.command-line .command-line-prompt > span:before {
color: var(--syntax-gutter);
}
/* Match Braces plugin overrides */
/* Note: Outline colour is inherited from the braces */
.rainbow-braces .token.token.punctuation.brace-level-1,
.rainbow-braces .token.token.punctuation.brace-level-5,
.rainbow-braces .token.token.punctuation.brace-level-9 {
color: var(--hue-5);
}
.rainbow-braces .token.token.punctuation.brace-level-2,
.rainbow-braces .token.token.punctuation.brace-level-6,
.rainbow-braces .token.token.punctuation.brace-level-10 {
color: var(--hue-4);
}
.rainbow-braces .token.token.punctuation.brace-level-3,
.rainbow-braces .token.token.punctuation.brace-level-7,
.rainbow-braces .token.token.punctuation.brace-level-11 {
color: var(--hue-2);
}
.rainbow-braces .token.token.punctuation.brace-level-4,
.rainbow-braces .token.token.punctuation.brace-level-8,
.rainbow-braces .token.token.punctuation.brace-level-12 {
color: var(--hue-3);
}
/* Diff Highlight plugin overrides */
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
pre.diff-highlight > code .token.token.deleted:not(.prefix),
pre > code.diff-highlight .token.token.deleted:not(.prefix) {
background-color: hsla(353, 100%, 66%, 0.15);
}
pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
pre.diff-highlight > 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);
}
pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
pre.diff-highlight > 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);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix),
pre > code.diff-highlight .token.token.inserted:not(.prefix) {
background-color: hsla(137, 100%, 55%, 0.15);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection,
pre.diff-highlight > 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);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
pre.diff-highlight > 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);
}
/* Previewers plugin overrides */
/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */
/* Border around popup */
.prism-previewer.prism-previewer:before,
.prism-previewer-gradient.prism-previewer-gradient div {
border-color: hsl(224, 13%, 17%);
}
/* Angle and time should remain as circles and are hence not included */
.prism-previewer-color.prism-previewer-color:before,
.prism-previewer-gradient.prism-previewer-gradient div,
.prism-previewer-easing.prism-previewer-easing:before {
border-radius: 0.3em;
}
/* Triangles pointing to the code */
.prism-previewer.prism-previewer:after {
border-top-color: hsl(224, 13%, 17%);
}
.prism-previewer-flipped.prism-previewer-flipped.after {
border-bottom-color: hsl(224, 13%, 17%);
}
/* Background colour within the popup */
.prism-previewer-angle.prism-previewer-angle:before,
.prism-previewer-time.prism-previewer-time:before,
.prism-previewer-easing.prism-previewer-easing {
background: hsl(219, 13%, 22%);
}
/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */
/* For time, this is the alternate colour */
.prism-previewer-angle.prism-previewer-angle circle,
.prism-previewer-time.prism-previewer-time circle {
stroke: var(--syntax-fg);
stroke-opacity: 1;
}
/* Stroke colours of the handle, direction point, and vector itself */
.prism-previewer-easing.prism-previewer-easing circle,
.prism-previewer-easing.prism-previewer-easing path,
.prism-previewer-easing.prism-previewer-easing line {
stroke: var(--syntax-fg);
}
/* Fill colour of the handle */
.prism-previewer-easing.prism-previewer-easing circle {
fill: transparent;
}
+131
View File
@@ -0,0 +1,131 @@
import { Metadata } from "next";
import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
import { cn } from "~/utils";
import config from "~/config/gIndex.config";
import Footer from "./@footer";
import Navbar from "./@navbar";
import Password from "./@password";
import { CheckSitePassword } from "./actions";
import "./globals.css";
import "./markdown.css";
import ThemeProvider from "./theme-provider";
const sourceSans3 = Source_Sans_3({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-source-sans-3",
});
const jetbrainsMono = JetBrains_Mono({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-jetbrains-mono",
});
export const metadata: Metadata = {
metadataBase: new URL(config.basePath),
title: {
default: config.siteConfig.siteName,
template: config.siteConfig.siteNameTemplate || "%s",
},
description: config.siteConfig.siteDescription,
authors: config.siteConfig.siteAuthor
? {
name: config.siteConfig.siteAuthor,
}
: undefined,
creator: "mbaharip",
icons: [
{
url: config.siteConfig.favIcon,
},
],
keywords: ["gdrive", "index", "nextjs", "reactjs"],
openGraph: {
type: "website",
siteName: config.siteConfig.siteName,
images: [
{
url: "/og.png",
width: 1200,
height: 630,
},
],
},
twitter: {
card: "summary_large_image",
creator: config.siteConfig.twitterHandle,
},
robots: config.siteConfig.robots,
};
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const unlocked = await CheckSitePassword();
const formatFooter = (text: string | string[]): string => {
let toFormat: string;
if (Array.isArray(text)) {
toFormat = text.join(`\n\n`);
} else {
toFormat = text;
}
return toFormat
.replaceAll("{{ year }}", new Date().getFullYear().toString())
.replaceAll(
"{{ repository }}",
"[Repository](https://github.com/mbaharip/next-gdrive-index)",
)
.replaceAll("{{ author }}", config.siteConfig.siteAuthor || "mbaharip")
.replaceAll("{{ version }}", config.version || "0.0.0")
.replaceAll("{{ siteName }}", config.siteConfig.siteName)
.replaceAll("{{ creator }}", "mbaharip");
};
return (
<html lang='en'>
<body
className={cn(
"h-full bg-background font-sans text-foreground",
jetbrainsMono.variable,
sourceSans3.variable,
)}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<Navbar />
<main
slot='content'
className={cn(
"mx-auto h-full w-full max-w-screen-desktop",
"relative left-0 top-0",
"flex flex-grow flex-col gap-3 p-6",
"tablet:gap-6",
)}
>
{config.siteConfig.privateIndex && !unlocked.success ? (
<Password
path='global'
errorMessage={unlocked.message}
/>
) : (
<>{children}</>
)}
</main>
{config.siteConfig.footer && (
<Footer content={formatFooter(config.siteConfig.footer)} />
)}
</ThemeProvider>
</body>
</html>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
export default function RootLoading() {
return (
<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 data...</p>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
@tailwind components;
@layer components {
.markdown {
@apply bg-card text-base leading-relaxed tracking-wide;
}
.markdown p {
@apply leading-loose;
}
/* .markdown h1 {
@apply text-4xl font-medium;
}
.markdown h2 {
@apply text-3xl font-medium;
}
.markdown h3 {
@apply text-2xl font-medium;
}
.markdown h4 {
@apply text-xl font-medium;
}
.markdown h5 {
@apply text-lg font-medium;
}
.markdown h6 {
@apply text-base;
} */
.markdown h1,
.markdown h2,
.markdown h3 {
@apply mt-4 border-b-2 border-b-border first-letter:capitalize;
}
.markdown code:not(pre code) {
@apply whitespace-nowrap rounded-[var(--radius)] bg-muted px-2 py-0.5 leading-normal text-foreground;
}
.markdown a {
@apply text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400;
}
.markdown p {
@apply whitespace-normal text-pretty;
}
.markdown img {
@apply rounded-[var(--radius)];
}
.markdown ul {
@apply my-1.5 list-disc leading-loose;
}
.markdown ol {
@apply my-1.5 list-decimal leading-loose;
}
.markdown li {
@apply my-0 ml-2;
}
.markdown table {
@apply mb-4 block w-full border-collapse border-spacing-0 overflow-auto;
}
.markdown table th,
.markdown table td {
@apply border border-border px-2 py-1;
}
.markdown table th {
@apply bg-background text-foreground;
}
.markdown table tr {
@apply bg-secondary/50 text-foreground;
}
.markdown table tr:nth-child(2n) {
@apply bg-secondary/25 text-foreground;
}
.markdown ul.contains-task-list {
@apply list-none;
}
.markdown ul.contains-task-list li {
@apply ml-1 flex items-center;
}
.markdown input[type="checkbox"] {
@apply mr-2 aspect-square h-4 w-4 rounded-full p-2;
@apply appearance-none border border-border;
@apply disabled:bg-primary disabled:checked:bg-blue-600 dark:disabled:checked:bg-blue-400;
}
.markdown blockquote {
@apply my-2 rounded-r-[var(--radius)] border-l-4 border-l-primary bg-muted py-4 pl-2 text-foreground;
}
.markdown hr {
@apply my-4 border-b border-t-0 border-b-border;
}
}
+5
View File
@@ -0,0 +1,5 @@
import NotFoundComponent from "./@not-found";
export default function NotFound() {
return <NotFoundComponent />;
}
+63
View File
@@ -0,0 +1,63 @@
import { cn } from "~/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import FileBrowser from "./@explorer";
import Header from "./@header";
import HeaderButton from "./@header.button";
import Markdown from "./@markdown";
import { GetFiles, GetReadme } from "./actions";
export const revalidate = 300;
export const dynamic = "force-dynamic";
export default async function RootPage() {
const [data, readme] = await Promise.all([
GetFiles({}),
GetReadme(undefined),
]);
return (
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
<Header name='Root' />
<div
slot='content'
className='w-full'
>
<Card>
<CardHeader className='pb-0'>
<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'>
<FileBrowser
files={data.files}
nextPageToken={data.nextPageToken}
root
/>
</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>
)}
</div>
);
}
+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;
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { ThemeProvider as NextThemeProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
import NextTopLoader from "nextjs-toploader";
import { Toaster } from "react-hot-toast";
import { cn } from "~/utils";
import { TooltipProvider } from "~/components/ui/tooltip";
import LayoutProvider from "~/context/layoutContext";
import config from "~/config/gIndex.config";
export default function ThemeProvider({
children,
...props
}: ThemeProviderProps) {
return (
<LayoutProvider>
<NextTopLoader color='hsl(var(--primary))' />
<NextThemeProvider {...props}>
<TooltipProvider delayDuration={250}>
<div
vaul-drawer-wrapper=''
className={cn(
"bg-background font-sans text-foreground",
"h-full min-h-screen w-full",
"flex flex-col items-start",
// "tablet:flex-row",
"pr-1.5",
)}
>
{children}
</div>
</TooltipProvider>
<Toaster
position={config.siteConfig.toaster?.position}
toastOptions={{
style: {
background: "hsl(var(--accent))",
color: "hsl(var(--accent-foreground))",
borderRadius: "var(--radius)",
border: "1px solid hsl(var(--border))",
},
duration: config.siteConfig.toaster?.duration,
loading: {
duration: 0,
},
}}
/>
</NextThemeProvider>
</LayoutProvider>
);
}
-53
View File
@@ -1,53 +0,0 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { twMerge } from "tailwind-merge";
interface AlertProps {
variant?: "success" | "error" | "warning" | "info";
icon?: string | IconifyIcon;
title?: string;
message: string;
className?: string;
}
export default function Alert(props: AlertProps) {
const {
variant = "info",
icon,
title,
message,
className,
} = props;
return (
<div
className={twMerge(
"w-full h-fit flex flex-col rounded-lg gap-1 px-4 py-2",
variant === "success" &&
"bg-success-100 text-success-main",
variant === "error" &&
"bg-danger-100 text-danger-main",
variant === "warning" &&
"bg-warning-100 text-warning-main",
variant === "info" && "bg-info-100 text-info-main",
className && className,
)}
>
{(icon || title) && (
<div className='flex gap-1 items-center'>
{icon && (
<Icon
icon={icon}
color='currentColor'
width={24}
height={24}
/>
)}
{title && (
<span className='font-medium text-lg'>
{title}
</span>
)}
</div>
)}
<p className='text-sm'>{message}</p>
</div>
);
}
-165
View File
@@ -1,165 +0,0 @@
import { Icon, IconifyIcon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Fragment, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
interface BreadcrumbProps {
paths: Record<"label" | "path", string>[];
separator?: string | IconifyIcon;
maxPath?: number;
}
export default function Breadcrumb(props: BreadcrumbProps) {
const router = useRouter();
const {
paths,
separator = "ion:chevron-forward",
maxPath = 2,
} = props;
const [breadcrumbPaths, setBreadcrumbPaths] =
useState<Record<"label" | "path", string>[]>(paths);
const [isPathSliced, setIsPathSliced] =
useState<boolean>(false);
const [hiddenPaths, setHiddenPaths] = useState<
Record<"label" | "path", string>[]
>([]);
const [menuAnchor, setMenuAnchor] =
useState<HTMLElement | null>(null);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
useEffect(() => {
if (paths.length > maxPath) {
const newPaths = paths.slice(
paths.length - maxPath,
paths.length,
);
const hiddenPaths = paths.slice(
0,
paths.length - maxPath,
);
setBreadcrumbPaths(newPaths);
setIsPathSliced(true);
setHiddenPaths(hiddenPaths);
} else {
setBreadcrumbPaths(paths);
setIsPathSliced(false);
}
}, [paths, maxPath]);
return (
<>
<div className='flex items-center flex-wrap w-full gap-2'>
<Link href={"/"}>
<ButtonIcon
icon={"ion:home"}
variant='transparent'
className='flex-shrink-0 flex-grow-0 p-1'
/>
{/* <Icon
icon='ion:home'
color='currentColor'
width={20}
height={20}
className='flex-shrink-0 flex-grow-0'
/> */}
</Link>
{isPathSliced && (
<>
<Icon
icon={separator}
color='currentColor'
width={20}
height={20}
/>
<ButtonIcon
icon={"ion:ellipsis-horizontal"}
variant='transparent'
className='flex-shrink-0 flex-grow-0 p-1'
ref={setMenuAnchor}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
{/* <span className='mx-1'>...</span> */}
</>
)}
{breadcrumbPaths.map((path, index) => (
<Fragment key={`breadcrumb-${path}@${index}`}>
<Icon
icon={separator}
color='currentColor'
width={20}
height={20}
className='flex-shrink-0 flex-grow-0'
/>
<Link
href={path.path}
className={`${
path.path === router.asPath
? "text-primary-50 pointer-events-none cursor-default"
: "text-primary-300 hover:text-primary-100 cursor-pointer"
} transition-smooth max-w-md mx-1 w-fit ${
index === breadcrumbPaths.length - 1 &&
"line-clamp-1"
}`}
>
{decodeURIComponent(path.label)}
</Link>
</Fragment>
))}
</div>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-full max-w-sm bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen
? `opacity-100 pointer-events-auto`
: `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor
? menuAnchor?.getBoundingClientRect()
.bottom ?? 0
: "unset",
left: menuAnchor
? menuAnchor?.getBoundingClientRect().left ??
0
: "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{hiddenPaths.map((path, index) => (
<Link
href={path.path}
key={`hidden-path-${path}@${index}`}
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2 w-full'
title={decodeURIComponent(path.label)}
>
{decodeURIComponent(path.label)}
</Link>
))}
</div>
</div>
</ClickAway>
</>
);
}
-121
View File
@@ -1,121 +0,0 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
fullWidth?: boolean;
variant?:
| "primary"
| "accent"
| "success"
| "danger"
| "warning"
| "info"
| "transparent";
size?: "small" | "medium" | "large";
pill?: boolean;
square?: boolean;
rounded?: "none" | "small" | "medium" | "large";
startIcon?: string | IconifyIcon;
endIcon?: string | IconifyIcon;
children: ReactNode;
loading?: boolean;
className?: string;
}
export default function Button(props: ButtonProps) {
const {
fullWidth = false,
variant = "primary",
size = "medium",
pill = false,
square = false,
rounded = "none",
loading = false,
startIcon,
endIcon,
children,
className,
...rest
} = props;
return (
<button
className={twMerge(
"outline-none border-none capitalize transition-smooth",
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
"rounded-lg w-fit flex items-center justify-center flex-grow",
fullWidth && "w-full",
rounded === "none" && "rounded-none",
rounded === "small" && "rounded-sm",
rounded === "medium" && "rounded-md",
rounded === "large" && "rounded-lg",
pill && "rounded-full",
size === "small" && "px-2 py-1 text-sm gap-1",
size === "medium" && "px-4 py-2 text-base gap-2",
size === "large" && "px-8 py-4 text-lg gap-4",
square && "aspect-square",
variant === "primary" &&
"bg-primary-600 text-primary-50 hover:bg-primary-500 active:bg-primary-700",
variant === "accent" &&
"bg-accent-600 text-accent-50 hover:bg-accent-500 active:bg-accent-700",
variant === "success" &&
"bg-success-600 text-success-50 hover:bg-success-500 active:bg-success-700",
variant === "danger" &&
"bg-danger-600 text-danger-50 hover:bg-danger-500 active:bg-danger-700",
variant === "warning" &&
"bg-warning-600 text-warning-50 hover:bg-warning-500 active:bg-warning-700",
variant === "info" &&
"bg-info-600 text-info-50 hover:bg-info-500 active:bg-info-700",
variant === "transparent" &&
"bg-transparent text-primary-50 hover:bg-primary-500 active:bg-primary-700",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-primary-700 disabled:hover:bg-primary-700 disabled:active:bg-primary-700 disabled:text-primary-50",
className && className,
)}
disabled={loading ?? props.disabled}
{...rest}
>
{props.loading ? (
<Icon
icon={"mdi:loading"}
color='white'
className={twMerge(
"w-5 h-5 animate-spin",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
) : (
<>
{startIcon && (
<Icon
icon={startIcon}
color='white'
className={twMerge(
"w-5 h-5",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
)}
{children}
{endIcon && (
<Icon
icon={endIcon}
color='white'
className={twMerge(
"w-5 h-5",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
)}
</>
)}
</button>
);
}
-36
View File
@@ -1,36 +0,0 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonGroupProps {
children: ReactNode;
fullWidth?: boolean;
rounded?: "none" | "small" | "medium" | "large";
className?: string;
}
export default function ButtonGroup(
props: ButtonGroupProps,
) {
const {
children,
fullWidth = false,
rounded = "large",
className,
} = props;
return (
<div
className={twMerge(
"flex flex-row items-center justify-center overflow-hidden",
fullWidth && "w-full",
rounded === "none" && "rounded-none",
rounded === "small" && "rounded-sm",
rounded === "medium" && "rounded-md",
rounded === "large" && "rounded-lg",
className && className,
)}
>
{children}
</div>
);
}
-58
View File
@@ -1,58 +0,0 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { ForwardRefRenderFunction, forwardRef } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonIconProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: string | IconifyIcon;
size?: "small" | "medium" | "large";
variant?: "primary" | "accent" | "success" | "danger" | "warning" | "info" | "transparent";
className?: string;
}
const ButtonIcon: ForwardRefRenderFunction<HTMLButtonElement, ButtonIconProps> = (props, ref) => {
const { icon, size = "medium", variant = "primary", className, ...rest } = props;
let iconSize = 24;
switch (size) {
case "small":
iconSize = 16;
break;
case "medium":
iconSize = 24;
break;
case "large":
iconSize = 32;
}
return (
<button
ref={ref}
className={twMerge(
"outline-none border-none capitalize transition-smooth aspect-square",
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
"rounded-full flex items-center justify-center w-fit h-fit p-0",
size === "small" && "w-6 h-6",
size === "medium" && "w-8 h-8",
size === "large" && "w-12 h-12",
variant === "primary" && "bg-primary-600/25 text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
variant === "accent" && "bg-accent-600/25 text-accent-50 hover:bg-accent-500/50 active:bg-accent-700",
variant === "success" && "bg-success-600/25 text-success-50 hover:bg-success-500/50 active:bg-success-700",
variant === "danger" && "bg-danger-600/25 text-danger-50 hover:bg-danger-500/50 active:bg-danger-700",
variant === "warning" && "bg-warning-600/25 text-warning-50 hover:bg-warning-500/50 active:bg-warning-700",
variant === "info" && "bg-info-600/25 text-info-50 hover:bg-info-500/50 active:bg-info-700",
variant === "transparent" && "bg-transparent text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
props.disabled && "opacity-50 cursor-not-allowed",
className && className,
)}
{...rest}
>
<Icon
icon={icon}
color='white'
width={iconSize}
height={iconSize}
/>
</button>
);
};
export default forwardRef(ButtonIcon);
-38
View File
@@ -1,38 +0,0 @@
import { ReactNode, useEffect } from "react";
import { twMerge } from "tailwind-merge";
interface ClickAwayProps extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
open: boolean;
onClickAway: (e: any) => void;
className?: string;
}
export default function ClickAway(props: ClickAwayProps) {
const { children, open, onClickAway, className, ...rest } = props;
useEffect(() => {
const body = document.querySelector("body");
const isScrollbarRendered = document.documentElement.clientHeight < document.documentElement.scrollHeight;
const isMobile = window.matchMedia("(max-width: 768px)").matches;
if (open && isScrollbarRendered) {
body?.style.setProperty("overflow", "hidden");
if (!isMobile) body?.style.setProperty("padding-right", "4px");
} else {
body?.style.removeProperty("overflow");
if (!isMobile) body?.style.removeProperty("padding-right");
}
}, [open]);
return (
<div
className={twMerge(
"w-full h-full bg-primary-950/50 backdrop-blur-none transition-smooth",
className && className,
)}
onClick={onClickAway}
{...rest}
>
{children}
</div>
);
}
-233
View File
@@ -1,233 +0,0 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { stripExtension } from "utils/filesHelper";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { getPreviewIcon } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
interface ListItemProps {
file: IGDriveFiles;
isProtected: boolean;
}
export default function ListItem(props: ListItemProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
const [fileInfo, setFileInfo] = useState<string[]>([]);
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
const [filePath, setFilePath] = useState<string>("");
useEffect(() => {
const currentPath = router.asPath.slice(1);
const filePath = props.file.name;
const path = [currentPath, encodeURIComponent(filePath)].join("/");
// If start with /, remove it
if (path.startsWith("/")) {
setFilePath(path.slice(1));
} else {
setFilePath(path);
}
}, [router.asPath, props.file.name]);
useEffect(() => {
const info = [];
const extInfo = [];
props.file.modifiedTime &&
info.push(
new Date(props.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
);
props.file.size && info.push(bytesToReadable(props.file.size));
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
setExtensionInfo(extInfo);
setFileInfo(info);
}, [props.file]);
const handleCopyFileLink = () => {
try {
const url = `${window.location.origin}/${filePath}`;
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
try {
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
window.open(
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
"_blank",
);
setMenuOpen(false);
};
return (
<>
<Link
title={props.file.name}
href={filePath}
className='w-full p-1 flex relative items-stretch gap-2 tablet:gap-4 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
<div className='flex flex-col items-stretch gap-2 w-full relative'>
{props.file.thumbnailLink &&
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
<img
src={props.file.thumbnailLink}
alt={props.file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48'
loading='lazy'
/>
) : (
<Icon
icon={
props.file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48 p-8 tablet:p-16'
/>
)}
<div className='flex flex-col p-1'>
<span className='line-clamp-1 break-all whitespace-pre-wrap w-full text-center my-1'>
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
</span>{" "}
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
</div>
</div>
</div>
<div className='absolute top-1 right-1 z-10'>
<Tooltip content='More'>
<ButtonIcon
className='bg-primary-950/50'
ref={setMenuAnchor}
icon='ion:ellipsis-vertical'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
</Tooltip>
</div>
</Link>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor ? menuAnchor?.getBoundingClientRect().bottom ?? 0 : "unset",
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 150 : "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{props.file.mimeType.includes("folder") ? (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy folder link</span>
</div>
</>
) : (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy file link</span>
</div>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy download link</span>
</div>
<div className='w-full h-px bg-primary-500' />
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleDownload(props.file, props.isProtected)}
>
<Icon
icon='ion:download'
color='currentColor'
width={18}
height={18}
/>
<span>Download</span>
</div>
</>
)}
</div>
</div>
</ClickAway>
</>
);
}
-232
View File
@@ -1,232 +0,0 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { stripExtension } from "utils/filesHelper";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { getPreviewIcon } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
interface ListItemProps {
file: IGDriveFiles;
isProtected: boolean;
}
export default function ListItem(props: ListItemProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
const [fileInfo, setFileInfo] = useState<string[]>([]);
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
const [filePath, setFilePath] = useState<string>("");
useEffect(() => {
const currentPath = router.asPath.slice(1);
const filePath = props.file.name;
const path = [currentPath, encodeURIComponent(filePath)].join("/");
// If start with /, remove it
if (path.startsWith("/")) {
setFilePath(path.slice(1));
} else {
setFilePath(path);
}
}, [router.asPath, props.file.name]);
useEffect(() => {
const info = [];
const extInfo = [];
props.file.modifiedTime &&
info.push(
new Date(props.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
);
props.file.size && info.push(bytesToReadable(props.file.size));
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
setExtensionInfo(extInfo);
setFileInfo(info);
}, [props.file]);
const handleCopyFileLink = () => {
try {
const url = `${window.location.origin}/${filePath}`;
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
try {
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
window.open(
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
"_blank",
);
setMenuOpen(false);
};
return (
<>
<Link
title={props.file.name}
href={filePath}
className='w-full p-1 pr-2 flex items-center gap-4 tablet:gap-8 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
<div className='flex items-center gap-4 w-full flex-grow-0 flex-shrink'>
{props.file.thumbnailLink &&
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
<img
src={props.file.thumbnailLink}
alt={props.file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
loading='lazy'
/>
) : (
<Icon
icon={
props.file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
/>
)}
<div className='flex flex-col'>
<span className='line-clamp-1 break-all whitespace-pre-wrap'>
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
</span>{" "}
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
</div>
</div>
</div>
<div className='relative z-10'>
<Tooltip content='More'>
<ButtonIcon
ref={setMenuAnchor}
icon='ion:ellipsis-vertical'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
</Tooltip>
</div>
</Link>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor ? (menuAnchor?.getBoundingClientRect().bottom ?? 0) - 36 : "unset",
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 200 : "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{props.file.mimeType.includes("folder") ? (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy folder link</span>
</div>
</>
) : (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy file link</span>
</div>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy download link</span>
</div>
<div className='w-full h-px bg-primary-500' />
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleDownload(props.file, props.isProtected)}
>
<Icon
icon='ion:download'
color='currentColor'
width={18}
height={18}
/>
<span>Download</span>
</div>
</>
)}
</div>
</div>
</ClickAway>
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { LucideProps, icons } from "lucide-react";
import { ReactElement } from "react";
type Props = {
name: keyof typeof icons;
wrapper?: ReactElement<"div">;
} & LucideProps;
export default function Icon({ name, wrapper, ...props }: Props) {
const LucideIcon = icons[name];
return (
<div {...wrapper}>
<LucideIcon {...props} />
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
import GridItem from "components/File/GridItem";
import { IGDriveFiles } from "types/api/files";
interface GridViewProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
}
export default function ViewGrid(props: GridViewProps) {
return (
<div className='w-full gap-4 grid grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4'>
{props.data.folders.map((folder) => (
<GridItem
key={folder.encryptedId}
file={folder}
isProtected={props.isProtected}
/>
))}
{props.data.files.map((file) => (
<GridItem
key={file.encryptedId}
file={file}
isProtected={props.isProtected}
/>
))}
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
import ListItem from "components/File/ListItem";
import { IGDriveFiles } from "types/api/files";
interface ViewListProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
}
export default function ViewList(props: ViewListProps) {
return (
<div className='w-full flex flex-col gap-2 rounded-lg'>
{props.data.folders.map((folder) => (
<ListItem
key={folder.encryptedId}
file={folder}
isProtected={props.isProtected}
/>
))}
{props.data.files.map((file) => (
<ListItem
key={file.encryptedId}
file={file}
isProtected={props.isProtected}
/>
))}
</div>
);
}
-240
View File
@@ -1,240 +0,0 @@
import axios from "axios";
import gIndexConfig from "config";
import { useRouter } from "next/router";
import { poppins } from "pages/_app";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import Breadcrumb from "components/Breadcrumb";
import Button from "components/Button";
import Markdown from "components/Markdown";
import { addNewPassword } from "utils/passwordHelper";
import { IGDriveFiles } from "types/api/files";
import { APIGetFileResponse } from "types/api/response";
import { Constant } from "types/constant";
import Header from "../Header";
import PasswordLayout from "../Password";
import ViewGrid from "./Grid";
import ViewList from "./List";
const Loading = () => (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading view...</span>
</div>
</div>
);
interface ExplorerLayoutProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
readmeFile: string | null;
isProtected: boolean;
isFileProtected?: boolean;
encryptedId?: string;
}
export default function ExplorerLayout(props: ExplorerLayoutProps) {
const router = useRouter();
const [data, setData] = useState<ExplorerLayoutProps["data"]>(props.data);
const [view, setView] = useState<"grid" | "list">(() => {
const viewType = localStorage.getItem(Constant.storage_ViewType);
if (!viewType) {
localStorage.setItem(Constant.storage_ViewType, "list");
return "list";
}
if (viewType === "grid") {
return "grid";
} else {
return "list";
}
});
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
const [isButtonLoading, setIsButtonLoading] = useState<boolean>(false);
const [ViewComponent, setViewComponent] = useState<ReactNode>(
<ViewList
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
useEffect(() => {
new Promise(async (resolve) => {
if (view === "list") {
setViewComponent(
<ViewList
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
} else if (view === "grid") {
setViewComponent(
<ViewGrid
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
}
await new Promise((resolve) => setTimeout(resolve, 50));
resolve(true);
}).then(() => {
setIsViewLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, data]);
useEffect(() => {
setShowPasswordLayout(props.isProtected);
}, [props.isProtected]);
useEffect(() => {
const paths = router.asPath.split("/").filter((path) => path !== "");
const newPaths: Record<"label" | "path", string>[] = [];
paths.forEach((path, index) => {
newPaths.push({
label: path,
path: `/${paths.slice(0, index + 1).join("/")}`,
});
});
setBreadcrumbPaths(newPaths);
}, [router]);
const handleChangeView = useCallback(
(view: "grid" | "list") => {
setIsViewLoading(true);
setView(view);
localStorage.setItem(Constant.storage_ViewType, view);
},
[setView],
);
const handleLoadNextPage = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsButtonLoading(true);
if (!data.pageToken) {
setIsButtonLoading(false);
return;
}
const newData = await axios.get<APIGetFileResponse>("/api/getData", {
params: {
encryptedId: props.encryptedId,
pageToken: data.pageToken,
},
});
setData((prev) => ({
...prev,
files: [...prev.files, ...newData.data.data.files],
folders: [...prev.folders, ...newData.data.data.folders],
pageToken: newData.data.data.pageToken,
}));
setIsButtonLoading(false);
},
[data, props.encryptedId],
);
return (
<div className='w-full flex h-full flex-col gap-4 relative'>
{/* Top */}
<Header
viewSelector={{ view, setView: handleChangeView }}
showPasswordLayout={showPasswordLayout}
/>
<div className='w-full h-px bg-primary-500' />
{/* Header */}
{!showPasswordLayout && (
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
{/* Breadcrumb */}
<Breadcrumb paths={breadcrumbPaths} />
</div>
)}
{/* Content */}
{showPasswordLayout ? (
<PasswordLayout
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 1] ?? "/")}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<div
className={twMerge(
"w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4",
!props.readmeFile && "grid-cols-1 tablet:grid-cols-12 desktop:grid-cols-12",
)}
>
<div
className={twMerge(
"col-span-full desktop:col-span-8 overflow-auto",
!props.readmeFile && "desktop:col-span-full",
)}
>
{isViewLoading ? (
<Loading />
) : (
<div className='w-full flex flex-col gap-4'>
{data.files.length || data.folders.length ? (
<>{ViewComponent}</>
) : (
<div className='w-full min-h-[5rem] grid place-items-center'>
Can&apos;t find any files or folders in this directory
</div>
)}
{data.pageToken && (
<Button
variant='accent'
fullWidth
rounded='large'
className='mt-4'
loading={isButtonLoading}
onClick={handleLoadNextPage}
>
Load more...
</Button>
)}
</div>
)}
</div>
{props.readmeFile && (
<div className='col-span-full desktop:col-span-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 rounded-lg sticky top-4 overflow-auto ${poppins.className}`}
>
<Markdown content={props.readmeFile} />
</div>
</div>
)}
</div>
)}
</div>
);
}
-259
View File
@@ -1,259 +0,0 @@
import { Icon } from "@iconify/react";
import axios from "axios";
import gIndexConfig from "config";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import Button from "components/Button";
import ButtonGroup from "components/ButtonGroup";
import ButtonIcon from "components/ButtonIcon";
import Modal from "components/Modal";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { getPreviewIcon } from "utils/previewHelper";
import { IGDriveFiles } from "types/api/files";
import { APISearchResponse } from "types/api/response";
interface HeaderProps {
viewSelector?: {
view: "grid" | "list";
setView: (view: "grid" | "list") => void;
};
showPasswordLayout?: boolean;
}
export default function Header(props: HeaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [searchModalOpen, setSearchModalOpen] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>("");
const [axiosCancelToken, setAxiosCancelToken] = useState<ReturnType<typeof axios.CancelToken.source> | null>(null); // eslint-disable-line
const [searchLoading, setSearchLoading] = useState<boolean>(false);
const [searchResults, setSearchResults] = useState<(IGDriveFiles & { redirect: string })[]>([]);
useEffect(() => {
if (!searchQuery) {
setSearchLoading(false);
return;
}
setSearchLoading(true);
if (axiosCancelToken) {
axiosCancelToken.cancel();
}
const timeout = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 500);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
useEffect(() => {
if (!searchModalOpen) {
setSearchResults([]);
setSearchQuery("");
setSearchLoading(false);
}
}, [searchModalOpen]);
useEffect(() => {
if (!debouncedSearchQuery) {
setSearchLoading(false);
setSearchResults([]);
return;
}
const source = axios.CancelToken.source();
setAxiosCancelToken(source);
axios
.get<APISearchResponse>("/api/search", {
params: {
query: debouncedSearchQuery,
},
cancelToken: source.token,
})
.then((res) => {
setSearchResults(res.data.files);
})
.catch((err) => {
if (axios.isCancel(err)) {
return;
} else {
console.error(err);
throw new Error(err);
}
})
.finally(() => {
setSearchLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchQuery]);
return (
<>
<div className='flex items-center justify-between gap-4'>
<h1 className='text-2xl font-medium'>{gIndexConfig.siteConfig.siteName}</h1>
{!props.showPasswordLayout && (
<div className='flex items-center gap-4'>
{props.viewSelector && (
<ButtonGroup className='border border-primary-700 tablet:flex hidden'>
<Button
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
startIcon={"ion:list"}
onClick={() => props.viewSelector?.setView("list")}
>
List view
</Button>
<Button
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
startIcon={"ion:grid"}
onClick={() => props.viewSelector?.setView("grid")}
>
Grid view
</Button>
</ButtonGroup>
)}
<Tooltip content='Search file'>
<ButtonIcon
icon='ion:search'
variant='transparent'
onClick={() => {
setSearchModalOpen(true);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}}
/>
</Tooltip>
</div>
)}
</div>
{!props.showPasswordLayout && props.viewSelector && (
<div className='flex w-full items-center tablet:hidden'>
<ButtonGroup className='border border-primary-700 w-full'>
<Button
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
size='small'
startIcon={"ion:list"}
onClick={() => props.viewSelector?.setView("list")}
>
List view
</Button>
<Button
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
size='small'
startIcon={"ion:grid"}
onClick={() => props.viewSelector?.setView("grid")}
>
Grid view
</Button>
</ButtonGroup>
</div>
)}
<Modal
key={"search-modal"}
open={searchModalOpen}
onClose={() => setSearchModalOpen(false)}
title='Search'
>
<div className='w-full flex flex-col gap-2'>
<input
ref={inputRef}
type='text'
placeholder='Input search query'
className='w-full px-4 py-2 bg-primary-900 outline-none border border-primary-500 rounded-lg'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchLoading ? (
<div className='w-full py-8 flex items-center justify-center gap-2 animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Searching file...</span>
</div>
) : (
<div className='flex flex-col gap-2 max-h-96 overflow-auto'>
{searchResults.length === 0 ? (
<div className='w-full py-8 flex items-center justify-center gap-2'>
<span>No results found</span>
</div>
) : (
<>
{searchResults.map((file) => (
<Link
href={file.redirect}
key={file.encryptedId}
className='flex items-center gap-4 w-full flex-grow-0 flex-shrink bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
{file.thumbnailLink &&
(file.mimeType.startsWith("video") || file.mimeType.startsWith("image")) ? (
<img
src={file.thumbnailLink}
alt={file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
loading='lazy'
/>
) : (
<Icon
icon={
file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(file.fileExtension!, file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
/>
)}
<div className='flex flex-col'>
<span className='font-medium'>{file.name}</span>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>
{[
file.mimeType && file.mimeType.includes("folder") && "Folder",
file.fileExtension && file.fileExtension.toUpperCase(),
]
.filter((item) => item)
.join(" ・ ")}
</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>
{[
new Date(file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
file.size ? bytesToReadable(file.size) : undefined,
]
.filter((item) => item)
.join(" ・ ")}
</span>
</div>
</div>
</Link>
))}
</>
)}
</div>
)}
</div>
</Modal>
</>
);
}
-188
View File
@@ -1,188 +0,0 @@
import gIndexConfig from "config";
import { NextSeo, NextSeoProps } from "next-seo";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import {
addNewPassword,
checkPathPassword,
} from "utils/passwordHelper";
import { Constant } from "types/constant";
import PasswordLayout from "../Password";
interface LoaderLayoutProps
extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
seo?: NextSeoProps;
}
export default function LoaderLayout(
props: LoaderLayoutProps,
) {
const router = useRouter();
const { children, seo, className, ...rest } = props;
const sitePassword =
process.env.NEXT_PUBLIC_SITE_PASSWORD;
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isPasswordUnlocked, setIsPasswordUnlocked] =
useState<boolean>(
gIndexConfig.siteConfig.privateIndex ? false : true,
);
const handlePrivateIndexCheck = () => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
if (cookie) {
const password = cookie.split("=")[1];
if (!password) return setIsPasswordUnlocked(false);
if (!sitePassword)
throw new Error("Can't find site password");
const valid = checkPathPassword(
"site-wide",
password,
sitePassword as string,
);
if (valid) return setIsPasswordUnlocked(true);
}
};
useEffect(() => {
setIsLoading(true);
new Promise((resolve) =>
resolve(handlePrivateIndexCheck()),
).then(() => setIsLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const routeChangeStart = () => setIsLoading(true);
const routeChangeComplete = () => {
handlePrivateIndexCheck();
setIsLoading(false);
};
router.events.on("routeChangeStart", routeChangeStart);
router.events.on(
"routeChangeComplete",
routeChangeComplete,
);
router.events.on(
"routeChangeError",
routeChangeComplete,
);
return () => {
router.events.off(
"routeChangeStart",
routeChangeStart,
);
router.events.off(
"routeChangeComplete",
routeChangeComplete,
);
router.events.off(
"routeChangeError",
routeChangeComplete,
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.events]);
return (
<>
<NextSeo {...seo} />
{isLoading ? (
<div className='w-full h-auto flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Validating path...</span>
</div>
</div>
) : (
<div
className={`w-full h-auto flex flex-col p-4 tablet:px-8 desktop:px-16 ${className}`}
{...rest}
key={router.asPath}
>
{gIndexConfig.siteConfig.privateIndex &&
!isPasswordUnlocked ? (
<PasswordLayout
path='Private Mode Password'
onSubmitted={(password) => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
addNewPassword(
"site-wide",
password,
cookie?.split("=")[1] ?? undefined,
);
setIsLoading(true);
const validPassword = () => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
if (cookie) {
const password = cookie.split("=")[1];
if (!password) return false;
if (!sitePassword)
throw new Error(
"Can't find site password",
);
const valid = checkPathPassword(
"site-wide",
password,
sitePassword as string,
);
return valid;
}
};
new Promise((resolve) =>
resolve(validPassword()),
)
.then((data) => {
if (data) {
setIsPasswordUnlocked(true);
} else {
setIsPasswordUnlocked(false);
}
})
.finally(() => setIsLoading(false));
}}
/>
) : (
<>{children}</>
)}
</div>
)}
</>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { Icon } from "@iconify/react";
import { useRouter } from "next/router";
import { useState } from "react";
import Button from "components/Button";
import ButtonIcon from "components/ButtonIcon";
interface PasswordLayoutProps {
onSubmitted: (password: string) => void;
path: string;
}
export default function PasswordLayout(
props: PasswordLayoutProps,
) {
const router = useRouter();
const [showPassword, setShowPassword] =
useState<boolean>(false);
const [password, setPassword] = useState<string>("");
const [pending, setPending] = useState<boolean>(false);
return (
<div className='col-span-full text-center min-h-[50vh] flex-grow flex flex-col items-center justify-center'>
<figure>
<img
src='/images/password.png'
alt="You don't have access"
className='w-64 h-64 tablet:w-96 tablet:h-96'
/>
</figure>
<div className='w-full flex items-center justify-center flex-col my-8'>
<span className='text-lg font-medium'>
The folder / file you are trying to access is
protected with a password.
</span>
<pre className='max-w-sm w-full tablet:max-w-2xl'>
<code className='text-sm'>{props.path}</code>
</pre>
<span>Please enter the password to continue:</span>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
props.onSubmitted(password);
setPending(true);
}}
className='w-full max-w-lg flex items-center border border-primary-500 rounded-lg'
>
<div className='w-full flex items-center px-2 bg-primary-900 rounded-l-lg overflow-hidden'>
<input
type={showPassword ? "text" : "password"}
autoComplete='off'
className='w-full px-2 py-2 bg-primary-900 outline-none'
placeholder='Enter password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<ButtonIcon
type='button'
variant='transparent'
icon={showPassword ? "ion:eye-off" : "ion:eye"}
size='small'
className='p-1'
onClick={() => setShowPassword(!showPassword)}
/>
</div>
<Button
variant='accent'
type='submit'
className='rounded-r-lg w-24'
disabled={pending}
>
{pending ? (
<Icon
icon='mdi:loading'
className='animate-spin'
width={24}
height={24}
color='currentColor'
/>
) : (
"Submit"
)}
</Button>
</form>
</div>
);
}
-306
View File
@@ -1,306 +0,0 @@
import { Icon } from "@iconify/react";
import gIndexConfig from "config";
import Link from "next/link";
import { useRouter } from "next/router";
import { poppins } from "pages/_app";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify";
import { twMerge } from "tailwind-merge";
import Breadcrumb from "components/Breadcrumb";
import Button from "components/Button";
import bytesToReadable from "utils/bytesFormat";
import { durationToReadable } from "utils/durationFormat";
import { decryptData } from "utils/encryptionHelper/hash";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { addNewPassword } from "utils/passwordHelper";
import { getPreviewComponent } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
import { Constant } from "types/constant";
import Header from "../Header";
import PasswordLayout from "../Password";
const Loading = () => (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading view...</span>
</div>
</div>
);
interface PreviewLayoutProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
isFileProtected: boolean;
encryptedId?: string;
}
export default function PreviewLayout(props: PreviewLayoutProps) {
const router = useRouter();
const [data, setData] = useState<PreviewLayoutProps["data"]>(props.data);
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
const [PreviewComponent, setPreviewComponent] = useState<ReactNode>();
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
const [downloadToken] = useState<string | null>(() => {
if (gIndexConfig.apiConfig.allowDownloadProtectedFile) return null;
if (props.isFileProtected) {
return createDownloadToken();
} else {
return null;
}
});
const [expiredIn, setExpiredIn] = useState<string>("");
const fileMetadata = useMemo<
{
key: string;
label: string;
value: string | number | null;
}[]
>(() => {
const metadata = [];
if (data.file) {
if (data.file.name)
metadata.push({
key: "name",
label: "Name",
value: data.file.name,
});
if (data.file.mimeType)
metadata.push({
key: "mimeType",
label: "Type",
value: data.file.mimeType,
});
if (data.file.modifiedTime)
metadata.push({
key: "modifiedTime",
label: "Last Modified",
value: new Date(data.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
});
if (data.file.size)
metadata.push({
key: "size",
label: "Size",
value: bytesToReadable(data.file.size),
});
if (data.file.imageMediaMetadata && data.file.imageMediaMetadata.width && data.file.imageMediaMetadata.height)
metadata.push({
key: "imageMediaMetadata",
label: "Dimension",
value: `${data.file.imageMediaMetadata.width}x${data.file.imageMediaMetadata.height}`,
});
if (data.file.videoMediaMetadata)
metadata.push(
{
key: "videoMediaMetadata",
label: "Dimension",
value: `${data.file.videoMediaMetadata.width}x${data.file.videoMediaMetadata.height}`,
},
{
key: "videoMediaMetadata",
label: "Duration",
value: durationToReadable(data.file.videoMediaMetadata.durationMillis),
},
);
}
return metadata;
}, [data.file]);
useEffect(() => {
if (!downloadToken) return;
const interval = setInterval(() => {
const expiredAt = Number(decryptData(downloadToken));
const date = new Date(expiredAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
const hours = Math.floor(diff / 1000 / 60 / 60);
const minutes = Math.floor(diff / 1000 / 60) % 60;
const seconds = Math.floor(diff / 1000) % 60;
setExpiredIn(`${hours}:${minutes}:${seconds}`);
}, 1000);
return () => clearInterval(interval);
}, [downloadToken]);
useEffect(() => {
new Promise(async (resolve) => {
const Preview = getPreviewComponent(props.data.file?.fileExtension ?? "unknown", props.data.file?.mimeType ?? "");
setPreviewComponent(<Preview file={props.data.file!} />);
await new Promise((resolve) => setTimeout(resolve, 50));
resolve(true);
}).then(() => {
setIsViewLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
setShowPasswordLayout(props.isProtected);
}, [props.isProtected]);
useEffect(() => {
const paths = router.asPath.split("/").filter((path) => path !== "");
const newPaths: Record<"label" | "path", string>[] = [];
paths.forEach((path, index) => {
newPaths.push({
label: path,
path: `/${paths.slice(0, index + 1).join("/")}`,
});
});
setBreadcrumbPaths(newPaths);
}, [router]);
return (
<div className='w-full flex h-full flex-col gap-4 relative'>
{/* Top */}
<Header showPasswordLayout={showPasswordLayout} />
<div className='w-full h-px bg-primary-500' />
{/* Header */}
{!showPasswordLayout && (
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
{/* Breadcrumb */}
<Breadcrumb paths={breadcrumbPaths} />
</div>
)}
{/* Content */}
{showPasswordLayout ? (
<PasswordLayout
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 2] ?? "/")}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<div className={twMerge("w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4")}>
<div className={twMerge("col-span-full desktop:col-span-8 overflow-auto")}>
{isViewLoading ? (
<Loading />
) : (
<div className='w-full flex flex-col gap-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 p-4 mb-4 rounded-lg overflow-auto ${poppins.className}`}
>
{PreviewComponent}
</div>
</div>
)}
</div>
<div className='col-span-full desktop:col-span-4'>
<div className='sticky top-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 mb-4 rounded-lg overflow-auto ${poppins.className}`}
>
<div className='flex items-center gap-2'>
<Icon
icon={"ion:information-circle"}
className='w-6 h-6'
/>
<h2 className='text-2xl font-medium'>File info</h2>
</div>
<div className='w-full h-px bg-primary-500' />
<div className='flex flex-col gap-2 w-full'>
{fileMetadata.map((item) => (
<div
className='grid grid-cols-3'
key={`${item.key}-${item.label.toLowerCase()}`}
>
<span className='col-span-1 font-bold'>{item.label}</span>
<p className='col-span-2 break-all whitespace-pre-line'>{item.value ?? "-"}</p>
</div>
))}
</div>
</div>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 my-4 rounded-lg overflow-auto ${poppins.className}`}
>
<Link
href={generatedDownloadLink(data.file!, props.isFileProtected, false, downloadToken)}
target='_blank'
rel='noopener noreferrer'
>
<Button
fullWidth
variant='accent'
startIcon={"ion:download"}
rounded='large'
>
Download
</Button>
</Link>
<Button
fullWidth
variant='primary'
startIcon={"ion:copy"}
rounded='large'
onClick={() => {
try {
const url = generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken);
navigator.clipboard.writeText(url);
toast.success("URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy URL to clipboard.");
console.error(error.message);
}
}}
>
Copy URL
</Button>
<div className='w-full px-2 bg-primary-950 outline-none rounded-lg border border-primary-500 select-all'>
<div className='overflow-x-scroll py-2 whitespace-nowrap'>
{generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken)}
</div>
</div>
{!gIndexConfig.apiConfig.allowDownloadProtectedFile && props.isFileProtected && (
<span className='text-center mt-4 text-sm'>
This file is inside protected folder.
<br />
Download link will be expired in <b>{expiredIn}</b>.
</span>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}
-124
View File
@@ -1,124 +0,0 @@
import { ReactNode, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
interface ModalProps {
children: ReactNode;
open: boolean;
onClose: () => void;
title: string;
footer?: ReactNode;
keepMounted?: boolean;
}
export default function Modal(props: ModalProps) {
let DELAY = 10;
const [showModal, setShowModal] = useState<boolean>(props.open);
const [keepMounted, setKeepMounted] = useState<boolean>(false);
const [shouldRender, setShouldRender] = useState<boolean>(false);
const [shouldUnmount, setShouldUnmount] = useState<boolean>(true);
useEffect(() => {
const handleClose = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.onClose();
}
};
window.addEventListener("keydown", handleClose);
return () => {
window.removeEventListener("keydown", handleClose);
};
}, [props]);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (showModal && !shouldRender) {
timeoutId = setTimeout(() => {
setShouldRender(true);
}, DELAY);
} else if (!showModal && shouldRender) {
timeoutId = setTimeout(() => {
setShouldRender(false);
}, DELAY);
}
return () => {
clearTimeout(timeoutId);
};
}, [showModal, shouldRender, DELAY]);
useEffect(() => {
setShowModal(props.open);
if (props.open) setShouldUnmount(false);
}, [props.open]);
useEffect(() => {
if (showModal && props.keepMounted) setKeepMounted(true);
}, [showModal, props.keepMounted]);
const handleClose = () => {
setShowModal(false);
if (!keepMounted) setKeepMounted(false);
props.onClose();
};
// useEffect(() => {
// if (!showModal && !keepMounted && shouldRender) {
// const timeout = setTimeout(() => {
// setShouldUnmount(true);
// }, 250);
// return () => {
// clearTimeout(timeout);
// };
// } else {
// setShouldUnmount(false);
// }
// }, [showModal, keepMounted, shouldRender, DELAY]);
if (shouldUnmount) return null;
return (
<ClickAway
open={showModal}
onClickAway={handleClose}
className={`fixed grid place-items-center top-0 left-0 z-[60] w-full h-full transition-smooth ${
shouldRender ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
onTransitionEnd={() => {
if (!showModal && !keepMounted) {
setShouldUnmount(true);
} else {
setShouldUnmount(false);
}
}}
>
<div
className='w-full px-4 max-w-screen-lg'
onClick={(e) => {
e.stopPropagation();
}}
>
<div className='bg-primary-900 text-primary-50 rounded-lg py-2 px-4 flex flex-col gap-2 w-full'>
{/* Header */}
<div className='flex flex-row items-center justify-between py-2'>
<span className='text-xl font-medium w-full line-clamp-1'>{props.title}</span>
<ButtonIcon
icon={"ion:close"}
onClick={handleClose}
/>
</div>
<div className='w-full h-px bg-primary-500' />
{/* Body */}
<div className='py-2'>{props.children}</div>
{/* Footer */}
{props.footer && <div className='py-2'>{props.footer}</div>}
</div>
</div>
</ClickAway>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { useState } from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function AudioPreview(props: PreviewProps) {
const [isError, setIsError] = useState<boolean>(false);
return (
<div className='w-full h-full'>
{isError ? (
<ErrorPreview />
) : (
<AudioPlayer
src={generatedDownloadLink(props.file, false)}
showJumpControls={false}
showFilledVolume
style={{
borderRadius: "0.375rem",
}}
onError={() => setIsError(true)}
/>
)}
</div>
);
}
-55
View File
@@ -1,55 +0,0 @@
import axios from "axios";
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import Markdown from "components/Markdown";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function CodePreview(props: PreviewProps) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
useEffect(() => {
axios
.get(generatedDownloadLink(props.file, false))
.then((res) => {
const isObject = typeof res.data === "object";
setContent(
`\`\`\`${props.file.fileExtension}\n${isObject ? JSON.stringify(res.data, null, 2) : res.data}\`\`\``,
);
setIsLoading(false);
})
.catch((err) => {
console.error(err);
setIsError(true);
});
}, [props.file, props.file.fileExtension]);
return (
<div className='w-full h-full'>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
)}
</div>
);
}
-59
View File
@@ -1,59 +0,0 @@
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
import gIndexConfig from "config";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
function LoadingRender() {
return (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
);
}
function ErrorRender() {
return <ErrorPreview />;
}
export default function DocumentPreview(props: PreviewProps) {
return (
<div className='w-full h-full'>
<DocViewer
documents={[
{
uri: generatedDownloadLink(props.file, false, true),
},
]}
pluginRenderers={DocViewerRenderers}
config={{
header: {
disableHeader: true,
},
loadingRenderer: {
overrideComponent: LoadingRender,
},
noRenderer: {
overrideComponent: ErrorRender,
},
}}
className='min-h-[70vh] max-h-[70vh] w-full h-full rounded-md'
theme={{
disableThemeScrollbar: true,
}}
/>
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
interface ErrorPreviewProps {
error?: string;
}
export default function ErrorPreview(props: ErrorPreviewProps) {
return (
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
<h2 className='text-2xl font-bold'>Failed to load file preview</h2>
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
Error has been occured while loading preview, it may be caused by{" "}
<span className='font-medium underline'>invalid file type</span> or{" "}
<span className='font-medium underline'>network error</span>.
<br />
You can try again later or download the file to view.
</span>
{props.error && (
<>
<span className='font-medium underline'>Error details</span>
<span className='text-sm tablet:text-base text-primary-300 my-2'>{props.error}</span>
</>
)}
</div>
);
}
-82
View File
@@ -1,82 +0,0 @@
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function ImagePreview(props: PreviewProps) {
const [imgSrc, setImgSrc] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
setIsError(false);
setIsLoading(true);
const timeout = setTimeout(() => {
setIsError(true);
setErrorMessage("Image took too long to load");
return;
}, 15000);
fetch(generatedDownloadLink(props.file, false))
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.blob();
})
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
setImgSrc(reader.result as string);
};
reader.readAsDataURL(blob);
})
.catch((err) => {
console.error(err);
setIsError(true);
setErrorMessage(err.message);
})
.finally(() => {
setIsLoading(false);
clearTimeout(timeout);
});
return () => clearTimeout(timeout);
}, [props.file]);
return (
<>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading image...</span>
</div>
</div>
) : (
<>
{isError ? (
<ErrorPreview />
) : (
<img
src={imgSrc}
alt={props.file.name}
className='w-full h-full rounded-md'
/>
)}
</>
)}
</>
);
}
-124
View File
@@ -1,124 +0,0 @@
import axios from "axios";
import gIndexConfig from "config";
import JSZip from "jszip";
import { useCallback, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function MangaPreview(props: PreviewProps) {
const [maxPageIndex, setMaxPageIndex] = useState<number>(4);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [viewPageIndex, setViewPageIndex] = useState<number>(0);
const [images, setImages] = useState<string[]>([]);
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
useEffect(() => {
const mangaImages: string[] = [];
axios
.get(generatedDownloadLink(props.file, false, false, null, true), {
responseType: "blob",
})
.then(async (res) => {
const zipData = await JSZip.loadAsync(res.data);
for (let i = 0; i <= maxPageIndex; i++) {
const file = zipData.file(Object.keys(zipData.files)[i]);
if (file) {
const blob = await file.async("blob");
const reader = new FileReader();
reader.onload = () => {
mangaImages.push(reader.result as string);
};
reader.readAsDataURL(blob);
}
}
setImages(mangaImages);
setMaxPageIndex(mangaImages.length);
})
.catch((err) => {
console.error(err);
setIsError(true);
})
.finally(() => {
setIsLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.file]);
const handleCurrentView = useCallback(
(pageNumber: number) => {
if (pageNumber < 0 || pageNumber > maxPageIndex) return;
setViewPageIndex(pageNumber);
},
[maxPageIndex],
);
return (
<div className='w-full h-full'>
<blockquote className='w-full h-auto flex items-center my-2 bg-primary-800 border-l-4 border-l-accent-500 pl-3 pr-2 py-2 text-sm tablet:text-base rounded-md'>
Preview only shows the first 5 pages. Please download the file to view the rest.
</blockquote>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>
{isError ? (
<ErrorPreview />
) : (
<div className='flex flex-col'>
<div className='w-full h-full relative'>
<img
src={images[viewPageIndex]}
alt={`${props.file.name} - page ${viewPageIndex}`}
className={`w-full h-full object-contain bg-primary-950 rounded-md ${
viewSize === "fit" ? "max-h-[70vh]" : ""
}`}
/>
<ButtonIcon
icon={viewSize === "fit" ? "ion:expand" : "ion:contract"}
className='absolute bottom-2 right-2'
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
/>
</div>
<div className='flex flex-row gap-2 items-center justify-center my-2'>
<ButtonIcon
variant='transparent'
icon={"ion:chevron-back"}
onClick={() => handleCurrentView(viewPageIndex - 1)}
disabled={viewPageIndex === 0}
/>
<span>
{viewPageIndex + 1} / {maxPageIndex + 1}
</span>
<ButtonIcon
variant='transparent'
icon={"ion:chevron-forward"}
onClick={() => handleCurrentView(viewPageIndex + 1)}
disabled={viewPageIndex === maxPageIndex}
/>
</div>
</div>
)}
</>
)}
</div>
);
}
-52
View File
@@ -1,52 +0,0 @@
import axios from "axios";
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import Markdown from "components/Markdown";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function RichPreview(props: PreviewProps) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
useEffect(() => {
axios
.get(generatedDownloadLink(props.file))
.then((res) => {
setContent(res.data);
setIsLoading(false);
})
.catch((err) => {
console.error(err);
setIsError(true);
});
}, [props.file]);
return (
<div className='w-full h-full'>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
)}
</div>
);
}
-12
View File
@@ -1,12 +0,0 @@
export default function UnknownPreview() {
return (
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
<h2 className='text-2xl font-bold'>File preview not available</h2>
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
This file type is not supported for preview.
<br />
You can download the file to view.
</span>
</div>
);
}
-36
View File
@@ -1,36 +0,0 @@
import { useState } from "react";
import ReactPlayer from "react-player";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function VideoPreview(props: PreviewProps) {
const [isError, setIsError] = useState<boolean>(false);
return (
<div className='w-full h-full'>
{isError ? (
<ErrorPreview />
) : (
<ReactPlayer
key={props.file.encryptedId}
url={generatedDownloadLink(props.file, false)}
controls
pip
wrapper={({ children }) => (
<div className='w-full h-[60vh] bg-primary-950 rounded-md overflow-hidden'>{children}</div>
)}
style={{
width: "100%",
height: "100%",
maxHeight: "60vh",
}}
onError={() => setIsError(true)}
/>
)}
</div>
);
}
-99
View File
@@ -1,99 +0,0 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { Popper, PopperProps } from "react-popper";
interface TooltipProps {
children: ReactNode;
content: ReactNode;
placement?: PopperProps<unknown>["placement"];
delay?: number;
}
export default function Tooltip(props: TooltipProps) {
const [refElement, setRefElement] = useState<HTMLElement | null>(null);
const [showTooltip, setShowTooltip] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
function handleMouseEnter() {
if (props.delay) {
timeoutRef.current = window.setTimeout(() => {
setShowTooltip(true);
}, props.delay);
} else {
setShowTooltip(true);
}
}
function handleMouseLeave() {
if (props.delay) {
window.clearTimeout(timeoutRef.current ?? undefined);
}
setShowTooltip(false);
}
function handleScroll() {
setShowTooltip(false);
}
if (refElement) {
refElement.addEventListener("mouseenter", handleMouseEnter);
refElement.addEventListener("mouseleave", handleMouseLeave);
refElement.addEventListener("click", handleMouseLeave);
window.addEventListener("scroll", handleScroll);
}
return () => {
if (refElement) {
refElement.removeEventListener("mouseenter", handleMouseEnter);
refElement.removeEventListener("mouseleave", handleMouseLeave);
refElement.removeEventListener("click", handleMouseLeave);
window.removeEventListener("scroll", handleScroll);
}
};
}, [refElement, props.delay]);
return (
<>
<div ref={setRefElement}>{props.children}</div>
<Popper
placement={props.placement ?? "top"}
modifiers={[
{
name: "offset",
options: {
offset: [0, 10],
},
},
{
name: "hide",
enabled: true,
phase: "main",
requires: ["computeStyles"],
fn: ({ state }) => {
const { height, width, x, y } = state.rects.reference;
const { innerHeight, innerWidth } = window;
state.styles.popper = {
...state.styles.popper,
visibility: height + y > innerHeight || width + x > innerWidth ? "hidden" : "visible",
};
},
},
]}
referenceElement={refElement as HTMLElement}
strategy='absolute'
>
{({ ref, style, placement }) => (
<div
ref={ref}
style={style}
data-placement={placement}
className={`bg-primary-800 max-w-sm text-primary-50 text-sm font-medium px-2 py-1 rounded-md shadow-md transition-smooth ${
showTooltip ? `opacity-100 pointer-events-auto` : "opacity-0 pointer-events-none"
}`}
>
{props.content}
</div>
)}
</Popper>
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => (
<nav
ref={ref}
aria-label='breadcrumb'
{...props}
/>
));
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-zinc-500 dark:text-zinc-400 sm:gap-2.5",
className,
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn(
"transition-colors hover:text-zinc-950 dark:hover:text-zinc-50",
className,
)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role='link'
aria-disabled='true'
aria-current='page'
className={cn("font-normal text-zinc-950 dark:text-zinc-50", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role='presentation'
aria-hidden='true'
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role='presentation'
aria-hidden='true'
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+57
View File
@@ -0,0 +1,57 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/utils";
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",
{
variants: {
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",
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",
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",
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",
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",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+89
View File
@@ -0,0 +1,89 @@
import * as React from "react";
import { cn } from "~/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("p-6 pt-0", className)}
{...props}
/>
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
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,
};
+121
View File
@@ -0,0 +1,121 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
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",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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",
className,
)}
{...props}
>
{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'>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+117
View File
@@ -0,0 +1,117 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "~/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className='mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted' />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
+199
View File
@@ -0,0 +1,199 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
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",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className='ml-auto h-4 w-4' />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
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",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
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",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
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",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
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",
className,
)}
checked={checked}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
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",
className,
)}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className='h-2 w-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "~/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
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",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "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 };
+235
View File
@@ -0,0 +1,235 @@
"use client";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className,
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
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",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
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",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className='ml-auto h-4 w-4' />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
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",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref,
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
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",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
),
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
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",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
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",
className,
)}
checked={checked}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
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",
className,
)}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator>
<Circle className='h-2 w-2 fill-current' />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};
+159
View File
@@ -0,0 +1,159 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
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",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className='h-4 w-4 opacity-50' />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className='h-4 w-4' />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className='h-4 w-4' />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"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" &&
"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,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
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",
className,
)}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<SelectPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+30
View File
@@ -0,0 +1,30 @@
"use client";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from "~/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+139
View File
@@ -0,0 +1,139 @@
"use client";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
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",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
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",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{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'>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "~/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react";
import { cn } from "~/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className='relative w-full overflow-auto'>
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn("[&_tr]:border-b", className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
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 };
+128
View File
@@ -0,0 +1,128 @@
"use client";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
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]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
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",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
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",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
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",
className,
)}
toast-close=''
{...props}
>
<X className='h-4 w-4' />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
+38
View File
@@ -0,0 +1,38 @@
"use client";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "~/components/ui/toast";
import { useToast } from "~/components/ui/use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast
key={id}
{...props}
>
<div className='grid gap-1'>
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "~/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
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",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+191
View File
@@ -0,0 +1,191 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "~/components/ui/toast";
const TOAST_LIMIT = 3;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { toast, useToast };

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