Remove .env

This commit is contained in:
mbaharip
2023-04-21 21:07:19 +07:00
commit b6fc85151a
72 changed files with 9502 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": "off"
}
}
+37
View File
@@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring file.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env file
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
!/.env
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+11
View File
@@ -0,0 +1,11 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="261" name="TypeScript" />
</Languages>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/drive-manager.iml" filepath="$PROJECT_DIR$/.idea/drive-manager.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myRunOnSave" value="true" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+22
View File
@@ -0,0 +1,22 @@
"use strict";
const tailwindConfig = require("prettier-plugin-tailwindcss");
module.exports = {
plugins: [tailwindConfig],
printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: true,
singleQuote: false,
quoteProps: "consistent",
jsxSingleQuote: true,
trailingComma: "all",
bracketSpacing: true,
jsxBracketSameLine: false,
arrowParens: "always",
proseWrap: "preserve",
htmlWhitespaceSensitivity: "css",
endOfLine: "lf",
embeddedLanguageFormatting: "auto",
singleAttributePerLine: true,
};
+30
View File
@@ -0,0 +1,30 @@
// LOGO OR BANNER GOES HERE
## What is this?
guDora-index is an indexer for Google Drive, it's a simple project that I made to index my files in Google Drive.
It's aim to simplify the process of sharing files using Google Drive, and also implements some features that I think is useful for sharing files.
This project are **HEAVILY INSPIRED** by [onedrive-vercel-index](https://github.com/spencerwooo/onedrive-vercel-index) by SpencerWooo.
### Why?
I know there already some Google Drive indexer out there, but all of them are using **Cloudflare** worker.
I've tried to use it, but the speed isn't that great for me.
Currently I'm using SpencerWooo's onedrive index, and it's working perfectly for me from usage perspective and speed.
But, if I compare the price between these two services, Google Drive still cheaper than Onedrive.
For 100GB plan, Google Drive is IDR 269.000 / ~$18 USD annually, and Onedrive is IDR 319.000 / ~$21 USD annually.
For free plan, Google Drive offering 15GB of storage, and Onedrive offering 5GB of storage.
I know there are a lot of people selling cheap edu account for Google Drive and Onedrive, but most of the time those account doesn't last long, especially Google Drive account. So I want to do it legit without buying cheap edu account or anything similar this time.
## Features
TBA
## TODO
- [x] Navigate through folders
- [ ] File preview
- [x] Search for files
- [x] Render Readme file
- [ ] Password protection
- [ ] Create token hash for sharing protected files that valid for x hours
- [ ] ~~Pretty path URL~~ (Not possible, since Google Drive allowing multiple files with same name)
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
+51
View File
@@ -0,0 +1,51 @@
{
"name": "drive-manager",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 5000",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/mime-types": "^2.1.1",
"@types/node": "18.15.11",
"@types/react": "18.0.35",
"@types/react-dom": "18.0.11",
"@types/react-pdf": "^6.2.0",
"autoprefixer": "10.4.14",
"axios": "^1.3.5",
"googleapis": "^118.0.0",
"jsonwebtoken": "^9.0.0",
"mime-types": "^2.1.35",
"next": "13.3.0",
"nextjs-progressbar": "^0.0.16",
"postcss": "8.4.22",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^4.8.0",
"react-loading": "^2.0.3",
"react-markdown": "^8.0.7",
"react-toastify": "^9.1.2",
"rehype-katex": "^6.0.2",
"rehype-prism-plus": "^1.5.1",
"rehype-raw": "^6.1.1",
"rehype-sanitize": "^5.0.1",
"remark-breaks": "^3.0.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"remark-slug": "^7.0.1",
"remark-toc": "^8.0.1",
"swr": "^2.1.3",
"tailwindcss": "3.3.1"
},
"devDependencies": {
"eslint": "8.38.0",
"eslint-config-next": "13.3.0",
"prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.7",
"typescript": "5.0.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

+11
View File
@@ -0,0 +1,11 @@
type Props = {
message?: string;
};
export default function EmptyFeedback({ message }: Props) {
return (
<div className='fillCard flex w-full items-center justify-center gap-2'>
{message || "Folder is empty"}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { MdWarning } from "react-icons/md";
type Props = {
message?: string;
useContainer?: boolean;
};
export default function ErrorFeedback({ message, useContainer = true }: Props) {
return (
<>
{useContainer ? (
<div className='card w-full'>
<div className='fillCard flex w-full items-center justify-center gap-2'>
<MdWarning className={"text-red-500 dark:text-red-400"} /> Error -{" "}
{message || "Something went wrong"}
</div>
</div>
) : (
<div className='fillCard flex w-full items-center justify-center gap-2'>
<MdWarning className={"text-red-500 dark:text-red-400"} /> Error -{" "}
{message || "Something went wrong"}
</div>
)}
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import ReactLoading from "react-loading";
type Props = {
message?: string;
useContainer?: boolean;
};
export default function LoadingFeedback({
message,
useContainer = true,
}: Props) {
return (
<>
{useContainer ? (
<div className='card w-full'>
<div className='fillCard flex w-full items-center justify-center gap-2'>
<ReactLoading
type='spin'
width={20}
height={20}
className={"loading"}
/>{" "}
{message || "Loading..."}
</div>
</div>
) : (
<div className='fillCard flex w-full items-center justify-center gap-2'>
<ReactLoading
type='spin'
width={20}
height={20}
className={"loading"}
/>{" "}
{message || "Loading..."}
</div>
)}
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { TFileParent } from "@/types/googleapis";
import Link from "next/link";
import { Fragment, useEffect, useState } from "react";
import { MdHome } from "react-icons/md";
import useLocalStorage from "@hooks/useLocalStorage";
import ReactLoading from "react-loading";
type Props = {
data: TFileParent[];
isLoading: boolean;
};
export default function Breadcrumb({ data, isLoading }: Props) {
const limitItem = 2;
const [limitedPath, setLimitedPath] = useState<TFileParent[]>([]);
const [slicedPath, setSlicedPath] = useState<TFileParent>();
const [isLimited, setIsLimited] = useState<boolean>();
// const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
// setIsLoading(true);
if (data.length > 0) {
setLimitedPath(data.slice(0, limitItem).reverse());
setSlicedPath(data.slice(limitItem)[0]);
setIsLimited(data.length > limitItem);
// setIsLoading(false);
}
}, [data]);
return (
<div className='flex items-center gap-2'>
<Link
href='/'
className='flex items-center gap-2'
>
<MdHome />
<span>Root</span>
</Link>
{isLoading && (
<ReactLoading
type='spin'
width={20}
height={20}
className={"loading"}
/>
)}
{isLimited && !isLoading && (
<Fragment>
<span>/</span>
<Link
href={`/folder/${slicedPath?.id}`}
title={slicedPath?.name}
className='flex items-center gap-2'
>
...
</Link>
</Fragment>
)}
{!isLoading && (
<>
{limitedPath.map((parent, idx) => (
<Fragment key={parent.id}>
<span>/</span>
{idx === limitedPath.length - 1 ? (
<span className='flex cursor-default items-center gap-2 font-bold'>
{parent.name}
</span>
) : (
<Link
href={`/folder/${parent.id}`}
className={`flex items-center gap-2 ${
idx === limitedPath.length - 1
? "cursor-default font-bold"
: "cursor-pointer"
}`}
>
{parent.name}
</Link>
)}
</Fragment>
))}
</>
)}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { formatDuration } from "@/utils/formatHelper";
import { drive_v3 } from "googleapis";
import Link from "next/link";
import { MdPlayCircleFilled } from "react-icons/md";
import { getFileIcon } from "@utils/mimeTypesHelper";
import { BsFolderFill } from "react-icons/bs";
type Props = {
data: drive_v3.Schema$File;
};
export default function GridFile({ data }: Props) {
const isFolder = data.mimeType === "application/vnd.google-apps.folder";
// const Icon = getMimeIcon(data.mimeType as string);
const Icon = isFolder
? BsFolderFill
: getFileIcon(data.fileExtension as string);
return (
<Link
href={isFolder ? `/folder/${data.id}` : `/file/${data.id}`}
key={data.id}
className={"hover:opacity-100"}
>
<div className='items relative mx-auto h-32 w-32'>
{data.thumbnailLink ? (
<div
className={
"group relative mx-auto h-full w-full opacity-90 transition-opacity hover:opacity-100"
}
>
<img
className='h-full w-full rounded-lg bg-zinc-950/10 object-cover transition duration-300 ease-in-out'
src={data.thumbnailLink}
alt={data.name as string}
/>
{data.videoMediaMetadata && (
<>
<MdPlayCircleFilled className='center absolute h-8 w-8 text-white/50 transition-colors duration-300 group-hover:text-white/80' />
<span className='absolute bottom-1 right-1 rounded-lg bg-zinc-950/50 px-1 py-0.5 text-xs text-white'>
{formatDuration(data.videoMediaMetadata.durationMillis!)}
</span>
</>
)}
</div>
) : (
<Icon className='h-full w-full p-8 transition-none' />
)}
</div>
<div className={"flex items-center justify-center gap-2"}>
{/*<Icon className='h-4 w-4 flex-shrink-0' />*/}
<span className='my-2 line-clamp-2 w-fit break-words text-center text-sm'>
{data.name}
</span>
</div>
</Link>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { drive_v3 } from "googleapis";
import Link from "next/link";
import { formatBytes, formatDate } from "@utils/formatHelper";
import { toast } from "react-toastify";
import { MdContentCopy, MdDownload } from "react-icons/md";
import { getFileIcon } from "@utils/mimeTypesHelper";
import { BsFolderFill } from "react-icons/bs";
type Props = {
data: drive_v3.Schema$File;
};
function CopyButton({ url, isFolder }: { url: string; isFolder: boolean }) {
return (
<button
className={"p-2"}
title={isFolder ? "Copy folder link" : "Copy raw file link"}
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (!navigator.clipboard) {
toast.error("Your browser doesn't support clipboard API");
return;
}
try {
const host = window.location.host;
const copyUrl = isFolder ? host + url : host + url + "/view";
await navigator.clipboard.writeText(copyUrl);
toast.success(
isFolder ? "Folder link copied" : "Raw file link copied",
);
} catch (error: any) {
toast.error("Failed to copy to clipboard");
console.error(error.message);
}
}}
>
<MdContentCopy />
</button>
);
}
function DownloadButton({ fileId }: { fileId: string }) {
return (
<button
className={"p-2"}
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
window.open(`/api/files/${fileId}/download`, "_self");
toast.info("Downloading file...");
}}
>
<MdDownload />
</button>
);
}
export default function ListFile({ data }: Props) {
const isFolder = data.mimeType === "application/vnd.google-apps.folder";
const Icon = isFolder
? BsFolderFill
: getFileIcon(data.fileExtension as string);
if (!data) return null;
return (
<Link
href={isFolder ? `/folder/${data.id}` : `/file/${data.id}`}
key={data.id}
className={
"col-span-full grid grid-cols-1 gap-2 rounded-lg px-4 py-2 hover:bg-zinc-300 dark:hover:bg-zinc-700 tablet:grid-cols-8"
}
>
<div className={"flex w-full items-center gap-4 tablet:col-span-4"}>
<Icon className={"h-4 w-4 flex-shrink-0"} />
<span className={"line-clamp-1 block w-auto flex-grow-0"}>
{data.name}
</span>
</div>
<div className={"hidden tablet:col-span-2 tablet:block"}>
{formatDate(new Date(data.modifiedTime as string))}
</div>
<div className={"hidden tablet:block"}>
{isFolder ? "" : formatBytes(data.size as string)}
</div>
<div className={"hidden gap-2 tablet:flex"}>
<CopyButton
url={isFolder ? `/folder/${data.id}` : `/file/${data.id}`}
isFolder={isFolder}
/>
{isFolder ? null : <DownloadButton fileId={data.id as string} />}
</div>
</Link>
);
}
@@ -0,0 +1,12 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function AudioPreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>Audio Preview</div>
);
}
@@ -0,0 +1,12 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function CodePreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>Code Preview</div>
);
}
@@ -0,0 +1,14 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function DefaultPreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>
Default Preview
</div>
);
}
@@ -0,0 +1,74 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
import { useEffect, useState } from "react";
import LoadingFeedback from "@components/APIFeedback/Loading";
import ErrorFeedback from "@components/APIFeedback/Error";
import config from "@config/site.config";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function ImagePreview({ data }: Props) {
const [image, setImage] = useState<string>("");
const [isImageLoaded, setIsImageLoaded] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (data) {
try {
let loaded = false;
const _image = new Image();
_image.src = `/api/files/${data.id}/view`;
_image.onload = () => {
setImage(_image.src);
setIsImageLoaded(true);
loaded = true;
};
timeout = setTimeout(() => {
if (!loaded) {
setIsError(true);
setError(
new Error(
"Cannot load image preview, extension probably not supported",
),
);
}
}, config.preview.timeout);
} catch (error: any) {
setIsError(true);
setError(error);
console.error(error);
}
}
return () => {
clearTimeout(timeout);
};
}, [data]);
return (
<div className='flex w-full items-center justify-center'>
{!isImageLoaded && !isError && (
<LoadingFeedback
message={"Loading image preview..."}
useContainer={false}
/>
)}
{!isImageLoaded && isError && (
<ErrorFeedback
message={error?.message || "Error loading image preview"}
useContainer={false}
/>
)}
{isImageLoaded && !isError && (
<img
src={image}
alt={data.name as string}
className='bg-size-checkerboard h-full w-full rounded-lg bg-checkerboard dark:bg-checkerboard-dark'
/>
)}
</div>
);
}
@@ -0,0 +1,14 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function MarkdownPreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>
Markdown Preview
</div>
);
}
@@ -0,0 +1,12 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function ModelPreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>Model Preview</div>
);
}
@@ -0,0 +1,14 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function OfficePreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>
Office Preview
</div>
);
}
@@ -0,0 +1,12 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function PDFPreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>PDF Preview</div>
);
}
@@ -0,0 +1,12 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function TextPreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>Text Preview</div>
);
}
@@ -0,0 +1,12 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function VideoPreview({ data }: Props) {
return (
<div className='flex w-full items-center justify-center'>Video Preview</div>
);
}
@@ -0,0 +1,61 @@
import { TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
import { MdCopyAll, MdDownload, MdOpenInBrowser } from "react-icons/md";
import Link from "next/link";
import { toast } from "react-toastify";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function DetailsButtons({ data }: Props) {
return (
<div className={"flex flex-col gap-2"}>
<Link
href={`/api/files/${data.id}/download`}
className={
"flex w-full items-center justify-center gap-2 rounded-lg bg-blue-500 py-4 text-center text-zinc-100 dark:bg-blue-400 tablet:py-2"
}
target={"_blank"}
rel={"noopener noreferrer"}
>
<MdDownload />
Download file
</Link>
<Link
href={`/api/files/${data.id}/view`}
className={
"flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-500 py-4 text-center text-zinc-100 dark:bg-zinc-500 tablet:py-2"
}
target={"_blank"}
rel={"noopener noreferrer"}
>
<MdOpenInBrowser />
Open in new tab
</Link>
<Link
href={``}
className={
"flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-500 py-4 text-center text-zinc-100 dark:bg-zinc-500 tablet:py-2"
}
target={"_blank"}
rel={"noopener noreferrer"}
onClick={async (e) => {
e.preventDefault();
if (!window.navigator) {
toast.error("Your browser does not support this feature");
return;
}
const host = window.location.host;
const url = `${host}/api/files/${data.id}/view`;
await window.navigator.clipboard.writeText(url);
toast.success("Copied to clipboard");
}}
>
<MdCopyAll />
Copy direct link
</Link>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { ErrorResponse, FileResponse, TFile } from "@/types/googleapis";
import { drive_v3 } from "googleapis";
import LoadingFeedback from "@components/APIFeedback/Loading";
import { formatBytes, formatDate, formatDuration } from "@utils/formatHelper";
import { useEffect, useState } from "react";
import DetailsButtons from "@components/layout/FileDetails/DetailsButtons";
import ImagePreview from "@components/FilePreview/ImagePreview";
import MarkdownRender from "@components/utility/MarkdownRender";
import fetcher from "@utils/swrFetch";
import useSWR from "swr";
type Props = {
data: TFile | drive_v3.Schema$File;
};
export default function FileDetails({ data }: Props) {
const [metadata, setMetadata] = useState<{ label: string; value: string }[]>(
[],
);
useEffect(() => {
if (data) {
const _metadata = [
{ label: "Name", value: data.name as string },
{ label: "Size", value: formatBytes(data.size as string) },
{ label: "Type", value: data.mimeType as string },
{
label: "Created",
value: formatDate(new Date(data.createdTime as string)),
},
{
label: "Modified",
value: formatDate(new Date(data.modifiedTime as string)),
},
];
// Insert at index 3
if (
data.imageMediaMetadata &&
!(data.mimeType as string).endsWith("svg+xml")
) {
const { width, height } = data.imageMediaMetadata;
_metadata.splice(3, 0, {
label: "Dimension",
value: `${width || 0}px x ${height || 0}px`,
});
}
if (data.videoMediaMetadata) {
const { width, height, durationMillis } = data.videoMediaMetadata;
_metadata.splice(3, 0, {
label: "Dimension",
value: `${width || 0}px x ${height || 0}px`,
});
_metadata.splice(4, 0, {
label: "Duration",
value: formatDuration(durationMillis as string),
});
}
setMetadata(_metadata);
}
}, [data]);
return (
<>
{!data ? (
<LoadingFeedback message={"Loading file details..."} />
) : (
<div className={"grid grid-cols-1 gap-4 tablet:grid-cols-4"}>
<div className={"card h-fit tablet:col-span-3"}>
<div className='flex w-full items-center justify-between rounded-lg px-4'>
<span className='font-bold'>Preview</span>
</div>
<div className={"divider-horizontal"} />
{data.mimeType?.startsWith("image") && <ImagePreview data={data} />}
{/*{data.mimeType?.startsWith("audio") && (*/}
<div className='flex w-full items-center justify-center'>
{/*<video*/}
{/* poster={data.thumbnailLink as string}*/}
{/* autoPlay={true}*/}
{/* controls*/}
{/* className={"h-full w-full"}*/}
{/*>*/}
{/* <source*/}
{/* src={`/api/files/${data.id}/view`}*/}
{/* type={data.mimeType as string}*/}
{/* />*/}
{/* Browser not supported*/}
{/*</video>*/}
</div>
{/*)}*/}
</div>
<div
className={
"col-span-1 flex h-fit flex-col gap-2 tablet:sticky tablet:top-16"
}
>
<div className={"card"}>
<div className='flex w-full items-center justify-between rounded-lg px-2'>
<span className='font-bold'>Details</span>
</div>
<div className={"divider-horizontal"} />
<div className='flex w-full flex-col items-center justify-center gap-4 px-2'>
{metadata.map((item, index) => (
<div
className='flex w-full flex-col justify-center'
key={index}
>
<span className='font-bold text-inherit'>{item.label}</span>
<span className='whitespace-pre-wrap break-words text-inherit'>
{item.value}
</span>
</div>
))}
</div>
</div>
<div className={"card"}>
<DetailsButtons data={data} />
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,90 @@
import { FilesResponse } from "@/types/googleapis";
import GridFile from "@components/File/Grid";
import { drive_v3 } from "googleapis";
import EmptyFeedback from "@components/APIFeedback/Empty";
type Props = {
data: FilesResponse | undefined;
pagination: {
swrData: FilesResponse[] | undefined;
size: number;
setSize: (size: number) => Promise<FilesResponse[] | undefined>;
isLoadingMore: boolean | undefined;
isReachingEnd: boolean | undefined;
};
};
export default function GridLayout({ data, pagination }: Props) {
const { swrData, isLoadingMore, isReachingEnd, size, setSize } = pagination;
return (
<>
{data?.folders.length === 0 && data?.files.length === 0 && (
<EmptyFeedback />
)}
{data!.folders.length > 0 && (
<>
<div className='flex w-full items-center justify-between rounded-lg px-4'>
<span className='font-bold'>Folders</span>
</div>
<div className='my-4 grid grid-cols-2 gap-4 tablet:grid-cols-5 desktop:grid-cols-7'>
{data?.folders.length === 0 ? (
<div className='col-span-full mb-4 flex w-full items-center justify-center'>
<span className='text-gray-500'>No folders</span>
</div>
) : (
<>
{data?.folders?.map((folder) => (
<GridFile
data={folder as drive_v3.Schema$File}
key={folder.id}
/>
))}
</>
)}
</div>
</>
)}
{data!.folders.length > 0 && data!.files.length > 0 && (
<div className='divider-horizontal' />
)}
{data!.files.length > 0 && (
<>
<div className='flex w-full items-center justify-between rounded-lg px-4'>
<span className='font-bold'>Files</span>
</div>
<div className='my-4 grid grid-cols-2 gap-4 tablet:grid-cols-5 desktop:grid-cols-7'>
{data?.files.length === 0 ? (
<div className='col-span-full mb-4 flex w-full items-center justify-center'>
<span className='text-gray-500'>No files</span>
</div>
) : (
<>
{data?.files.map((file) => (
<GridFile
data={file as drive_v3.Schema$File}
key={file.id}
/>
))}
</>
)}
</div>
</>
)}
{swrData?.[0].nextPageToken && !isReachingEnd && (
<button
disabled={isLoadingMore}
onClick={() => {
setSize(size + 1);
}}
className='w-full rounded-lg'
>
{isLoadingMore ? "Loading..." : "Load more file"}
</button>
)}
</>
);
}
@@ -0,0 +1,71 @@
import { FilesResponse } from "@/types/googleapis";
import ListFile from "@components/File/List";
import { drive_v3 } from "googleapis";
import EmptyFeedback from "@components/APIFeedback/Empty";
type Props = {
data: FilesResponse | undefined;
pagination: {
swrData: FilesResponse[] | undefined;
size: number;
setSize: (size: number) => Promise<FilesResponse[] | undefined>;
isLoadingMore: boolean | undefined;
isReachingEnd: boolean | undefined;
};
};
export default function ListLayout({ data, pagination }: Props) {
const { swrData, isLoadingMore, isReachingEnd, size, setSize } = pagination;
if (!data) return null;
return (
<>
{/* Head */}
<div
className={"grid grid-cols-1 gap-2 px-4 font-bold tablet:grid-cols-8"}
>
<div className={"tablet:col-span-4"}>Name</div>
<div className={"hidden tablet:col-span-2 tablet:block"}>Modified</div>
<div className={"hidden tablet:block"}>Size</div>
<div className={"hidden tablet:block"}>Action</div>
</div>
<div className={"divider-horizontal"} />
{/* Items */}
<div className={"grid grid-cols-1 gap-2 tablet:grid-cols-8"}>
{data?.folders.length === 0 && data?.files.length === 0 && (
<div className={"col-span-full"}>
<EmptyFeedback />
</div>
)}
{data.folders.map((folder) => (
<ListFile
data={folder as drive_v3.Schema$File}
key={folder.id}
/>
))}
{data!.files.map((file) => (
<ListFile
data={file as drive_v3.Schema$File}
key={file.id}
/>
))}
</div>
{swrData?.[0].nextPageToken && !isReachingEnd && (
<button
disabled={isLoadingMore}
onClick={() => {
setSize(size + 1);
}}
className='w-full rounded-lg'
>
{isLoadingMore ? "Loading..." : "Load more file"}
</button>
)}
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import siteConfig from "@config/site.config";
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<div className='flex w-full items-center justify-center gap-2 py-1'>
<span className='text-xs font-semibold'>
{currentYear} - {siteConfig.footerText}
</span>
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
import { useCallback, useEffect, useState } from "react";
import siteConfig from "@config/site.config";
import useLocalStorage from "@/hooks/useLocalStorage";
import Link from "next/link";
import Image from "next/image";
import {
MdLightMode,
MdDarkMode,
MdSearch,
MdMenu,
MdClose,
} from "react-icons/md";
import Modal from "@/components/utility/Modal";
import { formatBytes } from "@/utils/formatHelper";
import { ErrorResponse, SearchResponse } from "@/types/googleapis";
import useSWR from "swr";
import fetcher from "@utils/swrFetch";
import { drive_v3 } from "googleapis";
import LoadingFeedback from "@components/APIFeedback/Loading";
import ErrorFeedback from "@components/APIFeedback/Error";
import EmptyFeedback from "@components/APIFeedback/Empty";
import { BsFolderFill } from "react-icons/bs";
import { getFileIcon } from "@utils/mimeTypesHelper";
export default function Navbar() {
const [isDarkMode, setIsDarkMode] = useLocalStorage("isDarkMode", "false");
const [isDark, setIsDark] = useState<boolean>(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<drive_v3.Schema$File[]>(); // [
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (isDarkMode === "true") {
document.querySelector("html")?.classList.add("dark");
setIsDark(true);
} else {
document.querySelector("html")?.classList.remove("dark");
setIsDark(false);
}
}, [isDarkMode]);
useEffect(() => {
const timerId = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 500);
return () => {
clearTimeout(timerId);
};
}, [searchQuery]);
const { data, error, isLoading } = useSWR<SearchResponse, ErrorResponse>(
`/api/search?query=${debouncedSearchQuery}`,
fetcher,
);
useEffect(() => {
if (!isLoading && data) {
setSearchResults(data.files as drive_v3.Schema$File[]);
}
}, [data, isLoading]);
const handleDarkMode = useCallback(() => {
const darkMode = document.querySelector("html")?.classList.contains("dark");
if (darkMode) {
document.querySelector("html")?.classList.remove("dark");
setIsDarkMode("false");
setIsDark(false);
} else {
document.querySelector("html")?.classList.add("dark");
setIsDarkMode("true");
setIsDark(true);
}
}, [setIsDarkMode]);
const handleCloseSearch = useCallback(() => {
setIsSearching(false);
setSearchQuery("");
setSearchResults([]);
}, []);
return (
<>
{/* Navbar */}
<div className='navbar sticky top-0 z-[1000] flex w-full items-center justify-start gap-2 bg-inherit bg-opacity-90 px-2 py-2 tablet:gap-4'>
{/* Web Logo */}
<Link
href='/'
className='flex items-center gap-2'
>
<Image
src={siteConfig.siteIcon}
alt={siteConfig.siteName}
width={32}
height={32}
className='aspect-square h-6 w-6'
/>
<span className='hidden text-lg font-bold tablet:block'>
{siteConfig.siteName}
</span>
</Link>
{/* Gap */}
<div className='flex-grow'></div>
{/* Search */}
<div className='interactive flex items-center gap-2'>
<div
className='relative flex aspect-square h-6 w-6 cursor-pointer items-center justify-center text-inherit'
onClick={() => setIsSearching(true)}
>
<MdSearch className='h-full w-full' />
</div>
</div>
{/* Dark mode */}
<div
className='interactive relative flex aspect-square h-6 w-6 cursor-pointer items-center justify-center text-inherit'
onClick={handleDarkMode}
>
<MdDarkMode
className={`absolute left-0 h-full w-full transition-all duration-150 ${
isDark
? "pointer-events-none rotate-90 opacity-0"
: "pointer-events-auto rotate-0 opacity-100"
}`}
/>
<MdLightMode
className={`absolute left-0 h-full w-full transition-all duration-150 ${
isDark
? "pointer-events-auto rotate-0 opacity-100"
: "pointer-events-none rotate-90 opacity-0"
}`}
/>
</div>
{/* Menu Tablet+ */}
<div className='hidden gap-4 tablet:flex'>
{siteConfig.navbarLinks.map((link, linkIdx) => (
<Link
key={`navbar-link-${linkIdx}`}
href={link.href}
rel={link.newTab ? "noopener noreferrer" : ""}
target={link.newTab ? "_blank" : "_self"}
className='flex items-center gap-2'
>
{link.icon && <link.icon />}
{link.name}
</Link>
))}
</div>
{/* Menu mobile */}
<div
className='interactive relative flex aspect-square h-6 w-6 items-center justify-center text-inherit tablet:hidden'
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
<MdMenu
className={`absolute left-0 h-full w-full ${
isMenuOpen
? "pointer-events-none rotate-90 opacity-0"
: "pointer-events-auto rotate-0 opacity-100"
}`}
/>
<MdClose
className={`absolute left-0 h-full w-full ${
isMenuOpen
? "pointer-events-auto rotate-0 opacity-100"
: "pointer-events-none rotate-90 opacity-0"
}`}
/>
{/* <span
className='cursor-pointer'
onPointerDown={() => setIsMenuOpen(!isMenuOpen)}
>
<MdMenu className='h-full w-full' />
</span> */}
</div>
</div>
{/* MobileMenu */}
<div
className={`flex h-full w-full flex-col tablet:hidden ${
isMenuOpen ? "right-0" : "-right-full"
} navbar fixed z-[999] bg-zinc-100 py-16 transition-all dark:bg-zinc-900`}
>
{siteConfig.navbarLinks.map((link, linkIdx) => (
<Link
key={`mobile-link-${linkIdx}`}
href={link.href}
rel={link.newTab ? "noopener noreferrer" : ""}
target={link.newTab ? "_blank" : "_self"}
className='flex w-full items-center justify-center gap-2 px-4 py-2 text-center text-lg'
>
{link.icon && <link.icon />}
{link.name}
</Link>
))}
</div>
{/* Search Modal */}
<Modal
title={
<span className='flex items-center text-lg'>
<MdSearch /> Search files
</span>
}
isOpen={isSearching}
isCentered={false}
onClose={handleCloseSearch}
>
{/* Input */}
<div className='relative flex w-full flex-grow items-center'>
<MdSearch className='absolute right-4' />
<input
type='text'
className='w-full flex-grow py-2 pr-8'
placeholder='Type folder or file name...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className='divider-horizontal' />
{/* Result */}
<div className='flex max-h-96 w-full flex-col overflow-auto'>
{isLoading && (
<LoadingFeedback
message={"Searching..."}
useContainer={false}
/>
)}
{!isLoading && error && (
<ErrorFeedback
message={error?.errors.message}
useContainer={false}
/>
)}
{!isLoading && searchResults?.length === 0 && (
<EmptyFeedback
message={
debouncedSearchQuery
? "No results found"
: "Type something to search"
}
/>
)}
{!isLoading && searchResults && (
<>
{searchResults.map((item) => {
const isFolder =
item.mimeType === "application/vnd.google-apps.folder";
const Icon = isFolder
? BsFolderFill
: getFileIcon(item.fileExtension as string);
return (
<Link
href={isFolder ? `/folder/${item.id}` : `/file/${item.id}`}
key={item.id}
className='rounded-lg py-1 hover:bg-zinc-300 dark:hover:bg-zinc-600 tablet:px-2 tablet:py-2'
>
<div className='flex items-center gap-4'>
<div className='flex aspect-square h-10 w-10 items-center justify-center overflow-hidden rounded-lg tablet:h-12 tablet:w-12'>
{item.thumbnailLink ? (
<img
src={item.thumbnailLink}
alt={item.name as string}
className='h-full w-full object-cover'
/>
) : (
<Icon className='h-full w-full' />
)}
</div>
<div className='flex flex-col'>
<div className={"flex items-center gap-2"}>
<Icon className='h-4 w-4' />
<span className='text-sm font-medium'>
{item.name}
</span>
</div>
<span className='text-xs text-gray-500'>
{item.mimeType}
{!isFolder
? `${formatBytes(item.size as string)}`
: ""}
</span>
</div>
</div>
</Link>
);
})}
</>
)}
</div>
</Modal>
</>
);
}
+63
View File
@@ -0,0 +1,63 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkBreaks from "remark-breaks";
import remarkSlug from "remark-slug";
import remarkToc from "remark-toc";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import rehypePrism from "rehype-prism-plus";
import { SpecialComponents } from "react-markdown/lib/ast-to-react";
import { NormalComponents } from "react-markdown/lib/complex-types";
import config from "@/config/site.config";
type Props = {
content: string;
};
const customComponents: Partial<
Omit<NormalComponents, keyof SpecialComponents> & SpecialComponents
> = {
img({ alt, src, ...props }: any) {
return (
<div className='flex flex-col gap-0'>
<img
src={src}
alt={alt}
className='mx-auto w-full max-w-screen-tablet rounded-lg drop-shadow'
{...props}
onClick={(e) => {
window;
}}
/>
{config.readme.renderImageAlt && (
<span className='my-0.5 text-center text-sm italic tablet:text-base'>
{alt} |{" "}
<a
href={src}
target='_blank'
rel='noopener noreferrer'
>
Open in new tab
</a>
</span>
)}
</div>
);
},
};
export default function MarkdownRender({ content }: Props) {
return (
<div className='markdown'>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
rehypePlugins={[rehypeKatex, rehypeRaw, rehypePrism]}
components={customComponents}
>
{content}
</ReactMarkdown>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { ReactNode } from "react";
import { MdClose } from "react-icons/md";
type Props = {
title: string | ReactNode;
isOpen: boolean;
isCentered: boolean;
onClose: () => void;
children: ReactNode;
};
export default function Modal({
title,
isOpen,
isCentered = true,
onClose,
children,
}: Props) {
return (
<div
className={`fixed left-0 top-0 z-[1001] flex h-full w-full justify-center bg-zinc-200/75 text-inherit transition-all duration-300 dark:bg-zinc-900/75 ${
isOpen
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0"
} ${isCentered ? "items-center" : "items-start py-24"}`}
onClick={() => {
onClose();
}}
>
<div
className={`relative flex w-full min-w-[320px] max-w-sm flex-col gap-4 rounded-lg bg-zinc-200 p-4 drop-shadow-lg dark:bg-zinc-700 tablet:max-w-screen-tablet ${
isOpen ? "top-0" : "-top-12"
} transition-all duration-300`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div className='flex items-center justify-between'>
<span>{title}</span>
<span
className='interactive cursor-pointer'
onClick={onClose}
>
<MdClose />
</span>
</div>
<div className='flex flex-col items-center gap-2'>{children}</div>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import useLocalStorage from "@hooks/useLocalStorage";
import { MdArrowDropDown, MdGridView, MdList } from "react-icons/md";
import { useEffect, useRef, useState } from "react";
type Props = {
setLayoutStyle: (layoutStyle: "grid" | "list") => void;
};
export default function SwitchLayout({ setLayoutStyle }: Props) {
const [renderStyle, setRenderStyle] = useLocalStorage<"grid" | "list">(
"renderStyle",
"grid",
);
const [isRenderGrid, setIsRenderGrid] = useState<boolean>();
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setIsRenderGrid(renderStyle === "grid");
}, [renderStyle]);
// Check if click outside dropdownRef
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownRef]);
return (
<div
className={"relative flex w-fit flex-col"}
ref={dropdownRef}
>
<div
className={"layoutDropdown capitalize"}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<div className={"flex items-center gap-2"}>
{isRenderGrid && <MdGridView className={"h-4 w-4"} />}
{!isRenderGrid && <MdList className={"h-4 w-4"} />}
<span>{isRenderGrid ? "Grid" : "List"}</span>
</div>
<MdArrowDropDown />
</div>
<div
className={`layoutOption absolute left-0 right-0 top-0 z-10 flex flex-col ${
isDropdownOpen
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0"
}`}
>
<div
data-active={isRenderGrid}
className={"layoutItem"}
onClick={() => {
setLayoutStyle("grid");
setRenderStyle("grid");
setIsRenderGrid(true);
setIsDropdownOpen(false);
}}
>
<MdGridView className={"h-4 w-4"} />
<span>Grid</span>
</div>
<div
data-active={!isRenderGrid}
className={"layoutItem"}
onClick={() => {
setLayoutStyle("list");
setRenderStyle("list");
setIsRenderGrid(false);
setIsDropdownOpen(false);
}}
>
<MdList className={"h-4 w-4"} />
<span>List</span>
</div>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
client_id:
"740683322791-5glqp7unsscric9g51ccdsctu5nenkgd.apps.googleusercontent.com",
redirect_uri: "http://localhost:5000/auth/google/callback",
};
+75
View File
@@ -0,0 +1,75 @@
import { BsGithub, BsDiscord, BsPaypal } from "react-icons/bs";
const config = {
/* Site MetaData */
// The name of the site
siteName: "My Drive Linker",
// Fav icon of the site
// Also used as the logo of the site on the navbar
siteIcon: "/favicon.ico",
// Navbar menu links
navbarLinks: [
{
icon: BsDiscord,
name: "Discord",
href: "https://discord.gg/xxxxxx",
newTab: true,
},
{
icon: BsGithub,
name: "GitHub",
href: "https://www.github.com/mbaharip",
newTab: true,
},
{
icon: BsPaypal,
name: "Donate",
href: "https://www.paypal.me/mbaharip",
newTab: true,
},
{
name: "Main Website",
href: "https://www.mbaharip.com",
newTab: true,
},
],
// Footer text
// It will be rendered as
// {year} {footerText} - Powered by guDora-index {version} ❤️
footerText: "gDrive Linker",
/* Config for files render */
files: {
// How many files to show per page
itemsPerPage: 100,
// Max number of files to show in search result
searchResult: 10,
// Starting point of the drive
// Use 'root' to use My Drive as starting point
// Or use folder id to use a specific folder as starting point
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j",
// If this set to true, any user can download or view protected files.
// If this set to false, only authorized users can download or view protected files.
// The authorized users URL will have a token in it that valid for 1 hour.
allowDownloadProtectedFiles: true, // If this set to true, any user can download protected files, but can't see the details of the file or folder.
},
/* Config for readme file render */
readme: {
// If this set to true, it will render the readme file on the page.
render: true,
// Position of the readme file on the page.
// It can be "start" or "end".
position: "end",
// Render image description based on the image alt text.
renderImageAlt: true,
},
/* Config for file preview */
preview: {
// Timeout duration for preview request
// If the request takes longer than this, it will be canceled.
// And the thumbnail or the file icon will be shown instead.
// The value is in milliseconds.
timeout: 30000,
},
};
export default config;
+23
View File
@@ -0,0 +1,23 @@
import { toast } from "react-toastify";
export default function useCopyText() {
return (text: string) => {
if (!navigator.clipboard) {
toast.error("Your browser does not support copying to clipboard.");
return;
}
if (!text) {
toast.error("No text to copy.");
return;
}
navigator.clipboard
.writeText(text)
.then(() => {
toast.success("Copied to clipboard.");
})
.catch(() => {
toast.error("Could not copy to clipboard.");
});
};
}
+58
View File
@@ -0,0 +1,58 @@
import { useState, useEffect } from "react";
type SetValue<T> = (value: T | ((val: T) => T)) => void;
export default function useLocalStorage<T>(key: string, initialValue: T) {
// First check if there is a value in localStorage
function readLocalStorage() {
if (typeof window === "undefined") return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValue;
}
}
const [storedValue, setStoredValue] = useState<T>(readLocalStorage);
// Create dispatch function to set or remove localStorage
const setValue: SetValue<T> = (value) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error("Error setting localStorage:", error);
}
};
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key) {
try {
if (event.newValue === null) {
setStoredValue(initialValue);
return;
}
const newValue = JSON.parse(event.newValue);
setStoredValue(newValue);
} catch (error) {
console.log(error);
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return [storedValue, setValue] as const;
}
+76
View File
@@ -0,0 +1,76 @@
import type { AppProps } from "next/app";
import { SWRConfig } from "swr";
import Navbar from "@/components/layout/Navbar";
import Footer from "@/components/layout/Footer";
import NextNProgress from "nextjs-progressbar";
import "@/styles/globals.css";
import "@/styles/markdown.css";
import "@/styles/highlight.css";
import "@config/site.config";
import "react-toastify/dist/ReactToastify.min.css";
import { Exo_2, Source_Sans_Pro, JetBrains_Mono } from "next/font/google";
import { IconContext } from "react-icons";
import { ToastContainer } from "react-toastify";
import useLocalStorage from "@hooks/useLocalStorage";
const exo2 = Exo_2({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-exo2",
});
const sourceSansPro = Source_Sans_Pro({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-source-sans-pro",
});
const jetBrainsMono = JetBrains_Mono({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-jetbrains-mono",
});
export default function App({ Component, pageProps }: AppProps) {
const [isDarkMode] = useLocalStorage<boolean>("isDarkMode", false);
return (
<main
className={`${exo2.variable} ${sourceSansPro.variable} ${jetBrainsMono.variable} font-body`}
>
<IconContext.Provider
value={{
size: "18px",
className: "react-icons",
}}
>
<ToastContainer
position='bottom-center'
autoClose={2500}
limit={3}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover={false}
theme={isDarkMode ? "dark" : "light"}
/>
<Navbar />
<NextNProgress options={{ showSpinner: false }} />
<div className='h-full w-full flex-grow items-center justify-center bg-zinc-300 px-4 py-4 dark:bg-zinc-800'>
<Component {...pageProps} />
</div>
<Footer />
</IconContext.Provider>
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Html, Head, Main, NextScript } from 'next/document'
import { Inter, Merriweather, JetBrains_Mono } from "next/font/google";
export default function Document() {
return (
<Html lang='en'>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { ErrorResponse } from "@/types/googleapis";
import drive from "@utils/driveClient";
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
try {
const { id } = request.query;
const fetchFileMetadata = await drive.files.get({
fileId: id as string,
fields: "id, name, mimeType, size",
});
const { name, mimeType, size } = fetchFileMetadata.data;
if (mimeType === "application/vnd.google-apps.folder") {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 400,
errors: {
message: "Cannot download folder",
reason: "badRequest",
},
};
return response.status(400).json(payload);
}
response.setHeader(
"Content-Disposition",
`attachment; filename=${encodeURIComponent(name as string)}`,
);
response.setHeader("Content-Type", mimeType || "application/octet-stream");
response.setHeader("Content-Length", size || 0);
const streamFile = await drive.files.get(
{
fileId: id as string,
alt: "media",
},
{
responseType: "stream",
},
);
streamFile.data.on("error", (error: any) => {
throw error;
});
streamFile.data.on("data", (chunk: Buffer) => {
response.write(chunk);
});
streamFile.data.on("end", () => {
response.end();
});
return response.status(200);
} catch (error: any) {
if (error satisfies ErrorResponse) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: error.code,
errors: {
message: error.errors?.[0].message || error.message,
reason: error.errors?.[0].reason || "internalError",
},
};
return response.status(error.code).json(payload);
}
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 500,
errors: {
message: error.message,
reason: "internalError",
},
};
return response.status(500).json(payload);
}
}
+199
View File
@@ -0,0 +1,199 @@
import {
ErrorResponse,
FileResponse,
FilesResponse,
TFileParent,
} from "@/types/googleapis";
import drive from "@/utils/driveClient";
import { buildQuery } from "@/utils/driveHelper";
import { NextApiRequest, NextApiResponse } from "next";
import config from "@config/site.config";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse<FilesResponse | FileResponse | ErrorResponse>,
) {
try {
const { id } = request.query;
const parentsArray: TFileParent[] = [];
const fetchFile = await drive.files.get({
fileId: id as string,
fields:
"id, name, mimeType, parents, thumbnailLink, fileExtension, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, exportLinks",
// "*",
});
// Fetch parents
if (fetchFile.data.mimeType === "application/vnd.google-apps.folder") {
parentsArray.push({
id: fetchFile.data.id as string,
name: fetchFile.data.name as string,
});
}
let parents = fetchFile.data.parents || [];
while (parents.length > 0) {
const fetchParents = await drive.files.get({
fileId: parents[0],
fields: "id, name, parents",
});
if (fetchParents.data.id === config.files.rootFolder) break;
parents = fetchParents.data.parents || [];
if (!parents.length) break;
parentsArray.push({
id: fetchParents.data.id as string,
name: fetchParents.data.name as string,
});
}
// Check for password file
// Password checking takes too long.
// Try to find a better way or just keep this as it is.
const passwordQuery = [
"name = '.password'",
"'me' in owners",
"trashed = false",
];
const fetchPassword = await drive.files.list({
q: passwordQuery.join(" and "),
fields: "files(id, name, parents)",
pageSize: 1000,
});
const passwordFile = fetchPassword.data.files?.find((file) => {
if (file.parents?.[0] === id) return true;
return parentsArray.some((parent) => parent.id === file.parents?.[0]);
});
if (passwordFile) {
// const {authorization} = request.headers;
// const userPassword = authorization?.split(" ")[1];
// For dev purpose, get password from query
const userPassword = request.query.password as string;
if (!userPassword)
return response.status(401).json({
success: false,
timestamp: new Date().toISOString(),
passwordRequired: true,
code: 401,
errors: {
message: "Unauthorized",
reason: "passwordRequired",
},
});
const getPassword = await drive.files.get(
{
fileId: passwordFile.id as string,
alt: "media",
},
{ responseType: "text" },
);
if (getPassword.data !== userPassword)
return response.status(401).json({
success: false,
timestamp: new Date().toISOString(),
passwordRequired: true,
code: 401,
errors: {
message: "Unauthorized",
reason: "passwordWrong",
},
});
}
// Check if file is folder
if (fetchFile.data.mimeType === "application/vnd.google-apps.folder") {
const { pageToken } = request.query;
const fetchFiles = await drive.files.list({
q: buildQuery({
id: id as string,
extraQuery: ["not mimeType contains 'application/vnd.google-apps'"],
}),
fields:
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size, videoMediaMetadata), nextPageToken",
orderBy: "folder, name asc",
pageSize: config.files.itemsPerPage,
pageToken: (pageToken as string) || undefined,
});
const fetchFolders = await drive.files.list({
q: buildQuery({
id: id as string,
extraQuery: ["mimeType = 'application/vnd.google-apps.folder'"],
}),
fields:
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size, videoMediaMetadata), nextPageToken",
orderBy: "folder, name asc",
pageSize: config.files.itemsPerPage,
pageToken: (pageToken as string) || undefined,
});
const checkReadme = await drive.files.list({
q: buildQuery({ id: id as string, extraQuery: ["name = 'readme.md'"] }),
});
const folders =
fetchFolders.data.files?.filter(
(file) => file.mimeType === "application/vnd.google-apps.folder",
) || [];
const files =
fetchFiles.data.files?.filter(
(file) => file.mimeType !== "application/vnd.google-apps.folder",
) || [];
const payload: FilesResponse = {
success: true,
timestamp: new Date().toISOString(),
parents: parentsArray,
passwordRequired: !!passwordFile,
passwordValidated: true,
folders,
files,
nextPageToken: fetchFiles.data.nextPageToken || undefined,
readmeExists: !!checkReadme.data.files?.length,
};
return response.status(200).json(payload);
}
const payload: FileResponse = {
success: true,
timestamp: new Date().toISOString(),
parents: parentsArray,
passwordRequired: !!passwordFile,
passwordValidated: true,
file: fetchFile.data,
};
return response.status(200).json(payload);
} catch (error: any) {
if (error satisfies ErrorResponse) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: error.code,
errors: {
message: error.errors[0].message,
reason: error.errors[0].reason,
},
};
return response.status(error.code).json(payload);
}
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 500,
errors: {
message: error.message,
reason: "internalError",
},
};
return response.status(500).json(payload);
}
}
+174
View File
@@ -0,0 +1,174 @@
import { ErrorResponse, TFileParent } from "@/types/googleapis";
import drive from "@utils/driveClient";
import { NextApiRequest, NextApiResponse } from "next";
import config from "@config/site.config";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
try {
const { id } = request.query;
const fetchFileMetadata = await drive.files.get({
fileId: id as string,
fields: "id, name, mimeType, size, exportLinks, parents",
});
if (!config.files.allowDownloadProtectedFiles) {
const parentsArray: TFileParent[] = [];
// Fetch parents
if (
fetchFileMetadata.data.mimeType === "application/vnd.google-apps.folder"
) {
parentsArray.push({
id: fetchFileMetadata.data.id as string,
name: fetchFileMetadata.data.name as string,
});
}
let parents = fetchFileMetadata.data.parents || [];
while (parents.length > 0) {
const fetchParents = await drive.files.get({
fileId: parents[0],
fields: "id, name, parents",
});
if (fetchParents.data.id === config.files.rootFolder) break;
parents = fetchParents.data.parents || [];
if (!parents.length) break;
parentsArray.push({
id: fetchParents.data.id as string,
name: fetchParents.data.name as string,
});
}
// Check for password file
// Password checking takes too long.
// Try to find a better way or just keep this as it is.
const passwordQuery = [
"name = '.password'",
"'me' in owners",
"trashed = false",
];
const fetchPassword = await drive.files.list({
q: passwordQuery.join(" and "),
fields: "files(id, name, parents)",
pageSize: 1000,
});
const passwordFile = fetchPassword.data.files?.find((file) => {
if (file.parents?.[0] === id) return true;
return parentsArray.some((parent) => parent.id === file.parents?.[0]);
});
if (passwordFile) {
// const {authorization} = request.headers;
// const userPassword = authorization?.split(" ")[1];
// For dev purpose, get password from query
const userPassword = request.query.password as string;
if (!userPassword)
return response.status(401).json({
success: false,
timestamp: new Date().toISOString(),
passwordRequired: true,
code: 401,
errors: {
message: "Unauthorized",
reason: "passwordRequired",
},
});
const getPassword = await drive.files.get(
{
fileId: passwordFile.id as string,
alt: "media",
},
{ responseType: "text" },
);
if (getPassword.data !== userPassword)
return response.status(401).json({
success: false,
timestamp: new Date().toISOString(),
passwordRequired: true,
code: 401,
errors: {
message: "Unauthorized",
reason: "passwordWrong",
},
});
}
}
console.log("Serve file.");
const { name, mimeType, size, exportLinks } = fetchFileMetadata.data;
if (mimeType === "application/vnd.google-apps.folder") {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 400,
errors: {
message: "Cannot view folder",
reason: "badRequest",
},
};
return response.status(400).json(payload);
}
response.setHeader(
"Content-Disposition",
`inline; filename=${encodeURIComponent(name as string)}`,
);
response.setHeader("Content-Type", mimeType || "application/octet-stream");
response.setHeader("Content-Length", size || 0);
const streamFile = await drive.files.get(
{
fileId: id as string,
alt: "media",
},
{
responseType: "stream",
},
);
streamFile.data.on("error", (error: any) => {
throw error;
});
streamFile.data.on("data", (chunk: Buffer) => {
response.write(chunk);
});
streamFile.data.on("end", () => {
response.end();
});
return response.status(200);
} catch (error: any) {
if (error satisfies ErrorResponse) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: error.code,
errors: {
message: error.errors?.[0].message || error.message,
reason: error.errors?.[0].reason || "internalError",
},
};
return response.status(error.code).json(payload);
}
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 500,
errors: {
message: error.message,
reason: "internalError",
},
};
return response.status(500).json(payload);
}
}
+84
View File
@@ -0,0 +1,84 @@
import { ErrorResponse, FilesResponse } from "@/types/googleapis";
import drive from "@/utils/driveClient";
import { buildQuery } from "@/utils/driveHelper";
import { NextApiRequest, NextApiResponse } from "next";
import config from "@config/site.config";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse<FilesResponse | ErrorResponse>,
) {
try {
const { pageToken } = request.query;
const fetchFiles = await drive.files.list({
q: buildQuery({
extraQuery: ["not mimeType contains 'application/vnd.google-apps'"],
}),
fields:
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size, videoMediaMetadata), nextPageToken",
orderBy: "folder, name asc",
pageSize: config.files.itemsPerPage,
pageToken: (pageToken as string) || undefined,
});
const fetchFolders = await drive.files.list({
q: buildQuery({
extraQuery: ["mimeType = 'application/vnd.google-apps.folder'"],
}),
fields:
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size, videoMediaMetadata), nextPageToken",
orderBy: "folder, name asc",
pageSize: config.files.itemsPerPage,
pageToken: (pageToken as string) || undefined,
});
const checkReadme = await drive.files.list({
q: buildQuery({ extraQuery: ["name = 'readme.md'"] }),
});
const folders =
fetchFolders.data.files?.filter(
(file) => file.mimeType === "application/vnd.google-apps.folder",
) || [];
const files =
fetchFiles.data.files?.filter(
(file) => file.mimeType !== "application/vnd.google-apps.folder",
) || [];
const payload: FilesResponse = {
success: true,
timestamp: new Date().toISOString(),
folders,
files,
nextPageToken: fetchFiles.data.nextPageToken || undefined,
readmeExists: !!checkReadme.data.files?.length,
};
return response.status(200).json(payload);
} catch (error: any) {
if (error satisfies ErrorResponse) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: error.code,
errors: {
message: error.errors[0].message,
reason: error.errors[0].reason,
},
};
return response.status(error.code).json(payload);
}
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 500,
errors: {
message: error.message,
reason: "internalError",
},
};
return response.status(500).json(payload);
}
}
+86
View File
@@ -0,0 +1,86 @@
import { ErrorResponse, ReadmeResponse } from "@/types/googleapis";
import { buildQuery } from "@/utils/driveHelper";
import drive from "@/utils/driveClient";
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
try {
const { id } = request.query;
const fetchReadme = await drive.files.list({
q: buildQuery({ id: id as string, extraQuery: ["name = 'readme.md'"] }),
fields: "files(id, name, mimeType, size)",
});
if (!fetchReadme.data.files?.length) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 404,
errors: {
message: "Can't find readme",
reason: "notFound",
},
};
return response.status(404).json(payload);
}
const { id: fileId, name, mimeType, size } = fetchReadme.data.files[0];
response.setHeader(
"Content-Disposition",
`inline; filename=${encodeURIComponent(name as string)}`,
);
response.setHeader("Content-Type", mimeType || "application/octet-stream");
response.setHeader("Content-Length", size || 0);
const streamFile = await drive.files.get(
{
fileId: fileId as string,
alt: "media",
},
{
responseType: "stream",
},
);
streamFile.data.on("error", (error: any) => {
throw error;
});
streamFile.data.on("data", (chunk: Buffer) => {
response.write(chunk);
});
streamFile.data.on("end", () => {
response.end();
});
} catch (error: any) {
if (error satisfies ErrorResponse) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: error.code,
errors: {
message: error.errors[0].message,
reason: error.errors[0].reason,
},
};
return response.status(error.code).json(payload);
}
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 500,
errors: {
message: error.message,
reason: "internalError",
},
};
return response.status(500).json(payload);
}
}
+90
View File
@@ -0,0 +1,90 @@
import { ErrorResponse, ReadmeResponse } from "@/types/googleapis";
import { buildQuery } from "@/utils/driveHelper";
import drive from "@/utils/driveClient";
import { NextApiRequest, NextApiResponse } from "next";
import { drive_v3 } from "googleapis";
import axios from "axios";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
try {
const fetchReadme = await drive.files.list({
q: buildQuery({ extraQuery: ["name = 'readme.md'"] }),
fields: "files(id, name, mimeType, size)",
orderBy: "modifiedTime desc",
});
if (!fetchReadme.data.files?.length) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 404,
errors: {
message: "Can't find readme",
reason: "notFound",
},
};
return response.status(404).json(payload);
}
const { id, name, mimeType, size } = fetchReadme.data.files[0];
response.setHeader(
"Content-Disposition",
`inline; filename=${encodeURIComponent(name as string)}`,
);
response.setHeader("Content-Type", mimeType || "application/octet-stream");
response.setHeader("Content-Length", size || 0);
const streamFile = await drive.files.get(
{
fileId: id as string,
alt: "media",
},
{
responseType: "stream",
},
);
streamFile.data.on("error", (error: any) => {
throw error;
});
streamFile.data.on("data", (chunk: Buffer) => {
response.write(chunk);
});
streamFile.data.on("end", () => {
response.end();
});
return response.status(200);
} catch (error: any) {
if (error satisfies ErrorResponse) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: error.code,
errors: {
message: error.errors?.[0]?.message || "",
reason: error.errors?.[0]?.reason || "",
},
};
return response.status(error.code).json(payload);
}
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 500,
errors: {
message: error.message,
reason: "internalError",
},
};
return response.status(500).json(payload);
}
}
+71
View File
@@ -0,0 +1,71 @@
import {
FilesResponse,
ErrorResponse,
SearchResponse,
} from "@/types/googleapis";
import drive from "@/utils/driveClient";
import { buildQuery } from "@/utils/driveHelper";
import { NextApiRequest, NextApiResponse } from "next";
import config from "@config/site.config";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse<SearchResponse | ErrorResponse>,
) {
try {
const { query } = request.query;
if (!query) {
const payload: SearchResponse = {
success: true,
timestamp: new Date().toISOString(),
files: [],
};
return response.status(200).json(payload);
}
const fetchFiles = await drive.files.list({
q: buildQuery({
extraQuery: [`name contains '${query}'`],
globalSearch: true,
}),
fields:
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size)",
pageSize: config.files.searchResult,
});
const payload: SearchResponse = {
success: true,
timestamp: new Date().toISOString(),
files: fetchFiles.data.files || [],
};
return response.status(200).json(payload);
} catch (error: any) {
if (error satisfies ErrorResponse) {
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: error.code,
errors: {
message: error.errors[0].message,
reason: error.errors[0].reason,
},
};
return response.status(error.code).json(payload);
}
const payload: ErrorResponse = {
success: false,
timestamp: new Date().toISOString(),
code: 500,
errors: {
message: error.message,
reason: "internalError",
},
};
return response.status(500).json(payload);
}
}
+57
View File
@@ -0,0 +1,57 @@
import useSWR from "swr";
import fetcher from "@utils/swrFetch";
import { ErrorResponse, FileResponse, TFileParent } from "@/types/googleapis";
import Breadcrumb from "@/components/Breadcrumb";
import { useEffect, useState } from "react";
import LoadingFeedback from "@components/APIFeedback/Loading";
import ErrorFeedback from "@components/APIFeedback/Error";
import { useRouter } from "next/router";
import FileDetails from "@components/layout/FileDetails";
export default function File() {
const router = useRouter();
const { id } = router.query;
const [data, setData] = useState<FileResponse>();
const [dataLoading, setDataLoading] = useState<boolean>(true);
const {
data: swrData,
error,
isLoading,
} = useSWR<FileResponse, ErrorResponse>(`/api/files/${id}`, fetcher);
useEffect(() => {
setDataLoading(true);
if (swrData) {
const parentsArray: TFileParent[] | undefined = swrData.parents;
parentsArray?.unshift({
id: swrData.file.id as string,
name: swrData.file.name as string,
});
const payload: FileResponse = {
parents: parentsArray,
...swrData,
};
setData(payload);
setDataLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swrData, error, isLoading]);
return (
<div className='mx-auto flex max-w-screen-xl flex-col gap-4'>
<div className='flex items-center justify-between'>
<Breadcrumb
data={data?.parents || []}
isLoading={dataLoading}
/>
</div>
{isLoading && <LoadingFeedback message={"Loading file details..."} />}
{!isLoading && error && <ErrorFeedback message={error.errors?.message} />}
{!isLoading && !error && data && <FileDetails data={data.file} />}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import fetcher, { buildNextKey } from "@utils/swrFetch";
import { ErrorResponse, FilesResponse, TFile } from "@/types/googleapis";
import Breadcrumb from "@/components/Breadcrumb";
import { drive_v3 } from "googleapis";
import { useEffect, useState } from "react";
import MarkdownRender from "@/components/utility/MarkdownRender";
import config from "@config/site.config";
import GridLayout from "@components/layout/Files/GridLayout";
import useLocalStorage from "@hooks/useLocalStorage";
import SwitchLayout from "@components/utility/SwitchLayout";
import ListLayout from "@components/layout/Files/ListLayout";
import LoadingFeedback from "@components/APIFeedback/Loading";
import ErrorFeedback from "@components/APIFeedback/Error";
import { useRouter } from "next/router";
export default function Folder() {
const router = useRouter();
const { id } = router.query;
const [data, setData] = useState<FilesResponse>();
const [dataLoading, setDataLoading] = useState<boolean>(true);
const [isReadmeExists, setIsReadmeExists] = useState<boolean>(false);
const [renderStyle] = useLocalStorage<"grid" | "list">("renderStyle", "grid");
const [layoutStyle, setLayoutStyle] = useState<"grid" | "list">(renderStyle);
const getNextKey = buildNextKey(`/api/files/${id}`);
const {
data: swrData,
error,
isLoading,
size,
setSize,
} = useSWRInfinite<FilesResponse, ErrorResponse>(getNextKey, fetcher);
const {
data: readmeData,
error: readmeError,
isLoading: readmeLoading,
} = useSWR(`/api/readme/${id}`, fetcher);
const isLoadingInitialData = !swrData && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && swrData && typeof swrData[size - 1] === "undefined");
const isEmpty =
swrData?.[0]?.files.length === 0 && swrData?.[0]?.folders.length === 0;
const isReachingEnd =
isEmpty ||
(swrData &&
typeof swrData[swrData.length - 1]?.nextPageToken === "undefined");
useEffect(() => {
setDataLoading(true);
const files: (TFile | drive_v3.Schema$File)[] | undefined =
swrData?.flatMap((item: FilesResponse) => item.files);
const folders: (TFile | drive_v3.Schema$File)[] | undefined =
swrData?.flatMap((item: FilesResponse) => item.folders);
const newData: FilesResponse = {
...(swrData?.[size - 1] as FilesResponse),
files: files || [],
folders: folders || [],
};
setData(newData);
setDataLoading(false);
if (newData.readmeExists) setIsReadmeExists(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swrData, error, isLoading, size]);
return (
<div className='mx-auto flex max-w-screen-xl flex-col gap-4'>
<div className='flex items-center justify-between'>
<Breadcrumb
data={data?.parents || []}
isLoading={dataLoading}
/>
<SwitchLayout setLayoutStyle={setLayoutStyle} />
</div>
{isLoading && <LoadingFeedback message={"Loading file..."} />}
{!isLoading && error && <ErrorFeedback message={error.errors?.message} />}
{!isLoading && !error && data && (
<>
{isReadmeExists && config.readme.position === "start" && (
<div className='card w-full'>
{readmeLoading && (
<LoadingFeedback message={"Loading readme..."} />
)}
{readmeError && !readmeLoading && (
<ErrorFeedback message={readmeError.errors?.message} />
)}
{readmeData && !readmeLoading && (
<MarkdownRender content={readmeData as string} />
)}
</div>
)}
<div className={"card"}>
{layoutStyle === "list" && (
<ListLayout
data={data}
pagination={{
swrData,
isLoadingMore,
isReachingEnd,
size,
setSize,
}}
/>
)}
{layoutStyle === "grid" && (
<GridLayout
data={data}
pagination={{
swrData,
isLoadingMore,
isReachingEnd,
size,
setSize,
}}
/>
)}
</div>
{isReadmeExists && config.readme.position === "end" && (
<div className='card w-full'>
{readmeLoading && (
<LoadingFeedback message={"Loading readme..."} />
)}
{readmeError && !readmeLoading && (
<ErrorFeedback message={readmeError.errors?.message} />
)}
{readmeData && !readmeLoading && (
<MarkdownRender content={readmeData as string} />
)}
</div>
)}
</>
)}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import fetcher, { buildNextKey } from "@utils/swrFetch";
import { ErrorResponse, FilesResponse, TFile } from "@/types/googleapis";
import Breadcrumb from "@/components/Breadcrumb";
import { drive_v3 } from "googleapis";
import { useEffect, useState } from "react";
import MarkdownRender from "@/components/utility/MarkdownRender";
import config from "@config/site.config";
import GridLayout from "@components/layout/Files/GridLayout";
import useLocalStorage from "@hooks/useLocalStorage";
import SwitchLayout from "@components/utility/SwitchLayout";
import ListLayout from "@components/layout/Files/ListLayout";
import LoadingFeedback from "@components/APIFeedback/Loading";
import ErrorFeedback from "@components/APIFeedback/Error";
export default function Home() {
const [data, setData] = useState<FilesResponse>();
const [dataLoading, setDataLoading] = useState<boolean>(true);
const [isReadmeExists, setIsReadmeExists] = useState<boolean>(false);
const [renderStyle] = useLocalStorage<"grid" | "list">("renderStyle", "grid");
const [layoutStyle, setLayoutStyle] = useState<"grid" | "list">(renderStyle);
const getNextKey = buildNextKey("/api/files/");
const {
data: swrData,
error,
isLoading,
size,
setSize,
} = useSWRInfinite<FilesResponse, ErrorResponse>(getNextKey, fetcher);
const {
data: readmeData,
error: readmeError,
isLoading: readmeLoading,
} = useSWR("/api/readme/", fetcher);
const isLoadingInitialData = !swrData && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && swrData && typeof swrData[size - 1] === "undefined");
const isEmpty =
swrData?.[0]?.files.length === 0 && swrData?.[0]?.folders.length === 0;
const isReachingEnd =
isEmpty ||
(swrData &&
typeof swrData[swrData.length - 1]?.nextPageToken === "undefined");
useEffect(() => {
setDataLoading(true);
const files: (TFile | drive_v3.Schema$File)[] | undefined =
swrData?.flatMap((item: FilesResponse) => item.files);
const folders: (TFile | drive_v3.Schema$File)[] | undefined =
swrData?.flatMap((item: FilesResponse) => item.folders);
const newData: FilesResponse = {
...(swrData?.[size - 1] as FilesResponse),
files: files || [],
folders: folders || [],
};
setData(newData);
setDataLoading(false);
if (newData.readmeExists) setIsReadmeExists(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swrData, error, isLoading, size]);
return (
<div className='mx-auto flex max-w-screen-xl flex-col gap-4'>
<div className='flex items-center justify-between'>
<Breadcrumb
data={data?.parents || []}
isLoading={dataLoading}
/>
<SwitchLayout setLayoutStyle={setLayoutStyle} />
</div>
{isLoading && <LoadingFeedback message={"Loading file..."} />}
{!isLoading && error && <ErrorFeedback message={error.errors?.message} />}
{!isLoading && !error && data && (
<>
{isReadmeExists && config.readme.position === "start" && (
<div className='card w-full'>
{readmeLoading && (
<LoadingFeedback message={"Loading readme..."} />
)}
{readmeError && !readmeLoading && (
<ErrorFeedback message={readmeError.errors?.message} />
)}
{readmeData && !readmeLoading && (
<MarkdownRender content={readmeData as string} />
)}
</div>
)}
<div className={"card"}>
{layoutStyle === "list" && (
<ListLayout
data={data}
pagination={{
swrData,
isLoadingMore,
isReachingEnd,
size,
setSize,
}}
/>
)}
{layoutStyle === "grid" && (
<GridLayout
data={data}
pagination={{
swrData,
isLoadingMore,
isReachingEnd,
size,
setSize,
}}
/>
)}
</div>
{isReadmeExists && config.readme.position === "end" && (
<div className='card w-full'>
{readmeLoading && (
<LoadingFeedback message={"Loading readme..."} />
)}
{readmeError && !readmeLoading && (
<ErrorFeedback message={readmeError.errors?.message} />
)}
{readmeData && !readmeLoading && (
<MarkdownRender content={readmeData as string} />
)}
</div>
)}
</>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { MdContentCopy } from "react-icons/md";
import { useState } from "react";
import { generateRandomEncryptionKey } from "@utils/encryptionHelper";
import useCopyText from "@hooks/useCopyText";
import { toast } from "react-toastify";
export default function Setup() {
const [key, setKey] = useState<string>("");
const copyText = useCopyText();
return (
<div className='mx-auto flex max-w-screen-xl flex-col gap-4'>
<div className={"card"}>
<div className='flex w-full items-center justify-between rounded-lg px-4'>
<span className='font-bold'>Generate random encryption key</span>
</div>
<div className={"divider-horizontal"} />
<p className={"mx-auto max-w-screen-md px-4 py-4 text-center"}>
On this page you can generate a random encryption key.
<br />
This key will be used to encrypt your files.
<br />
You can also copy the key to your clipboard and save it somewhere
safe.
</p>
<div
className={"mx-auto flex w-full max-w-screen-md flex-col gap-2 px-4"}
>
<div className={"flex flex-col gap-2"}>
<span className='font-bold'>Encryption key</span>
<input
type={"text"}
disabled
className={"pr-4"}
value={key}
/>
</div>
<div
className={
"mx-auto mt-4 flex w-full max-w-md flex-col items-center justify-center gap-2 tablet:flex-row tablet:gap-4"
}
>
<button
className={"flex w-full items-center justify-center"}
onClick={(e) => {
e.preventDefault();
const generated = generateRandomEncryptionKey()
.then((key) => {
setKey(key);
})
.catch((err) => {
toast.error("Failed to generate encryption key");
console.error(err);
});
}}
>
Generate
</button>
<button
className={"flex w-full items-center justify-center"}
onClick={(e) => {
e.preventDefault();
copyText(key);
}}
>
Copy
</button>
</div>
</div>
</div>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Base */
@layer base {
html {
@apply text-sm tablet:text-base;
}
/* Scrollbar */
::-webkit-scrollbar {
@apply w-1.5;
}
html ::-webkit-scrollbar-track {
@apply bg-zinc-100 rounded;
}
html ::-webkit-scrollbar-thumb {
@apply bg-zinc-400 rounded;
}
html ::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-500;
}
html.dark ::-webkit-scrollbar-track {
@apply bg-zinc-500;
}
html.dark ::-webkit-scrollbar-thumb {
@apply bg-zinc-900;
}
html.dark ::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-800;
}
main {
@apply flex flex-col items-center justify-start min-h-screen h-full w-full;
@apply bg-zinc-100 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100;
}
/* Typography */
h1 {
@apply text-4xl leading-loose font-bold;
}
h2 {
@apply text-3xl leading-loose font-bold;
}
h3 {
@apply text-2xl leading-loose font-bold;
}
h4 {
@apply text-xl leading-relaxed font-semibold;
}
h5 {
@apply text-lg leading-relaxed font-semibold;
}
h6 {
@apply text-base leading-normal font-semibold;
}
p {
@apply text-base leading-relaxed whitespace-pre-wrap;
}
span {
@apply text-base leading-normal;
}
a {
@apply hover:opacity-75 transition duration-150;
}
input, textarea, button, select {
@apply rounded-full px-4 py-2 tablet:py-1;
@apply text-sm tablet:text-base;
@apply outline-none border;
@apply border-zinc-400 hover:border-blue-300 focus:border-blue-400;
@apply bg-zinc-200 hover:bg-zinc-300 focus:bg-zinc-50;
@apply dark:border-zinc-600 dark:hover:border-blue-400 dark:focus:border-blue-500;
@apply dark:bg-zinc-800 dark:hover:bg-zinc-700 dark:focus:bg-zinc-950;
@apply disabled:border-zinc-300 dark:disabled:border-zinc-700;
@apply disabled:bg-zinc-400 dark:disabled:bg-zinc-800;
@apply disabled:text-zinc-500;
@apply transition duration-150;
}
button {
@apply cursor-pointer disabled:cursor-default;
}
button.primary {
@apply text-zinc-100 dark:text-zinc-300;
@apply border-blue-400 hover:border-blue-500 active:border-blue-600;
@apply bg-blue-500 hover:bg-blue-600 active:bg-blue-400;
@apply dark:border-blue-500 dark:hover:border-blue-600 dark:active:border-blue-700;
@apply dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-600;
}
button.secondary{
@apply text-zinc-100 dark:text-zinc-300;
@apply border-zinc-400 hover:border-zinc-500 active:border-zinc-600;
@apply bg-zinc-500 hover:bg-zinc-600 active:bg-zinc-400;
@apply dark:border-zinc-500 dark:hover:border-zinc-600 dark:active:border-zinc-700;
@apply dark:bg-zinc-500 dark:hover:bg-zinc-400 dark:active:bg-zinc-600;
}
table {
@apply w-full border-collapse;
}
th, td {
@apply text-left;
}
th {
@apply border-b border-zinc-400 dark:border-zinc-700 pt-1 pb-2;
}
td {
@apply border-b border-zinc-400 dark:border-zinc-700 py-1.5;
}
}
@layer components {
div.card {
@apply rounded-lg p-2 tablet:p-4;
@apply border border-zinc-400 dark:border-zinc-700;
@apply drop-shadow;
@apply bg-zinc-100 dark:bg-zinc-900;
}
div.layoutDropdown {
@apply rounded-lg pr-2 pl-4 py-1 cursor-pointer;
@apply flex items-center justify-between gap-8;
@apply text-sm tablet:text-base;
@apply border;
@apply border-zinc-400 hover:border-blue-300 focus:border-blue-400;
@apply bg-zinc-200 hover:bg-zinc-300 focus:bg-zinc-50;
@apply dark:border-zinc-600 dark:hover:border-blue-400 dark:focus:border-blue-500;
@apply dark:bg-zinc-800 dark:hover:bg-zinc-700 dark:focus:bg-zinc-950;
@apply disabled:border-zinc-300 dark:disabled:border-zinc-700;
@apply disabled:bg-zinc-400 dark:disabled:bg-zinc-800;
@apply disabled:text-zinc-500;
@apply transition duration-150;
}
div.layoutOption {
@apply rounded-lg overflow-hidden;
@apply flex flex-col justify-center;
@apply text-sm tablet:text-base;
@apply border;
@apply border-blue-400;
@apply bg-zinc-200 hover:bg-zinc-300 focus:bg-zinc-50;
@apply dark:border-blue-500;
@apply dark:bg-zinc-800 dark:hover:bg-zinc-700 dark:focus:bg-zinc-950;
@apply transition duration-300 ease-in-out;
}
div.layoutItem {
@apply flex items-center justify-start gap-2 pr-2 pl-4 py-1 h-full w-full cursor-pointer;
@apply bg-zinc-200 hover:bg-zinc-300 focus:bg-zinc-50;
@apply dark:bg-zinc-800 dark:hover:bg-zinc-700 dark:focus:bg-zinc-950;
}
div.layoutItem[data-active="true"] {
@apply bg-blue-300 cursor-default;
@apply dark:bg-blue-400;
}
div.items {
@apply rounded-xl p-1 duration-75;
@apply bg-zinc-300/50 hover:bg-zinc-300;
@apply dark:bg-zinc-800/50 dark:hover:bg-zinc-800;
@apply text-zinc-900/80 hover:text-zinc-950;
@apply dark:text-zinc-100/80 dark:hover:text-zinc-50;
@apply transition duration-200;
}
div.loading > svg {
@apply fill-zinc-900 dark:fill-zinc-100;
}
}
/* Utility */
@layer utilities {
.react-icons {
}
.fillCard {
@apply h-[15vh]
}
.navbar a {
@apply text-inherit font-normal not-italic;
}
.interactive {
@apply hover:opacity-75 transition duration-150;
}
.center {
@apply top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2;
}
div.divider-horizontal {
@apply w-full h-px my-2 bg-zinc-400 dark:bg-zinc-500 tablet:my-4;
}
div.divider-vertical {
@apply h-full w-px mx-2 bg-zinc-400 dark:bg-zinc-500 tablet:mx-4;
}
.grid-auto-fit {
grid-template-columns: repeat(auto-fit, minmax(16px, 1fr));
}
}
+470
View File
@@ -0,0 +1,470 @@
: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);
}
html.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-"] {
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;
-o-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-"] {
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 Invisibles 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;
}
+68
View File
@@ -0,0 +1,68 @@
@tailwind components;
@layer components {
.markdown {
@apply leading-normal text-inherit bg-inherit;
}
.markdown p {
@apply leading-loose;
}
.markdown h1, .markdown h2, .markdown h3 {
@apply mb-4 border-b-2 tablet:border-b-4 border-b-zinc-400 dark:border-b-zinc-700 first-letter:capitalize;
}
.markdown code:not(pre code) {
@apply bg-zinc-300 dark:bg-zinc-700 px-1.5 rounded-lg;
}
.markdown code {
@apply font-code;
}
.markdown a {
@apply text-blue-500 dark:text-blue-400;
}
.markdown p {
@apply whitespace-normal;
}
.markdown img {
@apply rounded-lg;
}
.markdown ul {
@apply list-disc ml-8 leading-loose;
}
.markdown table {
@apply block w-full overflow-auto border-collapse mb-4 border-spacing-0;
}
.markdown table th, .markdown table td {
@apply py-1 px-3 border border-zinc-500 dark:border-zinc-500;
}
.markdown table th {
@apply bg-zinc-100 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100;
}
.markdown table tr {
@apply bg-zinc-100 dark:bg-zinc-900;
}
.markdown table tr:nth-child(2n) {
@apply bg-zinc-200 dark:bg-zinc-800;
}
.markdown ul.contains-task-list {
@apply list-none ml-4;
}
.markdown input[type="checkbox"]{
@apply mr-2 w-4 h-4 aspect-square p-2;
@apply appearance-none border border-zinc-500;
@apply disabled:bg-zinc-200 dark:disabled:bg-zinc-700 disabled:checked:bg-blue-500 dark:disabled:checked:bg-blue-400;
}
.markdown blockquote {
@apply pl-2 border-l-4 border-l-zinc-500;
}
.markdown hr {
@apply my-2
}
}
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
import { drive_v3 } from "googleapis";
export interface APIResponse {
success: boolean;
timestamp: string;
}
export type TFileParent = {
id: string;
name: string;
};
export type TFolder = {
id: string;
name: string;
mimeType: string;
parents?: string[];
createdTime: string;
modifiedTime: string;
};
export type TFile = {
id: string;
name: string;
mimeType: string;
parents?: string[];
thumbnailLink?: string;
fileExtension?: string;
createdTime: string;
modifiedTime: string;
size?: string;
imageMediaMetadata?: {
width: number;
height: number;
rotation: number;
location: {
latitude: number;
longitude: number;
altitude: number;
};
time: string;
cameraMake: string;
cameraModel: string;
exposureTime: number;
aperture: number;
flashUsed: boolean;
focalLength: number;
isoSpeed: number;
meteringMode: string;
sensor: string;
exposureMode: string;
colorSpace: string;
whiteBalance: string;
exposureBias: number;
maxApertureValue: number;
subjectDistance: number;
lens: string;
};
videoMediaMetadata?: {
width: number;
height: number;
durationMillis: number;
};
exportLinks?: {
"application/rtf": string;
"application/vnd.oasis.opendocument.text": string;
"text/html": string;
"application/pdf": string;
"application/epub+zip": string;
"application/zip": string;
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": string;
"text/plain": string;
};
};
export interface FilesResponse extends APIResponse {
passwordRequired: boolean;
passwordValidated?: boolean;
parents?: TFileParent[];
folders: (TFolder | drive_v3.Schema$File)[];
files: (TFile | drive_v3.Schema$File)[];
readmeExists: boolean;
nextPageToken?: string;
}
export interface FileResponse extends APIResponse {
parents?: TFileParent[];
passwordRequired: boolean;
passwordValidated?: boolean;
file: TFile | drive_v3.Schema$File;
}
export interface SearchResponse extends APIResponse {
files: (TFile | drive_v3.Schema$File)[];
}
export interface ReadmeResponse extends APIResponse {
file: TFile | drive_v3.Schema$File;
}
export interface ErrorResponse extends APIResponse {
code: number;
errors: {
message: string;
reason: string;
};
}
+33
View File
@@ -0,0 +1,33 @@
import apiConfig from "@config/api.config";
import { google, drive_v3 } from "googleapis";
class DriveClient {
private instance: drive_v3.Drive;
constructor() {
const oauth2Client = new google.auth.OAuth2(
apiConfig.client_id,
process.env.CLIENT_SECRET,
apiConfig.redirect_uri,
);
oauth2Client.setCredentials({ refresh_token: process.env.REFRESH_TOKEN });
this.instance = google.drive({ version: "v3", auth: oauth2Client });
}
getInstance() {
if (!this.instance) {
const oauth2Client = new google.auth.OAuth2(
apiConfig.client_id,
process.env.CLIENT_SECRET,
apiConfig.redirect_uri,
);
oauth2Client.setCredentials({ refresh_token: process.env.REFRESH_TOKEN });
this.instance = google.drive({ version: "v3", auth: oauth2Client });
}
return this.instance;
}
}
const drive = new DriveClient();
export default drive.getInstance();
+70
View File
@@ -0,0 +1,70 @@
import drive from "@utils/driveClient";
import config from "@config/site.config";
export function buildQuery({
id,
extraQuery,
globalSearch = false,
}: {
id?: string;
extraQuery?: string[];
globalSearch?: boolean;
}) {
const query = [
"name != '.password'",
"'me' in owners",
"trashed = false",
// "mimeType != 'application/vnd.google-apps.shortcut'",
];
if (id && !globalSearch) {
query.unshift(`'${id}' in parents`);
}
if (!id && !globalSearch) {
query.unshift(`'${config.files.rootFolder}' in parents`);
}
if (extraQuery) {
query.unshift(...extraQuery);
}
return query.join(" and ");
}
export async function checkProtected(id: string) {
try {
const files = await drive.files.list({
q: id ? buildQuery({ id }) : buildQuery({}),
fields: "files(id)",
});
return {
protected: files.data.files?.length,
id: files.data.files?.[0].id || null,
};
} catch (error: any) {
return {
protected: false,
id: null,
};
}
}
export async function validateFolderPassword(
passwordFileId: string,
password: string,
) {
try {
const folderPassword = await drive.files.get(
{
fileId: passwordFileId,
alt: "media",
},
{ responseType: "text" },
);
return folderPassword.data === password;
} catch (error: any) {
return false;
}
}
+12
View File
@@ -0,0 +1,12 @@
import crypto from "crypto";
export function generateRandomEncryptionKey(): Promise<string> {
return new Promise((resolve, reject) => {
crypto.randomBytes(32, (err, buffer) => {
if (err) {
reject(err);
}
resolve(buffer.toString("hex"));
});
});
}
+78
View File
@@ -0,0 +1,78 @@
// Get locale from browser
export function getLocale() {
if (typeof window === "undefined") return "en-US";
if (window.navigator.languages) return window.navigator.languages[0];
return window.navigator.language;
}
export function formatDuration(milliseconds: string | number) {
let ms: number = milliseconds as number;
if (typeof milliseconds === "string") {
ms = parseInt(milliseconds);
}
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const hourString = hours > 0 ? `${hours}:` : "";
const minuteString =
minutes > 0 ? `${String(minutes).padStart(2, "0")}:` : "00:";
const secondString = seconds > 0 ? String(seconds).padStart(2, "0") : "00";
return `${hourString}${minuteString}${secondString}`;
}
export function formatBytes(bytes: number | string) {
let b: number = typeof bytes === "string" ? parseInt(bytes) : bytes;
if (b === 0) return "0 Bytes";
const k = 1024;
const dm = 2;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(b) / Math.log(k));
return parseFloat((b / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
export function formatDate(date: Date, locale = getLocale()) {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: false,
}).format(date);
}
type RelativeTimeFormatUnit =
| "year"
| "quarter"
| "month"
| "week"
| "day"
| "hour"
| "minute"
| "second";
export function formatRelativeDate(
date: Date,
unit: RelativeTimeFormatUnit = "day",
locale = getLocale(),
) {
const currentDate = Date.now();
let diff = (date.getTime() - currentDate) / (1000 * 60 * 60 * 24);
// if negative, round up, else round down
if (diff < 0) {
diff = Math.ceil(diff);
} else {
diff = Math.floor(diff);
}
return new Intl.RelativeTimeFormat(locale, {
localeMatcher: "best fit",
numeric: "auto",
style: "long",
}).format(diff, unit);
}
+239
View File
@@ -0,0 +1,239 @@
import mime from "mime-types";
import { IconType } from "react-icons";
import {
BsBoxFill,
BsDatabaseFill,
BsFileEarmarkBinaryFill,
BsFileEarmarkBreakFill,
BsFileEarmarkCodeFill,
BsFileEarmarkFill,
BsFileEarmarkFontFill,
BsFileEarmarkImageFill,
BsFileEarmarkMusicFill,
BsFileEarmarkPdfFill,
BsFileEarmarkPlayFill,
BsFileEarmarkRichtextFill,
BsFileEarmarkRuledFill,
BsFileEarmarkSlidesFill,
BsFileEarmarkSpreadsheetFill,
BsFileEarmarkTextFill,
BsFileEarmarkWordFill,
BsFileEarmarkZipFill,
BsFillDatabaseFill,
BsFolderFill,
} from "react-icons/bs";
import ModelPreview from "@components/FilePreview/ModelPreview";
import AudioPreview from "@components/FilePreview/AudioPreview";
import DefaultPreview from "@components/FilePreview/DefaultPreview";
import MarkdownPreview from "@components/FilePreview/MarkdownPreview";
import OfficePreview from "@components/FilePreview/OfficePreview";
import PDFPreview from "@components/FilePreview/PDFPreview";
import ImagePreview from "@components/FilePreview/ImagePreview";
import CodePreview from "@components/FilePreview/CodePreview";
import TextPreview from "@components/FilePreview/TextPreview";
import VideoPreview from "@components/FilePreview/VideoPreview";
export default function findMimeType(extension: string): string {
return mime.lookup(extension) || "application/octet-stream";
}
const type = {
"3d": "3d", // model preview
"audio": "audio", // audio preview
"archive": "archive", // default preview
"rich_text": "markdown", // markdown preview
"officeWord": "officeWord", // office preview
"officeExcel": "officeExcel", // office preview
"officePowerPoint": "officePowerPoint", // office preview
"pdf": "pdf", // pdf preview
"database": "database", // default preview
"image": "image", // image preview
"code": "code", // code preview
"text": "text", // text preview
"video": "video", // video preview
"font": "font", // default preview
"default": "default", // default preview
"binary": "binary", // default preview
};
const iconsForType: { [key: string]: IconType } = {
"3d": BsBoxFill,
"archive": BsFileEarmarkZipFill,
"audio": BsFileEarmarkMusicFill,
"rich_text": BsFileEarmarkRichtextFill,
"officeWord": BsFileEarmarkWordFill,
"officeExcel": BsFileEarmarkSpreadsheetFill,
"officePowerPoint": BsFileEarmarkSlidesFill,
"pdf": BsFileEarmarkPdfFill,
"database": BsDatabaseFill,
"image": BsFileEarmarkImageFill,
"code": BsFileEarmarkCodeFill,
"text": BsFileEarmarkTextFill,
"video": BsFileEarmarkPlayFill,
"font": BsFileEarmarkFontFill,
"default": BsFileEarmarkFill,
"binary": BsFileEarmarkBinaryFill,
};
const overlapVideo = ["ts", "ogg"];
const extToTypeMap: { [key: string]: string } = {
// 3D models
"fbx": type["3d"],
"obj": type["3d"],
"stl": type["3d"],
"gltf": type["3d"],
"glb": type["3d"],
// Audio
"aac": type.audio,
"flac": type.audio,
"m4a": type.audio,
"mp3": type.audio,
"ogg": type.audio, // Check for audio or video
"opus": type.audio,
"wav": type.audio,
// Archives
"7z": type.default,
"bz2": type.default,
"gz": type.default,
"rar": type.default,
"tar": type.default,
"zip": type.default,
// Rich text
"md": type.rich_text,
// Office
"doc": type.officeWord,
"docx": type.officeWord,
"odt": type.officeWord,
"xls": type.officeExcel,
"xlsx": type.officeExcel,
"ods": type.officeExcel,
"ppt": type.officePowerPoint,
"pptx": type.officePowerPoint,
"odp": type.officePowerPoint,
// PDF
"pdf": type.pdf,
// Database
"db": type.database,
"dbf": type.database,
"mdb": type.database,
"pdb": type.database,
"sql": type.database,
"csv": type.database,
"tsv": type.database,
// Images
"bmp": type.image,
"gif": type.image,
"jpg": type.image,
"jpeg": type.image,
"png": type.image,
"svg": type.image,
"webp": type.image,
"apng": type.image, //Should be able to preview according to MDN
"avif": type.image,
"ico": type.image,
// Code
"c": type.code,
"cpp": type.code,
"cs": type.code,
"css": type.code,
"go": type.code,
"h": type.code,
"html": type.code,
"ini": type.code,
"java": type.code,
"js": type.code,
"json": type.code,
"jsx": type.code,
"php": type.code,
"py": type.code,
"rb": type.code,
"rs": type.code,
"rust": type.code,
"sass": type.code,
"scss": type.code,
"sh": type.code,
"swift": type.code,
"ts": type.code, // Check for video also
"tsx": type.code,
"xml": type.code,
"yaml": type.code,
// Text
"txt": type.text,
// Video
"mp4": type.video,
"webm": type.video,
"3gp": type.video,
"mpeg": type.video,
"mov": type.video,
"mkv": type.video,
// Fonts
"woff": type.font,
"woff2": type.font,
"ttf": type.font,
"otf": type.font,
// Binary
"dat": type.binary,
"bin": type.binary,
"exe": type.binary,
"dll": type.binary,
"msi": type.binary,
};
export function getFilePreview(extension: string) {
if (overlapVideo.includes(extension)) {
const isVideo = findMimeType(extension).startsWith("video");
if (isVideo) {
return VideoPreview;
}
}
const category = extToTypeMap[extension] || type.default;
switch (category) {
case type["3d"]:
return ModelPreview;
case type.audio:
return AudioPreview;
case type.rich_text:
return MarkdownPreview;
case type.officeWord:
case type.officeExcel:
case type.officePowerPoint:
return OfficePreview;
case type.pdf:
return PDFPreview;
case type.image:
return ImagePreview;
case type.code:
return CodePreview;
case type.text:
return TextPreview;
case type.video:
return VideoPreview;
default:
return DefaultPreview;
}
}
export function getFileIcon(extension: string): IconType {
if (overlapVideo.includes(extension)) {
const isVideo = findMimeType(extension).startsWith("video");
if (isVideo) {
return iconsForType["video"];
}
}
const category = extToTypeMap[extension] || type.default;
return iconsForType[category];
}
+28
View File
@@ -0,0 +1,28 @@
import axios from "axios";
import {FilesResponse} from "@/types/googleapis";
const fetcher = async <T>(url: string) =>
axios.get<T>(url).then((res) => res.data);
function getNextKey(
pageIndex: number,
previousPageData: FilesResponse,
): string | null {
if (previousPageData && !previousPageData.nextPageToken) {
return null;
}
const pageToken = previousPageData ? previousPageData.nextPageToken : "";
return `/api/files?pageToken=${pageToken}`;
}
export function buildNextKey(apiURL:string) {
return (pageIndex: number, previousPageData: FilesResponse) => {
if (previousPageData && !previousPageData.nextPageToken) {
return null;
}
const pageToken = previousPageData ? previousPageData.nextPageToken : "";
return `${apiURL}?pageToken=${pageToken}`;
}
}
export default fetcher;
+38
View File
@@ -0,0 +1,38 @@
const defaultTheme = require("tailwindcss/defaultTheme");
/** @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: {
extend: {
fontFamily: {
body: ["var(--font-exo2)", ...defaultTheme.fontFamily.sans],
docs: ["var(--font-source-sans-pro", ...defaultTheme.fontFamily.sans],
code: ["var(--font-jetbrains-mono)", ...defaultTheme.fontFamily.mono],
},
screens: {
mobile: "320px",
tablet: "768px",
desktop: "1024px",
},
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")],
};
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext", "es2019"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@config/*": ["./src/config/*"],
"@hooks/*": ["./src/hooks/*"],
"@styles/*": ["./src/styles/*"],
"@utils/*": ["./src/utils/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", ".next"]
}
+3928
View File
File diff suppressed because it is too large Load Diff