diff --git a/.env-example b/.env-example
index 27cfbc1..6678578 100644
--- a/.env-example
+++ b/.env-example
@@ -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.
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 42fe619..5be0a6c 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -8,5 +8,15 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 99e8026..67e9587 100644
--- a/README.md
+++ b/README.md
@@ -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)
\ No newline at end of file
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..50ea839
--- /dev/null
+++ b/TODO.md
@@ -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
\ No newline at end of file
diff --git a/src/components/APIFeedback/Empty.tsx b/src/components/APIFeedback/Empty.tsx
index 1b210a2..d0a24bd 100644
--- a/src/components/APIFeedback/Empty.tsx
+++ b/src/components/APIFeedback/Empty.tsx
@@ -4,8 +4,10 @@ type Props = {
export default function EmptyFeedback({ message }: Props) {
return (
-
- {message || "Folder is empty"}
+
+
+ {message || "Folder is empty"}
+
);
}
diff --git a/src/components/APIFeedback/Error.tsx b/src/components/APIFeedback/Error.tsx
index 9e74b5e..441bb90 100644
--- a/src/components/APIFeedback/Error.tsx
+++ b/src/components/APIFeedback/Error.tsx
@@ -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 ? (
-
-
- Error -{" "}
- {message || "Something went wrong"}
-
-
- ) : (
-
- Error -{" "}
- {message || "Something went wrong"}
-
- )}
- >
+
+
+
+
+
+
Error details:
+
+ {message || "Internal server error"}
+
+
+
);
}
diff --git a/src/components/APIFeedback/Loading.tsx b/src/components/APIFeedback/Loading.tsx
index f067fc6..21460d4 100644
--- a/src/components/APIFeedback/Loading.tsx
+++ b/src/components/APIFeedback/Loading.tsx
@@ -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 ? (
-
-
- {" "}
- {message || "Loading..."}
-
-
- ) : (
-
- {" "}
- {message || "Loading..."}
-
- )}
- >
+
+
+
+ {message || "Loading..."}
+
+
);
}
diff --git a/src/components/Breadcrumb/index.tsx b/src/components/Breadcrumb/index.tsx
index 66e5b4b..b2916d7 100644
--- a/src/components/Breadcrumb/index.tsx
+++ b/src/components/Breadcrumb/index.tsx
@@ -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
([]);
- const [isLimited, setIsLimited] = useState();
- // const [isLoading, setIsLoading] = useState(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 (
-
+ <>
{isLoading ? (
-
+
) : (
- <>
+
+ {/* Home */}
Root
- {isLimited && (
+ {data?.isLimitReached && (
- /
- ...
+ ...
+ {siteConfig.breadcrumb.limiter}
)}
- <>
- {limitedPath.map((parent, idx) => (
-
- /
-
- {idx === limitedPath.length - 1 ? (
-
- {parent.name}
-
- ) : (
-
- {parent.name}
-
- )}
-
- ))}
- >
- >
+ {data?.breadcrumbs.map((parent: TFileParent, index: number) => (
+
+ {parent.name}
+ {index !== data.breadcrumbs.length - 1 && (
+ {siteConfig.breadcrumb.limiter}
+ )}
+
+ ))}
+
)}
-
+ >
);
}
diff --git a/src/components/File/Grid.tsx b/src/components/File/Grid.tsx
index 2a687a7..7b16937 100644
--- a/src/components/File/Grid.tsx
+++ b/src/components/File/Grid.tsx
@@ -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 (
-
- {data.thumbnailLink ? (
+ {/* Thumbnail */}
+
+ {data.thumbnailLink && allowThumbnail ? (
{data.videoMediaMetadata && (
- <>
-
-
+
+
+
{formatDuration(data.videoMediaMetadata.durationMillis || 0)}
- >
+
)}
) : (
-
+
)}
-
- {/*
*/}
-
+
+ {/* File name */}
+
+ {siteConfig.files.showFileNameIcon && (
+ <>
+ {data.iconLink ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
{data.name}
diff --git a/src/components/File/List.tsx b/src/components/File/List.tsx
index 2fb2179..27ea95f 100644
--- a/src/components/File/List.tsx
+++ b/src/components/File/List.tsx
@@ -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 (
-
-
- {data.name}
-
+ {siteConfig.files.listUseFileIcon ? (
+
+ ) : (
+
+ )}
+
+
+ {data.name}
+
+
+
+ {formatDate(new Date(data.modifiedTime as string))}
+
+ {siteConfig.files.listMobileShowFileSize && (
+
+ {isFolder ? "" : ` ・ ${formatBytes(data.size as string)}`}
+
+ )}
+
+
{formatDate(new Date(data.modifiedTime as string))}
@@ -93,7 +123,11 @@ export default function ListFile({ data }: Props) {
{isFolder ? null : (
diff --git a/src/components/layout/DefaultLayout/index.tsx b/src/components/layout/DefaultLayout/index.tsx
new file mode 100644
index 0000000..db8ee38
--- /dev/null
+++ b/src/components/layout/DefaultLayout/index.tsx
@@ -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
(
+ `/api/files/${fileId}/getPath`,
+ fetcher,
+ );
+
+ return (
+
+ );
+}
diff --git a/src/components/layout/Files/GridLayout.tsx b/src/components/layout/Files/GridLayout.tsx
index 1337412..ddb12db 100644
--- a/src/components/layout/Files/GridLayout.tsx
+++ b/src/components/layout/Files/GridLayout.tsx
@@ -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 && (
diff --git a/src/components/layout/Footer/index.tsx b/src/components/layout/Footer/index.tsx
index e1e8480..8919336 100644
--- a/src/components/layout/Footer/index.tsx
+++ b/src/components/layout/Footer/index.tsx
@@ -9,11 +9,11 @@ export default function Footer() {
{currentYear} {siteConfig.footerText} - Powered by{" "}
- gudora-index
+ next-gdrive-index
{" "}
❤️
diff --git a/src/components/layout/Navbar/index.tsx b/src/components/layout/Navbar/index.tsx
index 9e2fa26..015d431 100644
--- a/src/components/layout/Navbar/index.tsx
+++ b/src/components/layout/Navbar/index.tsx
@@ -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(false);
+type Props = {
+ isUnlocked: boolean;
+ setIsUnlocked: (isUnlocked: boolean) => void;
+};
+export default function Navbar({ isUnlocked, setIsUnlocked }: Props) {
+ const { theme, setTheme } = useContext(ThemeContext);
+ const [_, startTransition] = useTransition();
const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isLogoutOpen, setIsLogoutOpen] = useState(false);
+
const searchInputRef = useRef(null);
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState(); // [
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(
- `/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() {
{/* Search */}
-
+ {(siteConfig.privateIndex && isUnlocked) || !siteConfig.privateIndex ? (
{
- setIsSearching(true);
- searchInputRef.current?.focus();
- }}
+ id={"search-modal-toggle"}
+ className='interactive flex items-center gap-2'
+ role={"button"}
+ title={"Search"}
>
-
+
{
+ setIsSearching(true);
+ searchInputRef.current?.focus();
+ }}
+ >
+
+
-
+ ) : (
+ <>>
+ )}
{/* Dark mode */}
{
+ setTheme(theme === "dark" ? "light" : "dark");
+ }}
role={"button"}
+ title={"Toggle theme"}
>
+ {/* Remove password / logout */}
+ {siteConfig.privateIndex && isUnlocked && (
+ setIsLogoutOpen(true)}
+ >
+
+ Logout
+
+ )}
+
{/* Menu mobile */}
-
Search files
-
+
Search files
}
isOpen={isSearching}
isCentered={false}
@@ -239,17 +250,9 @@ export default function Navbar() {
{/* Result */}
- {isLoading && (
-
- )}
+ {isLoading &&
}
{!isLoading && error && (
-
+
)}
{!isLoading && searchResults?.length === 0 && (
{item.thumbnailLink ? (
-
) : (
@@ -299,7 +309,7 @@ export default function Navbar() {
- {item.mimeType}
+ {formatDate(new Date(item.modifiedTime as string))}
{!isFolder
? ` ・ ${formatBytes(item.size as string)}`
: ""}
@@ -313,6 +323,44 @@ export default function Navbar() {
)}
+
+ {/* Logout Modal */}
+
Logout}
+ isOpen={isLogoutOpen}
+ isCentered={true}
+ onClose={handleCloseLogout}
+ >
+
+
Are you sure you want to logout?
+
+ It will remove all your data from this device and you will need to
+ login again.
+
+
+
+ Cancel
+
+ {
+ if (typeof window !== "undefined") {
+ window.localStorage.removeItem("sitePassword");
+ startTransition(() => {
+ setIsLogoutOpen(false);
+ setIsUnlocked(false);
+ });
+ }
+ }}
+ className={"primary flex-grow"}
+ >
+ Logout
+
+
+
+
>
);
}
diff --git a/src/components/layout/Readme/index.tsx b/src/components/layout/Readme/index.tsx
new file mode 100644
index 0000000..3aa7a5d
--- /dev/null
+++ b/src/components/layout/Readme/index.tsx
@@ -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
(false);
+
+ useEffect(() => {
+ if (!isReadmeLoading && isReadmeExist && readmeData) {
+ setIsMounted(true);
+ }
+ }, [isReadmeExist, isReadmeLoading, readmeData]);
+
+ if (!isMounted) return <>>;
+ return (
+
+ {isReadmeLoading && }
+ {!isReadmeLoading && readmeData && (
+
+ )}
+
+ );
+}
diff --git a/src/components/layout/SWRLayout/index.tsx b/src/components/layout/SWRLayout/index.tsx
new file mode 100644
index 0000000..c7322b2
--- /dev/null
+++ b/src/components/layout/SWRLayout/index.tsx
@@ -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(false);
+
+ useEffect(() => {
+ if (!isLoading && !error && data) {
+ setIsMounted(true);
+ }
+ }, [data, error, isLoading]);
+
+ return (
+ <>
+ {isLoading && }
+ {!isLoading && error && }
+ {isMounted && <>{children}>}
+ >
+ );
+}
diff --git a/src/components/utility/Modal.tsx b/src/components/utility/Modal.tsx
index 488c04a..b2d5a29 100644
--- a/src/components/utility/Modal.tsx
+++ b/src/components/utility/Modal.tsx
@@ -18,7 +18,7 @@ export default function Modal({
}: Props) {
return (
{
diff --git a/src/components/utility/SwitchLayout.tsx b/src/components/utility/SwitchLayout.tsx
index 97cf7f8..49221bb 100644
--- a/src/components/utility/SwitchLayout.tsx
+++ b/src/components/utility/SwitchLayout.tsx
@@ -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
(LayoutContext);
const [isRenderGrid, setIsRenderGrid] = useState();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef(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)}
>
-
+
{isRenderGrid && }
{!isRenderGrid && }
{isRenderGrid ? "Grid" : "List"}
@@ -51,18 +44,17 @@ export default function SwitchLayout({ setLayoutStyle }: Props) {
{
- 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);
}}
diff --git a/src/config/api.config.js b/src/config/api.config.js
index 0c2e98d..e85a2b9 100644
--- a/src/config/api.config.js
+++ b/src/config/api.config.js
@@ -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
diff --git a/src/config/site.config.js b/src/config/site.config.js
index b900ae8..72e535c 100644
--- a/src/config/site.config.js
+++ b/src/config/site.config.js
@@ -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
diff --git a/src/context/layoutContext.tsx b/src/context/layoutContext.tsx
new file mode 100644
index 0000000..530db21
--- /dev/null
+++ b/src/context/layoutContext.tsx
@@ -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
({
+ layout: getLayoutFromLocalStorage(),
+ setLayout: () => {},
+});
+
+type TLayoutProvider = {
+ children: React.ReactNode;
+};
+const LayoutProvider = ({ children }: TLayoutProvider) => {
+ const [layout, setLayout] = useState("grid");
+
+ useEffect(() => {
+ setLayout(getLayoutFromLocalStorage());
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/context/themeContext.tsx b/src/context/themeContext.tsx
new file mode 100644
index 0000000..92ccdcd
--- /dev/null
+++ b/src/context/themeContext.tsx
@@ -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({
+ theme: getThemeFromLocalStorage(),
+ setTheme: () => {},
+});
+
+type TThemeProvider = {
+ children: React.ReactNode;
+};
+
+const ThemeProvider = ({ children }: TThemeProvider) => {
+ const [theme, setTheme] = useState("dark");
+
+ useEffect(() => {
+ setTheme(getThemeFromLocalStorage());
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index b4275a2..01d9126 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -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("isDarkMode", false);
+ const [theme, setTheme] = useState<"light" | "dark">("light");
+ const [layoutStyle, setLayoutStyle] = useState<"grid" | "list">("grid");
+
+ const [isUnlocked, setIsUnlocked] = useState(false);
+ const [passwordInput, setPasswordInput] = useState("");
+
+ 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) {
-
-
-
-
-
+
+ {
+ if (typeof window !== "undefined") {
+ localStorage.setItem("theme", theme);
+ }
+ setTheme(theme);
+ },
+ }}
+ >
+
+
+
+
-
-
-
+ {
+ if (typeof window !== "undefined") {
+ localStorage.setItem("layout", layout);
+ }
+ setLayoutStyle(layout);
+ },
+ }}
+ >
+
+ {siteConfig.privateIndex && !isUnlocked ? (
+
+
+ Preview
+
+
+
+
+ You have reached a private site that requires authorized
+ access.
+ If you have a valid password, please enter it below to
+ proceed. Otherwise, please leave this site immediately.
+ Thank you for your cooperation.
+
+
+
+
+ ) : (
+
+ )}
+
+
-
-
+
+
+
+
);
}
diff --git a/src/pages/api/banner/[folderId]/index.ts b/src/pages/api/banner/[folderId]/index.ts
new file mode 100644
index 0000000..52a9f55
--- /dev/null
+++ b/src/pages/api/banner/[folderId]/index.ts
@@ -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,
+) {
+ 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);
+ }
+});
diff --git a/src/pages/api/banner/index.ts b/src/pages/api/banner/index.ts
new file mode 100644
index 0000000..0ddde5d
--- /dev/null
+++ b/src/pages/api/banner/index.ts
@@ -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,
+) {
+ 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);
+ }
+});
diff --git a/src/pages/api/files/[id]/getPath.ts b/src/pages/api/files/[id]/getPath.ts
new file mode 100644
index 0000000..9232fcc
--- /dev/null
+++ b/src/pages/api/files/[id]/getPath.ts
@@ -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,
+) {
+ 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);
+ }
+});
diff --git a/src/pages/api/files/[id]/index.ts b/src/pages/api/files/[id]/index.ts
index 830208c..53ba9b9 100644
--- a/src/pages/api/files/[id]/index.ts
+++ b/src/pages/api/files/[id]/index.ts
@@ -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,
diff --git a/src/pages/api/files/index.ts b/src/pages/api/files/index.ts
index 0e39c00..adff645 100644
--- a/src/pages/api/files/index.ts
+++ b/src/pages/api/files/index.ts
@@ -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,
+ 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,
};
diff --git a/src/pages/api/og.tsx b/src/pages/api/og.tsx
index cbcdc5f..c9232b2 100644
--- a/src/pages/api/og.tsx
+++ b/src/pages/api/og.tsx
@@ -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(
(
-
+
-
+
(LayoutContext);
const [data, setData] = useState();
+
const [isReadmeExists, setIsReadmeExists] = useState(false);
- const [renderStyle] = useLocalStorage<"grid" | "list">("renderStyle", "grid");
- const [layoutStyle, setLayoutStyle] = useState<"grid" | "list">(renderStyle);
- const [globalLoading, setGlobalLoading] = useState(true);
+ const [isReadmeLoading, setIsReadmeLoading] = useState(false);
+ const [readmeData, setReadmeData] = useState();
- 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(
getNextKey,
- (url, headers) =>
+ (url: string, headers: AxiosHeaders) =>
axios
.get(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(`/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 (
-
-
-
-
-
-
- {globalLoading &&
}
- {!globalLoading && error && (
-
- )}
- {!globalLoading && !error && data && (
- <>
- {/* If the root folder have password and the password isn't validated, show password input */}
- {data.passwordRequired && !data.passwordValidated && (
-
+
+
+ {siteConfig.readme.position === "start" && (
+
+ )}
+
+ {layout === "grid" && (
+
)}
- {/* If password is validated or the root folder doesn't require password, show the files */}
- {(data.passwordValidated || !data.passwordRequired) && (
- <>
- {isReadmeExists && config.readme.position === "start" && (
-
- {readmeLoading && (
-
- )}
- {readmeError && !readmeLoading && (
-
- )}
- {readmeData && !readmeLoading && (
-
- )}
-
- )}
-
-
- {layoutStyle === "list" && (
-
- )}
- {layoutStyle === "grid" && (
-
- )}
-
-
- {isReadmeExists && config.readme.position === "end" && (
-
- {readmeLoading && (
-
- )}
- {readmeError && !readmeLoading && (
-
- )}
- {readmeData && !readmeLoading && (
-
- )}
-
- )}
- >
+ {layout === "list" && (
+
)}
- >
- )}
-
+
+ {siteConfig.readme.position === "end" && (
+
+ )}
+
+
);
}
+
+export const getStaticProps: GetStaticProps = async () => {
+ const fetchBanner = await axios.get(
+ `${process.env.NEXT_PUBLIC_DOMAIN}/api/banner`,
+ );
+ if (!fetchBanner.data.banner) {
+ return {
+ props: {},
+ };
+ }
+
+ const bannerFileId = createFileId(fetchBanner.data.banner, true);
+ return {
+ props: {
+ bannerFileId,
+ },
+ };
+};
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 9626b30..89b57d9 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -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 {
diff --git a/src/styles/highlight.css b/src/styles/highlight.css
index 0d1cc20..e4547b1 100644
--- a/src/styles/highlight.css
+++ b/src/styles/highlight.css
@@ -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
diff --git a/src/styles/markdown.css b/src/styles/markdown.css
index 089787c..3c324a3 100644
--- a/src/styles/markdown.css
+++ b/src/styles/markdown.css
@@ -4,6 +4,7 @@
.markdown {
@apply leading-normal text-inherit bg-inherit;
}
+
.markdown p {
@apply leading-loose;
diff --git a/src/types/googleapis.ts b/src/types/googleapis.ts
index 5a628f3..99030f7 100644
--- a/src/types/googleapis.ts
+++ b/src/types/googleapis.ts
@@ -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;
diff --git a/src/utils/driveClient.ts b/src/utils/driveClient.ts
index 1e96279..30b7628 100644
--- a/src/utils/driveClient.ts
+++ b/src/utils/driveClient.ts
@@ -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(
diff --git a/src/utils/driveHelper.ts b/src/utils/driveHelper.ts
index 37e977b..2589d3a 100644
--- a/src/utils/driveHelper.ts
+++ b/src/utils/driveHelper.ts
@@ -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;
diff --git a/src/utils/encryptionHelper.ts b/src/utils/encryptionHelper.ts
index 6737056..dbed390 100644
--- a/src/utils/encryptionHelper.ts
+++ b/src/utils/encryptionHelper.ts
@@ -34,7 +34,7 @@ export function createEncryptionKey(passphrase: string): Promise {
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 {
diff --git a/src/utils/jwtHelper.ts b/src/utils/jwtHelper.ts
index 6e18229..d84e100 100644
--- a/src/utils/jwtHelper.ts
+++ b/src/utils/jwtHelper.ts
@@ -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(
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),
isExpired: false,
diff --git a/src/utils/mimeTypesHelper.ts b/src/utils/mimeTypesHelper.ts
index 4cf954b..5269168 100644
--- a/src/utils/mimeTypesHelper.ts
+++ b/src/utils/mimeTypesHelper.ts
@@ -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
diff --git a/tsconfig.json b/tsconfig.json
index f831cee..f9525c6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -23,6 +23,7 @@
"styles/*": ["styles/*"],
"utils/*": ["utils/*"],
"types/*": ["types/*"],
+ "context/*": ["context/*"],
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"],