mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
basic layout
This commit is contained in:
+3
-5
@@ -1,10 +1,7 @@
|
||||
"use strict";
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require("prettier-plugin-tailwindcss"),
|
||||
require("@trivago/prettier-plugin-sort-imports"),
|
||||
],
|
||||
printWidth: 120,
|
||||
plugins: [require("@trivago/prettier-plugin-sort-imports"), require("prettier-plugin-tailwindcss")],
|
||||
printWidth: 80,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
@@ -30,4 +27,5 @@ module.exports = {
|
||||
],
|
||||
importOrderSeparation: true,
|
||||
importOrderSortSpecifiers: true,
|
||||
tailwindFunctions: ["twsx", "cn"],
|
||||
};
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
<a href='https://github.com/mbahArip/next-gdrive-index/wiki/FAQ'>FAQ</a>
|
||||
</p>
|
||||
|
||||
## Version 2
|
||||
|
||||
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.
|
||||
|
||||
Here's what changed in version 2:
|
||||
|
||||
- Upgrade to `Next.js 14` app directory structure.
|
||||
- Use `shadcn/ui` for the UI components.
|
||||
- Add light and dark mode.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+28
-27
@@ -1,34 +1,35 @@
|
||||
/** @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 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"],
|
||||
// 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
// 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 = withMDX(nextConfig);
|
||||
module.exports = nextConfig;
|
||||
|
||||
+54
-15
@@ -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.0",
|
||||
"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",
|
||||
"lucide-react": "^0.363.0",
|
||||
"next": "^14.1.4",
|
||||
"next-themes": "^0.3.0",
|
||||
"nextjs-toploader": "^1.6.11",
|
||||
"react": "^18.2.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-h5-audio-player": "^3.8.6",
|
||||
"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",
|
||||
@@ -50,15 +90,14 @@
|
||||
"@types/react": "18.0.35",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@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",
|
||||
"eslint-config-next": "^14.1.4",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-tailwindcss": "^0.2.7",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss": "3.3.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "5.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
|
||||
const [showToTopButton, setShowToTopButton] = useState<boolean>(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState<boolean>(false);
|
||||
const [clearPasswordModalOpen, setClearPasswordModalOpen] = useState<boolean>(false);
|
||||
const [clearPasswordModalOpen, setClearPasswordModalOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -85,7 +86,7 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<div
|
||||
id='next-gdrive-index'
|
||||
className={`${kanit.className} flex flex-col tablet:flex-row w-full`}
|
||||
className={`${kanit.className} flex w-full flex-col tablet:flex-row`}
|
||||
>
|
||||
<DefaultSeo
|
||||
defaultTitle={gIndexConfig.siteConfig.siteName}
|
||||
@@ -125,8 +126,8 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<nav className='w-full fixed top-0 z-50 tablet:w-20 h-12 tablet:min-h-screen bg-primary-950 text-primary-50 flex flex-row tablet:flex-col items-center justify-between'>
|
||||
<div className='w-full tablet:w-20 h-12 tablet:min-h-screen px-4 tablet:py-4 bg-primary-950 relative z-50 flex flex-row tablet:flex-col items-center justify-between'>
|
||||
<nav className='bg-primary-950 text-primary-50 fixed top-0 z-50 flex h-12 w-full flex-row items-center justify-between tablet:min-h-screen tablet:w-20 tablet:flex-col'>
|
||||
<div className='bg-primary-950 relative z-50 flex h-12 w-full flex-row items-center justify-between px-4 tablet:min-h-screen tablet:w-20 tablet:flex-col tablet:py-4'>
|
||||
<Link
|
||||
href='/'
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
@@ -138,20 +139,22 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
<img
|
||||
src={gIndexConfig.siteConfig.siteIcon}
|
||||
alt={gIndexConfig.siteConfig.siteName}
|
||||
className='w-8 tablet:w-10 h-8 tablet:h-10'
|
||||
className='h-8 w-8 tablet:h-10 tablet:w-10'
|
||||
loading='eager'
|
||||
/>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
<div className='hidden tablet:flex flex-col gap-4'>
|
||||
<div className='hidden flex-col gap-4 tablet:flex'>
|
||||
{gIndexConfig.siteConfig.navbarItems.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
key={`navbar-${link.name}`}
|
||||
target={link.external ? "_blank" : undefined}
|
||||
rel={link.external ? "noopener noreferrer" : undefined}
|
||||
className={`flex flex-col items-center justify-center opacity-80 hover:opacity-100 transition-smooth ${
|
||||
router.pathname === link.href ? "opacity-100 cursor-default" : "cursor-pointer"
|
||||
className={`transition-smooth flex flex-col items-center justify-center opacity-80 hover:opacity-100 ${
|
||||
router.pathname === link.href
|
||||
? "cursor-default opacity-100"
|
||||
: "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<Tooltip
|
||||
@@ -165,7 +168,7 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
</Tooltip>
|
||||
</Link>
|
||||
))}
|
||||
<div className='w-full h-px bg-primary-500' />
|
||||
<div className='bg-primary-500 h-px w-full' />
|
||||
<Tooltip
|
||||
content='Clear password'
|
||||
placement='right'
|
||||
@@ -173,12 +176,12 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
<ButtonIcon
|
||||
icon={"ion:log-out"}
|
||||
variant='transparent'
|
||||
className='opacity-80 hover:opacity-100 transition-smooth'
|
||||
className='transition-smooth opacity-80 hover:opacity-100'
|
||||
onClick={() => setClearPasswordModalOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='tablet:hidden h-full flex items-center'>
|
||||
<div className='flex h-full items-center tablet:hidden'>
|
||||
<Button
|
||||
variant='transparent'
|
||||
pill
|
||||
@@ -192,8 +195,8 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
color='white'
|
||||
className={`transition-smooth absolute ${
|
||||
isMobileMenuOpen
|
||||
? "opacity-0 rotate-90 pointer-events-none"
|
||||
: "opacity-100 rotate-0 pointer-events-auto"
|
||||
? "pointer-events-none rotate-90 opacity-0"
|
||||
: "pointer-events-auto rotate-0 opacity-100"
|
||||
}`}
|
||||
/>
|
||||
<Icon
|
||||
@@ -203,8 +206,8 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
color='white'
|
||||
className={`transition-smooth absolute ${
|
||||
isMobileMenuOpen
|
||||
? "opacity-100 rotate-0 pointer-events-auto"
|
||||
: "opacity-0 rotate-90 pointer-events-none"
|
||||
? "pointer-events-auto rotate-0 opacity-100"
|
||||
: "pointer-events-none rotate-90 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
@@ -213,20 +216,28 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
<ClickAway
|
||||
open={isMobileMenuOpen}
|
||||
onClickAway={() => setIsMobileMenuOpen(false)}
|
||||
className={`transition-smooth w-full h-screen block tablet:hidden absolute top-0 z-40 ${
|
||||
isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
className={`transition-smooth absolute top-0 z-40 block h-screen w-full tablet:hidden ${
|
||||
isMobileMenuOpen
|
||||
? "pointer-events-auto opacity-100"
|
||||
: "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className={`transition-smooth ${isMobileMenuOpen ? "translate-y-0" : "-translate-y-full"}`}>
|
||||
<div className='h-fit flex px-4 w-full flex-col gap-4 py-16 pb-4 bg-primary-950 drop-shadow-md z-40'>
|
||||
<div
|
||||
className={`transition-smooth ${
|
||||
isMobileMenuOpen ? "translate-y-0" : "-translate-y-full"
|
||||
}`}
|
||||
>
|
||||
<div className='bg-primary-950 z-40 flex h-fit w-full flex-col gap-4 px-4 py-16 pb-4 drop-shadow-md'>
|
||||
{gIndexConfig.siteConfig.navbarItems.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
key={`navbar-${link.name}`}
|
||||
target={link.external ? "_blank" : undefined}
|
||||
rel={link.external ? "noopener noreferrer" : undefined}
|
||||
className={`flex items-center justify-start gap-4 w-full opacity-80 hover:opacity-100 transition-smooth ${
|
||||
router.pathname === link.href ? "opacity-100 cursor-default" : "cursor-pointer"
|
||||
className={`transition-smooth flex w-full items-center justify-start gap-4 opacity-80 hover:opacity-100 ${
|
||||
router.pathname === link.href
|
||||
? "cursor-default opacity-100"
|
||||
: "cursor-pointer"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
// If href === current page, prevent default
|
||||
@@ -262,20 +273,20 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
</nav>
|
||||
|
||||
<main
|
||||
className={`w-full min-h-screen bg-primary-950 text-primary-50 flex flex-col tablet:pl-20 pl-0 pt-12 tablet:pt-0`}
|
||||
className={`bg-primary-950 text-primary-50 flex min-h-screen w-full flex-col pl-0 pt-12 tablet:pl-20 tablet:pt-0`}
|
||||
>
|
||||
<div className='flex-grow w-full h-full flex'>
|
||||
<div className='flex h-full w-full flex-grow'>
|
||||
<Component {...pageProps} />
|
||||
</div>
|
||||
|
||||
<footer className='w-full h-8 text-center text-sm py-8 flex items-center justify-center'>
|
||||
<footer className='flex h-8 w-full items-center justify-center py-8 text-center text-sm'>
|
||||
<span>
|
||||
Powered by{" "}
|
||||
<Link
|
||||
href={"https://www.github.com/mbaharip/next-gdrive-index"}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='underline underline-offset-8 hover:underline-offset-1 decoration-wavy decoration-transparent hover:decoration-accent-400 text-accent-400 transition-smooth'
|
||||
className='hover:decoration-accent-400 text-accent-400 transition-smooth underline decoration-transparent decoration-wavy underline-offset-8 hover:underline-offset-1'
|
||||
>
|
||||
next-gdrive-index
|
||||
</Link>
|
||||
@@ -297,7 +308,9 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
size='large'
|
||||
className={twMerge(
|
||||
"fixed right-4 z-50",
|
||||
showToTopButton ? "opacity-100 pointer-events-auto bottom-4" : "opacity-0 pointer-events-none bottom-0",
|
||||
showToTopButton
|
||||
? "pointer-events-auto bottom-4 opacity-100"
|
||||
: "pointer-events-none bottom-0 opacity-0",
|
||||
)}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
/>
|
||||
@@ -311,9 +324,12 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-lg'>Are you sure you want to clear your password?</span>
|
||||
<span className='text-sm text-primary-400'>
|
||||
You will need to re-enter your password to access protected folders / files.
|
||||
<span className='text-lg'>
|
||||
Are you sure you want to clear your password?
|
||||
</span>
|
||||
<span className='text-primary-400 text-sm'>
|
||||
You will need to re-enter your password to access protected
|
||||
folders / files.
|
||||
</span>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import FileGrid from "./@file.grid";
|
||||
import FileList from "./@file.list";
|
||||
import { GetFiles } from "./actions";
|
||||
|
||||
type Props = {
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
};
|
||||
export default function FileBrowser({ files, nextPageToken }: Props) {
|
||||
const { layout } = useContext(LayoutContext);
|
||||
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 = new Set([...fileList, ...data.files]);
|
||||
setFileList(Array.from(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",
|
||||
// layout === "grid"
|
||||
// ? "grid grid-cols-1 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4"
|
||||
// : "flex flex-col",
|
||||
)}
|
||||
>
|
||||
{!fileList.length && (
|
||||
<div className='col-span-full flex flex-col items-center justify-center py-6'>
|
||||
<p className='large'>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
/>
|
||||
</p>
|
||||
<p className='muted'>No files found</p>
|
||||
</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='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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
import Icon from "~/components/Icon";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function FileGrid({ data }: Props) {
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)].join("/");
|
||||
|
||||
// If start with /, remove it
|
||||
if (path.startsWith("/")) {
|
||||
return path.slice(1);
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
}, [data, pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 bg-muted/25'>
|
||||
{data.thumbnailLink &&
|
||||
(data.mimeType.startsWith("video") ||
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<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'
|
||||
/>
|
||||
|
||||
{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-50'
|
||||
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-balance break-all text-center'>
|
||||
{data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<Separator />
|
||||
<div className='muted flex items-center justify-between gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{bytesToReadable(data.size || 0)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import config from "~/config/gIndex.config";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
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 path = [pathname, encodeURIComponent(data.name)].join("/");
|
||||
|
||||
// If start with /, remove it
|
||||
if (path.startsWith("/")) {
|
||||
return path.slice(1);
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
}, [data, pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"w-full",
|
||||
"px-1.5 py-1",
|
||||
"rounded-[var(--radius)]",
|
||||
"flex 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='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-12 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover'
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder")
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-12 flex-shrink-0 flex-grow-0 p-3'
|
||||
/>
|
||||
)}
|
||||
</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") ? null : data.mimeType}
|
||||
</span>
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={14}
|
||||
/>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{bytesToReadable(data.size || 0)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.promise(
|
||||
navigator.clipboard.writeText(
|
||||
new URL(filePath, config.basePath).toString(),
|
||||
),
|
||||
{
|
||||
loading: "Copying link...",
|
||||
success: "Link copied!",
|
||||
error: "Failed to copy link",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={data.mimeType.includes("folder")}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token)
|
||||
throw new Error("Failed to create download token");
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(
|
||||
`/api/download/${data.encryptedId}?token=${token}`,
|
||||
);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"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 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 config from "~/config/gIndex.config";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import { Schema_Breadcrumb } from "~/schema";
|
||||
|
||||
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='my-1.5 h-6 w-1/2' />;
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-nowrap items-center'>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href={"/"}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon
|
||||
name='FolderRoot'
|
||||
size={16}
|
||||
/>
|
||||
~
|
||||
</div>
|
||||
</BreadcrumbLink>
|
||||
|
||||
{!!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
|
||||
href={array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}
|
||||
className='w-full'
|
||||
>
|
||||
{item.label}
|
||||
</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}`}
|
||||
>
|
||||
<Link
|
||||
href={array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}
|
||||
className='w-full py-1.5'
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</div>
|
||||
<DrawerFooter className='pt-4'>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
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
|
||||
href={data
|
||||
.map((item) => item.href)
|
||||
.join("/")
|
||||
.replace(/\/\//g, "/")}
|
||||
>
|
||||
{item.label}
|
||||
</BreadcrumbLink>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbPage className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.label}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{item.href && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, useContext } from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { LayoutContext, TLayout } from "~/context/layoutContext";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
export default function HeaderButton({
|
||||
children,
|
||||
loading,
|
||||
}: PropsWithChildren<{ loading?: boolean }>) {
|
||||
const { layout, setLayout } = useContext(LayoutContext);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("w-full", "flex items-center justify-end gap-1.5")}>
|
||||
<Skeleton className='my-0.5 h-8 w-16 tablet:w-24' />{" "}
|
||||
<Skeleton className='my-0.5 h-8 w-8' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto max-w-screen-desktop flex-grow",
|
||||
"flex items-center justify-end gap-1.5",
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
value={layout}
|
||||
onValueChange={(value) => setLayout(value as TLayout)}
|
||||
>
|
||||
<SelectTrigger className='w-16 tablet:w-24'>
|
||||
<SelectValue placeholder={"Layout"}>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Icon
|
||||
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
||||
size={16}
|
||||
/>
|
||||
<span className={cn("hidden", "tablet:block")}>
|
||||
{layout === "grid" ? "Grid" : "List"}
|
||||
</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align='end'>
|
||||
<SelectItem value='grid'>Grid</SelectItem>
|
||||
<SelectItem value='list'>List</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Dialog>
|
||||
<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 flex-col'>
|
||||
<Input placeholder='Input your query...' />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"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";
|
||||
import HeaderButton from "./@header.button";
|
||||
|
||||
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";
|
||||
@@ -9,11 +10,50 @@ import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkSlug from "remark-slug";
|
||||
import remarkToc from "remark-toc";
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
import Button from "components/Button";
|
||||
import "./highlight.css";
|
||||
|
||||
import "styles/highlight.css";
|
||||
import "styles/markdown.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 +65,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 +80,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 +91,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 +109,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
"use client";
|
||||
|
||||
import { TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
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 config from "~/config/gIndex.config";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { ClearPassword } from "./actions";
|
||||
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const { theme, themes, setTheme } = useTheme();
|
||||
|
||||
const [open, setOpen] = 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);
|
||||
return "All saved password cleared successfully!";
|
||||
},
|
||||
error: (error) => error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative left-0 top-0 z-50",
|
||||
"h-12 w-full",
|
||||
// "tablet:h-screen tablet:w-20",
|
||||
"flex-shrink-0",
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
slot='nav'
|
||||
className={cn(
|
||||
"bg-background",
|
||||
"fixed left-0 top-0",
|
||||
"h-12 w-full",
|
||||
// "tablet:h-screen tablet:w-20",
|
||||
"px-6 py-3",
|
||||
// "tablet:px-3 tablet:py-6",
|
||||
"flex flex-row items-center justify-between",
|
||||
// "tablet:flex-col",
|
||||
"border-b border-border",
|
||||
// "tablet:border-b-0 tablet:border-r",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-between",
|
||||
"mx-auto w-full max-w-screen-desktop",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Link
|
||||
href={"/"}
|
||||
onClick={() => setOpen(false)}
|
||||
className='flex items-center gap-3'
|
||||
>
|
||||
<img
|
||||
src={config.siteConfig.siteIcon}
|
||||
alt={config.siteConfig.siteName}
|
||||
className={cn("h-6 w-6 invert dark:invert-0")}
|
||||
loading='eager'
|
||||
/>
|
||||
<span className={cn("large", "hidden", "tablet:block")}>
|
||||
{config.siteConfig.siteName}
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Back to Root folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className={cn("hidden", "tablet: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}>
|
||||
<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>
|
||||
This action will clear all saved password from your browser.
|
||||
<br />
|
||||
You will need to re-enter the password for each
|
||||
password-protected folder.
|
||||
<br />
|
||||
<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={cn("flex", "tablet:hidden")}>
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name='Menu'
|
||||
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'>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
className='gap-3'
|
||||
size={"sm"}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogTitle>Clear all saved password?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action will clear all saved password from your
|
||||
browser.
|
||||
<br />
|
||||
You will need to re-enter the password for each
|
||||
password-protected folder.
|
||||
<br />
|
||||
<br />
|
||||
<b>Are you sure you want to continue?</b>
|
||||
</DialogDescription>
|
||||
<DialogFooter className='gap-1.5'>
|
||||
<DialogClose asChild>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={onClearPassword}
|
||||
>
|
||||
Clear all saved password
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { CheckSitePassword, SetSitePassword } from "./actions";
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
export default function Password({ path, errorMessage }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
toast.loading("Checking password...", {
|
||||
id: "password",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!input) throw new Error("Password is required");
|
||||
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);
|
||||
|
||||
toast.success("Password accepted! Refreshing...", {
|
||||
id: "password",
|
||||
});
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: "password",
|
||||
});
|
||||
} finally {
|
||||
setInput("");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ encryptedId }: { encryptedId: string },
|
||||
) {
|
||||
return NextResponse.json({
|
||||
encryptedId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import config from "~/config/gIndex.config";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { Constant } from "~/types/constant";
|
||||
import { decryptData, encryptData } from "~/utils/encryptionHelper/hash";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
|
||||
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,
|
||||
});
|
||||
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 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);
|
||||
const filterName: string = config.apiConfig.hiddenFiles
|
||||
.map((item) => `name != '${item}'`)
|
||||
.join(" and ");
|
||||
const query: string = [
|
||||
...config.apiConfig.defaultQuery,
|
||||
// `'1owGkP0NsJ4am8vVYebpjnoxzY5VOc7Vh' in parents`,
|
||||
decryptedId
|
||||
? `'${decryptedId}' in parents`
|
||||
: `'${config.apiConfig.rootFolder}' 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),
|
||||
}
|
||||
: 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 GetReadme(
|
||||
encryptedId: string | undefined,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
let decryptedId: string | undefined;
|
||||
if (encryptedId) decryptedId = await decryptData(encryptedId);
|
||||
else decryptedId = 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}), nextPageToken`,
|
||||
orderBy: config.apiConfig.defaultOrder,
|
||||
pageSize: config.apiConfig.itemsPerPage,
|
||||
pageToken: undefined,
|
||||
});
|
||||
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 CreateDownloadToken(
|
||||
duration: number = config.apiConfig.temporaryTokenDuration,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const data = {
|
||||
expiredAt: Date.now() + duration * 60 * 60 * 1000,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+131
-131
@@ -1,141 +1,141 @@
|
||||
import gIndexConfig from "config";
|
||||
import { drive_v3 } from "googleapis";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
// 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 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";
|
||||
// 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(),
|
||||
};
|
||||
}
|
||||
// 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"]);
|
||||
// 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");
|
||||
// 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 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)) ?? [];
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
// data = {
|
||||
// ...data,
|
||||
// files: listFiles,
|
||||
// folders: listFolders,
|
||||
// pageToken: folderContent.data.nextPageToken ?? null,
|
||||
// };
|
||||
// }
|
||||
|
||||
const payload: APIGetFileResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: Date.now() - reqStart,
|
||||
data,
|
||||
};
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
// return NextResponse.json(payload, {
|
||||
// status: 200,
|
||||
// headers: {
|
||||
// "Cache-Control": gIndexConfig.cacheControl,
|
||||
// },
|
||||
// });
|
||||
// } catch (error: any) {
|
||||
// const res: ErrorResponse = {
|
||||
// timestamp: Date.now(),
|
||||
// responseTime: Date.now() - reqStart,
|
||||
// error: {
|
||||
// code: error.code || 500,
|
||||
// message: error.message,
|
||||
// reason: error.errors?.[0].reason,
|
||||
// },
|
||||
// };
|
||||
// return NextResponse.json(res, {
|
||||
// status: error.code || 500,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import config from "~/config/gIndex.config";
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
|
||||
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, thumbnailLink",
|
||||
supportsAllDrives: config.apiConfig.isTeamDrive,
|
||||
});
|
||||
if (!data.thumbnailLink) throw new Error("No thumbnail for this file");
|
||||
return NextResponse.redirect(data.thumbnailLink);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: e.message,
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
message: "Hello World!",
|
||||
});
|
||||
}
|
||||
@@ -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 bg-primary/50 hover:bg-primary/75;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Metadata } from "next";
|
||||
import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
|
||||
import config from "~/config/gIndex.config";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
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.siteIcon,
|
||||
},
|
||||
],
|
||||
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(
|
||||
"bg-background font-sans text-foreground",
|
||||
jetbrainsMono.variable,
|
||||
sourceSans3.variable,
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-screen w-full overflow-x-hidden",
|
||||
"flex flex-col items-start",
|
||||
// "tablet:flex-row",
|
||||
)}
|
||||
>
|
||||
<Navbar />
|
||||
<main
|
||||
slot='content'
|
||||
className={cn(
|
||||
"mx-auto h-full min-h-screen 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)} />
|
||||
)}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import FileBrowser from "./@explorer";
|
||||
import Header from "./@header";
|
||||
import Markdown from "./@markdown";
|
||||
import { GetFiles, GetReadme } from "./actions";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
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'>
|
||||
<CardTitle>Browse files</CardTitle>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemeProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { TooltipProvider } from "~/components/ui/tooltip";
|
||||
import config from "~/config/gIndex.config";
|
||||
import LayoutProvider from "~/context/layoutContext";
|
||||
|
||||
export default function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<NextThemeProvider {...props}>
|
||||
<TooltipProvider delayDuration={250}>{children}</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-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 sm:gap-2.5 dark:text-zinc-400",
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,79 @@
|
||||
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 }
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-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,
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"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,
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-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,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 }
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { Check, ChevronRight, Circle } from "lucide-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,
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-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,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,117 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-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,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,190 @@
|
||||
"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 };
|
||||
+130
-24
@@ -1,7 +1,14 @@
|
||||
import { icons } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import { ToastPosition } from "react-hot-toast";
|
||||
import colors from "tailwindcss/colors";
|
||||
|
||||
const config: gIndexConfig = {
|
||||
version: "1.0.0",
|
||||
/**
|
||||
* If possible, please don't change this value
|
||||
* Even if you're creating a PR, just let me change it myself
|
||||
*/
|
||||
version: "2.0",
|
||||
/**
|
||||
* Base path of the app, used for generating links
|
||||
* If you're not using Vercel, you need to change this to your domain name
|
||||
@@ -11,15 +18,17 @@ const config: gIndexConfig = {
|
||||
* @default process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
*/
|
||||
basePath:
|
||||
process.env.NODE_ENV === "development" ? "http://localhost:3000" : `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`,
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://localhost:3000"
|
||||
: `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`,
|
||||
|
||||
/**
|
||||
* DEPRECATED
|
||||
*
|
||||
* Hashed key for fetching protected files / folders from the server
|
||||
* This key will bypass the file / folder password
|
||||
*
|
||||
* 2023/09/10: This is not used anymore
|
||||
*/
|
||||
masterKey: "masterkey",
|
||||
// masterKey: "masterkey",
|
||||
|
||||
/**
|
||||
* How long the cache will be stored in the browser
|
||||
@@ -41,12 +50,15 @@ const config: gIndexConfig = {
|
||||
*/
|
||||
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j",
|
||||
isTeamDrive: false, // Set this to true if you're using Team Drive
|
||||
defaultQuery: ["trashed = false", "(not mimeType contains 'google-apps' or mimeType contains 'folder')"],
|
||||
defaultQuery: [
|
||||
"trashed = false",
|
||||
"(not mimeType contains 'google-apps' or mimeType contains 'folder')",
|
||||
],
|
||||
defaultField:
|
||||
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed",
|
||||
defaultOrder: "folder, name asc, modifiedTime desc",
|
||||
itemsPerPage: 50,
|
||||
searchResult: 5,
|
||||
itemsPerPage: 12,
|
||||
searchResult: 10,
|
||||
|
||||
/**
|
||||
* Special file name that will be used for certain purposes
|
||||
@@ -95,15 +107,55 @@ const config: gIndexConfig = {
|
||||
|
||||
siteConfig: {
|
||||
/**
|
||||
* This value will be used for metadata
|
||||
* Site Name will be used for default metadata title
|
||||
* Site Name Template will be used if the page has a title
|
||||
* %s will be replaced with the page title
|
||||
*
|
||||
* You can set it to undefined if you don't want to use it
|
||||
*/
|
||||
siteName: "next-gdrive-index",
|
||||
siteNameTemplate: "%s - next-gdrive-index",
|
||||
siteDescription: "A simple file browser for Google Drive",
|
||||
siteIcon: "/flaticon.svg",
|
||||
siteAuthor: "mbaharip",
|
||||
favIcon: "/favicon.svg",
|
||||
/**
|
||||
* Next.js Metadata robots object
|
||||
*
|
||||
* ref: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#robots
|
||||
*/
|
||||
robots: undefined,
|
||||
twitterHandle: "@mbaharip_",
|
||||
|
||||
/**
|
||||
* Footer content
|
||||
* You can use string or array of string for multiple lines
|
||||
* You can also set it to empty array if you don't want to use it
|
||||
*
|
||||
* Basic markdown is supported (bold, italic, and link)
|
||||
* External link will be opened in new tab
|
||||
*
|
||||
* Template:
|
||||
* - {{ year }} will be replaced with the current year
|
||||
* - {{ repository }} will be replaced with the original repository link
|
||||
* - {{ author }} will be replaced with author from siteAuthor config above (If it's not set, it will be set to mbaharip)
|
||||
* - {{ version }} will be replaced with the current version
|
||||
* - {{ siteName }} will be replaced with the siteName config above
|
||||
* - {{ creator }} will be replaced with mbaharip if you want to credit me
|
||||
*/
|
||||
footer: [
|
||||
"{{ siteName }} *v{{ version }}* @ {{ repository }}",
|
||||
"{{ year }} - Made with ❤️ by **{{ author }}**",
|
||||
],
|
||||
|
||||
/**
|
||||
* DEPRECATED
|
||||
* Since we're using shadcn/ui now, please refer to their theming documentation
|
||||
* https://ui.shadcn.com/docs/theming
|
||||
*
|
||||
* Or you can use their themes, and replace the color in /src/app/globals.css
|
||||
* https://ui.shadcn.com/themes
|
||||
*
|
||||
* Tailwind color name.
|
||||
* Ref: https://tailwindcss.com/docs/customizing-colors
|
||||
*/
|
||||
@@ -116,12 +168,29 @@ const config: gIndexConfig = {
|
||||
* The site password are set from Environment Variable (NEXT_GDRIVE_INDEX_PASSWORD)
|
||||
* It's because I don't want to store sensitive data in the code
|
||||
*/
|
||||
privateIndex: false,
|
||||
privateIndex: true,
|
||||
|
||||
/**
|
||||
* Maximum breadcrumb length
|
||||
* If the breadcrumb is longer than this, it will be shortened
|
||||
*/
|
||||
breadcrumbMax: 3,
|
||||
|
||||
/**
|
||||
* Toast notification configuration
|
||||
*
|
||||
* position: Self-explanatory
|
||||
* duration: duration before the toast disappear in milliseconds
|
||||
*/
|
||||
toaster: {
|
||||
position: "bottom-right",
|
||||
duration: 3000,
|
||||
},
|
||||
|
||||
/**
|
||||
* Example item:
|
||||
* {
|
||||
* icon: string, // icon name from iconify (https://icon-sets.iconify.design/)
|
||||
* icon: string, // icon name from lucide icons (https://lucide.dev/icons/)
|
||||
* name: string,
|
||||
* href: string,
|
||||
* external?: boolean
|
||||
@@ -129,29 +198,50 @@ const config: gIndexConfig = {
|
||||
*/
|
||||
navbarItems: [
|
||||
{
|
||||
icon: "ion:document-text",
|
||||
icon: "FileText",
|
||||
name: "Documentation",
|
||||
href: "https://github.com/mbahArip/next-gdrive-index/wiki",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
icon: "ion:logo-github",
|
||||
icon: "Github",
|
||||
name: "Github",
|
||||
href: "https://www.github.com/mbaharip",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
icon: "ion:logo-paypal",
|
||||
name: "Buy me a coffee",
|
||||
href: "https://www.paypal.me/mbaharip",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
icon: "ion:mail",
|
||||
icon: "Mail",
|
||||
name: "Contact",
|
||||
href: "mailto:support@mbaharip.com",
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Add support / donation links on the navbar
|
||||
* Example item:
|
||||
* {
|
||||
* name: string,
|
||||
* currency: string,
|
||||
* href: string,
|
||||
* }
|
||||
*/
|
||||
supports: [
|
||||
{
|
||||
name: "Paypal",
|
||||
currency: "USD",
|
||||
href: "https://paypal.me/mbaharip",
|
||||
},
|
||||
{
|
||||
name: "Ko-fi",
|
||||
currency: "USD",
|
||||
href: "https://ko-fi.com/mbaharip",
|
||||
},
|
||||
{
|
||||
name: "Saweria",
|
||||
currency: "IDR",
|
||||
href: "https://saweria.co/mbaharip",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -160,7 +250,7 @@ export default config;
|
||||
interface gIndexConfig {
|
||||
version: string;
|
||||
basePath: string;
|
||||
masterKey: string;
|
||||
// masterKey: string;
|
||||
cacheControl: string;
|
||||
|
||||
apiConfig: {
|
||||
@@ -185,20 +275,36 @@ interface gIndexConfig {
|
||||
};
|
||||
siteConfig: {
|
||||
siteName: string;
|
||||
siteNameTemplate?: string;
|
||||
siteDescription: string;
|
||||
siteIcon: string;
|
||||
siteAuthor?: string;
|
||||
favIcon: string;
|
||||
twitterHandle: string;
|
||||
robots?: Metadata["robots"];
|
||||
twitterHandle?: string;
|
||||
|
||||
defaultAccentColor: keyof typeof colors;
|
||||
footer: string | string[];
|
||||
|
||||
defaultAccentColor?: keyof typeof colors;
|
||||
|
||||
privateIndex: boolean;
|
||||
breadcrumbMax: number;
|
||||
|
||||
toaster?: {
|
||||
position?: ToastPosition;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
navbarItems: {
|
||||
icon: string;
|
||||
icon: keyof typeof icons;
|
||||
name: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}[];
|
||||
supports: {
|
||||
name: string;
|
||||
currency: string;
|
||||
href: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { createContext, useEffect, useState } from "react";
|
||||
|
||||
export type TLayout = "grid" | "list";
|
||||
|
||||
@@ -36,9 +32,15 @@ const LayoutProvider = ({ children }: TLayoutProvider) => {
|
||||
setLayout(getLayoutFromLocalStorage());
|
||||
}, []);
|
||||
|
||||
const onChangeLayout = (layout: TLayout) => {
|
||||
setLayout(layout);
|
||||
localStorage.setItem("layout", layout);
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={{ layout, setLayout }}>
|
||||
<LayoutContext.Provider value={{ layout, setLayout: onChangeLayout }}>
|
||||
{children}
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
export default LayoutProvider;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useMediaQuery(query: string) {
|
||||
const [value, setValue] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onChange(event: MediaQueryListEvent) {
|
||||
setValue(event.matches);
|
||||
}
|
||||
|
||||
const result = matchMedia(query);
|
||||
result.addEventListener("change", onChange);
|
||||
setValue(result.matches);
|
||||
|
||||
return () => {
|
||||
result.removeEventListener("change", onChange);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const Schema_Breadcrumb = z.object({
|
||||
label: z.string(),
|
||||
href: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Schema_File = z.object({
|
||||
mimeType: z.string(),
|
||||
encryptedId: z.string(),
|
||||
name: z.string(),
|
||||
trashed: z.boolean(),
|
||||
modifiedTime: z.string(),
|
||||
fileExtension: z.string().optional(),
|
||||
encryptedWebContentLink: z.string().optional().nullable(),
|
||||
size: z.coerce.number().optional(),
|
||||
thumbnailLink: z.string().url().optional().nullable(),
|
||||
imageMediaMetadata: z
|
||||
.object({
|
||||
width: z.coerce.number(),
|
||||
height: z.coerce.number(),
|
||||
rotation: z.coerce.number(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
videoMediaMetadata: z
|
||||
.object({
|
||||
width: z.coerce.number(),
|
||||
height: z.coerce.number(),
|
||||
durationMillis: z.coerce.number(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
@@ -2,23 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--animation-duration: 300ms;
|
||||
--animation-timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
--accent-50: 240 249 255;
|
||||
--accent-100: 224 242 254;
|
||||
--accent-200: 186 230 253;
|
||||
--accent-300: 125 211 252;
|
||||
--accent-400: 56 189 248;
|
||||
--accent-500: 14 165 233;
|
||||
--accent-600: 2 132 199;
|
||||
--accent-700: 3 105 161;
|
||||
--accent-800: 7 89 133;
|
||||
--accent-900: 12 74 110;
|
||||
--accent-950: 8 47 73;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum Constant {
|
||||
cookies_FolderPassword = "next-gdrive-index-folder-password",
|
||||
cookies_SitePassword = "next-gdrive-index-password",
|
||||
storage_ViewType = "next-gdrive-index-view-type",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,23 +1,12 @@
|
||||
export function durationToReadable(
|
||||
durationMillis: number,
|
||||
): string {
|
||||
export function durationToReadable(durationMillis: number): string {
|
||||
const duration = durationMillis / 1000;
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration % 3600) / 60);
|
||||
const seconds = Math.floor(duration % 60);
|
||||
|
||||
const hStr =
|
||||
hours > 0
|
||||
? `${hours.toString().padStart(2, "0")}:`
|
||||
: "";
|
||||
const mStr =
|
||||
minutes > 0
|
||||
? `${minutes.toString().padStart(2, "0")}:`
|
||||
: "";
|
||||
const sStr =
|
||||
seconds > 0
|
||||
? `${seconds.toString().padStart(2, "0")}`
|
||||
: "00";
|
||||
const hStr = hours > 0 ? `${hours.toString().padStart(2, "0")}:` : "";
|
||||
const mStr = minutes > 0 ? `${minutes.toString().padStart(2, "0")}:` : "00:";
|
||||
const sStr = seconds > 0 ? `${seconds.toString().padStart(2, "0")}` : "00";
|
||||
|
||||
return `${hStr}${mStr}${sStr}`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
|
||||
const generateKey = () => {
|
||||
@@ -14,25 +16,16 @@ const generateKey = () => {
|
||||
const key = generateKey();
|
||||
const iv = Buffer.from(key);
|
||||
|
||||
export function encryptData(data: string): string {
|
||||
const cipher = crypto.createCipheriv(
|
||||
"aes-128-cbc",
|
||||
key,
|
||||
iv,
|
||||
export async function encryptData(data: string): Promise<string> {
|
||||
const cipher = crypto.createCipheriv("aes-128-cbc", key, iv);
|
||||
return Buffer.concat([cipher.update(data, "utf-8"), cipher.final()]).toString(
|
||||
"hex",
|
||||
);
|
||||
return Buffer.concat([
|
||||
cipher.update(data, "utf-8"),
|
||||
cipher.final(),
|
||||
]).toString("hex");
|
||||
}
|
||||
|
||||
export function decryptData(hash: string): string {
|
||||
export async function decryptData(hash: string): Promise<string> {
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-128-cbc",
|
||||
key,
|
||||
iv,
|
||||
);
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(hash, "hex"),
|
||||
|
||||
@@ -17,9 +17,7 @@ const serviceB64: {
|
||||
auth_provider_x509_cert_url: string;
|
||||
client_x509_cert_url: string;
|
||||
universe_domain: string;
|
||||
} = JSON.parse(
|
||||
base64Decode(process.env.GD_SERVICE_B64 as string),
|
||||
);
|
||||
} = JSON.parse(base64Decode(process.env.GD_SERVICE_B64 as string));
|
||||
// const serviceAccount = {
|
||||
// email: process.env.GD_SERVICE_EMAIL,
|
||||
// key: (process.env.GD_SERVICE_PVKEY as string).replace(
|
||||
@@ -55,6 +53,13 @@ if (!gdrive) {
|
||||
gdrive = google.drive({
|
||||
version: "v3",
|
||||
auth: serviceAccountAuth,
|
||||
fetchImplementation: (url, init) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
next: {
|
||||
revalidate: 3600,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+63
-48
@@ -1,22 +1,36 @@
|
||||
import AudioPreview from "components/Preview/Audio";
|
||||
import CodePreview from "components/Preview/Code";
|
||||
import DocumentPreview from "components/Preview/Document";
|
||||
import ImagePreview from "components/Preview/Image";
|
||||
import MangaPreview from "components/Preview/Manga";
|
||||
import RichPreview from "components/Preview/Rich";
|
||||
import UnknownPreview from "components/Preview/Unknown";
|
||||
import VideoPreview from "components/Preview/Video";
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { PreviewProps } from "types/api/files";
|
||||
|
||||
type FileTypes = "image" | "video" | "audio" | "markdown" | "pdf" | "document" | "code" | "text" | "manga";
|
||||
type FileTypes =
|
||||
| "image"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "markdown"
|
||||
| "pdf"
|
||||
| "document"
|
||||
| "code"
|
||||
| "text"
|
||||
| "manga"
|
||||
| "executable";
|
||||
const extensionsMap: Record<FileTypes, string[]> = {
|
||||
image: ["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "avif"],
|
||||
video: ["mp4", "mkv", "webm", "avi", "mov", "flv", "wmv", "mpg", "mpeg", "m4v", "3gp"],
|
||||
video: [
|
||||
"mp4",
|
||||
"mkv",
|
||||
"webm",
|
||||
"avi",
|
||||
"mov",
|
||||
"flv",
|
||||
"wmv",
|
||||
"mpg",
|
||||
"mpeg",
|
||||
"m4v",
|
||||
"3gp",
|
||||
],
|
||||
audio: ["mp3", "ogg", "wav", "flac", "m4a", "wma", "aac"],
|
||||
markdown: ["md"],
|
||||
pdf: ["pdf"],
|
||||
document: ["doc", "docx", "xls", "xlsx", "ppt", "pptx"],
|
||||
executable: ["exe", "msi"],
|
||||
code: [
|
||||
"html",
|
||||
"css",
|
||||
@@ -56,44 +70,45 @@ const extensionsMap: Record<FileTypes, string[]> = {
|
||||
manga: ["cbz"],
|
||||
};
|
||||
|
||||
const previewMap: Record<FileTypes | "unknown", (props: PreviewProps) => JSX.Element> = {
|
||||
image: ImagePreview,
|
||||
audio: AudioPreview,
|
||||
video: VideoPreview,
|
||||
markdown: RichPreview,
|
||||
pdf: DocumentPreview,
|
||||
document: DocumentPreview,
|
||||
code: CodePreview,
|
||||
text: CodePreview,
|
||||
manga: MangaPreview,
|
||||
unknown: UnknownPreview,
|
||||
};
|
||||
const iconMap: Record<FileTypes | "unknown", string> = {
|
||||
image: "ion:image",
|
||||
audio: "ion:musical-notes",
|
||||
video: "ion:videocam",
|
||||
markdown: "ion:document-text",
|
||||
pdf: "ion:document-text",
|
||||
document: "ion:document-text",
|
||||
code: "ion:code-slash",
|
||||
text: "ion:document-text",
|
||||
manga: "ion:book",
|
||||
unknown: "ion:document",
|
||||
// const previewMap: Record<FileTypes | "unknown", (props: PreviewProps) => JSX.Element> = {
|
||||
// image: ImagePreview,
|
||||
// audio: AudioPreview,
|
||||
// video: VideoPreview,
|
||||
// markdown: RichPreview,
|
||||
// pdf: DocumentPreview,
|
||||
// document: DocumentPreview,
|
||||
// code: CodePreview,
|
||||
// text: CodePreview,
|
||||
// manga: MangaPreview,
|
||||
// unknown: UnknownPreview,
|
||||
// };
|
||||
const iconMap: Record<FileTypes | "unknown", keyof typeof icons> = {
|
||||
image: "Image",
|
||||
audio: "Music",
|
||||
video: "Video",
|
||||
markdown: "FileType",
|
||||
pdf: "FileText",
|
||||
document: "FileSpreadsheet",
|
||||
code: "Code",
|
||||
executable: "AppWindow",
|
||||
text: "Text",
|
||||
manga: "BookText",
|
||||
unknown: "File",
|
||||
};
|
||||
|
||||
export function getPreviewComponent(fileExtension: string, mimeType: string) {
|
||||
if (fileExtension === "ts") {
|
||||
if (mimeType.includes("video")) {
|
||||
return previewMap["video"];
|
||||
} else {
|
||||
return previewMap["code"];
|
||||
}
|
||||
}
|
||||
const type = Object.keys(extensionsMap).find((key) =>
|
||||
extensionsMap[key as keyof typeof extensionsMap].includes(fileExtension),
|
||||
);
|
||||
return previewMap[type as keyof typeof previewMap] ?? previewMap["unknown"];
|
||||
}
|
||||
// export function getPreviewComponent(fileExtension: string, mimeType: string) {
|
||||
// if (fileExtension === "ts") {
|
||||
// if (mimeType.includes("video")) {
|
||||
// return previewMap["video"];
|
||||
// } else {
|
||||
// return previewMap["code"];
|
||||
// }
|
||||
// }
|
||||
// const type = Object.keys(extensionsMap).find((key) =>
|
||||
// extensionsMap[key as keyof typeof extensionsMap].includes(fileExtension),
|
||||
// );
|
||||
// return previewMap[type as keyof typeof previewMap] ?? previewMap["unknown"];
|
||||
// }
|
||||
|
||||
export function getPreviewIcon(fileExtension: string, mimeType: string) {
|
||||
if (fileExtension === "ts") {
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import gIndexConfig from "config";
|
||||
|
||||
import {
|
||||
decryptData,
|
||||
encryptData,
|
||||
} from "./encryptionHelper/hash";
|
||||
import { decryptData, encryptData } from "./encryptionHelper/hash";
|
||||
|
||||
export function createDownloadToken(
|
||||
durationH: number = gIndexConfig.apiConfig
|
||||
.temporaryTokenDuration,
|
||||
) {
|
||||
export function createDownloadToken(durationH: number = gIndexConfig.apiConfig.temporaryTokenDuration) {
|
||||
const duration = durationH * 60 * 60 * 1000;
|
||||
const expiredAt = Date.now() + duration;
|
||||
return encryptData(expiredAt.toString());
|
||||
}
|
||||
|
||||
export function isDownloadTokenValid(
|
||||
token: string,
|
||||
): boolean {
|
||||
export function isDownloadTokenValid(token: string): boolean {
|
||||
try {
|
||||
const expiredAt = Number(decryptData(token));
|
||||
return expiredAt > Date.now();
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
const defaultColors = require("tailwindcss/colors");
|
||||
|
||||
const colors = {
|
||||
inherit: "inherit",
|
||||
transparent: "transparent",
|
||||
white: "#ffffff",
|
||||
black: "#000000",
|
||||
primary: defaultColors.neutral,
|
||||
accent: {
|
||||
50: "rgb(var(--accent-50) / <alpha-value>)",
|
||||
100: "rgb(var(--accent-100) / <alpha-value>)",
|
||||
200: "rgb(var(--accent-200) / <alpha-value>)",
|
||||
300: "rgb(var(--accent-300) / <alpha-value>)",
|
||||
400: "rgb(var(--accent-400) / <alpha-value>)",
|
||||
500: "rgb(var(--accent-500) / <alpha-value>)",
|
||||
600: "rgb(var(--accent-600) / <alpha-value>)",
|
||||
700: "rgb(var(--accent-700) / <alpha-value>)",
|
||||
800: "rgb(var(--accent-800) / <alpha-value>)",
|
||||
900: "rgb(var(--accent-900) / <alpha-value>)",
|
||||
950: "rgb(var(--accent-950) / <alpha-value>)",
|
||||
},
|
||||
|
||||
success: {
|
||||
...defaultColors.emerald,
|
||||
main: defaultColors.emerald[600],
|
||||
hover: defaultColors.emerald[500],
|
||||
active: defaultColors.emerald[700],
|
||||
text: defaultColors.emerald[50],
|
||||
},
|
||||
warning: {
|
||||
...defaultColors.amber,
|
||||
main: defaultColors.amber[600],
|
||||
hover: defaultColors.amber[500],
|
||||
active: defaultColors.amber[700],
|
||||
text: defaultColors.amber[50],
|
||||
},
|
||||
danger: {
|
||||
...defaultColors.red,
|
||||
main: defaultColors.red[600],
|
||||
hover: defaultColors.red[500],
|
||||
active: defaultColors.red[700],
|
||||
text: defaultColors.red[50],
|
||||
},
|
||||
info: {
|
||||
...defaultColors.blue,
|
||||
main: defaultColors.blue[600],
|
||||
hover: defaultColors.blue[500],
|
||||
active: defaultColors.blue[700],
|
||||
text: defaultColors.blue[50],
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
colors,
|
||||
extend: {
|
||||
minHeight: {
|
||||
dynamic: "100dvh",
|
||||
},
|
||||
minWidth: {
|
||||
dynamic: "100dvw",
|
||||
},
|
||||
height: {
|
||||
dynamic: "100dvh",
|
||||
},
|
||||
width: {
|
||||
dynamic: "100dvw",
|
||||
},
|
||||
screens: {
|
||||
mobile: "320px",
|
||||
tablet: "768px",
|
||||
desktop: "1280px",
|
||||
},
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
"checkerboard": "repeating-linear-gradient(45deg, #71717a, #71717a 25%, #52525b 25%, #52525b 50%)", // zinc-500 - zinc-600
|
||||
"checkerboard-dark": "repeating-linear-gradient(45deg, #71717a, #71717a 25%, #a1a1aa 25%, #a1a1aa 50%)", // zinc-500 - zinc-400
|
||||
},
|
||||
backgroundSize: {
|
||||
checkerboard: "128px 128px",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/typography"),
|
||||
function ({ addUtilities }) {
|
||||
const newUtilities = {
|
||||
".transition-smooth": {
|
||||
transitionDuration: "250ms",
|
||||
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
},
|
||||
};
|
||||
addUtilities(newUtilities);
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import tw from "tailwindcss/defaultTheme";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./pages/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-source-sans-3)", ...tw.fontFamily.sans],
|
||||
mono: ["var(--font-jetbrains-mono)", ...tw.fontFamily.mono],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
screens: {
|
||||
mobile: "425px",
|
||||
tablet: "768px",
|
||||
desktop: "1280px",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
};
|
||||
|
||||
export default config;
|
||||
+3
-13
@@ -20,6 +20,7 @@
|
||||
// "components/*": [
|
||||
// "app/(__components)/*"
|
||||
// ],
|
||||
"~/*": ["*"],
|
||||
"components/*": ["components/*"],
|
||||
"config": ["config/gIndex.config.ts"],
|
||||
"hooks/*": ["hooks/*"],
|
||||
@@ -34,17 +35,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.js",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".next",
|
||||
"**/*/_legacy/**/*",
|
||||
"**/*/_app/**/*"
|
||||
]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", ".next", "**/*/_legacy/**/*", "**/*/_app/**/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user