Redo List and Grid layout, Implement private index.

This commit is contained in:
mbaharip
2023-05-07 17:32:49 +07:00
parent 249ecc6b6a
commit 89a73e7bc9
45 changed files with 1429 additions and 640 deletions
+1 -1
View File
@@ -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
View File
@@ -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>
+8 -4
View File
@@ -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)
+98
View File
@@ -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 -2
View File
@@ -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>
);
}
+22 -17
View File
@@ -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>
);
}
+12 -30
View File
@@ -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>
);
}
+27 -62
View File
@@ -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>
</>
);
}
+62 -17
View File
@@ -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>
+40 -6
View File
@@ -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 && (
+2 -2
View File
@@ -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>
+124 -76
View File
@@ -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>
</>
);
}
+33
View File
@@ -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>
);
}
+28
View File
@@ -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}</>}
</>
);
}
+2 -2
View File
@@ -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) => {
+11 -20
View File
@@ -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 -2
View File
@@ -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
+18
View File
@@ -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
+40
View File
@@ -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>
);
};
+41
View File
@@ -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
View File
@@ -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>
);
}
+79
View File
@@ -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);
}
});
+60
View File
@@ -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);
}
});
+119
View File
@@ -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);
}
});
+4 -2
View File
@@ -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,
+48 -12
View File
@@ -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
View File
@@ -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",
-79
View File
@@ -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);
}
}
+37 -52
View File
@@ -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);
}
}
});
+1 -1
View File
@@ -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
View File
@@ -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
+18 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@
.markdown {
@apply leading-normal text-inherit bg-inherit;
}
.markdown p {
@apply leading-loose;
+10
View File
@@ -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;
+2 -2
View File
@@ -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(
+16
View File
@@ -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;
+4 -3
View File
@@ -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 {
+2 -2
View File
@@ -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,
+21 -1
View File
@@ -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
+1
View File
@@ -23,6 +23,7 @@
"styles/*": ["styles/*"],
"utils/*": ["utils/*"],
"types/*": ["types/*"],
"context/*": ["context/*"],
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"],