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"} -
- )} - +
+
+ +

{errorMessage}

+
+ +
+
+ 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.name {data.videoMediaMetadata && ( - <> - - +
+ + {formatDuration(data.videoMediaMetadata.durationMillis || 0)} - +
)}
) : ( - + )}
-
- {/**/} - + + {/* File name */} +
+ {siteConfig.files.showFileNameIcon && ( + <> + {data.iconLink ? ( + {"icon"} + ) : ( + + )} + + )} + {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 ? ( + {"icon"} + ) : ( + + )} +
+ + {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 ( +
+
+ + +
+ + {children} +
+ ); +} 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 ? ( - {item.name ) : ( @@ -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. + +
+ + +
+
+
); } 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. +

+
{ + 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"} + > + { + setPasswordInput(e.target.value); + }} + /> + +
+
+
+ ) : ( + + )} +
+ -
- +
+ + +
); } 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"],