basic layout

This commit is contained in:
mbaharip
2024-03-31 00:38:18 +07:00
parent 2e8b6c390f
commit 77913a3688
93 changed files with 14471 additions and 8333 deletions
+3 -5
View File
@@ -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"],
};
+10
View File
@@ -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.
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/utils"
}
}
+28 -27
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "drive-manager",
"version": "0.1.0",
"name": "next-gdrive-index",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
@@ -12,24 +12,59 @@
},
"dependencies": {
"@cyntler/react-doc-viewer": "^1.13.0",
"@iconify/react": "^4.1.1",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "^13.4.19",
"@popperjs/core": "^2.11.8",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-menubar": "^1.0.4",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"axios": "^1.3.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.0.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"
}
}
+45 -29
View File
@@ -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>
+151
View File
@@ -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>
);
}
+115
View File
@@ -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>
</>
);
}
+180
View File
@@ -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>
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import ReactMarkdown from "react-markdown";
type Props = {
content: string;
};
export default function Footer({ content }: Props) {
return (
<footer className='w-full pb-3'>
<ReactMarkdown
className='flex w-full select-none flex-col items-center justify-center'
components={{
p: ({ node, children, ...props }) => (
<p
{...props}
className='muted text-balance text-sm'
>
{children}
</p>
),
a: ({ node, children, ...props }) => {
const isExternal = props.href?.startsWith("http");
return (
<a
{...props}
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
>
{children}
</a>
);
},
}}
>
{content}
</ReactMarkdown>
</footer>
);
}
+181
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+495
View File
@@ -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>
);
}
+118
View File
@@ -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,
});
}
+268
View File
@@ -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
View File
@@ -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,
// });
// }
// }
+37
View File
@@ -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,
},
);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
return NextResponse.json({
message: "Hello World!",
});
}
+117
View File
@@ -0,0 +1,117 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Shadcn/ui theme */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 89.8%;
/* --ring: 0 0% 3.9%; */
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 14.9%;
/* --ring: 0 0% 83.1%; */
}
}
html,
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@layer base {
:root {
@apply text-[14px] tablet:text-[16px];
}
* {
@apply border-border;
/* @apply outline outline-1 outline-red-500; */
}
body {
@apply bg-background text-foreground;
}
::-webkit-scrollbar {
@apply h-1.5 w-1.5;
}
::-webkit-scrollbar-track {
@apply bg-background;
}
::-webkit-scrollbar-thumb {
@apply 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;
}
}
+472
View File
@@ -0,0 +1,472 @@
:root {
/**
* One Light theme for prism.js
* Based on Atom's One Light theme: https://github.com/atom/atom/tree/master/packages/one-light-syntax
*/
/* From colors.less */
--mono-1: hsl(230, 8%, 24%);
--mono-2: hsl(230, 6%, 44%);
--mono-3: hsl(230, 4%, 64%);
--hue-1: hsl(198, 99%, 37%);
--hue-2: hsl(221, 87%, 60%);
--hue-3: hsl(301, 63%, 40%);
--hue-4: hsl(119, 34%, 47%);
--hue-5: hsl(5, 74%, 59%);
--hue-5-2: hsl(344, 84%, 43%);
--hue-6: hsl(35, 99%, 36%);
--hue-6-2: hsl(35, 99%, 40%);
--syntax-fg: hsl(230, 8%, 24%);
--syntax-bg: hsl(230, 1%, 98%);
--syntax-gutter: hsl(230, 1%, 62%);
--syntax-guide: hsla(230, 8%, 24%, 0.2);
--syntax-accent: hsl(230, 100%, 66%);
/* From syntax-variables.less */
--syntax-selection-color: hsl(230, 1%, 90%);
--syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
--syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
}
.dark {
/**
* One Dark theme for prism.js
* Based on Atom's One Dark theme: https://github.com/atom/atom/tree/master/packages/one-dark-syntax
*/
/* From colors.less */
--mono-1: hsl(220, 14%, 71%);
--mono-2: hsl(220, 9%, 55%);
--mono-3: hsl(220, 10%, 40%);
--hue-1: hsl(187, 47%, 55%);
--hue-2: hsl(207, 82%, 66%);
--hue-3: hsl(286, 60%, 67%);
--hue-4: hsl(95, 38%, 62%);
--hue-5: hsl(355, 65%, 65%);
--hue-5-2: hsl(5, 48%, 51%);
--hue-6: hsl(29, 54%, 61%);
--hue-6-2: hsl(39, 67%, 69%);
--syntax-fg: hsl(220, 14%, 71%);
--syntax-bg: hsl(220, 13%, 18%);
--syntax-gutter: hsl(220, 14%, 45%);
--syntax-guide: hsla(220, 14%, 71%, 0.15);
--syntax-accent: hsl(220, 100%, 66%);
/* From syntax-variables.less */
--syntax-selection-color: hsl(220, 13%, 28%);
--syntax-gutter-background-color-selected: hsl(220, 13%, 26%);
--syntax-cursor-line: hsla(220, 100%, 80%, 0.04);
}
code[class*="language-"],
pre[class*="language-"],
pre {
background: var(--syntax-bg);
color: var(--syntax-fg);
text-shadow: 0 1px rgba(0, 0, 0, 0);
font-size: inherit;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
line-height: 1.5;
-moz-tab-size: 2;
tab-size: 2;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Selection */
code[class*="language-"]::-moz-selection,
code[class*="language-"] *::-moz-selection,
pre[class*="language-"] *::-moz-selection {
background: var(--syntax-selection-color);
color: inherit;
text-shadow: none;
}
code[class*="language-"]::selection,
code[class*="language-"] *::selection,
pre[class*="language-"] *::selection {
background: var(--syntax-selection-color);
color: inherit;
text-shadow: none;
}
/* Code blocks */
pre[class*="language-"],
pre {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: 0.2em 0.3em;
border-radius: 0.3em;
white-space: normal;
}
/* Print */
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
.token.comment,
.token.prolog,
.token.cdata {
color: var(--mono-3);
}
.token.doctype,
.token.punctuation,
.token.entity {
color: var(--syntax-fg);
}
.token.attr-name,
.token.class-name,
.token.boolean,
.token.constant,
.token.number,
.token.atrule {
color: var(--hue-6);
}
.token.keyword {
color: var(--hue-3);
}
.token.property,
.token.tag,
.token.symbol,
.token.deleted,
.token.important {
color: var(--hue-5);
}
.token.selector,
.token.string,
.token.char,
.token.builtin,
.token.inserted,
.token.regex,
.token.attr-value,
.token.attr-value > .token.punctuation {
color: var(--hue-4);
}
.token.variable,
.token.operator,
.token.function {
color: var(--hue-2);
}
.token.url {
color: var(--hue-1);
}
/* HTML overrides */
.token.attr-value > .token.punctuation.attr-equals,
.token.special-attr > .token.attr-value > .token.value.css {
color: var(--syntax-fg);
}
/* CSS overrides */
.language-css .token.selector {
color: var(--hue-5);
}
.language-css .token.property {
color: var(--syntax-fg);
}
.language-css .token.function,
.language-css .token.url > .token.function {
color: var(--hue-1);
}
.language-css .token.url > .token.string.url {
color: var(--hue-4);
}
.language-css .token.important,
.language-css .token.atrule .token.rule {
color: var(--hue-3);
}
/* JS overrides */
.language-javascript .token.operator {
color: var(--hue-3);
}
.language-javascript
.token.template-string
> .token.interpolation
> .token.interpolation-punctuation.punctuation {
color: var(--hue-5-2);
}
/* JSON overrides */
.language-json .token.operator {
color: var(--syntax-fg);
}
.language-json .token.null.keyword {
color: var(--hue-6);
}
/* MD overrides */
.language-markdown .token.url,
.language-markdown .token.url > .token.operator,
.language-markdown .token.url-reference.url > .token.string {
color: var(--syntax-fg);
}
.language-markdown .token.url > .token.content {
color: var(--hue-2);
}
.language-markdown .token.url > .token.url,
.language-markdown .token.url-reference.url {
color: var(--hue-1);
}
.language-markdown .token.blockquote.punctuation,
.language-markdown .token.hr.punctuation {
color: var(--mono-3);
font-style: italic;
}
.language-markdown .token.code-snippet {
color: var(--hue-4);
}
.language-markdown .token.bold .token.content {
color: var(--hue-6);
}
.language-markdown .token.italic .token.content {
color: var(--hue-3);
}
.language-markdown .token.strike .token.content,
.language-markdown .token.strike .token.punctuation,
.language-markdown .token.list.punctuation,
.language-markdown .token.title.important > .token.punctuation {
color: var(--hue-5);
}
/* General */
.token.bold {
font-weight: bold;
}
.token.comment,
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.namespace {
opacity: 0.8;
}
/* Plugin overrides */
/* Selectors should have higher specificity than those in the plugins' default stylesheets */
/* Show Invisible plugin overrides */
.token.token.tab:not(:empty):before,
.token.token.cr:before,
.token.token.lf:before,
.token.token.space:before {
color: var(--syntax-guide);
text-shadow: none;
}
/* Toolbar plugin overrides */
/* Space out all buttons and move them away from the right edge of the code block */
div.code-toolbar > .toolbar.toolbar > .toolbar-item {
margin-right: 0.4em;
}
/* Styling the buttons */
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
background: var(--syntax-gutter-background-color-selected);
color: var(--mono-2);
padding: 0.1em 0.4em;
border-radius: 0.3em;
}
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
background: var(--syntax-selection-color);
color: var(--syntax-fg);
}
/* Line Highlight plugin overrides */
/* The highlighted line itself */
.line-highlight.line-highlight {
background: var(--syntax-cursor-line);
}
/* Default line numbers in Line Highlight plugin */
.line-highlight.line-highlight:before,
.line-highlight.line-highlight[data-end]:after {
background: var(--syntax-gutter-background-color-selected);
color: var(--syntax-fg);
padding: 0.1em 0.6em;
border-radius: 0.3em;
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */
}
/* Hovering over a linkable line number (in the gutter area) */
/* Requires Line Numbers plugin as well */
pre[id].linkable-line-numbers.linkable-line-numbers
span.line-numbers-rows
> span:hover:before {
background-color: var(--syntax-cursor-line);
}
/* Line Numbers and Command Line plugins overrides */
/* Line separating gutter from coding area */
.line-numbers.line-numbers .line-numbers-rows,
.command-line .command-line-prompt {
border-right-color: var(--syntax-guide);
}
/* Stuff in the gutter */
.line-numbers .line-numbers-rows > span:before,
.command-line .command-line-prompt > span:before {
color: var(--syntax-gutter);
}
/* Match Braces plugin overrides */
/* Note: Outline colour is inherited from the braces */
.rainbow-braces .token.token.punctuation.brace-level-1,
.rainbow-braces .token.token.punctuation.brace-level-5,
.rainbow-braces .token.token.punctuation.brace-level-9 {
color: var(--hue-5);
}
.rainbow-braces .token.token.punctuation.brace-level-2,
.rainbow-braces .token.token.punctuation.brace-level-6,
.rainbow-braces .token.token.punctuation.brace-level-10 {
color: var(--hue-4);
}
.rainbow-braces .token.token.punctuation.brace-level-3,
.rainbow-braces .token.token.punctuation.brace-level-7,
.rainbow-braces .token.token.punctuation.brace-level-11 {
color: var(--hue-2);
}
.rainbow-braces .token.token.punctuation.brace-level-4,
.rainbow-braces .token.token.punctuation.brace-level-8,
.rainbow-braces .token.token.punctuation.brace-level-12 {
color: var(--hue-3);
}
/* Diff Highlight plugin overrides */
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
pre.diff-highlight > code .token.token.deleted:not(.prefix),
pre > code.diff-highlight .token.token.deleted:not(.prefix) {
background-color: hsla(353, 100%, 66%, 0.15);
}
pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection {
background-color: hsla(353, 95%, 66%, 0.25);
}
pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
background-color: hsla(353, 95%, 66%, 0.25);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix),
pre > code.diff-highlight .token.token.inserted:not(.prefix) {
background-color: hsla(137, 100%, 55%, 0.15);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection,
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection,
pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection,
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection {
background-color: hsla(135, 73%, 55%, 0.25);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
background-color: hsla(135, 73%, 55%, 0.25);
}
/* Previewers plugin overrides */
/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */
/* Border around popup */
.prism-previewer.prism-previewer:before,
.prism-previewer-gradient.prism-previewer-gradient div {
border-color: hsl(224, 13%, 17%);
}
/* Angle and time should remain as circles and are hence not included */
.prism-previewer-color.prism-previewer-color:before,
.prism-previewer-gradient.prism-previewer-gradient div,
.prism-previewer-easing.prism-previewer-easing:before {
border-radius: 0.3em;
}
/* Triangles pointing to the code */
.prism-previewer.prism-previewer:after {
border-top-color: hsl(224, 13%, 17%);
}
.prism-previewer-flipped.prism-previewer-flipped.after {
border-bottom-color: hsl(224, 13%, 17%);
}
/* Background colour within the popup */
.prism-previewer-angle.prism-previewer-angle:before,
.prism-previewer-time.prism-previewer-time:before,
.prism-previewer-easing.prism-previewer-easing {
background: hsl(219, 13%, 22%);
}
/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */
/* For time, this is the alternate colour */
.prism-previewer-angle.prism-previewer-angle circle,
.prism-previewer-time.prism-previewer-time circle {
stroke: var(--syntax-fg);
stroke-opacity: 1;
}
/* Stroke colours of the handle, direction point, and vector itself */
.prism-previewer-easing.prism-previewer-easing circle,
.prism-previewer-easing.prism-previewer-easing path,
.prism-previewer-easing.prism-previewer-easing line {
stroke: var(--syntax-fg);
}
/* Fill colour of the handle */
.prism-previewer-easing.prism-previewer-easing circle {
fill: transparent;
}
+138
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+107
View File
@@ -0,0 +1,107 @@
@tailwind components;
@layer components {
.markdown {
@apply bg-card text-base leading-relaxed tracking-wide;
}
.markdown p {
@apply leading-loose;
}
/* .markdown h1 {
@apply text-4xl font-medium;
}
.markdown h2 {
@apply text-3xl font-medium;
}
.markdown h3 {
@apply text-2xl font-medium;
}
.markdown h4 {
@apply text-xl font-medium;
}
.markdown h5 {
@apply text-lg font-medium;
}
.markdown h6 {
@apply text-base;
} */
.markdown h1,
.markdown h2,
.markdown h3 {
@apply mt-4 border-b-2 border-b-border first-letter:capitalize;
}
.markdown code:not(pre code) {
@apply whitespace-nowrap rounded-[var(--radius)] bg-muted px-2 py-0.5 leading-normal text-foreground;
}
.markdown a {
@apply text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400;
}
.markdown p {
@apply whitespace-normal text-pretty;
}
.markdown img {
@apply rounded-[var(--radius)];
}
.markdown ul {
@apply my-1.5 list-disc leading-loose;
}
.markdown ol {
@apply my-1.5 list-decimal leading-loose;
}
.markdown li {
@apply my-0 ml-2;
}
.markdown table {
@apply mb-4 block w-full border-collapse border-spacing-0 overflow-auto;
}
.markdown table th,
.markdown table td {
@apply border border-border px-2 py-1;
}
.markdown table th {
@apply bg-background text-foreground;
}
.markdown table tr {
@apply bg-secondary/50 text-foreground;
}
.markdown table tr:nth-child(2n) {
@apply bg-secondary/25 text-foreground;
}
.markdown ul.contains-task-list {
@apply list-none;
}
.markdown ul.contains-task-list li {
@apply ml-1 flex items-center;
}
.markdown input[type="checkbox"] {
@apply mr-2 aspect-square h-4 w-4 rounded-full p-2;
@apply appearance-none border border-border;
@apply disabled:bg-primary disabled:checked:bg-blue-600 dark:disabled:checked:bg-blue-400;
}
.markdown blockquote {
@apply my-2 rounded-r-[var(--radius)] border-l-4 border-l-primary bg-muted py-4 pl-2 text-foreground;
}
.markdown hr {
@apply my-4 border-b border-t-0 border-b-border;
}
}
+56
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
-53
View File
@@ -1,53 +0,0 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { twMerge } from "tailwind-merge";
interface AlertProps {
variant?: "success" | "error" | "warning" | "info";
icon?: string | IconifyIcon;
title?: string;
message: string;
className?: string;
}
export default function Alert(props: AlertProps) {
const {
variant = "info",
icon,
title,
message,
className,
} = props;
return (
<div
className={twMerge(
"w-full h-fit flex flex-col rounded-lg gap-1 px-4 py-2",
variant === "success" &&
"bg-success-100 text-success-main",
variant === "error" &&
"bg-danger-100 text-danger-main",
variant === "warning" &&
"bg-warning-100 text-warning-main",
variant === "info" && "bg-info-100 text-info-main",
className && className,
)}
>
{(icon || title) && (
<div className='flex gap-1 items-center'>
{icon && (
<Icon
icon={icon}
color='currentColor'
width={24}
height={24}
/>
)}
{title && (
<span className='font-medium text-lg'>
{title}
</span>
)}
</div>
)}
<p className='text-sm'>{message}</p>
</div>
);
}
-165
View File
@@ -1,165 +0,0 @@
import { Icon, IconifyIcon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Fragment, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
interface BreadcrumbProps {
paths: Record<"label" | "path", string>[];
separator?: string | IconifyIcon;
maxPath?: number;
}
export default function Breadcrumb(props: BreadcrumbProps) {
const router = useRouter();
const {
paths,
separator = "ion:chevron-forward",
maxPath = 2,
} = props;
const [breadcrumbPaths, setBreadcrumbPaths] =
useState<Record<"label" | "path", string>[]>(paths);
const [isPathSliced, setIsPathSliced] =
useState<boolean>(false);
const [hiddenPaths, setHiddenPaths] = useState<
Record<"label" | "path", string>[]
>([]);
const [menuAnchor, setMenuAnchor] =
useState<HTMLElement | null>(null);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
useEffect(() => {
if (paths.length > maxPath) {
const newPaths = paths.slice(
paths.length - maxPath,
paths.length,
);
const hiddenPaths = paths.slice(
0,
paths.length - maxPath,
);
setBreadcrumbPaths(newPaths);
setIsPathSliced(true);
setHiddenPaths(hiddenPaths);
} else {
setBreadcrumbPaths(paths);
setIsPathSliced(false);
}
}, [paths, maxPath]);
return (
<>
<div className='flex items-center flex-wrap w-full gap-2'>
<Link href={"/"}>
<ButtonIcon
icon={"ion:home"}
variant='transparent'
className='flex-shrink-0 flex-grow-0 p-1'
/>
{/* <Icon
icon='ion:home'
color='currentColor'
width={20}
height={20}
className='flex-shrink-0 flex-grow-0'
/> */}
</Link>
{isPathSliced && (
<>
<Icon
icon={separator}
color='currentColor'
width={20}
height={20}
/>
<ButtonIcon
icon={"ion:ellipsis-horizontal"}
variant='transparent'
className='flex-shrink-0 flex-grow-0 p-1'
ref={setMenuAnchor}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
{/* <span className='mx-1'>...</span> */}
</>
)}
{breadcrumbPaths.map((path, index) => (
<Fragment key={`breadcrumb-${path}@${index}`}>
<Icon
icon={separator}
color='currentColor'
width={20}
height={20}
className='flex-shrink-0 flex-grow-0'
/>
<Link
href={path.path}
className={`${
path.path === router.asPath
? "text-primary-50 pointer-events-none cursor-default"
: "text-primary-300 hover:text-primary-100 cursor-pointer"
} transition-smooth max-w-md mx-1 w-fit ${
index === breadcrumbPaths.length - 1 &&
"line-clamp-1"
}`}
>
{decodeURIComponent(path.label)}
</Link>
</Fragment>
))}
</div>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-full max-w-sm bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen
? `opacity-100 pointer-events-auto`
: `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor
? menuAnchor?.getBoundingClientRect()
.bottom ?? 0
: "unset",
left: menuAnchor
? menuAnchor?.getBoundingClientRect().left ??
0
: "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{hiddenPaths.map((path, index) => (
<Link
href={path.path}
key={`hidden-path-${path}@${index}`}
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2 w-full'
title={decodeURIComponent(path.label)}
>
{decodeURIComponent(path.label)}
</Link>
))}
</div>
</div>
</ClickAway>
</>
);
}
-121
View File
@@ -1,121 +0,0 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
fullWidth?: boolean;
variant?:
| "primary"
| "accent"
| "success"
| "danger"
| "warning"
| "info"
| "transparent";
size?: "small" | "medium" | "large";
pill?: boolean;
square?: boolean;
rounded?: "none" | "small" | "medium" | "large";
startIcon?: string | IconifyIcon;
endIcon?: string | IconifyIcon;
children: ReactNode;
loading?: boolean;
className?: string;
}
export default function Button(props: ButtonProps) {
const {
fullWidth = false,
variant = "primary",
size = "medium",
pill = false,
square = false,
rounded = "none",
loading = false,
startIcon,
endIcon,
children,
className,
...rest
} = props;
return (
<button
className={twMerge(
"outline-none border-none capitalize transition-smooth",
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
"rounded-lg w-fit flex items-center justify-center flex-grow",
fullWidth && "w-full",
rounded === "none" && "rounded-none",
rounded === "small" && "rounded-sm",
rounded === "medium" && "rounded-md",
rounded === "large" && "rounded-lg",
pill && "rounded-full",
size === "small" && "px-2 py-1 text-sm gap-1",
size === "medium" && "px-4 py-2 text-base gap-2",
size === "large" && "px-8 py-4 text-lg gap-4",
square && "aspect-square",
variant === "primary" &&
"bg-primary-600 text-primary-50 hover:bg-primary-500 active:bg-primary-700",
variant === "accent" &&
"bg-accent-600 text-accent-50 hover:bg-accent-500 active:bg-accent-700",
variant === "success" &&
"bg-success-600 text-success-50 hover:bg-success-500 active:bg-success-700",
variant === "danger" &&
"bg-danger-600 text-danger-50 hover:bg-danger-500 active:bg-danger-700",
variant === "warning" &&
"bg-warning-600 text-warning-50 hover:bg-warning-500 active:bg-warning-700",
variant === "info" &&
"bg-info-600 text-info-50 hover:bg-info-500 active:bg-info-700",
variant === "transparent" &&
"bg-transparent text-primary-50 hover:bg-primary-500 active:bg-primary-700",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-primary-700 disabled:hover:bg-primary-700 disabled:active:bg-primary-700 disabled:text-primary-50",
className && className,
)}
disabled={loading ?? props.disabled}
{...rest}
>
{props.loading ? (
<Icon
icon={"mdi:loading"}
color='white'
className={twMerge(
"w-5 h-5 animate-spin",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
) : (
<>
{startIcon && (
<Icon
icon={startIcon}
color='white'
className={twMerge(
"w-5 h-5",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
)}
{children}
{endIcon && (
<Icon
icon={endIcon}
color='white'
className={twMerge(
"w-5 h-5",
size === "small" && "w-4 h-4",
size === "medium" && "w-5 h-5",
size === "large" && "w-6 h-6",
)}
/>
)}
</>
)}
</button>
);
}
-36
View File
@@ -1,36 +0,0 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonGroupProps {
children: ReactNode;
fullWidth?: boolean;
rounded?: "none" | "small" | "medium" | "large";
className?: string;
}
export default function ButtonGroup(
props: ButtonGroupProps,
) {
const {
children,
fullWidth = false,
rounded = "large",
className,
} = props;
return (
<div
className={twMerge(
"flex flex-row items-center justify-center overflow-hidden",
fullWidth && "w-full",
rounded === "none" && "rounded-none",
rounded === "small" && "rounded-sm",
rounded === "medium" && "rounded-md",
rounded === "large" && "rounded-lg",
className && className,
)}
>
{children}
</div>
);
}
-58
View File
@@ -1,58 +0,0 @@
import { Icon, IconifyIcon } from "@iconify/react";
import { ForwardRefRenderFunction, forwardRef } from "react";
import { twMerge } from "tailwind-merge";
interface ButtonIconProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: string | IconifyIcon;
size?: "small" | "medium" | "large";
variant?: "primary" | "accent" | "success" | "danger" | "warning" | "info" | "transparent";
className?: string;
}
const ButtonIcon: ForwardRefRenderFunction<HTMLButtonElement, ButtonIconProps> = (props, ref) => {
const { icon, size = "medium", variant = "primary", className, ...rest } = props;
let iconSize = 24;
switch (size) {
case "small":
iconSize = 16;
break;
case "medium":
iconSize = 24;
break;
case "large":
iconSize = 32;
}
return (
<button
ref={ref}
className={twMerge(
"outline-none border-none capitalize transition-smooth aspect-square",
"focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500",
"rounded-full flex items-center justify-center w-fit h-fit p-0",
size === "small" && "w-6 h-6",
size === "medium" && "w-8 h-8",
size === "large" && "w-12 h-12",
variant === "primary" && "bg-primary-600/25 text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
variant === "accent" && "bg-accent-600/25 text-accent-50 hover:bg-accent-500/50 active:bg-accent-700",
variant === "success" && "bg-success-600/25 text-success-50 hover:bg-success-500/50 active:bg-success-700",
variant === "danger" && "bg-danger-600/25 text-danger-50 hover:bg-danger-500/50 active:bg-danger-700",
variant === "warning" && "bg-warning-600/25 text-warning-50 hover:bg-warning-500/50 active:bg-warning-700",
variant === "info" && "bg-info-600/25 text-info-50 hover:bg-info-500/50 active:bg-info-700",
variant === "transparent" && "bg-transparent text-primary-50 hover:bg-primary-500/50 active:bg-primary-700",
props.disabled && "opacity-50 cursor-not-allowed",
className && className,
)}
{...rest}
>
<Icon
icon={icon}
color='white'
width={iconSize}
height={iconSize}
/>
</button>
);
};
export default forwardRef(ButtonIcon);
-38
View File
@@ -1,38 +0,0 @@
import { ReactNode, useEffect } from "react";
import { twMerge } from "tailwind-merge";
interface ClickAwayProps extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
open: boolean;
onClickAway: (e: any) => void;
className?: string;
}
export default function ClickAway(props: ClickAwayProps) {
const { children, open, onClickAway, className, ...rest } = props;
useEffect(() => {
const body = document.querySelector("body");
const isScrollbarRendered = document.documentElement.clientHeight < document.documentElement.scrollHeight;
const isMobile = window.matchMedia("(max-width: 768px)").matches;
if (open && isScrollbarRendered) {
body?.style.setProperty("overflow", "hidden");
if (!isMobile) body?.style.setProperty("padding-right", "4px");
} else {
body?.style.removeProperty("overflow");
if (!isMobile) body?.style.removeProperty("padding-right");
}
}, [open]);
return (
<div
className={twMerge(
"w-full h-full bg-primary-950/50 backdrop-blur-none transition-smooth",
className && className,
)}
onClick={onClickAway}
{...rest}
>
{children}
</div>
);
}
-233
View File
@@ -1,233 +0,0 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { stripExtension } from "utils/filesHelper";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { getPreviewIcon } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
interface ListItemProps {
file: IGDriveFiles;
isProtected: boolean;
}
export default function ListItem(props: ListItemProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
const [fileInfo, setFileInfo] = useState<string[]>([]);
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
const [filePath, setFilePath] = useState<string>("");
useEffect(() => {
const currentPath = router.asPath.slice(1);
const filePath = props.file.name;
const path = [currentPath, encodeURIComponent(filePath)].join("/");
// If start with /, remove it
if (path.startsWith("/")) {
setFilePath(path.slice(1));
} else {
setFilePath(path);
}
}, [router.asPath, props.file.name]);
useEffect(() => {
const info = [];
const extInfo = [];
props.file.modifiedTime &&
info.push(
new Date(props.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
);
props.file.size && info.push(bytesToReadable(props.file.size));
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
setExtensionInfo(extInfo);
setFileInfo(info);
}, [props.file]);
const handleCopyFileLink = () => {
try {
const url = `${window.location.origin}/${filePath}`;
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
try {
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
window.open(
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
"_blank",
);
setMenuOpen(false);
};
return (
<>
<Link
title={props.file.name}
href={filePath}
className='w-full p-1 flex relative items-stretch gap-2 tablet:gap-4 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
<div className='flex flex-col items-stretch gap-2 w-full relative'>
{props.file.thumbnailLink &&
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
<img
src={props.file.thumbnailLink}
alt={props.file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48'
loading='lazy'
/>
) : (
<Icon
icon={
props.file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-full h-full max-h-32 tablet:max-h-48 p-8 tablet:p-16'
/>
)}
<div className='flex flex-col p-1'>
<span className='line-clamp-1 break-all whitespace-pre-wrap w-full text-center my-1'>
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
</span>{" "}
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
</div>
</div>
</div>
<div className='absolute top-1 right-1 z-10'>
<Tooltip content='More'>
<ButtonIcon
className='bg-primary-950/50'
ref={setMenuAnchor}
icon='ion:ellipsis-vertical'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
</Tooltip>
</div>
</Link>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor ? menuAnchor?.getBoundingClientRect().bottom ?? 0 : "unset",
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 150 : "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{props.file.mimeType.includes("folder") ? (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy folder link</span>
</div>
</>
) : (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy file link</span>
</div>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy download link</span>
</div>
<div className='w-full h-px bg-primary-500' />
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleDownload(props.file, props.isProtected)}
>
<Icon
icon='ion:download'
color='currentColor'
width={18}
height={18}
/>
<span>Download</span>
</div>
</>
)}
</div>
</div>
</ClickAway>
</>
);
}
-232
View File
@@ -1,232 +0,0 @@
import { Icon } from "@iconify/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { stripExtension } from "utils/filesHelper";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { getPreviewIcon } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
interface ListItemProps {
file: IGDriveFiles;
isProtected: boolean;
}
export default function ListItem(props: ListItemProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>();
const [fileInfo, setFileInfo] = useState<string[]>([]);
const [extensionInfo, setExtensionInfo] = useState<string[]>([]);
const [filePath, setFilePath] = useState<string>("");
useEffect(() => {
const currentPath = router.asPath.slice(1);
const filePath = props.file.name;
const path = [currentPath, encodeURIComponent(filePath)].join("/");
// If start with /, remove it
if (path.startsWith("/")) {
setFilePath(path.slice(1));
} else {
setFilePath(path);
}
}, [router.asPath, props.file.name]);
useEffect(() => {
const info = [];
const extInfo = [];
props.file.modifiedTime &&
info.push(
new Date(props.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
);
props.file.size && info.push(bytesToReadable(props.file.size));
props.file.mimeType && props.file.mimeType.includes("folder") && extInfo.push("Folder");
props.file.fileExtension && extInfo.push(props.file.fileExtension.toUpperCase());
setExtensionInfo(extInfo);
setFileInfo(info);
}, [props.file]);
const handleCopyFileLink = () => {
try {
const url = `${window.location.origin}/${filePath}`;
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleCopyDownloadLink = (file: IGDriveFiles, isProtected: boolean = false) => {
try {
const url = generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined);
navigator.clipboard.writeText(url);
toast.success("Download URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy Download URL to clipboard.");
console.error(error.message);
} finally {
setMenuOpen(false);
}
};
const handleDownload = (file: IGDriveFiles, isProtected: boolean = false) => {
window.open(
generatedDownloadLink(file, isProtected, true, isProtected ? createDownloadToken() : undefined),
"_blank",
);
setMenuOpen(false);
};
return (
<>
<Link
title={props.file.name}
href={filePath}
className='w-full p-1 pr-2 flex items-center gap-4 tablet:gap-8 justify-between bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
<div className='flex items-center gap-4 w-full flex-grow-0 flex-shrink'>
{props.file.thumbnailLink &&
(props.file.mimeType.startsWith("video") || props.file.mimeType.startsWith("image")) ? (
<img
src={props.file.thumbnailLink}
alt={props.file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
loading='lazy'
/>
) : (
<Icon
icon={
props.file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(props.file.fileExtension!, props.file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
/>
)}
<div className='flex flex-col'>
<span className='line-clamp-1 break-all whitespace-pre-wrap'>
{props.file.fileExtension ? stripExtension(props.file.name, props.file.fileExtension) : props.file.name}
</span>{" "}
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{extensionInfo.join(" ・ ")}</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>{fileInfo.join(" ・ ")}</span>
</div>
</div>
</div>
<div className='relative z-10'>
<Tooltip content='More'>
<ButtonIcon
ref={setMenuAnchor}
icon='ion:ellipsis-vertical'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
/>
</Tooltip>
</div>
</Link>
<ClickAway
open={menuOpen}
onClickAway={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(false);
}}
className={`w-screen h-screen z-30 fixed top-0 left-0 transition-smooth backdrop-blur-none ${
menuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div
id='more-menu'
className={`absolute drop-shadow-md mr-4 w-48 bg-primary-800 rounded-lg shadow-lg overflow-hidden transition-opacity transition-smooth ${
menuOpen ? `opacity-100 pointer-events-auto` : `opacity-0 pointer-events-none `
}`}
style={{
top: menuAnchor ? (menuAnchor?.getBoundingClientRect().bottom ?? 0) - 36 : "unset",
left: menuAnchor ? (menuAnchor?.getBoundingClientRect().left ?? 0) - 200 : "unset",
}}
>
<div className='w-full bg-primary-800 flex flex-col gap-1'>
{props.file.mimeType.includes("folder") ? (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy folder link</span>
</div>
</>
) : (
<>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={handleCopyFileLink}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy file link</span>
</div>
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleCopyDownloadLink(props.file, props.isProtected)}
>
<Icon
icon='ion:share-social'
color='currentColor'
width={18}
height={18}
/>
<span>Copy download link</span>
</div>
<div className='w-full h-px bg-primary-500' />
<div
className='flex items-center gap-2 hover:bg-primary-50 hover:text-primary-950 cursor-pointer p-2'
onClick={() => handleDownload(props.file, props.isProtected)}
>
<Icon
icon='ion:download'
color='currentColor'
width={18}
height={18}
/>
<span>Download</span>
</div>
</>
)}
</div>
</div>
</ClickAway>
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { LucideProps, icons } from "lucide-react";
import { ReactElement } from "react";
type Props = {
name: keyof typeof icons;
wrapper?: ReactElement<"div">;
} & LucideProps;
export default function Icon({ name, wrapper, ...props }: Props) {
const LucideIcon = icons[name];
return (
<div {...wrapper}>
<LucideIcon {...props} />
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
import GridItem from "components/File/GridItem";
import { IGDriveFiles } from "types/api/files";
interface GridViewProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
}
export default function ViewGrid(props: GridViewProps) {
return (
<div className='w-full gap-4 grid grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4'>
{props.data.folders.map((folder) => (
<GridItem
key={folder.encryptedId}
file={folder}
isProtected={props.isProtected}
/>
))}
{props.data.files.map((file) => (
<GridItem
key={file.encryptedId}
file={file}
isProtected={props.isProtected}
/>
))}
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
import ListItem from "components/File/ListItem";
import { IGDriveFiles } from "types/api/files";
interface ViewListProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
}
export default function ViewList(props: ViewListProps) {
return (
<div className='w-full flex flex-col gap-2 rounded-lg'>
{props.data.folders.map((folder) => (
<ListItem
key={folder.encryptedId}
file={folder}
isProtected={props.isProtected}
/>
))}
{props.data.files.map((file) => (
<ListItem
key={file.encryptedId}
file={file}
isProtected={props.isProtected}
/>
))}
</div>
);
}
-240
View File
@@ -1,240 +0,0 @@
import axios from "axios";
import gIndexConfig from "config";
import { useRouter } from "next/router";
import { poppins } from "pages/_app";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import Breadcrumb from "components/Breadcrumb";
import Button from "components/Button";
import Markdown from "components/Markdown";
import { addNewPassword } from "utils/passwordHelper";
import { IGDriveFiles } from "types/api/files";
import { APIGetFileResponse } from "types/api/response";
import { Constant } from "types/constant";
import Header from "../Header";
import PasswordLayout from "../Password";
import ViewGrid from "./Grid";
import ViewList from "./List";
const Loading = () => (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading view...</span>
</div>
</div>
);
interface ExplorerLayoutProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
readmeFile: string | null;
isProtected: boolean;
isFileProtected?: boolean;
encryptedId?: string;
}
export default function ExplorerLayout(props: ExplorerLayoutProps) {
const router = useRouter();
const [data, setData] = useState<ExplorerLayoutProps["data"]>(props.data);
const [view, setView] = useState<"grid" | "list">(() => {
const viewType = localStorage.getItem(Constant.storage_ViewType);
if (!viewType) {
localStorage.setItem(Constant.storage_ViewType, "list");
return "list";
}
if (viewType === "grid") {
return "grid";
} else {
return "list";
}
});
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
const [isButtonLoading, setIsButtonLoading] = useState<boolean>(false);
const [ViewComponent, setViewComponent] = useState<ReactNode>(
<ViewList
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
useEffect(() => {
new Promise(async (resolve) => {
if (view === "list") {
setViewComponent(
<ViewList
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
} else if (view === "grid") {
setViewComponent(
<ViewGrid
key={"file-explorer"}
data={data}
isProtected={props.isFileProtected ?? false}
/>,
);
}
await new Promise((resolve) => setTimeout(resolve, 50));
resolve(true);
}).then(() => {
setIsViewLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, data]);
useEffect(() => {
setShowPasswordLayout(props.isProtected);
}, [props.isProtected]);
useEffect(() => {
const paths = router.asPath.split("/").filter((path) => path !== "");
const newPaths: Record<"label" | "path", string>[] = [];
paths.forEach((path, index) => {
newPaths.push({
label: path,
path: `/${paths.slice(0, index + 1).join("/")}`,
});
});
setBreadcrumbPaths(newPaths);
}, [router]);
const handleChangeView = useCallback(
(view: "grid" | "list") => {
setIsViewLoading(true);
setView(view);
localStorage.setItem(Constant.storage_ViewType, view);
},
[setView],
);
const handleLoadNextPage = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsButtonLoading(true);
if (!data.pageToken) {
setIsButtonLoading(false);
return;
}
const newData = await axios.get<APIGetFileResponse>("/api/getData", {
params: {
encryptedId: props.encryptedId,
pageToken: data.pageToken,
},
});
setData((prev) => ({
...prev,
files: [...prev.files, ...newData.data.data.files],
folders: [...prev.folders, ...newData.data.data.folders],
pageToken: newData.data.data.pageToken,
}));
setIsButtonLoading(false);
},
[data, props.encryptedId],
);
return (
<div className='w-full flex h-full flex-col gap-4 relative'>
{/* Top */}
<Header
viewSelector={{ view, setView: handleChangeView }}
showPasswordLayout={showPasswordLayout}
/>
<div className='w-full h-px bg-primary-500' />
{/* Header */}
{!showPasswordLayout && (
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
{/* Breadcrumb */}
<Breadcrumb paths={breadcrumbPaths} />
</div>
)}
{/* Content */}
{showPasswordLayout ? (
<PasswordLayout
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 1] ?? "/")}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<div
className={twMerge(
"w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4",
!props.readmeFile && "grid-cols-1 tablet:grid-cols-12 desktop:grid-cols-12",
)}
>
<div
className={twMerge(
"col-span-full desktop:col-span-8 overflow-auto",
!props.readmeFile && "desktop:col-span-full",
)}
>
{isViewLoading ? (
<Loading />
) : (
<div className='w-full flex flex-col gap-4'>
{data.files.length || data.folders.length ? (
<>{ViewComponent}</>
) : (
<div className='w-full min-h-[5rem] grid place-items-center'>
Can&apos;t find any files or folders in this directory
</div>
)}
{data.pageToken && (
<Button
variant='accent'
fullWidth
rounded='large'
className='mt-4'
loading={isButtonLoading}
onClick={handleLoadNextPage}
>
Load more...
</Button>
)}
</div>
)}
</div>
{props.readmeFile && (
<div className='col-span-full desktop:col-span-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 rounded-lg sticky top-4 overflow-auto ${poppins.className}`}
>
<Markdown content={props.readmeFile} />
</div>
</div>
)}
</div>
)}
</div>
);
}
-259
View File
@@ -1,259 +0,0 @@
import { Icon } from "@iconify/react";
import axios from "axios";
import gIndexConfig from "config";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import Button from "components/Button";
import ButtonGroup from "components/ButtonGroup";
import ButtonIcon from "components/ButtonIcon";
import Modal from "components/Modal";
import Tooltip from "components/Tooltip";
import bytesToReadable from "utils/bytesFormat";
import { getPreviewIcon } from "utils/previewHelper";
import { IGDriveFiles } from "types/api/files";
import { APISearchResponse } from "types/api/response";
interface HeaderProps {
viewSelector?: {
view: "grid" | "list";
setView: (view: "grid" | "list") => void;
};
showPasswordLayout?: boolean;
}
export default function Header(props: HeaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [searchModalOpen, setSearchModalOpen] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>("");
const [axiosCancelToken, setAxiosCancelToken] = useState<ReturnType<typeof axios.CancelToken.source> | null>(null); // eslint-disable-line
const [searchLoading, setSearchLoading] = useState<boolean>(false);
const [searchResults, setSearchResults] = useState<(IGDriveFiles & { redirect: string })[]>([]);
useEffect(() => {
if (!searchQuery) {
setSearchLoading(false);
return;
}
setSearchLoading(true);
if (axiosCancelToken) {
axiosCancelToken.cancel();
}
const timeout = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 500);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
useEffect(() => {
if (!searchModalOpen) {
setSearchResults([]);
setSearchQuery("");
setSearchLoading(false);
}
}, [searchModalOpen]);
useEffect(() => {
if (!debouncedSearchQuery) {
setSearchLoading(false);
setSearchResults([]);
return;
}
const source = axios.CancelToken.source();
setAxiosCancelToken(source);
axios
.get<APISearchResponse>("/api/search", {
params: {
query: debouncedSearchQuery,
},
cancelToken: source.token,
})
.then((res) => {
setSearchResults(res.data.files);
})
.catch((err) => {
if (axios.isCancel(err)) {
return;
} else {
console.error(err);
throw new Error(err);
}
})
.finally(() => {
setSearchLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchQuery]);
return (
<>
<div className='flex items-center justify-between gap-4'>
<h1 className='text-2xl font-medium'>{gIndexConfig.siteConfig.siteName}</h1>
{!props.showPasswordLayout && (
<div className='flex items-center gap-4'>
{props.viewSelector && (
<ButtonGroup className='border border-primary-700 tablet:flex hidden'>
<Button
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
startIcon={"ion:list"}
onClick={() => props.viewSelector?.setView("list")}
>
List view
</Button>
<Button
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
startIcon={"ion:grid"}
onClick={() => props.viewSelector?.setView("grid")}
>
Grid view
</Button>
</ButtonGroup>
)}
<Tooltip content='Search file'>
<ButtonIcon
icon='ion:search'
variant='transparent'
onClick={() => {
setSearchModalOpen(true);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}}
/>
</Tooltip>
</div>
)}
</div>
{!props.showPasswordLayout && props.viewSelector && (
<div className='flex w-full items-center tablet:hidden'>
<ButtonGroup className='border border-primary-700 w-full'>
<Button
variant={props.viewSelector.view === "list" ? "accent" : "transparent"}
size='small'
startIcon={"ion:list"}
onClick={() => props.viewSelector?.setView("list")}
>
List view
</Button>
<Button
variant={props.viewSelector.view === "grid" ? "accent" : "transparent"}
size='small'
startIcon={"ion:grid"}
onClick={() => props.viewSelector?.setView("grid")}
>
Grid view
</Button>
</ButtonGroup>
</div>
)}
<Modal
key={"search-modal"}
open={searchModalOpen}
onClose={() => setSearchModalOpen(false)}
title='Search'
>
<div className='w-full flex flex-col gap-2'>
<input
ref={inputRef}
type='text'
placeholder='Input search query'
className='w-full px-4 py-2 bg-primary-900 outline-none border border-primary-500 rounded-lg'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchLoading ? (
<div className='w-full py-8 flex items-center justify-center gap-2 animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Searching file...</span>
</div>
) : (
<div className='flex flex-col gap-2 max-h-96 overflow-auto'>
{searchResults.length === 0 ? (
<div className='w-full py-8 flex items-center justify-center gap-2'>
<span>No results found</span>
</div>
) : (
<>
{searchResults.map((file) => (
<Link
href={file.redirect}
key={file.encryptedId}
className='flex items-center gap-4 w-full flex-grow-0 flex-shrink bg-primary-950 border border-primary-700 hover:bg-primary-900 transition-smooth text-primary-50 rounded-lg'
>
{file.thumbnailLink &&
(file.mimeType.startsWith("video") || file.mimeType.startsWith("image")) ? (
<img
src={file.thumbnailLink}
alt={file.name}
className='rounded-md object-cover flex-shrink-0 flex-grow-0 w-16 h-16'
loading='lazy'
/>
) : (
<Icon
icon={
file.mimeType === "application/vnd.google-apps.folder"
? "ion:folder"
: getPreviewIcon(file.fileExtension!, file.mimeType!)
}
color='currentColor'
className='flex-shrink-0 flex-grow-0 w-16 h-16 p-4'
/>
)}
<div className='flex flex-col'>
<span className='font-medium'>{file.name}</span>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>
{[
file.mimeType && file.mimeType.includes("folder") && "Folder",
file.fileExtension && file.fileExtension.toUpperCase(),
]
.filter((item) => item)
.join(" ・ ")}
</span>
</div>
<div className='flex items-center justify-start gap-2 text-primary-500'>
<span className='text-sm whitespace-nowrap'>
{[
new Date(file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
file.size ? bytesToReadable(file.size) : undefined,
]
.filter((item) => item)
.join(" ・ ")}
</span>
</div>
</div>
</Link>
))}
</>
)}
</div>
)}
</div>
</Modal>
</>
);
}
-188
View File
@@ -1,188 +0,0 @@
import gIndexConfig from "config";
import { NextSeo, NextSeoProps } from "next-seo";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import {
addNewPassword,
checkPathPassword,
} from "utils/passwordHelper";
import { Constant } from "types/constant";
import PasswordLayout from "../Password";
interface LoaderLayoutProps
extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
seo?: NextSeoProps;
}
export default function LoaderLayout(
props: LoaderLayoutProps,
) {
const router = useRouter();
const { children, seo, className, ...rest } = props;
const sitePassword =
process.env.NEXT_PUBLIC_SITE_PASSWORD;
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isPasswordUnlocked, setIsPasswordUnlocked] =
useState<boolean>(
gIndexConfig.siteConfig.privateIndex ? false : true,
);
const handlePrivateIndexCheck = () => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
if (cookie) {
const password = cookie.split("=")[1];
if (!password) return setIsPasswordUnlocked(false);
if (!sitePassword)
throw new Error("Can't find site password");
const valid = checkPathPassword(
"site-wide",
password,
sitePassword as string,
);
if (valid) return setIsPasswordUnlocked(true);
}
};
useEffect(() => {
setIsLoading(true);
new Promise((resolve) =>
resolve(handlePrivateIndexCheck()),
).then(() => setIsLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const routeChangeStart = () => setIsLoading(true);
const routeChangeComplete = () => {
handlePrivateIndexCheck();
setIsLoading(false);
};
router.events.on("routeChangeStart", routeChangeStart);
router.events.on(
"routeChangeComplete",
routeChangeComplete,
);
router.events.on(
"routeChangeError",
routeChangeComplete,
);
return () => {
router.events.off(
"routeChangeStart",
routeChangeStart,
);
router.events.off(
"routeChangeComplete",
routeChangeComplete,
);
router.events.off(
"routeChangeError",
routeChangeComplete,
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.events]);
return (
<>
<NextSeo {...seo} />
{isLoading ? (
<div className='w-full h-auto flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Validating path...</span>
</div>
</div>
) : (
<div
className={`w-full h-auto flex flex-col p-4 tablet:px-8 desktop:px-16 ${className}`}
{...rest}
key={router.asPath}
>
{gIndexConfig.siteConfig.privateIndex &&
!isPasswordUnlocked ? (
<PasswordLayout
path='Private Mode Password'
onSubmitted={(password) => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
addNewPassword(
"site-wide",
password,
cookie?.split("=")[1] ?? undefined,
);
setIsLoading(true);
const validPassword = () => {
const cookie =
document.cookie
.split(";")
.find((cookie) =>
cookie.startsWith(
`${Constant.cookies_SitePassword}=`,
),
) ?? undefined;
if (cookie) {
const password = cookie.split("=")[1];
if (!password) return false;
if (!sitePassword)
throw new Error(
"Can't find site password",
);
const valid = checkPathPassword(
"site-wide",
password,
sitePassword as string,
);
return valid;
}
};
new Promise((resolve) =>
resolve(validPassword()),
)
.then((data) => {
if (data) {
setIsPasswordUnlocked(true);
} else {
setIsPasswordUnlocked(false);
}
})
.finally(() => setIsLoading(false));
}}
/>
) : (
<>{children}</>
)}
</div>
)}
</>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { Icon } from "@iconify/react";
import { useRouter } from "next/router";
import { useState } from "react";
import Button from "components/Button";
import ButtonIcon from "components/ButtonIcon";
interface PasswordLayoutProps {
onSubmitted: (password: string) => void;
path: string;
}
export default function PasswordLayout(
props: PasswordLayoutProps,
) {
const router = useRouter();
const [showPassword, setShowPassword] =
useState<boolean>(false);
const [password, setPassword] = useState<string>("");
const [pending, setPending] = useState<boolean>(false);
return (
<div className='col-span-full text-center min-h-[50vh] flex-grow flex flex-col items-center justify-center'>
<figure>
<img
src='/images/password.png'
alt="You don't have access"
className='w-64 h-64 tablet:w-96 tablet:h-96'
/>
</figure>
<div className='w-full flex items-center justify-center flex-col my-8'>
<span className='text-lg font-medium'>
The folder / file you are trying to access is
protected with a password.
</span>
<pre className='max-w-sm w-full tablet:max-w-2xl'>
<code className='text-sm'>{props.path}</code>
</pre>
<span>Please enter the password to continue:</span>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
props.onSubmitted(password);
setPending(true);
}}
className='w-full max-w-lg flex items-center border border-primary-500 rounded-lg'
>
<div className='w-full flex items-center px-2 bg-primary-900 rounded-l-lg overflow-hidden'>
<input
type={showPassword ? "text" : "password"}
autoComplete='off'
className='w-full px-2 py-2 bg-primary-900 outline-none'
placeholder='Enter password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<ButtonIcon
type='button'
variant='transparent'
icon={showPassword ? "ion:eye-off" : "ion:eye"}
size='small'
className='p-1'
onClick={() => setShowPassword(!showPassword)}
/>
</div>
<Button
variant='accent'
type='submit'
className='rounded-r-lg w-24'
disabled={pending}
>
{pending ? (
<Icon
icon='mdi:loading'
className='animate-spin'
width={24}
height={24}
color='currentColor'
/>
) : (
"Submit"
)}
</Button>
</form>
</div>
);
}
-306
View File
@@ -1,306 +0,0 @@
import { Icon } from "@iconify/react";
import gIndexConfig from "config";
import Link from "next/link";
import { useRouter } from "next/router";
import { poppins } from "pages/_app";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify";
import { twMerge } from "tailwind-merge";
import Breadcrumb from "components/Breadcrumb";
import Button from "components/Button";
import bytesToReadable from "utils/bytesFormat";
import { durationToReadable } from "utils/durationFormat";
import { decryptData } from "utils/encryptionHelper/hash";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { addNewPassword } from "utils/passwordHelper";
import { getPreviewComponent } from "utils/previewHelper";
import { createDownloadToken } from "utils/tokenHelper";
import { IGDriveFiles } from "types/api/files";
import { Constant } from "types/constant";
import Header from "../Header";
import PasswordLayout from "../Password";
const Loading = () => (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading view...</span>
</div>
</div>
);
interface PreviewLayoutProps {
data: {
file: IGDriveFiles | null;
files: IGDriveFiles[];
folders: IGDriveFiles[];
pageToken: string | null;
};
isProtected: boolean;
isFileProtected: boolean;
encryptedId?: string;
}
export default function PreviewLayout(props: PreviewLayoutProps) {
const router = useRouter();
const [data, setData] = useState<PreviewLayoutProps["data"]>(props.data);
const [isViewLoading, setIsViewLoading] = useState<boolean>(false);
const [PreviewComponent, setPreviewComponent] = useState<ReactNode>();
const [showPasswordLayout, setShowPasswordLayout] = useState<boolean>(props.isProtected);
const [breadcrumbPaths, setBreadcrumbPaths] = useState<Record<"label" | "path", string>[]>([]);
const [downloadToken] = useState<string | null>(() => {
if (gIndexConfig.apiConfig.allowDownloadProtectedFile) return null;
if (props.isFileProtected) {
return createDownloadToken();
} else {
return null;
}
});
const [expiredIn, setExpiredIn] = useState<string>("");
const fileMetadata = useMemo<
{
key: string;
label: string;
value: string | number | null;
}[]
>(() => {
const metadata = [];
if (data.file) {
if (data.file.name)
metadata.push({
key: "name",
label: "Name",
value: data.file.name,
});
if (data.file.mimeType)
metadata.push({
key: "mimeType",
label: "Type",
value: data.file.mimeType,
});
if (data.file.modifiedTime)
metadata.push({
key: "modifiedTime",
label: "Last Modified",
value: new Date(data.file.modifiedTime).toLocaleDateString("default", {
year: "numeric",
month: "short",
day: "numeric",
}),
});
if (data.file.size)
metadata.push({
key: "size",
label: "Size",
value: bytesToReadable(data.file.size),
});
if (data.file.imageMediaMetadata && data.file.imageMediaMetadata.width && data.file.imageMediaMetadata.height)
metadata.push({
key: "imageMediaMetadata",
label: "Dimension",
value: `${data.file.imageMediaMetadata.width}x${data.file.imageMediaMetadata.height}`,
});
if (data.file.videoMediaMetadata)
metadata.push(
{
key: "videoMediaMetadata",
label: "Dimension",
value: `${data.file.videoMediaMetadata.width}x${data.file.videoMediaMetadata.height}`,
},
{
key: "videoMediaMetadata",
label: "Duration",
value: durationToReadable(data.file.videoMediaMetadata.durationMillis),
},
);
}
return metadata;
}, [data.file]);
useEffect(() => {
if (!downloadToken) return;
const interval = setInterval(() => {
const expiredAt = Number(decryptData(downloadToken));
const date = new Date(expiredAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
const hours = Math.floor(diff / 1000 / 60 / 60);
const minutes = Math.floor(diff / 1000 / 60) % 60;
const seconds = Math.floor(diff / 1000) % 60;
setExpiredIn(`${hours}:${minutes}:${seconds}`);
}, 1000);
return () => clearInterval(interval);
}, [downloadToken]);
useEffect(() => {
new Promise(async (resolve) => {
const Preview = getPreviewComponent(props.data.file?.fileExtension ?? "unknown", props.data.file?.mimeType ?? "");
setPreviewComponent(<Preview file={props.data.file!} />);
await new Promise((resolve) => setTimeout(resolve, 50));
resolve(true);
}).then(() => {
setIsViewLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
setShowPasswordLayout(props.isProtected);
}, [props.isProtected]);
useEffect(() => {
const paths = router.asPath.split("/").filter((path) => path !== "");
const newPaths: Record<"label" | "path", string>[] = [];
paths.forEach((path, index) => {
newPaths.push({
label: path,
path: `/${paths.slice(0, index + 1).join("/")}`,
});
});
setBreadcrumbPaths(newPaths);
}, [router]);
return (
<div className='w-full flex h-full flex-col gap-4 relative'>
{/* Top */}
<Header showPasswordLayout={showPasswordLayout} />
<div className='w-full h-px bg-primary-500' />
{/* Header */}
{!showPasswordLayout && (
<div className='w-full flex relative z-10 flex-col md:flex-row items-center justify-between gap-4'>
{/* Breadcrumb */}
<Breadcrumb paths={breadcrumbPaths} />
</div>
)}
{/* Content */}
{showPasswordLayout ? (
<PasswordLayout
path={decodeURIComponent(router.asPath.split("/")[router.asPath.split("/").length - 2] ?? "/")}
onSubmitted={(password) => {
const cookie =
document.cookie.split(";").find((cookie) => cookie.startsWith(`${Constant.cookies_SitePassword}=`)) ??
undefined;
addNewPassword(decodeURIComponent(router.asPath), password, cookie?.split("=")[1] ?? undefined);
router.reload();
}}
/>
) : (
<div className={twMerge("w-full relative grid grid-cols-1 tablet:grid-cols-8 desktop:grid-cols-12 gap-4")}>
<div className={twMerge("col-span-full desktop:col-span-8 overflow-auto")}>
{isViewLoading ? (
<Loading />
) : (
<div className='w-full flex flex-col gap-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 p-4 mb-4 rounded-lg overflow-auto ${poppins.className}`}
>
{PreviewComponent}
</div>
</div>
)}
</div>
<div className='col-span-full desktop:col-span-4'>
<div className='sticky top-4'>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 mb-4 rounded-lg overflow-auto ${poppins.className}`}
>
<div className='flex items-center gap-2'>
<Icon
icon={"ion:information-circle"}
className='w-6 h-6'
/>
<h2 className='text-2xl font-medium'>File info</h2>
</div>
<div className='w-full h-px bg-primary-500' />
<div className='flex flex-col gap-2 w-full'>
{fileMetadata.map((item) => (
<div
className='grid grid-cols-3'
key={`${item.key}-${item.label.toLowerCase()}`}
>
<span className='col-span-1 font-bold'>{item.label}</span>
<p className='col-span-2 break-all whitespace-pre-line'>{item.value ?? "-"}</p>
</div>
))}
</div>
</div>
<div
className={`w-full flex flex-col gap-2 bg-primary-900 px-4 py-2 my-4 rounded-lg overflow-auto ${poppins.className}`}
>
<Link
href={generatedDownloadLink(data.file!, props.isFileProtected, false, downloadToken)}
target='_blank'
rel='noopener noreferrer'
>
<Button
fullWidth
variant='accent'
startIcon={"ion:download"}
rounded='large'
>
Download
</Button>
</Link>
<Button
fullWidth
variant='primary'
startIcon={"ion:copy"}
rounded='large'
onClick={() => {
try {
const url = generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken);
navigator.clipboard.writeText(url);
toast.success("URL copied to clipboard.");
} catch (error: any) {
toast.error("Failed to copy URL to clipboard.");
console.error(error.message);
}
}}
>
Copy URL
</Button>
<div className='w-full px-2 bg-primary-950 outline-none rounded-lg border border-primary-500 select-all'>
<div className='overflow-x-scroll py-2 whitespace-nowrap'>
{generatedDownloadLink(data.file!, props.isFileProtected, true, downloadToken)}
</div>
</div>
{!gIndexConfig.apiConfig.allowDownloadProtectedFile && props.isFileProtected && (
<span className='text-center mt-4 text-sm'>
This file is inside protected folder.
<br />
Download link will be expired in <b>{expiredIn}</b>.
</span>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}
-124
View File
@@ -1,124 +0,0 @@
import { ReactNode, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import ClickAway from "components/ClickAway";
interface ModalProps {
children: ReactNode;
open: boolean;
onClose: () => void;
title: string;
footer?: ReactNode;
keepMounted?: boolean;
}
export default function Modal(props: ModalProps) {
let DELAY = 10;
const [showModal, setShowModal] = useState<boolean>(props.open);
const [keepMounted, setKeepMounted] = useState<boolean>(false);
const [shouldRender, setShouldRender] = useState<boolean>(false);
const [shouldUnmount, setShouldUnmount] = useState<boolean>(true);
useEffect(() => {
const handleClose = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.onClose();
}
};
window.addEventListener("keydown", handleClose);
return () => {
window.removeEventListener("keydown", handleClose);
};
}, [props]);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (showModal && !shouldRender) {
timeoutId = setTimeout(() => {
setShouldRender(true);
}, DELAY);
} else if (!showModal && shouldRender) {
timeoutId = setTimeout(() => {
setShouldRender(false);
}, DELAY);
}
return () => {
clearTimeout(timeoutId);
};
}, [showModal, shouldRender, DELAY]);
useEffect(() => {
setShowModal(props.open);
if (props.open) setShouldUnmount(false);
}, [props.open]);
useEffect(() => {
if (showModal && props.keepMounted) setKeepMounted(true);
}, [showModal, props.keepMounted]);
const handleClose = () => {
setShowModal(false);
if (!keepMounted) setKeepMounted(false);
props.onClose();
};
// useEffect(() => {
// if (!showModal && !keepMounted && shouldRender) {
// const timeout = setTimeout(() => {
// setShouldUnmount(true);
// }, 250);
// return () => {
// clearTimeout(timeout);
// };
// } else {
// setShouldUnmount(false);
// }
// }, [showModal, keepMounted, shouldRender, DELAY]);
if (shouldUnmount) return null;
return (
<ClickAway
open={showModal}
onClickAway={handleClose}
className={`fixed grid place-items-center top-0 left-0 z-[60] w-full h-full transition-smooth ${
shouldRender ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
onTransitionEnd={() => {
if (!showModal && !keepMounted) {
setShouldUnmount(true);
} else {
setShouldUnmount(false);
}
}}
>
<div
className='w-full px-4 max-w-screen-lg'
onClick={(e) => {
e.stopPropagation();
}}
>
<div className='bg-primary-900 text-primary-50 rounded-lg py-2 px-4 flex flex-col gap-2 w-full'>
{/* Header */}
<div className='flex flex-row items-center justify-between py-2'>
<span className='text-xl font-medium w-full line-clamp-1'>{props.title}</span>
<ButtonIcon
icon={"ion:close"}
onClick={handleClose}
/>
</div>
<div className='w-full h-px bg-primary-500' />
{/* Body */}
<div className='py-2'>{props.children}</div>
{/* Footer */}
{props.footer && <div className='py-2'>{props.footer}</div>}
</div>
</div>
</ClickAway>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { useState } from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function AudioPreview(props: PreviewProps) {
const [isError, setIsError] = useState<boolean>(false);
return (
<div className='w-full h-full'>
{isError ? (
<ErrorPreview />
) : (
<AudioPlayer
src={generatedDownloadLink(props.file, false)}
showJumpControls={false}
showFilledVolume
style={{
borderRadius: "0.375rem",
}}
onError={() => setIsError(true)}
/>
)}
</div>
);
}
-55
View File
@@ -1,55 +0,0 @@
import axios from "axios";
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import Markdown from "components/Markdown";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function CodePreview(props: PreviewProps) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
useEffect(() => {
axios
.get(generatedDownloadLink(props.file, false))
.then((res) => {
const isObject = typeof res.data === "object";
setContent(
`\`\`\`${props.file.fileExtension}\n${isObject ? JSON.stringify(res.data, null, 2) : res.data}\`\`\``,
);
setIsLoading(false);
})
.catch((err) => {
console.error(err);
setIsError(true);
});
}, [props.file, props.file.fileExtension]);
return (
<div className='w-full h-full'>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
)}
</div>
);
}
-59
View File
@@ -1,59 +0,0 @@
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
import gIndexConfig from "config";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
function LoadingRender() {
return (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
);
}
function ErrorRender() {
return <ErrorPreview />;
}
export default function DocumentPreview(props: PreviewProps) {
return (
<div className='w-full h-full'>
<DocViewer
documents={[
{
uri: generatedDownloadLink(props.file, false, true),
},
]}
pluginRenderers={DocViewerRenderers}
config={{
header: {
disableHeader: true,
},
loadingRenderer: {
overrideComponent: LoadingRender,
},
noRenderer: {
overrideComponent: ErrorRender,
},
}}
className='min-h-[70vh] max-h-[70vh] w-full h-full rounded-md'
theme={{
disableThemeScrollbar: true,
}}
/>
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
interface ErrorPreviewProps {
error?: string;
}
export default function ErrorPreview(props: ErrorPreviewProps) {
return (
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
<h2 className='text-2xl font-bold'>Failed to load file preview</h2>
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
Error has been occured while loading preview, it may be caused by{" "}
<span className='font-medium underline'>invalid file type</span> or{" "}
<span className='font-medium underline'>network error</span>.
<br />
You can try again later or download the file to view.
</span>
{props.error && (
<>
<span className='font-medium underline'>Error details</span>
<span className='text-sm tablet:text-base text-primary-300 my-2'>{props.error}</span>
</>
)}
</div>
);
}
-82
View File
@@ -1,82 +0,0 @@
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function ImagePreview(props: PreviewProps) {
const [imgSrc, setImgSrc] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>("");
useEffect(() => {
setIsError(false);
setIsLoading(true);
const timeout = setTimeout(() => {
setIsError(true);
setErrorMessage("Image took too long to load");
return;
}, 15000);
fetch(generatedDownloadLink(props.file, false))
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.blob();
})
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
setImgSrc(reader.result as string);
};
reader.readAsDataURL(blob);
})
.catch((err) => {
console.error(err);
setIsError(true);
setErrorMessage(err.message);
})
.finally(() => {
setIsLoading(false);
clearTimeout(timeout);
});
return () => clearTimeout(timeout);
}, [props.file]);
return (
<>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading image...</span>
</div>
</div>
) : (
<>
{isError ? (
<ErrorPreview />
) : (
<img
src={imgSrc}
alt={props.file.name}
className='w-full h-full rounded-md'
/>
)}
</>
)}
</>
);
}
-124
View File
@@ -1,124 +0,0 @@
import axios from "axios";
import gIndexConfig from "config";
import JSZip from "jszip";
import { useCallback, useEffect, useState } from "react";
import ButtonIcon from "components/ButtonIcon";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function MangaPreview(props: PreviewProps) {
const [maxPageIndex, setMaxPageIndex] = useState<number>(4);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [viewPageIndex, setViewPageIndex] = useState<number>(0);
const [images, setImages] = useState<string[]>([]);
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
useEffect(() => {
const mangaImages: string[] = [];
axios
.get(generatedDownloadLink(props.file, false, false, null, true), {
responseType: "blob",
})
.then(async (res) => {
const zipData = await JSZip.loadAsync(res.data);
for (let i = 0; i <= maxPageIndex; i++) {
const file = zipData.file(Object.keys(zipData.files)[i]);
if (file) {
const blob = await file.async("blob");
const reader = new FileReader();
reader.onload = () => {
mangaImages.push(reader.result as string);
};
reader.readAsDataURL(blob);
}
}
setImages(mangaImages);
setMaxPageIndex(mangaImages.length);
})
.catch((err) => {
console.error(err);
setIsError(true);
})
.finally(() => {
setIsLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.file]);
const handleCurrentView = useCallback(
(pageNumber: number) => {
if (pageNumber < 0 || pageNumber > maxPageIndex) return;
setViewPageIndex(pageNumber);
},
[maxPageIndex],
);
return (
<div className='w-full h-full'>
<blockquote className='w-full h-auto flex items-center my-2 bg-primary-800 border-l-4 border-l-accent-500 pl-3 pr-2 py-2 text-sm tablet:text-base rounded-md'>
Preview only shows the first 5 pages. Please download the file to view the rest.
</blockquote>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>
{isError ? (
<ErrorPreview />
) : (
<div className='flex flex-col'>
<div className='w-full h-full relative'>
<img
src={images[viewPageIndex]}
alt={`${props.file.name} - page ${viewPageIndex}`}
className={`w-full h-full object-contain bg-primary-950 rounded-md ${
viewSize === "fit" ? "max-h-[70vh]" : ""
}`}
/>
<ButtonIcon
icon={viewSize === "fit" ? "ion:expand" : "ion:contract"}
className='absolute bottom-2 right-2'
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
/>
</div>
<div className='flex flex-row gap-2 items-center justify-center my-2'>
<ButtonIcon
variant='transparent'
icon={"ion:chevron-back"}
onClick={() => handleCurrentView(viewPageIndex - 1)}
disabled={viewPageIndex === 0}
/>
<span>
{viewPageIndex + 1} / {maxPageIndex + 1}
</span>
<ButtonIcon
variant='transparent'
icon={"ion:chevron-forward"}
onClick={() => handleCurrentView(viewPageIndex + 1)}
disabled={viewPageIndex === maxPageIndex}
/>
</div>
</div>
)}
</>
)}
</div>
);
}
-52
View File
@@ -1,52 +0,0 @@
import axios from "axios";
import gIndexConfig from "config";
import { useEffect, useState } from "react";
import Markdown from "components/Markdown";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function RichPreview(props: PreviewProps) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const [content, setContent] = useState<string>("");
useEffect(() => {
axios
.get(generatedDownloadLink(props.file))
.then((res) => {
setContent(res.data);
setIsLoading(false);
})
.catch((err) => {
console.error(err);
setIsError(true);
});
}, [props.file]);
return (
<div className='w-full h-full'>
{isLoading ? (
<div className='w-full h-auto min-h-[50vh] flex items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center animate-pulse'>
<div className='w-fit h-fit relative grid place-items-center'>
<img
src={gIndexConfig.siteConfig.siteIcon}
alt={gIndexConfig.siteConfig.siteName}
className='w-12 -top-1 relative'
/>
<div className='absolute w-12 h-12 bg-transparent border border-primary-50 rounded-full animate-ping' />
</div>
<span>Loading content...</span>
</div>
</div>
) : (
<>{isError ? <ErrorPreview /> : <Markdown content={content} />}</>
)}
</div>
);
}
-12
View File
@@ -1,12 +0,0 @@
export default function UnknownPreview() {
return (
<div className='w-full h-auto min-h-[25vh] flex items-center justify-center flex-col'>
<h2 className='text-2xl font-bold'>File preview not available</h2>
<span className='text-sm tablet:text-base text-primary-300 my-2 text-center'>
This file type is not supported for preview.
<br />
You can download the file to view.
</span>
</div>
);
}
-36
View File
@@ -1,36 +0,0 @@
import { useState } from "react";
import ReactPlayer from "react-player";
import { generatedDownloadLink } from "utils/generateDownloadLink";
import { PreviewProps } from "types/api/files";
import ErrorPreview from "./Error";
export default function VideoPreview(props: PreviewProps) {
const [isError, setIsError] = useState<boolean>(false);
return (
<div className='w-full h-full'>
{isError ? (
<ErrorPreview />
) : (
<ReactPlayer
key={props.file.encryptedId}
url={generatedDownloadLink(props.file, false)}
controls
pip
wrapper={({ children }) => (
<div className='w-full h-[60vh] bg-primary-950 rounded-md overflow-hidden'>{children}</div>
)}
style={{
width: "100%",
height: "100%",
maxHeight: "60vh",
}}
onError={() => setIsError(true)}
/>
)}
</div>
);
}
-99
View File
@@ -1,99 +0,0 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { Popper, PopperProps } from "react-popper";
interface TooltipProps {
children: ReactNode;
content: ReactNode;
placement?: PopperProps<unknown>["placement"];
delay?: number;
}
export default function Tooltip(props: TooltipProps) {
const [refElement, setRefElement] = useState<HTMLElement | null>(null);
const [showTooltip, setShowTooltip] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
function handleMouseEnter() {
if (props.delay) {
timeoutRef.current = window.setTimeout(() => {
setShowTooltip(true);
}, props.delay);
} else {
setShowTooltip(true);
}
}
function handleMouseLeave() {
if (props.delay) {
window.clearTimeout(timeoutRef.current ?? undefined);
}
setShowTooltip(false);
}
function handleScroll() {
setShowTooltip(false);
}
if (refElement) {
refElement.addEventListener("mouseenter", handleMouseEnter);
refElement.addEventListener("mouseleave", handleMouseLeave);
refElement.addEventListener("click", handleMouseLeave);
window.addEventListener("scroll", handleScroll);
}
return () => {
if (refElement) {
refElement.removeEventListener("mouseenter", handleMouseEnter);
refElement.removeEventListener("mouseleave", handleMouseLeave);
refElement.removeEventListener("click", handleMouseLeave);
window.removeEventListener("scroll", handleScroll);
}
};
}, [refElement, props.delay]);
return (
<>
<div ref={setRefElement}>{props.children}</div>
<Popper
placement={props.placement ?? "top"}
modifiers={[
{
name: "offset",
options: {
offset: [0, 10],
},
},
{
name: "hide",
enabled: true,
phase: "main",
requires: ["computeStyles"],
fn: ({ state }) => {
const { height, width, x, y } = state.rects.reference;
const { innerHeight, innerWidth } = window;
state.styles.popper = {
...state.styles.popper,
visibility: height + y > innerHeight || width + x > innerWidth ? "hidden" : "visible",
};
},
},
]}
referenceElement={refElement as HTMLElement}
strategy='absolute'
>
{({ ref, style, placement }) => (
<div
ref={ref}
style={style}
data-placement={placement}
className={`bg-primary-800 max-w-sm text-primary-50 text-sm font-medium px-2 py-1 rounded-md shadow-md transition-smooth ${
showTooltip ? `opacity-100 pointer-events-auto` : "opacity-0 pointer-events-none"
}`}
>
{props.content}
</div>
)}
</Popper>
</>
);
}
+115
View File
@@ -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,
}
+56
View File
@@ -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 }
+79
View File
@@ -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 }
+122
View File
@@ -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,
}
+118
View File
@@ -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,
}
+200
View File
@@ -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,
}
+25
View File
@@ -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 }
+236
View File
@@ -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,
}
+160
View File
@@ -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,
}
+31
View File
@@ -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 }
+140
View File
@@ -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,
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "~/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+117
View File
@@ -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,
}
+129
View File
@@ -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,
}
+35
View File
@@ -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>
)
}
+30
View File
@@ -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 }
+190
View File
@@ -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
View File
@@ -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;
}[];
};
}
+8 -6
View File
@@ -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;
+21
View File
@@ -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;
}
+34
View File
@@ -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(),
});
-17
View File
@@ -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
View File
@@ -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",
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+4 -15
View File
@@ -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}`;
}
+8 -15
View File
@@ -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"),
+8 -3
View File
@@ -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
View File
@@ -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") {
+3 -11
View File
@@ -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();
-104
View File
@@ -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);
},
],
};
+90
View File
@@ -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
View File
@@ -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/**/*"]
}
+9194 -5042
View File
File diff suppressed because it is too large Load Diff