Remove .env

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