mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
Redo List and Grid layout, Implement private index.
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
# You can generate encryption key from setup page.
|
||||
ENCRYPTION_KEY=""
|
||||
NEXT_PUBLIC_ENCRYPTION_KEY=""
|
||||
# You can use any random string for JWT_KEY, it is recommended to use a strong key by combining characters, numbers and symbols.
|
||||
JWT_KEY=""
|
||||
# Domain name of your website. Used for fetching the og-image from public directory.
|
||||
|
||||
+10
@@ -8,5 +8,15 @@
|
||||
</inspection_tool>
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ExceptionCaughtLocallyJS" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="myValues">
|
||||
<value>
|
||||
<list size="1">
|
||||
<item index="0" class="java.lang.String" itemvalue="tw" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
<option name="myCustomValuesEnabled" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@@ -45,9 +45,13 @@ I know there are a lot of people selling cheap edu account for Google Drive and
|
||||
|
||||
## TODO
|
||||
- [ ] Aim for max 1.5s response time for every API routes
|
||||
- Fetch all routes - 600~900ms
|
||||
- Fetch file or folder - 700~1400ms
|
||||
- Fetch all routes - 600~900ms (OK)
|
||||
- Fetch file or folder - 700~1400ms (OK)
|
||||
- Breadcrumb - 900~1400ms (OK)
|
||||
- Readme - 1100~1200ms (OK)
|
||||
- Banner - 500~800ms (OK)
|
||||
- [x] Navigate through folders
|
||||
- [ ] Use banner for OG image
|
||||
- [ ] File preview
|
||||
- [x] Audio
|
||||
- [x] Code
|
||||
@@ -63,7 +67,7 @@ I know there are a lot of people selling cheap edu account for Google Drive and
|
||||
- [x] Direct view the files
|
||||
- [x] Download files
|
||||
- [x] Render Readme file
|
||||
- [x] Password protection
|
||||
- [x] Create token hash for sharing protected ~~files that valid for x hours~~
|
||||
- [ ] Password protection
|
||||
- [ ] Create token hash for sharing protected ~~files that valid for x hours~~
|
||||
- [ ] Implement time limit for token
|
||||
- [x] ~~Pretty path URL~~ ~~(Not possible, since Google Drive allowing multiple files with same name)~~ (Kinda pretty now, it consist of the file name, then 8 character of file id for security)
|
||||
@@ -0,0 +1,98 @@
|
||||
# Todo
|
||||
Detailed what I need to do for this project.
|
||||
This is just a reminder for me, so I don't forget what I need to do.
|
||||
|
||||
Probably also include some ideas that I want to implement in the future.
|
||||
|
||||
## Frontend
|
||||
### General
|
||||
|
||||
### Navbar `8/8 Completed`
|
||||
- [x] Responsive
|
||||
- [x] Search
|
||||
- [x] Open / Close search modal
|
||||
- [x] Search for files or folder
|
||||
- [x] Configurable search limit
|
||||
- [x] Close search modal when click outside of modal
|
||||
- [x] Theme
|
||||
- [x] Switch theme
|
||||
- [x] Store theme preference in local storage
|
||||
- [x] Navbar menu
|
||||
|
||||
### View bar (Top bar) `9/10 Completed`
|
||||
This part contains only breadcrumb and view selection.
|
||||
- [x] Breadcrumb
|
||||
- [x] Fetch breadcrumb
|
||||
- [x] Navigate through breadcrumb
|
||||
- [x] Limit breadcrumb
|
||||
- [x] Add `...` if breadcrumb is too long
|
||||
- [ ] Add ellipsis if current file name is too long
|
||||
- [x] Configurable breadcrumb limit
|
||||
- [x] Layout view
|
||||
- [x] Grid view
|
||||
- [x] List view
|
||||
- [x] Store view preference in local storage
|
||||
- [x] Close view selection when click outside
|
||||
|
||||
### Home `/` | `4/5 Completed`
|
||||
- [x] Fetch files
|
||||
- [x] Fetch readme if exist
|
||||
- [x] ~~Fetch banner if exist~~ Fetch banner in server side for Opengraph Image
|
||||
- [x] Pagination
|
||||
- [ ] Protected root folder
|
||||
|
||||
### Folder `/folder/:id` | ``
|
||||
- [ ] Server side check if folder exist, if not redirect to 404 page
|
||||
- [ ] Fetch files
|
||||
- [ ] Fetch readme if exist
|
||||
- [ ] Fetch banner in server side for Opengraph Image
|
||||
- [ ] Protected folder
|
||||
|
||||
## Backend
|
||||
### General
|
||||
Since Google Drive direct download need the file to be public, I implement the `partial file id` to keep people from directly accessing the Google Drive itself, and also encrypt the `id` and `webContentLink` from Google Drive API to ensure that no one can access the file directly from Google Drive.
|
||||
- [x] Change `fileId` to `{file name}:{partial file id}`
|
||||
- [x] Encrypt `id` and `webContentLink`
|
||||
- [x] Making sure response time is fast enough. Aim for max 1.5s.
|
||||
- [x] Fetch root folder `600~900ms`
|
||||
- [x] Fetch file or folder `700~1400ms`
|
||||
- [x] Fetch breadcrumb `900~1500ms`
|
||||
- [x] Fetch readme `1100~1200ms`
|
||||
- [x] Fetch banner `500-900ms`
|
||||
- [ ] Protect folder
|
||||
|
||||
### Fetch files `/api/files` | `14/17 Completed`
|
||||
- [x] Fetch file
|
||||
- [x] Fetch folder
|
||||
- [x] Paginated
|
||||
- [x] Fetch info regarding readme file
|
||||
- [x] Fetch info regarding banner file
|
||||
- [x] Fetch info regarding password file (Not used yet)
|
||||
- [ ] Download file
|
||||
- [x] Redirect to Google Drive download link instead of using Vercel serverless function for files bigger than 4MB
|
||||
- [x] Only allow for files
|
||||
- [ ] Files inside protected folder
|
||||
- [x] Get thumbnail for files
|
||||
- [ ] Get banner inside folder
|
||||
- [x] Only if banner file is inside the folder
|
||||
- [ ] Redirect to Google Drive download link instead of using Vercel serverless function for files bigger than 4MB
|
||||
- [ ] Check if banner file is image
|
||||
- [x] Generate breadcrumb
|
||||
- [x] On folder
|
||||
- [x] On file
|
||||
- [x] Hide root folder
|
||||
- [x] Limiting breadcrumb length
|
||||
|
||||
### Get readme data `/api/readme` | `1/2 Completed`
|
||||
This API route doesn't need authorization, because it's only used for getting readme data.
|
||||
- [x] Get root folder readme
|
||||
- [ ] Get specific folder readme
|
||||
|
||||
### Get banner file id `/api/banner` | `2/2 Completed`
|
||||
This API route doesn't need authorization, because it's only used for getting banner file id for Opengraph Image.
|
||||
- [x] Get root folder banner
|
||||
- [x] Get specific folder banner
|
||||
|
||||
### Search for files `/api/search` | `2/2 Completed`
|
||||
- [x] Search for files and folder
|
||||
- [x] Limit search result
|
||||
@@ -4,8 +4,10 @@ type Props = {
|
||||
|
||||
export default function EmptyFeedback({ message }: Props) {
|
||||
return (
|
||||
<div className='fillCard flex w-full items-center justify-center gap-2'>
|
||||
{message || "Folder is empty"}
|
||||
<div className={"flex flex-col"}>
|
||||
<div className={"mx-auto flex items-center justify-center gap-4"}>
|
||||
<span>{message || "Folder is empty"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,30 @@ import { MdWarning } from "react-icons/md";
|
||||
|
||||
type Props = {
|
||||
message?: string;
|
||||
useContainer?: boolean;
|
||||
};
|
||||
|
||||
export default function ErrorFeedback({ message, useContainer = true }: Props) {
|
||||
export default function ErrorFeedback({ message }: Props) {
|
||||
const errorMessage: string = `An error occurred while processing your request.
|
||||
Please check the following details and try again:`;
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
<div className={"flex flex-col"}>
|
||||
<div className={"mx-auto flex items-center justify-center gap-4"}>
|
||||
<MdWarning className={"h-8 w-8 text-red-500 dark:text-red-400"} />
|
||||
<p className={"text-start"}>{errorMessage}</p>
|
||||
</div>
|
||||
|
||||
<div className={"divider-horizontal"} />
|
||||
<div
|
||||
className={
|
||||
"mx-auto flex w-full max-w-[50%] flex-col items-start justify-center"
|
||||
}
|
||||
>
|
||||
<span>Error details:</span>
|
||||
<pre className={"w-full py-2"}>
|
||||
<code>{message || "Internal server error"}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,38 +2,20 @@ import ReactLoading from "react-loading";
|
||||
|
||||
type Props = {
|
||||
message?: string;
|
||||
useContainer?: boolean;
|
||||
};
|
||||
|
||||
export default function LoadingFeedback({
|
||||
message,
|
||||
useContainer = true,
|
||||
}: Props) {
|
||||
export default function LoadingFeedback({ message }: 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>
|
||||
)}
|
||||
</>
|
||||
<div className={"flex flex-col"}>
|
||||
<div className={"mx-auto flex items-center justify-center gap-4"}>
|
||||
<ReactLoading
|
||||
type='spin'
|
||||
width={20}
|
||||
height={20}
|
||||
className={"loading"}
|
||||
/>
|
||||
<span>{message || "Loading..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,85 +1,50 @@
|
||||
import { TFileParent } from "types/googleapis";
|
||||
import { BreadCrumbsResponse, TFileParent } from "types/googleapis";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { MdHome } from "react-icons/md";
|
||||
import ReactLoading from "react-loading";
|
||||
import config from "config/site.config";
|
||||
import siteConfig from "config/site.config";
|
||||
|
||||
type Props = {
|
||||
data: TFileParent[];
|
||||
data: BreadCrumbsResponse | undefined;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export default function Breadcrumb({ data, isLoading }: Props) {
|
||||
const limitItem = 2;
|
||||
const [limitedPath, setLimitedPath] = useState<TFileParent[]>([]);
|
||||
const [isLimited, setIsLimited] = useState<boolean>();
|
||||
// const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
// setIsLoading(true);
|
||||
if (data.length > 0) {
|
||||
const findRoot = data.find((item) => item.id === config.files.rootFolder);
|
||||
let _data = data;
|
||||
if (findRoot) {
|
||||
_data = _data.filter((item) => item.id !== config.files.rootFolder);
|
||||
}
|
||||
setLimitedPath(_data.slice(0, limitItem).reverse());
|
||||
setIsLimited(_data.length > limitItem);
|
||||
// setIsLoading(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<>
|
||||
{isLoading ? (
|
||||
<ReactLoading
|
||||
type='spin'
|
||||
width={20}
|
||||
height={20}
|
||||
className={"loading"}
|
||||
/>
|
||||
<div className='flex h-[1.5rem] w-32 animate-pulse items-center gap-2 rounded bg-zinc-300 dark:bg-zinc-600' />
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
{/* Home */}
|
||||
<Link
|
||||
href='/'
|
||||
className='flex items-center gap-2'
|
||||
href={"/"}
|
||||
className={"flex items-center gap-1"}
|
||||
>
|
||||
<MdHome />
|
||||
<span>Root</span>
|
||||
</Link>
|
||||
{isLimited && (
|
||||
{data?.isLimitReached && (
|
||||
<Fragment>
|
||||
<span className={"cursor-default"}>/</span>
|
||||
<span className={"cursor-default"}>...</span>
|
||||
<span>...</span>
|
||||
<span>{siteConfig.breadcrumb.limiter}</span>
|
||||
</Fragment>
|
||||
)}
|
||||
<>
|
||||
{limitedPath.map((parent, idx) => (
|
||||
<Fragment key={parent.id}>
|
||||
<span className={"cursor-default"}>/</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>
|
||||
))}
|
||||
</>
|
||||
</>
|
||||
{data?.breadcrumbs.map((parent: TFileParent, index: number) => (
|
||||
<Link
|
||||
href={`/folder/${parent.id}`}
|
||||
key={index}
|
||||
className={"flex items-center gap-1"}
|
||||
>
|
||||
<span>{parent.name}</span>
|
||||
{index !== data.breadcrumbs.length - 1 && (
|
||||
<span>{siteConfig.breadcrumb.limiter}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ 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 { getFileIcon, getFileType } from "utils/mimeTypesHelper";
|
||||
import { BsFolderFill } from "react-icons/bs";
|
||||
import { createFileId } from "utils/driveHelper";
|
||||
import siteConfig from "config/site.config";
|
||||
|
||||
type Props = {
|
||||
data: drive_v3.Schema$File;
|
||||
@@ -15,42 +17,85 @@ export default function GridFile({ data }: Props) {
|
||||
const Icon = isFolder
|
||||
? BsFolderFill
|
||||
: getFileIcon(data.fileExtension as string, data.mimeType as string);
|
||||
const fileType = getFileType(
|
||||
data.fileExtension as string,
|
||||
data.mimeType as string,
|
||||
);
|
||||
const allowThumbnail =
|
||||
siteConfig.files.allowThumbnailFileType.includes(fileType);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={isFolder ? `/folder/${data.id}` : `/file/${data.id}`}
|
||||
href={
|
||||
isFolder
|
||||
? `/folder/${createFileId(data, true)}`
|
||||
: `/file/${createFileId(data, true)}`
|
||||
}
|
||||
key={data.id}
|
||||
className={"hover:opacity-100"}
|
||||
className={"file"}
|
||||
title={data.name as string}
|
||||
>
|
||||
<div className='items relative mx-auto h-32 w-32'>
|
||||
{data.thumbnailLink ? (
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
className={`relative mx-auto grid h-40 w-full place-items-center overflow-hidden rounded-lg tablet:h-32 tablet:rounded-xl ${
|
||||
!allowThumbnail && "border border-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{data.thumbnailLink && allowThumbnail ? (
|
||||
<div
|
||||
className={
|
||||
"group relative mx-auto h-full w-full opacity-90 transition-opacity hover:opacity-100"
|
||||
}
|
||||
className={"group relative h-full max-h-40 w-full tablet:max-h-32"}
|
||||
>
|
||||
<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}
|
||||
className={"h-full w-full bg-checkerboard-dark object-cover"}
|
||||
/>
|
||||
{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-0 right-0 rounded-lg rounded-bl-none rounded-tr-none bg-zinc-950/75 px-1 py-0.5 text-xs text-white'>
|
||||
<div
|
||||
className={
|
||||
"absolute top-0 z-10 grid h-full w-full place-items-center"
|
||||
}
|
||||
>
|
||||
<MdPlayCircleFilled className='h-16 w-16 text-white/50 transition-colors duration-300 group-hover:text-white/80' />
|
||||
<span
|
||||
className={
|
||||
"absolute bottom-0 right-0 rounded bg-black/50 px-2 py-0.5 drop-shadow tablet:text-xs"
|
||||
}
|
||||
>
|
||||
{formatDuration(data.videoMediaMetadata.durationMillis || 0)}
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Icon className='h-full w-full p-8 transition-none' />
|
||||
<Icon className='h-16 w-16' />
|
||||
)}
|
||||
</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'>
|
||||
|
||||
{/* File name */}
|
||||
<div
|
||||
className={
|
||||
"flex-grow-1 mx-auto my-2 flex h-auto w-full flex-shrink-0 items-center gap-2"
|
||||
}
|
||||
>
|
||||
{siteConfig.files.showFileNameIcon && (
|
||||
<>
|
||||
{data.iconLink ? (
|
||||
<img
|
||||
src={data.iconLink}
|
||||
alt={"icon"}
|
||||
className='h-4 w-4 flex-shrink-0'
|
||||
/>
|
||||
) : (
|
||||
<Icon className='h-4 w-4 flex-shrink-0' />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
"line-clamp-2 h-full w-full whitespace-pre-wrap break-words text-center"
|
||||
}
|
||||
>
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { toast } from "react-toastify";
|
||||
import { MdContentCopy, MdDownload } from "react-icons/md";
|
||||
import { getFileIcon } from "utils/mimeTypesHelper";
|
||||
import { BsFolderFill } from "react-icons/bs";
|
||||
import siteConfig from "config/site.config";
|
||||
import { createFileId } from "utils/driveHelper";
|
||||
|
||||
type Props = {
|
||||
data: drive_v3.Schema$File;
|
||||
@@ -73,17 +75,45 @@ export default function ListFile({ data }: Props) {
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={isFolder ? `/folder/${data.id}` : `/file/${data.id}`}
|
||||
href={
|
||||
isFolder
|
||||
? `/folder/${createFileId(data, true)}`
|
||||
: `/file/${createFileId(data, true)}`
|
||||
}
|
||||
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>
|
||||
{siteConfig.files.listUseFileIcon ? (
|
||||
<img
|
||||
src={data.iconLink as string}
|
||||
alt={"icon"}
|
||||
className={"h-4 w-4 flex-shrink-0 flex-grow-0"}
|
||||
/>
|
||||
) : (
|
||||
<Icon className={"h-4 w-4 flex-shrink-0 flex-grow-0"} />
|
||||
)}
|
||||
<div className={"line-clamp-1 flex w-full flex-col"}>
|
||||
<span
|
||||
className={
|
||||
"line-clamp-1 w-auto flex-grow-0 overflow-hidden break-words"
|
||||
}
|
||||
>
|
||||
{data.name}
|
||||
</span>
|
||||
<div className={"flex tablet:hidden"}>
|
||||
<span className={"text-xs"}>
|
||||
{formatDate(new Date(data.modifiedTime as string))}
|
||||
</span>
|
||||
{siteConfig.files.listMobileShowFileSize && (
|
||||
<span className={"text-xs"}>
|
||||
{isFolder ? "" : ` ・ ${formatBytes(data.size as string)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"hidden tablet:col-span-2 tablet:block"}>
|
||||
{formatDate(new Date(data.modifiedTime as string))}
|
||||
@@ -93,7 +123,11 @@ export default function ListFile({ data }: Props) {
|
||||
</div>
|
||||
<div className={"hidden gap-2 tablet:flex"}>
|
||||
<CopyButton
|
||||
url={isFolder ? `/folder/${data.id}` : `/file/${data.id}`}
|
||||
url={
|
||||
isFolder
|
||||
? `/folder/${createFileId(data, true)}`
|
||||
: `/file/${createFileId(data, true)}`
|
||||
}
|
||||
isFolder={isFolder}
|
||||
/>
|
||||
{isFolder ? null : (
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from "react";
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import SwitchLayout from "components/utility/SwitchLayout";
|
||||
import useSWR from "swr";
|
||||
import fetcher from "utils/swrFetch";
|
||||
import { BreadCrumbsResponse } from "types/googleapis";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
fileId: string;
|
||||
};
|
||||
|
||||
export default function DefaultLayout({ children, fileId }: Props) {
|
||||
const { data, isLoading } = useSWR<BreadCrumbsResponse>(
|
||||
`/api/files/${fileId}/getPath`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"mx-auto flex max-w-screen-xl flex-col gap-2"}>
|
||||
<div className={"flex items-center justify-between"}>
|
||||
<Breadcrumb
|
||||
data={fileId === "root" ? undefined : data}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<SwitchLayout />
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
|
||||
export default function GridLayout({ data, pagination }: Props) {
|
||||
const { swrData, isLoadingMore, isReachingEnd, size, setSize } = pagination;
|
||||
|
||||
return (
|
||||
<>
|
||||
{data?.folders.length === 0 && data?.files.length === 0 && (
|
||||
|
||||
@@ -9,11 +9,11 @@ export default function Footer() {
|
||||
{currentYear} {siteConfig.footerText} - Powered by{" "}
|
||||
<a
|
||||
className={"link"}
|
||||
href={"https://www.github.com/mbaharip/gudora-index"}
|
||||
href={"https://www.github.com/mbaharip/next-gdrive-index"}
|
||||
target={"blank"}
|
||||
rel={"noreferrer noopener"}
|
||||
>
|
||||
gudora-index
|
||||
next-gdrive-index
|
||||
</a>{" "}
|
||||
❤️
|
||||
</span>
|
||||
|
||||
@@ -1,48 +1,52 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import siteConfig from "config/site.config";
|
||||
import useLocalStorage from "hooks/useLocalStorage";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
MdClose,
|
||||
MdDarkMode,
|
||||
MdLightMode,
|
||||
MdLogout,
|
||||
MdMenu,
|
||||
MdSearch,
|
||||
} from "react-icons/md";
|
||||
import Modal from "components/utility/Modal";
|
||||
import { formatBytes } from "utils/formatHelper";
|
||||
import { formatBytes, formatDate } 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";
|
||||
import { ThemeContext, TThemeContext } from "context/themeContext";
|
||||
import { createFileId } from "utils/driveHelper";
|
||||
|
||||
export default function Navbar() {
|
||||
const [isDarkMode, setIsDarkMode] = useLocalStorage("isDarkMode", "false");
|
||||
const [isDark, setIsDark] = useState<boolean>(false);
|
||||
type Props = {
|
||||
isUnlocked: boolean;
|
||||
setIsUnlocked: (isUnlocked: boolean) => void;
|
||||
};
|
||||
export default function Navbar({ isUnlocked, setIsUnlocked }: Props) {
|
||||
const { theme, setTheme } = useContext<TThemeContext>(ThemeContext);
|
||||
const [_, startTransition] = useTransition();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const [isLogoutOpen, setIsLogoutOpen] = useState(false);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
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);
|
||||
@@ -54,17 +58,7 @@ export default function Navbar() {
|
||||
}, [searchQuery]);
|
||||
|
||||
const { data, error, isLoading } = useSWR<SearchResponse, ErrorResponse>(
|
||||
`/api/search?query=${debouncedSearchQuery}`,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshWhenOffline: false,
|
||||
refreshWhenHidden: false,
|
||||
refreshInterval: 0,
|
||||
shouldRetryOnError: false,
|
||||
revalidateIfStale: true,
|
||||
},
|
||||
`/api/search?q=${debouncedSearchQuery}`,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,23 +67,20 @@ export default function Navbar() {
|
||||
}
|
||||
}, [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([]);
|
||||
const timeout = setTimeout(() => {
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCloseLogout = useCallback(() => {
|
||||
setIsLogoutOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -117,38 +108,46 @@ export default function Navbar() {
|
||||
<div className='flex-grow'></div>
|
||||
|
||||
{/* Search */}
|
||||
<div
|
||||
id={"search-modal-toggle"}
|
||||
className='interactive flex items-center gap-2'
|
||||
role={"button"}
|
||||
>
|
||||
{(siteConfig.privateIndex && isUnlocked) || !siteConfig.privateIndex ? (
|
||||
<div
|
||||
className='relative flex aspect-square h-6 w-6 cursor-pointer items-center justify-center text-inherit'
|
||||
onClick={() => {
|
||||
setIsSearching(true);
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
id={"search-modal-toggle"}
|
||||
className='interactive flex items-center gap-2'
|
||||
role={"button"}
|
||||
title={"Search"}
|
||||
>
|
||||
<MdSearch className='h-full w-full' />
|
||||
<div
|
||||
className='relative flex aspect-square h-6 w-6 cursor-pointer items-center justify-center text-inherit'
|
||||
onClick={() => {
|
||||
setIsSearching(true);
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<MdSearch className='h-full w-full' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{/* Dark mode */}
|
||||
<div
|
||||
id={"btn-theme-toggle"}
|
||||
className='interactive relative flex aspect-square h-6 w-6 cursor-pointer items-center justify-center text-inherit'
|
||||
onClick={handleDarkMode}
|
||||
onClick={() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}}
|
||||
role={"button"}
|
||||
title={"Toggle theme"}
|
||||
>
|
||||
<MdDarkMode
|
||||
className={`absolute left-0 h-full w-full transition-all duration-150 ${
|
||||
isDark
|
||||
className={`absolute left-0 h-full w-full transition-all duration-75 ${
|
||||
theme === "dark"
|
||||
? "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
|
||||
className={`absolute left-0 h-full w-full transition-all duration-75 ${
|
||||
theme === "dark"
|
||||
? "pointer-events-auto rotate-0 opacity-100"
|
||||
: "pointer-events-none rotate-90 opacity-0"
|
||||
}`}
|
||||
@@ -171,6 +170,20 @@ export default function Navbar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Remove password / logout */}
|
||||
{siteConfig.privateIndex && isUnlocked && (
|
||||
<div
|
||||
id={"btn-logout"}
|
||||
className={"interactive flex items-center gap-2"}
|
||||
role={"button"}
|
||||
title={"Logout"}
|
||||
onClick={() => setIsLogoutOpen(true)}
|
||||
>
|
||||
<MdLogout className={"h-full w-full"} />
|
||||
<span className={"max-tablet:hidden"}>Logout</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu mobile */}
|
||||
<div
|
||||
className='interactive relative flex aspect-square h-6 w-6 items-center justify-center text-inherit tablet:hidden'
|
||||
@@ -216,9 +229,7 @@ export default function Navbar() {
|
||||
{/* Search Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<span className='flex items-center gap-2 text-lg'>
|
||||
<MdSearch /> Search files
|
||||
</span>
|
||||
<span className='flex items-center gap-2 text-lg'>Search files</span>
|
||||
}
|
||||
isOpen={isSearching}
|
||||
isCentered={false}
|
||||
@@ -239,17 +250,9 @@ export default function Navbar() {
|
||||
<div className='divider-horizontal' />
|
||||
{/* Result */}
|
||||
<div className='flex max-h-96 w-full flex-col overflow-auto'>
|
||||
{isLoading && (
|
||||
<LoadingFeedback
|
||||
message={"Searching..."}
|
||||
useContainer={false}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingFeedback message={"Searching..."} />}
|
||||
{!isLoading && error && (
|
||||
<ErrorFeedback
|
||||
message={error?.errors.message}
|
||||
useContainer={false}
|
||||
/>
|
||||
<ErrorFeedback message={error?.errors.message} />
|
||||
)}
|
||||
{!isLoading && searchResults?.length === 0 && (
|
||||
<EmptyFeedback
|
||||
@@ -274,7 +277,11 @@ export default function Navbar() {
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={isFolder ? `/folder/${item.id}` : `/file/${item.id}`}
|
||||
href={
|
||||
isFolder
|
||||
? `/folder/${createFileId(item)}`
|
||||
: `/file/${createFileId(item)}`
|
||||
}
|
||||
key={item.id}
|
||||
className='rounded-lg py-1 hover:bg-zinc-300 dark:hover:bg-zinc-600 tablet:px-2 tablet:py-2'
|
||||
onClick={handleCloseSearch}
|
||||
@@ -282,10 +289,13 @@ export default function Navbar() {
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex aspect-square h-10 w-10 flex-shrink-0 flex-grow-0 items-center justify-center overflow-hidden rounded-lg tablet:h-12 tablet:w-12'>
|
||||
{item.thumbnailLink ? (
|
||||
<img
|
||||
<Image
|
||||
priority={true}
|
||||
src={item.thumbnailLink}
|
||||
alt={item.name as string}
|
||||
className='h-full w-full object-cover'
|
||||
width={64}
|
||||
height={64}
|
||||
/>
|
||||
) : (
|
||||
<Icon className='h-full w-full' />
|
||||
@@ -299,7 +309,7 @@ export default function Navbar() {
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-xs text-gray-500'>
|
||||
{item.mimeType}
|
||||
{formatDate(new Date(item.modifiedTime as string))}
|
||||
{!isFolder
|
||||
? ` ・ ${formatBytes(item.size as string)}`
|
||||
: ""}
|
||||
@@ -313,6 +323,44 @@ export default function Navbar() {
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Logout Modal */}
|
||||
<Modal
|
||||
title={<span className={"text-lg"}>Logout</span>}
|
||||
isOpen={isLogoutOpen}
|
||||
isCentered={true}
|
||||
onClose={handleCloseLogout}
|
||||
>
|
||||
<div className={"flex flex-col gap-4"}>
|
||||
<span>Are you sure you want to logout?</span>
|
||||
<span>
|
||||
It will remove all your data from this device and you will need to
|
||||
login again.
|
||||
</span>
|
||||
<div className={"flex gap-4"}>
|
||||
<button
|
||||
onClick={handleCloseLogout}
|
||||
className={"secondary flex-grow"}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.removeItem("sitePassword");
|
||||
startTransition(() => {
|
||||
setIsLogoutOpen(false);
|
||||
setIsUnlocked(false);
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={"primary flex-grow"}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import LoadingFeedback from "components/APIFeedback/Loading";
|
||||
import MarkdownRender from "components/utility/MarkdownRender";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
isReadmeExist: boolean;
|
||||
isReadmeLoading: boolean;
|
||||
readmeData: string | undefined;
|
||||
};
|
||||
|
||||
export default function Readme({
|
||||
isReadmeExist,
|
||||
isReadmeLoading,
|
||||
readmeData,
|
||||
}: Props) {
|
||||
const [isMounted, setIsMounted] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReadmeLoading && isReadmeExist && readmeData) {
|
||||
setIsMounted(true);
|
||||
}
|
||||
}, [isReadmeExist, isReadmeLoading, readmeData]);
|
||||
|
||||
if (!isMounted) return <></>;
|
||||
return (
|
||||
<div className={"card"}>
|
||||
{isReadmeLoading && <LoadingFeedback useContainer={false} />}
|
||||
{!isReadmeLoading && readmeData && (
|
||||
<MarkdownRender content={readmeData} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import LoadingFeedback from "components/APIFeedback/Loading";
|
||||
import ErrorFeedback from "components/APIFeedback/Error";
|
||||
|
||||
type Props = {
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
isLoading: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function SWRLayout({ data, error, isLoading, children }: Props) {
|
||||
const [isMounted, setIsMounted] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !error && data) {
|
||||
setIsMounted(true);
|
||||
}
|
||||
}, [data, error, isLoading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingFeedback />}
|
||||
{!isLoading && error && <ErrorFeedback />}
|
||||
{isMounted && <>{children}</>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export default function Modal({
|
||||
}: 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 ${
|
||||
className={`fixed left-0 top-0 z-[1001] flex h-full w-full justify-center bg-zinc-500/75 text-inherit transition-all duration-300 dark:bg-zinc-900/75 ${
|
||||
isOpen
|
||||
? "pointer-events-auto opacity-100"
|
||||
: "pointer-events-none opacity-0"
|
||||
@@ -28,7 +28,7 @@ export default function Modal({
|
||||
}}
|
||||
>
|
||||
<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 ${
|
||||
className={`relative flex w-full min-w-[320px] max-w-sm flex-col gap-4 rounded-lg bg-zinc-100 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) => {
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import useLocalStorage from "hooks/useLocalStorage";
|
||||
import { MdArrowDropDown, MdGridView, MdList } from "react-icons/md";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { LayoutContext, TLayoutContext } from "context/layoutContext";
|
||||
|
||||
type Props = {
|
||||
setLayoutStyle: (layoutStyle: "grid" | "list") => void;
|
||||
};
|
||||
export default function SwitchLayout({ setLayoutStyle }: Props) {
|
||||
const [renderStyle, setRenderStyle] = useLocalStorage<"grid" | "list">(
|
||||
"renderStyle",
|
||||
"grid",
|
||||
);
|
||||
export default function SwitchLayout() {
|
||||
const { layout, setLayout } = useContext<TLayoutContext>(LayoutContext);
|
||||
const [isRenderGrid, setIsRenderGrid] = useState<boolean>();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsRenderGrid(renderStyle === "grid");
|
||||
}, [renderStyle]);
|
||||
setIsRenderGrid(layout === "grid");
|
||||
}, [layout]);
|
||||
|
||||
// Check if click outside dropdownRef
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
@@ -43,7 +36,7 @@ export default function SwitchLayout({ setLayoutStyle }: Props) {
|
||||
className={"layoutDropdown capitalize"}
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<div className={"flex w-16 items-center gap-2"}>
|
||||
{isRenderGrid && <MdGridView className={"h-4 w-4"} />}
|
||||
{!isRenderGrid && <MdList className={"h-4 w-4"} />}
|
||||
<span>{isRenderGrid ? "Grid" : "List"}</span>
|
||||
@@ -51,18 +44,17 @@ export default function SwitchLayout({ setLayoutStyle }: Props) {
|
||||
<MdArrowDropDown />
|
||||
</div>
|
||||
<div
|
||||
className={`layoutOption absolute left-0 right-0 top-0 z-10 flex flex-col ${
|
||||
className={`layoutOption absolute left-0 right-0 top-0 z-10 flex ${
|
||||
isDropdownOpen
|
||||
? "pointer-events-auto opacity-100"
|
||||
: "pointer-events-none opacity-0"
|
||||
}`}
|
||||
} ${isRenderGrid ? "flex-col" : "flex-col-reverse"}`}
|
||||
>
|
||||
<div
|
||||
data-active={isRenderGrid}
|
||||
className={"layoutItem"}
|
||||
onClick={() => {
|
||||
setLayoutStyle("grid");
|
||||
setRenderStyle("grid");
|
||||
setLayout("grid");
|
||||
setIsRenderGrid(true);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
@@ -74,8 +66,7 @@ export default function SwitchLayout({ setLayoutStyle }: Props) {
|
||||
data-active={!isRenderGrid}
|
||||
className={"layoutItem"}
|
||||
onClick={() => {
|
||||
setLayoutStyle("list");
|
||||
setRenderStyle("list");
|
||||
setLayout("list");
|
||||
setIsRenderGrid(false);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
|
||||
@@ -12,15 +12,25 @@ module.exports = {
|
||||
|
||||
files: {
|
||||
// How many files to show per page
|
||||
itemsPerPage: 50,
|
||||
itemsPerPage: 25,
|
||||
// Max number of files to show in search result
|
||||
searchResult: 10,
|
||||
searchResult: 5,
|
||||
// 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
|
||||
// TODO: Change when final
|
||||
// rootFolder: "root",
|
||||
rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j", // Test folder
|
||||
// Limit breadcrumb to specific depth
|
||||
// 0 = Unlimited
|
||||
// 1 = Only show current folder
|
||||
// 2 = Show current folder and its parent
|
||||
// 3 = Show current folder and its parent and grandparent
|
||||
// and so on.
|
||||
// Warning: More parent = More API calls = Slower loading time
|
||||
// There are no workaround for this yet, since Google Drive API v3 return only 1 parent
|
||||
// Default: 2
|
||||
breadcrumbDepth: 2,
|
||||
},
|
||||
|
||||
// Cache control header
|
||||
|
||||
@@ -10,6 +10,10 @@ const config = {
|
||||
// Fav icon of the site
|
||||
// Also used as the logo of the site on the navbar
|
||||
siteIcon: "/favicon.svg",
|
||||
//
|
||||
privateIndex: false,
|
||||
indexPassword:
|
||||
"ac0f794f72d4366a57a2a4122ce04321:8b2f087de80d0d7faa89b5372df51268",
|
||||
// Navbar menu links
|
||||
navbarLinks: [
|
||||
{
|
||||
@@ -41,10 +45,24 @@ const config = {
|
||||
// {year} {footerText} - Powered by next-gdrive-index ❤️
|
||||
footerText: "mbahArip Stash",
|
||||
|
||||
files: {
|
||||
// Show file icon from Google Drive before the file name
|
||||
showFileNameIcon: true,
|
||||
// Use Google Drive file icon instead of our file icon on list view
|
||||
listUseFileIcon: true,
|
||||
// Show file size on mobile list view
|
||||
listMobileShowFileSize: false,
|
||||
// Refer type to this file:
|
||||
// /src/utils/mimeTypesHelper.ts:40
|
||||
allowThumbnailFileType: ["image", "video", "pdf"],
|
||||
},
|
||||
/* Experimental: Render Banner */
|
||||
banner: {
|
||||
render: true,
|
||||
},
|
||||
breadcrumb: {
|
||||
limiter: "/",
|
||||
},
|
||||
/* Config for readme file render */
|
||||
readme: {
|
||||
// If this set
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { createContext, useState, useEffect } from "react";
|
||||
|
||||
export type TLayout = "grid" | "list";
|
||||
|
||||
const getLayoutFromLocalStorage = (): TLayout => {
|
||||
if (typeof window !== "undefined") {
|
||||
const layout = localStorage.getItem("layout");
|
||||
if (layout === "grid" || layout === "list") {
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
return "grid";
|
||||
};
|
||||
|
||||
export type TLayoutContext = {
|
||||
layout: TLayout;
|
||||
setLayout: (layout: TLayout) => void;
|
||||
};
|
||||
|
||||
export const LayoutContext = createContext<TLayoutContext>({
|
||||
layout: getLayoutFromLocalStorage(),
|
||||
setLayout: () => {},
|
||||
});
|
||||
|
||||
type TLayoutProvider = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
const LayoutProvider = ({ children }: TLayoutProvider) => {
|
||||
const [layout, setLayout] = useState<TLayout>("grid");
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(getLayoutFromLocalStorage());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={{ layout, setLayout }}>
|
||||
{children}
|
||||
</LayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { createContext, useState, useEffect } from "react";
|
||||
|
||||
export type TTheme = "dark" | "light";
|
||||
|
||||
const getThemeFromLocalStorage = (): TTheme => {
|
||||
if (typeof window !== "undefined") {
|
||||
const theme = localStorage.getItem("theme");
|
||||
if (theme === "dark" || theme === "light") {
|
||||
return theme;
|
||||
}
|
||||
}
|
||||
return "light";
|
||||
};
|
||||
|
||||
export type TThemeContext = {
|
||||
theme: TTheme;
|
||||
setTheme: (theme: TTheme) => void;
|
||||
};
|
||||
|
||||
export const ThemeContext = createContext<TThemeContext>({
|
||||
theme: getThemeFromLocalStorage(),
|
||||
setTheme: () => {},
|
||||
});
|
||||
|
||||
type TThemeProvider = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ThemeProvider = ({ children }: TThemeProvider) => {
|
||||
const [theme, setTheme] = useState<TTheme>("dark");
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(getThemeFromLocalStorage());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
+174
-30
@@ -1,4 +1,5 @@
|
||||
import type { AppProps } from "next/app";
|
||||
import { useEffect, useState } from "react";
|
||||
import Navbar from "components/layout/Navbar";
|
||||
import Footer from "components/layout/Footer";
|
||||
import NextNProgress from "nextjs-progressbar";
|
||||
@@ -6,15 +7,19 @@ 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 config from "config/site.config";
|
||||
|
||||
import { Exo_2, JetBrains_Mono, Source_Sans_Pro } from "next/font/google";
|
||||
import { IconContext } from "react-icons";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import useLocalStorage from "hooks/useLocalStorage";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import { DefaultSeo, DefaultSeoProps } from "next-seo";
|
||||
import config from "config/site.config";
|
||||
import { SWRConfig } from "swr";
|
||||
import { IconContext } from "react-icons";
|
||||
import { LayoutContext } from "context/layoutContext";
|
||||
import { ThemeContext } from "context/themeContext";
|
||||
import fetcher from "utils/swrFetch";
|
||||
import siteConfig from "config/site.config";
|
||||
import { decrypt, encrypt } from "utils/encryptionHelper";
|
||||
|
||||
const exo2 = Exo_2({
|
||||
weight: ["300", "400", "600", "700"],
|
||||
@@ -39,7 +44,51 @@ const jetBrainsMono = JetBrains_Mono({
|
||||
});
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
const [isDarkMode] = useLocalStorage<boolean>("isDarkMode", false);
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
const [layoutStyle, setLayoutStyle] = useState<"grid" | "list">("grid");
|
||||
|
||||
const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
|
||||
const [passwordInput, setPasswordInput] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const theme = localStorage.getItem("theme");
|
||||
const layout = localStorage.getItem("layout");
|
||||
const adminPassword = localStorage.getItem("sitePassword");
|
||||
|
||||
if (siteConfig.privateIndex) {
|
||||
const sitePassword = decrypt(siteConfig.indexPassword);
|
||||
if (adminPassword) {
|
||||
const decryptedPassword = decrypt(adminPassword);
|
||||
if (decryptedPassword === sitePassword) {
|
||||
setIsUnlocked(true);
|
||||
} else {
|
||||
setIsUnlocked(false);
|
||||
}
|
||||
} else {
|
||||
setIsUnlocked(false);
|
||||
}
|
||||
}
|
||||
if (layout) {
|
||||
setLayoutStyle(layout as "grid" | "list");
|
||||
} else {
|
||||
setLayoutStyle("grid");
|
||||
}
|
||||
if (theme) {
|
||||
setTheme(theme as "light" | "dark");
|
||||
} else {
|
||||
setTheme("light");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === "light") {
|
||||
document.body.classList.remove("dark");
|
||||
} else {
|
||||
document.body.classList.add("dark");
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const SEOConfig: DefaultSeoProps = {
|
||||
titleTemplate: `%s | ${config.siteName}`,
|
||||
@@ -73,35 +122,130 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
<main
|
||||
className={`${exo2.variable} ${sourceSansPro.variable} ${jetBrainsMono.variable} font-body`}
|
||||
>
|
||||
<DefaultSeo {...SEOConfig} />
|
||||
<IconContext.Provider
|
||||
<SWRConfig
|
||||
value={{
|
||||
size: "18px",
|
||||
className: "react-icons",
|
||||
fetcher: fetcher,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshWhenOffline: false,
|
||||
errorRetryCount: 3,
|
||||
}}
|
||||
>
|
||||
<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 }} />
|
||||
<DefaultSeo {...SEOConfig} />
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
setTheme: (theme) => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
setTheme(theme);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconContext.Provider
|
||||
value={{
|
||||
size: "18px",
|
||||
className: "react-icons",
|
||||
}}
|
||||
>
|
||||
<ToastContainer
|
||||
position='bottom-center'
|
||||
autoClose={2500}
|
||||
limit={3}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss={false}
|
||||
draggable
|
||||
pauseOnHover={false}
|
||||
theme={theme}
|
||||
/>
|
||||
<Navbar
|
||||
isUnlocked={isUnlocked}
|
||||
setIsUnlocked={setIsUnlocked}
|
||||
/>
|
||||
<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>
|
||||
<LayoutContext.Provider
|
||||
value={{
|
||||
layout: layoutStyle,
|
||||
setLayout: (layout) => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("layout", layout);
|
||||
}
|
||||
setLayoutStyle(layout);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className='h-full w-full flex-grow items-center justify-center bg-zinc-200 px-4 py-4 dark:bg-zinc-800'>
|
||||
{siteConfig.privateIndex && !isUnlocked ? (
|
||||
<div
|
||||
className={
|
||||
"card fillCard mx-auto flex max-w-screen-xl flex-col"
|
||||
}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between rounded-lg'>
|
||||
<span className='font-bold'>Preview</span>
|
||||
</div>
|
||||
<div className={"divider-horizontal"} />
|
||||
<div
|
||||
className={
|
||||
"flex h-full flex-grow flex-col items-center justify-center"
|
||||
}
|
||||
>
|
||||
<p className={"my-2 text-center"}>
|
||||
You have reached a private site that requires authorized
|
||||
access. <br />
|
||||
If you have a valid password, please enter it below to
|
||||
proceed. Otherwise, please leave this site immediately.
|
||||
Thank you for your cooperation.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const sitePassword = decrypt(
|
||||
siteConfig.indexPassword,
|
||||
);
|
||||
if (passwordInput === sitePassword) {
|
||||
toast("Welcome back!");
|
||||
setIsUnlocked(true);
|
||||
localStorage.setItem(
|
||||
"sitePassword",
|
||||
encrypt(passwordInput),
|
||||
);
|
||||
setPasswordInput("");
|
||||
} else {
|
||||
toast.error("Wrong password");
|
||||
setPasswordInput("");
|
||||
}
|
||||
}}
|
||||
className={"mx-auto my-2 flex max-w-md gap-2"}
|
||||
>
|
||||
<input
|
||||
className={`w-full`}
|
||||
type={"password"}
|
||||
placeholder={"Site password"}
|
||||
value={passwordInput}
|
||||
onChange={(e) => {
|
||||
setPasswordInput(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<button type={"submit"}>Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Component {...pageProps} />
|
||||
)}
|
||||
</div>
|
||||
</LayoutContext.Provider>
|
||||
|
||||
<Footer />
|
||||
</IconContext.Provider>
|
||||
<Footer />
|
||||
</IconContext.Provider>
|
||||
</ThemeContext.Provider>
|
||||
</SWRConfig>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import initMiddleware from "utils/apiMiddleware";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { BannerResponse, ErrorResponse } from "types/googleapis";
|
||||
import { ExtendedError } from "utils/driveHelper";
|
||||
import driveClient from "utils/driveClient";
|
||||
import { urlEncrypt } from "utils/encryptionHelper";
|
||||
|
||||
export default initMiddleware(async function handler(
|
||||
request: NextApiRequest,
|
||||
response: NextApiResponse<BannerResponse | ErrorResponse>,
|
||||
) {
|
||||
const _start = Date.now();
|
||||
|
||||
try {
|
||||
const { folderId } = request.query;
|
||||
|
||||
const [name, partialId] = (folderId as string).split(":");
|
||||
|
||||
if (!name || !partialId || partialId.length !== 8) {
|
||||
throw new ExtendedError(
|
||||
"Can't resolve name and id provided.",
|
||||
400,
|
||||
"invalidId",
|
||||
);
|
||||
}
|
||||
|
||||
const payload: BannerResponse = {
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime: Date.now() - _start,
|
||||
};
|
||||
|
||||
const findFolder = await driveClient.files.list({
|
||||
q: `name = '${name}' and trashed = false and 'me' in owners`,
|
||||
fields: "files(id, name, mimeType)",
|
||||
});
|
||||
const folder = findFolder.data.files?.find(
|
||||
(file) =>
|
||||
file.name === decodeURIComponent(name) &&
|
||||
(file.id as string).startsWith(partialId),
|
||||
);
|
||||
if (!folder) {
|
||||
throw new ExtendedError("Folder not found.", 404, "notFound");
|
||||
}
|
||||
|
||||
const listFiles = await driveClient.files.list({
|
||||
q: `'${folder.id}' in parents and trashed = false and 'me' in owners`,
|
||||
fields: "files(id, name, mimeType)",
|
||||
});
|
||||
|
||||
const banner = listFiles.data.files?.filter((file) =>
|
||||
file.name?.startsWith(".banner"),
|
||||
)[0];
|
||||
if (!banner || !banner.mimeType?.startsWith("image")) {
|
||||
payload.success = false;
|
||||
return response.status(200).json(payload);
|
||||
}
|
||||
|
||||
payload.banner = {
|
||||
id: urlEncrypt(banner.id as string),
|
||||
name: banner.name as string,
|
||||
};
|
||||
|
||||
return response.status(200).json(payload);
|
||||
} catch (error: any) {
|
||||
const payload: ErrorResponse = {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime: Date.now() - _start,
|
||||
code: error.code || 500,
|
||||
errors: {
|
||||
message: error.errors?.[0].message || error.message || "Unknown error",
|
||||
reason: error.errors?.[0].reason || error.cause || "internalError",
|
||||
},
|
||||
};
|
||||
|
||||
return response.status(payload.code || 500).json(payload);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BannerResponse, ErrorResponse } from "types/googleapis";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import initMiddleware from "utils/apiMiddleware";
|
||||
import apiConfig from "config/api.config";
|
||||
import driveClient from "utils/driveClient";
|
||||
import { urlEncrypt } from "utils/encryptionHelper";
|
||||
|
||||
export default initMiddleware(async function handler(
|
||||
request: NextApiRequest,
|
||||
response: NextApiResponse<BannerResponse | ErrorResponse>,
|
||||
) {
|
||||
const _start = Date.now();
|
||||
|
||||
try {
|
||||
const query: string[] = [
|
||||
"name contains '.banner'",
|
||||
`parents = '${apiConfig.files.rootFolder}'`,
|
||||
"trashed = false",
|
||||
"'me' in owners",
|
||||
];
|
||||
const getRootBanner = await driveClient.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: "files(id, name, mimeType)",
|
||||
});
|
||||
|
||||
const payload: BannerResponse = {
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime: Date.now() - _start,
|
||||
};
|
||||
|
||||
const banner = getRootBanner.data.files?.filter((item) =>
|
||||
item.name?.startsWith(".banner"),
|
||||
)[0];
|
||||
if (!banner || !banner.mimeType?.startsWith("image")) {
|
||||
payload.success = false;
|
||||
return response.status(200).json(payload);
|
||||
}
|
||||
|
||||
payload.banner = {
|
||||
id: urlEncrypt(banner.id as string),
|
||||
name: banner.name as string,
|
||||
};
|
||||
|
||||
return response.status(200).json(payload);
|
||||
} catch (error: any) {
|
||||
const payload: ErrorResponse = {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime: Date.now() - _start,
|
||||
code: error.code || 500,
|
||||
errors: {
|
||||
message: error.errors?.[0].message || error.message || "Unknown error",
|
||||
reason: error.errors?.[0].reason || error.cause || "internalError",
|
||||
},
|
||||
};
|
||||
|
||||
return response.status(payload.code || 500).json(payload);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import initMiddleware from "utils/apiMiddleware";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import {
|
||||
BreadCrumbsResponse,
|
||||
ErrorResponse,
|
||||
TFileParent,
|
||||
} from "types/googleapis";
|
||||
import { ExtendedError } from "utils/driveHelper";
|
||||
import driveClient from "utils/driveClient";
|
||||
import apiConfig from "config/api.config";
|
||||
|
||||
export default initMiddleware(async function handler(
|
||||
request: NextApiRequest,
|
||||
response: NextApiResponse<BreadCrumbsResponse | ErrorResponse>,
|
||||
) {
|
||||
const _start = Date.now();
|
||||
|
||||
try {
|
||||
const { id } = request.query;
|
||||
|
||||
if (id === "root") {
|
||||
return response.status(200).json({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime: Date.now() - _start,
|
||||
breadcrumbs: [],
|
||||
isLimitReached: false,
|
||||
});
|
||||
}
|
||||
|
||||
const [name, partialId] = (id as string).split(":");
|
||||
|
||||
if (!name || !partialId || partialId.length !== 8) {
|
||||
throw new ExtendedError(
|
||||
"Can't resolve name and id provided.",
|
||||
400,
|
||||
"invalidId",
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumbs: TFileParent[] = [];
|
||||
|
||||
const searchForFile = await driveClient.files.list({
|
||||
q: `name contains '${name}' and trashed = false and 'me' in owners`,
|
||||
fields: "files(id, name, mimeType, parents)",
|
||||
});
|
||||
|
||||
const file = searchForFile.data.files?.find(
|
||||
(file) =>
|
||||
file.name === decodeURIComponent(name) &&
|
||||
(file.id as string).startsWith(partialId),
|
||||
);
|
||||
if (!file) {
|
||||
throw new ExtendedError("File not found.", 404, "notFound");
|
||||
}
|
||||
|
||||
response.setHeader("Cache-Control", apiConfig.cache);
|
||||
let isLimitReached = false;
|
||||
|
||||
if (file.id === apiConfig.files.rootFolder) {
|
||||
return response.status(200).json({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime: Date.now() - _start,
|
||||
breadcrumbs,
|
||||
isLimitReached,
|
||||
});
|
||||
}
|
||||
|
||||
// if (file.mimeType === "application/vnd.google-apps.folder") {
|
||||
breadcrumbs.push({
|
||||
id: `${file.name}:${file.id?.slice(0, 8)}`,
|
||||
name: file.name as string,
|
||||
});
|
||||
// }
|
||||
|
||||
let tempParent: string[] = file.parents || [];
|
||||
while (tempParent.length > 0) {
|
||||
if (breadcrumbs.length === apiConfig.files.breadcrumbDepth) {
|
||||
isLimitReached = true;
|
||||
break;
|
||||
}
|
||||
const fetchParent = await driveClient.files.get({
|
||||
fileId: tempParent[0],
|
||||
fields: "id, name, parents",
|
||||
});
|
||||
if (fetchParent.data.id === apiConfig.files.rootFolder) {
|
||||
break;
|
||||
}
|
||||
breadcrumbs.push({
|
||||
id: `${fetchParent.data.name}:${fetchParent.data.id?.slice(0, 8)}`,
|
||||
name: fetchParent.data.name as string,
|
||||
});
|
||||
tempParent = fetchParent.data.parents || [];
|
||||
if (!tempParent.length) break;
|
||||
}
|
||||
|
||||
return response.status(200).json({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime: Date.now() - _start,
|
||||
breadcrumbs,
|
||||
isLimitReached,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const payload: ErrorResponse = {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime: Date.now() - _start,
|
||||
code: error.code || 500,
|
||||
errors: {
|
||||
message: error.errors?.[0].message || error.message || "Unknown error",
|
||||
reason: error.errors?.[0].reason || error.cause || "internalError",
|
||||
},
|
||||
};
|
||||
|
||||
return response.status(payload.code || 500).json(payload);
|
||||
}
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export default initMiddleware(async function handler(
|
||||
const searchForFile = await driveClient.files.list({
|
||||
q: `name contains '${name}' and trashed = false and 'me' in owners`,
|
||||
fields:
|
||||
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink)",
|
||||
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, iconLink)",
|
||||
});
|
||||
|
||||
const file = searchForFile.data.files?.find(
|
||||
@@ -66,6 +66,8 @@ export default initMiddleware(async function handler(
|
||||
{ responseType: "stream" },
|
||||
);
|
||||
|
||||
console.log(file);
|
||||
|
||||
response.setHeader(
|
||||
"Content-Type",
|
||||
file.mimeType || "application/octet-stream",
|
||||
@@ -103,7 +105,7 @@ export default initMiddleware(async function handler(
|
||||
const fetchFolderContents = await driveClient.files.list({
|
||||
q: `${query.join(" and ")}`,
|
||||
fields:
|
||||
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink), nextPageToken",
|
||||
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, iconLink), nextPageToken",
|
||||
orderBy: "folder, name asc, createdTime",
|
||||
pageSize: apiConfig.files.itemsPerPage,
|
||||
pageToken: (pageToken as string) || undefined,
|
||||
|
||||
@@ -1,38 +1,72 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import driveClient from "utils/driveClient";
|
||||
import apiConfig from "config/api.config";
|
||||
import { hiddenFiles } from "utils/driveHelper";
|
||||
import { ExtendedError, hiddenFiles } from "utils/driveHelper";
|
||||
import initMiddleware from "utils/apiMiddleware";
|
||||
import { ErrorResponse, FilesResponse } from "types/googleapis";
|
||||
import { urlEncrypt } from "utils/encryptionHelper";
|
||||
|
||||
export default initMiddleware(async function handler(
|
||||
request: NextApiRequest,
|
||||
response: NextApiResponse<FilesResponse | ErrorResponse>,
|
||||
response: NextApiResponse,
|
||||
) {
|
||||
const _start = Date.now();
|
||||
|
||||
try {
|
||||
const { pageToken } = request.query;
|
||||
const { pageToken, banner } = request.query;
|
||||
|
||||
const query = ["trashed = false", "'me' in owners"];
|
||||
if (apiConfig.files.rootFolder === "root") {
|
||||
query.push("parents = 'root'");
|
||||
} else {
|
||||
query.push(`parents = '${apiConfig.files.rootFolder}'`);
|
||||
}
|
||||
const query = [
|
||||
"trashed = false",
|
||||
"'me' in owners",
|
||||
`parents = '${apiConfig.files.rootFolder}'`,
|
||||
];
|
||||
const fetchFolderContents = await driveClient.files.list({
|
||||
q: `${query.join(" and ")}`,
|
||||
fields:
|
||||
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink), nextPageToken",
|
||||
"files(id, name, mimeType, thumbnailLink, fileExtension, fullFileExtension, createdTime, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, iconLink), nextPageToken",
|
||||
orderBy: "folder, name asc, createdTime",
|
||||
pageSize: apiConfig.files.itemsPerPage,
|
||||
pageToken: (pageToken as string) || undefined,
|
||||
});
|
||||
|
||||
const isReadmeExists = !!fetchFolderContents.data.files?.find(
|
||||
const isReadmeExists = fetchFolderContents.data.files?.find(
|
||||
(file) => file.name === ".readme.md",
|
||||
);
|
||||
const isBannerExists = fetchFolderContents.data.files?.find((file) =>
|
||||
file.name?.startsWith(".banner"),
|
||||
);
|
||||
const isPasswordExists = fetchFolderContents.data.files?.find((file) =>
|
||||
file.name?.startsWith(".password"),
|
||||
);
|
||||
|
||||
if (banner === "1") {
|
||||
if (!isBannerExists) {
|
||||
throw new ExtendedError("Banner not found.", 404, "notFound");
|
||||
}
|
||||
const bannerFile = fetchFolderContents.data.files?.find((file) =>
|
||||
file.name?.startsWith(".banner"),
|
||||
);
|
||||
const bannerFileStream = await driveClient.files.get(
|
||||
{
|
||||
fileId: bannerFile?.id as string,
|
||||
alt: "media",
|
||||
},
|
||||
{ responseType: "stream" },
|
||||
);
|
||||
response.setHeader(
|
||||
"Content-Type",
|
||||
bannerFile?.mimeType || "application/octet-stream",
|
||||
);
|
||||
response.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename=${encodeURIComponent(
|
||||
bannerFile?.name as string,
|
||||
)}`,
|
||||
);
|
||||
response.setHeader("Content-Length", bannerFile?.size as string);
|
||||
return response.status(200).send(bannerFileStream.data);
|
||||
}
|
||||
|
||||
const folderList =
|
||||
fetchFolderContents.data.files
|
||||
?.filter(
|
||||
@@ -66,7 +100,9 @@ export default initMiddleware(async function handler(
|
||||
responseTime: Date.now() - _start,
|
||||
folders: folderList,
|
||||
files: fileList,
|
||||
isReadmeExists: isReadmeExists,
|
||||
isReadmeExists: !!isReadmeExists,
|
||||
isBannerExists: !!isBannerExists,
|
||||
isPasswordExists: !!isPasswordExists,
|
||||
nextPageToken: fetchFolderContents.data.nextPageToken || undefined,
|
||||
};
|
||||
|
||||
|
||||
+17
-21
@@ -30,35 +30,31 @@ export default async function handler(request: NextRequest) {
|
||||
fileExt || "",
|
||||
);
|
||||
|
||||
console.log("Creating og image for", fileName);
|
||||
console.log(fileId, isImage);
|
||||
|
||||
let fileImage;
|
||||
if (fileId && isImage) {
|
||||
fileImage = `${process.env.NEXT_PUBLIC_DOMAIN}/api/files/${fileId}?download=1`;
|
||||
} else if (fileId && !isImage) {
|
||||
await fetch(
|
||||
`${process.env.NEXT_PUBLIC_DOMAIN}/api/files/${fileId}?thumbnail=1`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.headers.get("content-type")?.startsWith("image")) {
|
||||
return { success: true };
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!data.success) {
|
||||
throw new Error("File not found");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
throw new Error(err);
|
||||
});
|
||||
fileImage = `${process.env.NEXT_PUBLIC_DOMAIN}/api/files/${fileId}?thumbnail=1`;
|
||||
} else {
|
||||
throw new Error("Default image");
|
||||
}
|
||||
|
||||
await fetch(fileImage)
|
||||
.then((res) => {
|
||||
if (res.headers.get("content-type")?.startsWith("image")) {
|
||||
return { success: true };
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!data.success) {
|
||||
throw new Error("File not found");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
throw new Error(err);
|
||||
});
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
@@ -102,7 +98,7 @@ export default async function handler(request: NextRequest) {
|
||||
{siteName}
|
||||
</div>
|
||||
<div
|
||||
tw='text-xl py-8'
|
||||
tw='text-3xl py-8'
|
||||
style={{
|
||||
letterSpacing: "0",
|
||||
display: "flex",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { ErrorResponse } 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",
|
||||
},
|
||||
);
|
||||
|
||||
return response.send(streamFile.data);
|
||||
} catch (error: any) {
|
||||
if (error satisfies ErrorResponse) {
|
||||
const payload: ErrorResponse = {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
code: error.code || 500,
|
||||
errors: {
|
||||
message:
|
||||
error.errors?.[0].message || error.message || "Unknown error",
|
||||
reason: error.errors?.[0].reason || error.cause || "internalError",
|
||||
},
|
||||
};
|
||||
|
||||
return response.status(error.code).json(payload);
|
||||
}
|
||||
|
||||
const payload: ErrorResponse = {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
code: error.code || 500,
|
||||
errors: {
|
||||
message: error.message || "Unknown error",
|
||||
reason: error.cause || "internalError",
|
||||
},
|
||||
};
|
||||
|
||||
return response.status(500).json(payload);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,63 @@
|
||||
import { ErrorResponse } from "types/googleapis";
|
||||
import drive from "utils/driveClient";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import initMiddleware from "utils/apiMiddleware";
|
||||
import apiConfig from "config/api.config";
|
||||
import { ExtendedError } from "utils/driveHelper";
|
||||
|
||||
export default async function handler(
|
||||
export default initMiddleware(async function handler(
|
||||
request: NextApiRequest,
|
||||
response: NextApiResponse,
|
||||
) {
|
||||
const _start = Date.now();
|
||||
|
||||
try {
|
||||
const fetchReadme = await drive.files.list({
|
||||
q: "",
|
||||
fields: "files(id, name, mimeType, size)",
|
||||
orderBy: "modifiedTime desc",
|
||||
const query: string[] = [
|
||||
"name = '.readme.md'",
|
||||
`parents = '${apiConfig.files.rootFolder}'`,
|
||||
"trashed = false",
|
||||
"'me' in owners",
|
||||
];
|
||||
const getRootReadme = await drive.files.list({
|
||||
q: query.join(" and "),
|
||||
fields: "files(id, name, mimeType)",
|
||||
});
|
||||
|
||||
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 readme = getRootReadme.data.files?.[0];
|
||||
if (!readme) {
|
||||
throw new ExtendedError("Readme not found.", 404, "notFound");
|
||||
}
|
||||
|
||||
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(
|
||||
const readmeStream = await drive.files.get(
|
||||
{
|
||||
fileId: id as string,
|
||||
fileId: readme.id as string,
|
||||
alt: "media",
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
},
|
||||
{ responseType: "text" },
|
||||
);
|
||||
|
||||
return response.send(streamFile.data);
|
||||
response.setHeader(
|
||||
"Content-Type",
|
||||
readme.mimeType || "application/octet-stream",
|
||||
);
|
||||
response.setHeader(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${readme.name}"`,
|
||||
);
|
||||
response.setHeader("Cache-Control", "public, max-age=0, must-revalidate");
|
||||
|
||||
return response.status(200).send(readmeStream.data);
|
||||
} catch (error: any) {
|
||||
if (error satisfies ErrorResponse) {
|
||||
const payload: ErrorResponse = {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
code: error.code || 500,
|
||||
errors: {
|
||||
message:
|
||||
error.errors?.[0].message || error.message || "Unknown error",
|
||||
reason: error.errors?.[0].reason || error.cause || "internalError",
|
||||
},
|
||||
};
|
||||
|
||||
return response.status(error.code).json(payload);
|
||||
}
|
||||
|
||||
const payload: ErrorResponse = {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTime: Date.now() - _start,
|
||||
code: error.code || 500,
|
||||
errors: {
|
||||
message: error.message || "Unknown error",
|
||||
reason: error.cause || "internalError",
|
||||
message: error.errors?.[0].message || error.message || "Unknown error",
|
||||
reason: error.errors?.[0].reason || error.cause || "internalError",
|
||||
},
|
||||
};
|
||||
|
||||
return response.status(500).json(payload);
|
||||
return response.status(payload.code || 500).json(payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export default async function handler(
|
||||
q: `name contains '${q}' and 'me' in owners and trashed = false`,
|
||||
fields:
|
||||
"files(id, name, mimeType, thumbnailLink, fileExtension, createdTime, modifiedTime, size)",
|
||||
pageSize: apiConfig.files.itemsPerPage,
|
||||
pageSize: apiConfig.files.searchResult,
|
||||
orderBy: "modifiedTime desc",
|
||||
});
|
||||
|
||||
|
||||
+19
-1
@@ -11,6 +11,7 @@ import axios from "axios";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import Password from "components/layout/Password";
|
||||
import { NextSeo } from "next-seo";
|
||||
import config from "config/site.config";
|
||||
|
||||
type Props = {
|
||||
passwordParent?: string;
|
||||
@@ -112,7 +113,24 @@ export default function File({ passwordParent, fileName }: Props) {
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex max-w-screen-xl flex-col gap-4'>
|
||||
<NextSeo title={fileName || "File preview"} />
|
||||
<NextSeo
|
||||
title={fileName || "File preview"}
|
||||
openGraph={{
|
||||
type: "website",
|
||||
title: `${(id as string).split(":")[0]} @${config.siteName}`,
|
||||
description: config.siteDescription,
|
||||
url: `${process.env.NEXT_PUBLIC_DOMAIN}/file/${id}`,
|
||||
images: [
|
||||
{
|
||||
url: `${process.env.NEXT_PUBLIC_DOMAIN}/api/og?fileId=${id}`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: config.siteName,
|
||||
},
|
||||
],
|
||||
siteName: config.siteName,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Breadcrumb
|
||||
|
||||
@@ -146,7 +146,24 @@ export default function Folder({ passwordParent, folderName }: Props) {
|
||||
|
||||
return (
|
||||
<div className='mx-auto flex max-w-screen-xl flex-col gap-4'>
|
||||
<NextSeo title={folderName || "Folder"} />
|
||||
<NextSeo
|
||||
title={folderName || "Folder"}
|
||||
openGraph={{
|
||||
type: "website",
|
||||
title: `${(id as string).split(":")[0]} @${config.siteName}`,
|
||||
description: config.siteDescription,
|
||||
url: `${process.env.NEXT_PUBLIC_DOMAIN}/folder/${id}`,
|
||||
images: [
|
||||
{
|
||||
url: `${process.env.NEXT_PUBLIC_DOMAIN}/api/og-folder?fileId=${id}`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: config.siteName,
|
||||
},
|
||||
],
|
||||
siteName: config.siteName,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Breadcrumb
|
||||
|
||||
+148
-182
@@ -1,218 +1,184 @@
|
||||
import useSWR from "swr";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import DefaultLayout from "components/layout/DefaultLayout";
|
||||
import { LayoutContext, TLayoutContext } from "context/layoutContext";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import fetcher, { buildNextKey } from "utils/swrFetch";
|
||||
import { ErrorResponse, FilesResponse, TFile } from "types/googleapis";
|
||||
import Breadcrumb from "components/Breadcrumb";
|
||||
import { buildNextKey } from "utils/swrFetch";
|
||||
import axios, { AxiosHeaders } from "axios";
|
||||
import { BannerResponse, ErrorResponse, FilesResponse } from "types/googleapis";
|
||||
import SWRLayout from "components/layout/SWRLayout";
|
||||
import { drive_v3 } from "googleapis";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import MarkdownRender from "components/utility/MarkdownRender";
|
||||
import config from "config/site.config";
|
||||
import siteConfig from "config/site.config";
|
||||
import Readme from "components/layout/Readme";
|
||||
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 axios from "axios";
|
||||
import Password from "components/layout/Password";
|
||||
import { createFileId } from "utils/driveHelper";
|
||||
import { NextSeo } from "next-seo";
|
||||
import { GetStaticProps } from "next";
|
||||
|
||||
export default function Home() {
|
||||
type Props = {
|
||||
bannerFileId?: string;
|
||||
};
|
||||
export default function Home({ bannerFileId }: Props) {
|
||||
const { layout } = useContext<TLayoutContext>(LayoutContext);
|
||||
const [data, setData] = useState<FilesResponse>();
|
||||
|
||||
const [isReadmeExists, setIsReadmeExists] = useState<boolean>(false);
|
||||
const [renderStyle] = useLocalStorage<"grid" | "list">("renderStyle", "grid");
|
||||
const [layoutStyle, setLayoutStyle] = useState<"grid" | "list">(renderStyle);
|
||||
const [globalLoading, setGlobalLoading] = useState<boolean>(true);
|
||||
const [isReadmeLoading, setIsReadmeLoading] = useState<boolean>(false);
|
||||
const [readmeData, setReadmeData] = useState<string>();
|
||||
|
||||
const [passwordStorage, setPasswordStorage] = useLocalStorage<{
|
||||
[key: string]: string;
|
||||
}>("passwordStorage", {});
|
||||
const [password, setPassword] = useState<{ [p: string]: string }>(
|
||||
passwordStorage,
|
||||
);
|
||||
|
||||
const getNextKey = buildNextKey("/api/files/");
|
||||
/**
|
||||
* ===========================
|
||||
* START - fetch file data
|
||||
* ===========================
|
||||
* **/
|
||||
const getNextKey = buildNextKey("/api/files");
|
||||
const {
|
||||
data: swrData,
|
||||
error,
|
||||
isLoading,
|
||||
size,
|
||||
setSize,
|
||||
isValidating,
|
||||
mutate,
|
||||
} = useSWRInfinite<FilesResponse, ErrorResponse>(
|
||||
getNextKey,
|
||||
(url, headers) =>
|
||||
(url: string, headers: AxiosHeaders) =>
|
||||
axios
|
||||
.get<FilesResponse>(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
passwordStorage?.[config.files.rootFolder] || ""
|
||||
}`,
|
||||
Authorization: `Bearer TODO:ADD`,
|
||||
...headers,
|
||||
},
|
||||
})
|
||||
.then((res) => res.data),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshWhenOffline: false,
|
||||
refreshWhenHidden: false,
|
||||
refreshInterval: 0,
|
||||
shouldRetryOnError: false,
|
||||
revalidateIfStale: true,
|
||||
},
|
||||
);
|
||||
const {
|
||||
data: readmeData,
|
||||
error: readmeError,
|
||||
isLoading: readmeLoading,
|
||||
} = useSWR("/api/readme/", fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
refreshWhenOffline: false,
|
||||
refreshWhenHidden: false,
|
||||
refreshInterval: 0,
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
|
||||
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");
|
||||
const filePagination = {
|
||||
isLoadingInitialData: !swrData && !error,
|
||||
isLoadingMore:
|
||||
(!swrData && !error) ||
|
||||
(size > 0 && swrData && typeof swrData[size - 1] === "undefined"),
|
||||
isEmpty: swrData?.[0]?.files?.length === 0,
|
||||
isReachingEnd:
|
||||
swrData && swrData[swrData.length - 1]?.nextPageToken === undefined,
|
||||
};
|
||||
|
||||
// Since SWRInfinite returning array of response, we need to flatten it.
|
||||
useEffect(() => {
|
||||
setGlobalLoading(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 as drive_v3.Schema$File[]) || [],
|
||||
folders: (folders as drive_v3.Schema$File[]) || [],
|
||||
};
|
||||
setData(newData);
|
||||
if (newData.isReadmeExists) setIsReadmeExists(true);
|
||||
setGlobalLoading(false);
|
||||
if (swrData) {
|
||||
// Flatten files and folders, in case someone have more folders too.
|
||||
const files: drive_v3.Schema$File[] = swrData.flatMap(
|
||||
(item) => item.files,
|
||||
);
|
||||
const folders: drive_v3.Schema$File[] = swrData.flatMap(
|
||||
(item) => item.folders,
|
||||
);
|
||||
const flattenData: FilesResponse = {
|
||||
...swrData[size - 1],
|
||||
files,
|
||||
folders,
|
||||
};
|
||||
setData(flattenData);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [swrData, error, isLoading, size, isValidating, password]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isValidating) {
|
||||
setGlobalLoading(false);
|
||||
} else {
|
||||
setGlobalLoading(true);
|
||||
if (flattenData.isReadmeExists) {
|
||||
setIsReadmeExists(true);
|
||||
setIsReadmeLoading(true);
|
||||
// Since it's on root folder, we can fetch from readme api without id.
|
||||
axios
|
||||
.get<string>(`/api/readme`)
|
||||
.then((res) => {
|
||||
setReadmeData(res.data);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsReadmeLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading, isValidating]);
|
||||
|
||||
useEffect(() => {
|
||||
mutate(swrData, {
|
||||
revalidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [password]);
|
||||
|
||||
const inputPassCallback = useCallback(
|
||||
(data: { [p: string]: string }) => {
|
||||
setGlobalLoading(true);
|
||||
setPasswordStorage(data);
|
||||
setPassword(data);
|
||||
},
|
||||
[setPasswordStorage],
|
||||
);
|
||||
}, [swrData, size]);
|
||||
/**
|
||||
* ===========================
|
||||
* END - fetch file data
|
||||
* ===========================
|
||||
* **/
|
||||
|
||||
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={globalLoading}
|
||||
/>
|
||||
<SwitchLayout setLayoutStyle={setLayoutStyle} />
|
||||
</div>
|
||||
|
||||
{globalLoading && <LoadingFeedback message={"Loading file..."} />}
|
||||
{!globalLoading && error && (
|
||||
<ErrorFeedback message={error.errors?.message || "Unknown error"} />
|
||||
)}
|
||||
{!globalLoading && !error && data && (
|
||||
<>
|
||||
{/* If the root folder have password and the password isn't validated, show password input */}
|
||||
{data.passwordRequired && !data.passwordValidated && (
|
||||
<Password
|
||||
folderId={config.files.rootFolder}
|
||||
inputCallback={inputPassCallback}
|
||||
<DefaultLayout fileId={"root"}>
|
||||
<NextSeo
|
||||
openGraph={{
|
||||
images: [
|
||||
{
|
||||
url: `${
|
||||
process.env.NEXT_PUBLIC_DOMAIN
|
||||
}/api/og?fileId=${encodeURIComponent(bannerFileId as string)}`,
|
||||
alt: siteConfig.siteName,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<SWRLayout
|
||||
data={data}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{siteConfig.readme.position === "start" && (
|
||||
<Readme
|
||||
isReadmeExist={isReadmeExists}
|
||||
isReadmeLoading={isReadmeLoading}
|
||||
readmeData={readmeData}
|
||||
/>
|
||||
)}
|
||||
<div className={"card"}>
|
||||
{layout === "grid" && (
|
||||
<GridLayout
|
||||
data={data}
|
||||
pagination={{
|
||||
swrData,
|
||||
size,
|
||||
setSize,
|
||||
isLoadingMore: filePagination.isLoadingMore,
|
||||
isReachingEnd: filePagination.isReachingEnd,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* If password is validated or the root folder doesn't require password, show the files */}
|
||||
{(data.passwordValidated || !data.passwordRequired) && (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
{layout === "list" && (
|
||||
<ListLayout
|
||||
data={data}
|
||||
pagination={{
|
||||
swrData,
|
||||
size,
|
||||
setSize,
|
||||
isLoadingMore: filePagination.isLoadingMore,
|
||||
isReachingEnd: filePagination.isReachingEnd,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{siteConfig.readme.position === "end" && (
|
||||
<Readme
|
||||
isReadmeExist={isReadmeExists}
|
||||
isReadmeLoading={isReadmeLoading}
|
||||
readmeData={readmeData}
|
||||
/>
|
||||
)}
|
||||
</SWRLayout>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async () => {
|
||||
const fetchBanner = await axios.get<BannerResponse>(
|
||||
`${process.env.NEXT_PUBLIC_DOMAIN}/api/banner`,
|
||||
);
|
||||
if (!fetchBanner.data.banner) {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
|
||||
const bannerFileId = createFileId(fetchBanner.data.banner, true);
|
||||
return {
|
||||
props: {
|
||||
bannerFileId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+19
-9
@@ -73,6 +73,9 @@ html, body {
|
||||
@apply hover:opacity-75 transition duration-150;
|
||||
@apply text-zinc-900 dark:text-zinc-100 hover:text-zinc-800 dark:hover:text-zinc-200;
|
||||
}
|
||||
a.file {
|
||||
@apply opacity-90 hover:opacity-100 !important;
|
||||
}
|
||||
a.link {
|
||||
@apply text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500 underline underline-offset-2;
|
||||
}
|
||||
@@ -92,6 +95,13 @@ html, body {
|
||||
@apply transition duration-150;
|
||||
}
|
||||
|
||||
input.error {
|
||||
@apply border-red-400 hover:border-red-500 focus:border-red-600;
|
||||
@apply bg-red-500 hover:bg-red-600 focus:bg-red-700 placeholder-zinc-100;
|
||||
@apply dark:border-red-500 dark:hover:border-red-600 dark:focus:border-red-700 dark:placeholder-zinc-100;
|
||||
@apply dark:bg-red-500 dark:hover:bg-red-600 dark:focus:bg-red-700;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer disabled:cursor-default;
|
||||
}
|
||||
@@ -167,9 +177,9 @@ html, body {
|
||||
@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 bg-zinc-100 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 dark:bg-zinc-900 dark:hover:bg-zinc-800 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;
|
||||
@@ -177,19 +187,19 @@ html, body {
|
||||
}
|
||||
div.layoutOption {
|
||||
@apply rounded-lg overflow-hidden;
|
||||
@apply flex flex-col justify-center;
|
||||
@apply 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 bg-zinc-200;
|
||||
@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;
|
||||
@apply dark:bg-zinc-800;
|
||||
@apply transition duration-150 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;
|
||||
@apply bg-zinc-100 hover:bg-blue-300/25 focus:bg-zinc-50;
|
||||
@apply dark:bg-zinc-900 dark:hover:bg-blue-400/25 dark:focus:bg-zinc-950;
|
||||
}
|
||||
div.layoutItem[data-active="true"] {
|
||||
@apply bg-blue-300 cursor-default;
|
||||
@@ -228,7 +238,7 @@ html, body {
|
||||
}
|
||||
|
||||
.fillCard {
|
||||
@apply h-[15vh]
|
||||
@apply min-h-[25vh]
|
||||
}
|
||||
|
||||
.navbar a {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
--syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
|
||||
--syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
|
||||
}
|
||||
html.dark {
|
||||
body.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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
.markdown {
|
||||
@apply leading-normal text-inherit bg-inherit;
|
||||
}
|
||||
|
||||
|
||||
.markdown p {
|
||||
@apply leading-loose;
|
||||
|
||||
@@ -80,6 +80,8 @@ export interface FilesResponse extends APIResponse {
|
||||
folders: drive_v3.Schema$File[];
|
||||
files: drive_v3.Schema$File[];
|
||||
isReadmeExists?: boolean;
|
||||
isBannerExists?: boolean;
|
||||
isPasswordExists?: boolean;
|
||||
nextPageToken?: string;
|
||||
}
|
||||
|
||||
@@ -94,6 +96,14 @@ export interface FileResponse extends APIResponse {
|
||||
export interface SearchResponse extends APIResponse {
|
||||
files: drive_v3.Schema$File[];
|
||||
}
|
||||
export interface BreadCrumbsResponse extends APIResponse {
|
||||
breadcrumbs: TFileParent[];
|
||||
isLimitReached: boolean;
|
||||
}
|
||||
|
||||
export interface BannerResponse extends APIResponse {
|
||||
banner?: drive_v3.Schema$File;
|
||||
}
|
||||
|
||||
export interface ErrorResponse extends APIResponse {
|
||||
code: number;
|
||||
|
||||
@@ -4,11 +4,11 @@ import { decrypt } from "utils/encryptionHelper";
|
||||
|
||||
const decryptedSecret: string = decrypt(
|
||||
apiConfig.client_secret,
|
||||
process.env.ENCRYPTION_KEY as string,
|
||||
process.env.NEXT_PUBLIC_ENCRYPTION_KEY as string,
|
||||
);
|
||||
const decryptedRefreshToken: string = decrypt(
|
||||
apiConfig.refresh_token,
|
||||
process.env.ENCRYPTION_KEY as string,
|
||||
process.env.NEXT_PUBLIC_ENCRYPTION_KEY as string,
|
||||
);
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import { drive_v3 } from "googleapis";
|
||||
import { urlDecrypt } from "utils/encryptionHelper";
|
||||
|
||||
export const hiddenFiles = [".password", ".readme.md", ".banner"];
|
||||
export function createFileId(
|
||||
data: drive_v3.Schema$File,
|
||||
encrypted: boolean = false,
|
||||
) {
|
||||
if (process.env.ENCRYPTION_KEY) {
|
||||
}
|
||||
if (encrypted) {
|
||||
return `${encodeURIComponent(data.name as string)}:${urlDecrypt(
|
||||
data.id as string,
|
||||
)?.slice(0, 8)}`;
|
||||
}
|
||||
return `${encodeURIComponent(data.name as string)}:${data.id?.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export class ExtendedError extends Error {
|
||||
code?: number;
|
||||
|
||||
@@ -34,7 +34,7 @@ export function createEncryptionKey(passphrase: string): Promise<string> {
|
||||
|
||||
export function encrypt(
|
||||
data: string,
|
||||
encryptionKey: string = process.env.ENCRYPTION_KEY as string,
|
||||
encryptionKey: string = process.env.NEXT_PUBLIC_ENCRYPTION_KEY as string,
|
||||
) {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(
|
||||
@@ -49,7 +49,7 @@ export function encrypt(
|
||||
|
||||
export function decrypt(
|
||||
encryptedData: string,
|
||||
encryptionKey: string = process.env.ENCRYPTION_KEY as string,
|
||||
encryptionKey: string = process.env.NEXT_PUBLIC_ENCRYPTION_KEY as string,
|
||||
) {
|
||||
const [ivString, encryptedString] = encryptedData.split(":");
|
||||
const iv = Buffer.from(ivString, "hex");
|
||||
@@ -64,7 +64,8 @@ export function decrypt(
|
||||
return decrypted.toString();
|
||||
}
|
||||
|
||||
const urlKey = (process.env.ENCRYPTION_KEY as string).slice(0, 16);
|
||||
const urlKey =
|
||||
(process.env.NEXT_PUBLIC_ENCRYPTION_KEY as string).slice(0, 16) || "";
|
||||
const urlIV = Buffer.from(urlKey);
|
||||
|
||||
export function urlEncrypt(fileId: string): string {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ExpiredJWTPayload, JWTPayload } from "types/jwt";
|
||||
export function createJWTToken(payload: any, expiresIn: string = "3h") {
|
||||
return encrypt(
|
||||
JWT.sign({ payload }, process.env.JWT_KEY as string, { expiresIn }),
|
||||
process.env.ENCRYPTION_KEY as string,
|
||||
process.env.NEXT_PUBLIC_ENCRYPTION_KEY as string,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function verifyJWTToken<T = any>(
|
||||
try {
|
||||
return {
|
||||
...(JWT.verify(
|
||||
decrypt(token, process.env.ENCRYPTION_KEY as string),
|
||||
decrypt(token, process.env.NEXT_PUBLIC_ENCRYPTION_KEY as string),
|
||||
process.env.JWT_KEY as string,
|
||||
) as JWTPayload<T>),
|
||||
isExpired: false,
|
||||
|
||||
@@ -202,7 +202,8 @@ export function getFilePreview(extension: string, mimeType?: string) {
|
||||
return VideoPreview;
|
||||
}
|
||||
}
|
||||
const category = extToTypeMap[extension] || type.default;
|
||||
let category = extToTypeMap[extension] || type.default;
|
||||
|
||||
switch (category) {
|
||||
// case type["3d"]:
|
||||
// return ModelPreview;
|
||||
@@ -236,10 +237,29 @@ export function getFileIcon(extension: string, mimeType?: string): IconType {
|
||||
return iconsForType["video"];
|
||||
}
|
||||
}
|
||||
|
||||
const category = extToTypeMap[extension] || type.default;
|
||||
return iconsForType[category];
|
||||
}
|
||||
|
||||
export function getFileType(extension: string, mimeType?: string): string {
|
||||
if (overlapVideo.includes(extension)) {
|
||||
const isVideo = !!mimeType?.startsWith("video");
|
||||
if (isVideo) {
|
||||
return "video";
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType) {
|
||||
const type = mimeType.split("/")[0];
|
||||
if (type === "video") {
|
||||
return "video";
|
||||
}
|
||||
}
|
||||
|
||||
return extToTypeMap[extension] || type.default;
|
||||
}
|
||||
|
||||
// Not all extensions are included here
|
||||
// Taken from onedrive-vercel-index by SpencerWoo
|
||||
// https://github.com/spencerwooo/onedrive-vercel-index/blob/main/src/utils/getPreviewType.ts
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"styles/*": ["styles/*"],
|
||||
"utils/*": ["utils/*"],
|
||||
"types/*": ["types/*"],
|
||||
"context/*": ["context/*"],
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"],
|
||||
|
||||
Reference in New Issue
Block a user