mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
Remove .env
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@next/next/no-img-element": "off"
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
Generated
+12
@@ -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
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Generated
+6
@@ -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
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
client_id:
|
||||
"740683322791-5glqp7unsscric9g51ccdsctu5nenkgd.apps.googleusercontent.com",
|
||||
redirect_uri: "http://localhost:5000/auth/google/callback",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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.");
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user