+
{title && {title}}
{description && (
{description}
@@ -27,9 +30,9 @@ export function Toaster() {
{action}
- )
+ );
})}
- )
+ );
}
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index e0dd4e8..d94aaf7 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -1,15 +1,14 @@
-"use client"
+"use client";
-import * as React from "react"
-import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import * as React from "react";
+import { cn } from "~/utils";
-import { cn } from "~/utils"
+const TooltipProvider = TooltipPrimitive.Provider;
-const TooltipProvider = TooltipPrimitive.Provider
+const Tooltip = TooltipPrimitive.Root;
-const Tooltip = TooltipPrimitive.Root
-
-const TooltipTrigger = TooltipPrimitive.Trigger
+const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef,
@@ -20,11 +19,11 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
- className
+ className,
)}
{...props}
/>
-))
-TooltipContent.displayName = TooltipPrimitive.Content.displayName
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts
index f03611e..f9aa688 100644
--- a/src/components/ui/use-toast.ts
+++ b/src/components/ui/use-toast.ts
@@ -2,6 +2,7 @@
// Inspired by react-hot-toast library
import * as React from "react";
+
import type { ToastActionElement, ToastProps } from "~/components/ui/toast";
const TOAST_LIMIT = 3;
diff --git a/src/config/gIndex.config.ts b/src/config/gIndex.config.ts
index 327fc5c..0747385 100644
--- a/src/config/gIndex.config.ts
+++ b/src/config/gIndex.config.ts
@@ -1,9 +1,7 @@
-import { icons } from "lucide-react";
-import { Metadata } from "next";
-import { ToastPosition } from "react-hot-toast";
-import colors from "tailwindcss/colors";
+import { z } from "zod";
+import { Schema_Config } from "~/schema";
-const config: gIndexConfig = {
+const config: z.input = {
/**
* If possible, please don't change this value
* Even if you're creating a PR, just let me change it myself
@@ -11,19 +9,19 @@ const config: gIndexConfig = {
version: "2.0",
/**
* Base path of the app, used for generating links
- * If you're not using Vercel, you need to change this to your domain name
*
- * 2023/09/10: This is not used anymore, edit via env variable instead
+ * If you're using another port for development, you can set it here
*
- * @default process.env.NEXT_PUBLIC_VERCEL_URL
+ * @default process.env.NEXT_PUBLIC_DOMAIN
*/
basePath:
process.env.NODE_ENV === "development"
? "http://localhost:3000"
- : `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`,
+ : `https://${process.env.NEXT_PUBLIC_DOMAIN}`,
/**
* DEPRECATED
+ * Since in 2.0 we're using server side data fetching, this is not needed anymore.
*
* Hashed key for fetching protected files / folders from the server
* This key will bypass the file / folder password
@@ -35,9 +33,9 @@ const config: gIndexConfig = {
* Used for all pages and api routes
* Default is 5 minutes (300/60 = 5min)
*
- * @default "max-age=300, s-maxage=300, stale-while-revalidate, public"
+ * @default "max-age=0, s-maxage=60, stale-while-revalidate"
*/
- cacheControl: "max-age=300, s-maxage=300, stale-while-revalidate, public",
+ cacheControl: "max-age=0, s-maxage=60, stale-while-revalidate",
apiConfig: {
/**
@@ -48,7 +46,8 @@ const config: gIndexConfig = {
* You need to create a new folder and share it with the service account
* Then, copy the folder id and paste it here
*/
- rootFolder: "1KgPV6QB1GYT8fmn2uTfbtr9rDXqcRR0j",
+ rootFolder:
+ "49b52b93b109c0f8157af45a6a265fbd33f5e45293a70774780b53c96fee9ee1a6e78b077bea3476c13b9603af47d762",
isTeamDrive: false, // Set this to true if you're using Team Drive
defaultQuery: [
"trashed = false",
@@ -57,8 +56,23 @@ const config: gIndexConfig = {
defaultField:
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed",
defaultOrder: "folder, name asc, modifiedTime desc",
- itemsPerPage: 12,
- searchResult: 10,
+ itemsPerPage: 50,
+ searchResult: 5,
+
+ /**
+ * By default, the app will use the thumbnail URL from Google Drive
+ *
+ * Sometimes, the thumbnail can't be accessed because of CORS policy
+ * If you're having this issue, you can set this to true
+ *
+ * This will make the api fetch the thumbnail and serve it from the server
+ * instead of using the Google Drive thumbnail
+ *
+ * This will increase the server load, so use it wisely
+ *
+ * Default: true
+ */
+ proxyThumbnail: true,
/**
* Special file name that will be used for certain purposes
@@ -74,7 +88,22 @@ const config: gIndexConfig = {
*/
banner: ".banner",
},
- hiddenFiles: [".password", ".readme.md", ".banner"],
+ /**
+ * Reason why banner has multiple extensions:
+ * - If I use contains query, it will also match the file or folder that contains the word.
+ * (e.g: File / folder with the name of "Test Password" will be matched)
+ * - If I use = query, it will only match the exact name, hence the multiple extensions
+ *
+ * You can add more extensions if you want
+ */
+ hiddenFiles: [
+ ".password",
+ ".readme.md",
+ ".banner",
+ ".banner.jpg",
+ ".banner.png",
+ ".banner.webp",
+ ],
/**
* Allow user to download protected file without password.
@@ -86,6 +115,13 @@ const config: gIndexConfig = {
allowDownloadProtectedFile: false,
/**
* Duration in hours.
+ * In version 2, this will be used for download link expiration.
+ * If you need it under 1 hour, you can use math expression. (e.g: (5 / 60) * 1 = 5 minutes)
+ *
+ * This only affect when the user download the file
+ * For example if you set it for example 30 minutes (0.5)
+ * After 30 minutes, and the user still downloading the file, the download will NOT be interrupted
+ * But if the user refresh the page / trying to download again, the download link will be expired
*
* Default: 6 hours
*/
@@ -116,15 +152,15 @@ const config: gIndexConfig = {
siteName: "next-gdrive-index",
siteNameTemplate: "%s - next-gdrive-index",
siteDescription: "A simple file browser for Google Drive",
- siteIcon: "/flaticon.svg",
+ siteIcon: "/logo.svg",
siteAuthor: "mbaharip",
- favIcon: "/favicon.svg",
+ favIcon: "/favicon.png",
/**
* Next.js Metadata robots object
*
* ref: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#robots
*/
- robots: undefined,
+ robots: "noindex, nofollow",
twitterHandle: "@mbaharip_",
/**
@@ -159,7 +195,7 @@ const config: gIndexConfig = {
* Tailwind color name.
* Ref: https://tailwindcss.com/docs/customizing-colors
*/
- defaultAccentColor: "teal",
+ // defaultAccentColor: "teal",
/**
* Site wide password protection
@@ -246,65 +282,62 @@ const config: gIndexConfig = {
};
export default config;
+// export interface gIndexConfig {
+// version: string;
+// basePath: string;
+// cacheControl: string;
-interface gIndexConfig {
- version: string;
- basePath: string;
- // masterKey: string;
- cacheControl: string;
+// apiConfig: {
+// rootFolder: string;
+// isTeamDrive: boolean;
+// defaultQuery: string[];
+// defaultField: string;
+// defaultOrder: string;
+// itemsPerPage: number;
+// searchResult: number;
+// proxyThumbnail: boolean;
- apiConfig: {
- rootFolder: string;
- isTeamDrive: boolean;
- defaultQuery: string[];
- defaultField: string;
- defaultOrder: string;
- itemsPerPage: number;
- searchResult: number;
+// specialFile: {
+// password: string;
+// readme: string;
+// banner: string;
+// };
+// hiddenFiles: string[];
- specialFile: {
- password: string;
- readme: string;
- banner: string;
- };
- hiddenFiles: string[];
+// allowDownloadProtectedFile: boolean;
+// temporaryTokenDuration: number;
+// maxFileSize: number;
+// };
+// siteConfig: {
+// siteName: string;
+// siteNameTemplate?: string;
+// siteDescription: string;
+// siteIcon: string;
+// siteAuthor?: string;
+// favIcon: string;
+// robots?: Metadata["robots"];
+// twitterHandle?: string;
- allowDownloadProtectedFile: boolean;
- temporaryTokenDuration: number;
- maxFileSize: number;
- };
- siteConfig: {
- siteName: string;
- siteNameTemplate?: string;
- siteDescription: string;
- siteIcon: string;
- siteAuthor?: string;
- favIcon: string;
- robots?: Metadata["robots"];
- twitterHandle?: string;
+// footer: string | string[];
- footer: string | string[];
+// privateIndex: boolean;
+// breadcrumbMax: number;
- defaultAccentColor?: keyof typeof colors;
+// toaster?: {
+// position?: ToastPosition;
+// duration?: number;
+// };
- privateIndex: boolean;
- breadcrumbMax: number;
-
- toaster?: {
- position?: ToastPosition;
- duration?: number;
- };
-
- navbarItems: {
- icon: keyof typeof icons;
- name: string;
- href: string;
- external?: boolean;
- }[];
- supports: {
- name: string;
- currency: string;
- href: string;
- }[];
- };
-}
+// navbarItems: {
+// icon: keyof typeof icons;
+// name: string;
+// href: string;
+// external?: boolean;
+// }[];
+// supports: {
+// name: string;
+// currency: string;
+// href: string;
+// }[];
+// };
+// }
diff --git a/src/context/themeContext.tsx b/src/context/themeContext.tsx
index c32958a..7413e0e 100644
--- a/src/context/themeContext.tsx
+++ b/src/context/themeContext.tsx
@@ -1,10 +1,6 @@
"use client";
-import React, {
- createContext,
- useEffect,
- useState,
-} from "react";
+import React, { createContext, useEffect, useState } from "react";
export type TTheme = "dark" | "light";
diff --git a/src/hooks/useCopyText.ts b/src/hooks/useCopyText.ts
index db76931..8703b98 100644
--- a/src/hooks/useCopyText.ts
+++ b/src/hooks/useCopyText.ts
@@ -3,9 +3,7 @@ import { toast } from "react-toastify";
export default function useCopyText() {
return (text: string) => {
if (!navigator.clipboard) {
- toast.error(
- "Your browser does not support copying to clipboard.",
- );
+ toast.error("Your browser does not support copying to clipboard.");
return;
}
if (!text) {
diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts
index c9aacbf..2fd82bf 100644
--- a/src/hooks/useLocalStorage.ts
+++ b/src/hooks/useLocalStorage.ts
@@ -2,10 +2,7 @@ import { useEffect, useState } from "react";
type SetValue = (value: T | ((val: T) => T)) => void;
-export default function useLocalStorage(
- key: string,
- initialValue: T,
-) {
+export default function useLocalStorage(key: string, initialValue: T) {
// First check if there is a value in localStorage
function readLocalStorage() {
if (typeof window === "undefined") return initialValue;
@@ -14,30 +11,20 @@ export default function useLocalStorage(
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
- console.warn(
- `Error reading localStorage key “${key}”:`,
- error,
- );
+ console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValue;
}
}
- const [storedValue, setStoredValue] = useState(
- readLocalStorage,
- );
+ const [storedValue, setStoredValue] = useState(readLocalStorage);
// Create dispatch function to set or remove localStorage
const setValue: SetValue = (value) => {
try {
const valueToStore =
- value instanceof Function
- ? value(storedValue)
- : value;
+ value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
- window.localStorage.setItem(
- key,
- JSON.stringify(valueToStore),
- );
+ window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error("Error setting localStorage:", error);
}
@@ -54,10 +41,7 @@ export default function useLocalStorage(
const newValue = JSON.parse(event.newValue);
setStoredValue(newValue);
} catch (error) {
- console.error(
- "Error setting localStorage:",
- error,
- );
+ console.error("Error setting localStorage:", error);
}
}
};
@@ -65,10 +49,7 @@ export default function useLocalStorage(
window.addEventListener("storage", handleStorageChange);
return () => {
- window.removeEventListener(
- "storage",
- handleStorageChange,
- );
+ window.removeEventListener("storage", handleStorageChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
diff --git a/src/hooks/usePRouter.ts b/src/hooks/usePRouter.ts
new file mode 100644
index 0000000..cce98c1
--- /dev/null
+++ b/src/hooks/usePRouter.ts
@@ -0,0 +1,19 @@
+import { useRouter as useNextRouter } from "next/navigation";
+import NProgress from "nprogress";
+
+/**
+ * Custom useRouter hook that add NProgress to the router
+ * Why? Because NProgress is not working with Next.js 14 router
+ * So we need to add it manually
+ */
+export default function useRouter() {
+ const router = useNextRouter();
+ const { push } = router;
+
+ router.push = (...args: Parameters) => {
+ NProgress.start();
+ return push(...args);
+ };
+
+ return router;
+}
diff --git a/src/schema.ts b/src/schema.ts
index a6fc18f..30f090a 100644
--- a/src/schema.ts
+++ b/src/schema.ts
@@ -1,3 +1,4 @@
+import { icons } from "lucide-react";
import { z } from "zod";
export const Schema_Breadcrumb = z.object({
@@ -19,7 +20,7 @@ export const Schema_File = z.object({
.object({
width: z.coerce.number(),
height: z.coerce.number(),
- rotation: z.coerce.number(),
+ rotation: z.coerce.number().optional(),
})
.optional()
.nullable(),
@@ -32,3 +33,90 @@ export const Schema_File = z.object({
.optional()
.nullable(),
});
+
+export const Schema_Config = z.object({
+ version: z.string(),
+ basePath: z.string(),
+ cacheControl: z.string(),
+
+ apiConfig: z.object({
+ rootFolder: z.string(),
+ isTeamDrive: z.boolean(),
+ defaultQuery: z.array(z.string()),
+ defaultField: z.string(),
+ defaultOrder: z.string(),
+ itemsPerPage: z.number().positive(),
+ searchResult: z.number().positive(),
+ proxyThumbnail: z.boolean(),
+
+ specialFile: z.object({
+ password: z.string(),
+ readme: z.string(),
+ banner: z.string(),
+ }),
+ hiddenFiles: z.array(z.string()),
+
+ allowDownloadProtectedFile: z.boolean(),
+ temporaryTokenDuration: z.number().positive(),
+ maxFileSize: z.number().positive(),
+ }),
+
+ siteConfig: z.object({
+ siteName: z.string(),
+ siteNameTemplate: z.string().optional().default("%s"),
+ siteDescription: z.string(),
+ siteIcon: z.string(),
+ siteAuthor: z.string().optional().default("mbaharip"),
+ favIcon: z.string(),
+ robots: z.string().optional().default("noindex, nofollow"),
+ twitterHandle: z.string().optional().default("@__mbaharip__"),
+
+ footer: z
+ .string()
+ .or(z.array(z.string()))
+ .optional()
+ .default([
+ "{{ year }}",
+ "{{ repository }}",
+ "{{ author }}",
+ "{{ version }}",
+ "{{ siteName }}",
+ "{{ creator }}",
+ ]),
+
+ privateIndex: z.boolean().optional().default(false),
+ breadcrumbMax: z.number(),
+
+ toaster: z
+ .object({
+ position: z.enum([
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+ ]),
+ duration: z.number().positive(),
+ })
+ .optional()
+ .default({
+ position: "top-right",
+ duration: 5000,
+ }),
+
+ navbarItems: z.array(
+ z.object({
+ icon: z.enum(Object.keys(icons) as [keyof typeof icons]),
+ name: z.string(),
+ href: z.string(),
+ external: z.boolean().optional().default(false),
+ }),
+ ),
+ supports: z.array(
+ z.object({
+ name: z.string(),
+ currency: z.string(),
+ href: z.string(),
+ }),
+ ),
+ }),
+});
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 0e9cfa7..d285b99 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -12,7 +12,7 @@ body {
@layer base {
* {
@apply outline-none;
- @apply focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500;
+ @apply focus-visible:outline-accent-500 focus-visible:outline-2 focus-visible:-outline-offset-1;
}
html,
diff --git a/src/styles/highlight.css b/src/styles/highlight.css
index 8ad652f..d7542db 100644
--- a/src/styles/highlight.css
+++ b/src/styles/highlight.css
@@ -22,11 +22,7 @@
--syntax-accent: hsl(230, 100%, 66%);
/* From syntax-variables.less */
--syntax-selection-color: hsl(230, 1%, 90%);
- --syntax-gutter-background-color-selected: hsl(
- 230,
- 1%,
- 90%
- );
+ --syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
--syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
}
@@ -54,11 +50,7 @@ body {
--syntax-accent: hsl(220, 100%, 66%);
/* From syntax-variables.less */
--syntax-selection-color: hsl(220, 13%, 28%);
- --syntax-gutter-background-color-selected: hsl(
- 220,
- 13%,
- 26%
- );
+ --syntax-gutter-background-color-selected: hsl(220, 13%, 26%);
--syntax-cursor-line: hsla(220, 100%, 80%, 0.04);
}
@@ -231,9 +223,7 @@ pre {
/* MD overrides */
.language-markdown .token.url,
.language-markdown .token.url > .token.operator,
-.language-markdown
- .token.url-reference.url
- > .token.string {
+.language-markdown .token.url-reference.url > .token.string {
color: var(--syntax-fg);
}
@@ -267,9 +257,7 @@ pre {
.language-markdown .token.strike .token.content,
.language-markdown .token.strike .token.punctuation,
.language-markdown .token.list.punctuation,
-.language-markdown
- .token.title.important
- > .token.punctuation {
+.language-markdown .token.title.important > .token.punctuation {
color: var(--hue-5);
}
@@ -310,44 +298,21 @@ div.code-toolbar > .toolbar.toolbar > .toolbar-item {
}
/* Styling the buttons */
-div.code-toolbar
- > .toolbar.toolbar
- > .toolbar-item
- > button,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
- background: var(
- --syntax-gutter-background-color-selected
- );
+ background: var(--syntax-gutter-background-color-selected);
color: var(--mono-2);
padding: 0.1em 0.4em;
border-radius: 0.3em;
}
-div.code-toolbar
- > .toolbar.toolbar
- > .toolbar-item
- > button:hover,
-div.code-toolbar
- > .toolbar.toolbar
- > .toolbar-item
- > button:focus,
-div.code-toolbar
- > .toolbar.toolbar
- > .toolbar-item
- > a:hover,
-div.code-toolbar
- > .toolbar.toolbar
- > .toolbar-item
- > a:focus,
-div.code-toolbar
- > .toolbar.toolbar
- > .toolbar-item
- > span:hover,
-div.code-toolbar
- > .toolbar.toolbar
- > .toolbar-item
- > span:focus {
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
+div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
background: var(--syntax-selection-color);
color: var(--syntax-fg);
}
@@ -361,9 +326,7 @@ div.code-toolbar
/* Default line numbers in Line Highlight plugin */
.line-highlight.line-highlight:before,
.line-highlight.line-highlight[data-end]:after {
- background: var(
- --syntax-gutter-background-color-selected
- );
+ background: var(--syntax-gutter-background-color-selected);
color: var(--syntax-fg);
padding: 0.1em 0.6em;
border-radius: 0.3em;
@@ -420,86 +383,40 @@ pre[id].linkable-line-numbers.linkable-line-numbers
/* Diff Highlight plugin overrides */
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
pre.diff-highlight > code .token.token.deleted:not(.prefix),
-pre
- > code.diff-highlight
- .token.token.deleted:not(.prefix) {
+pre > code.diff-highlight .token.token.deleted:not(.prefix) {
background-color: hsla(353, 100%, 66%, 0.15);
}
-pre.diff-highlight
- > code
- .token.token.deleted:not(.prefix)::-moz-selection,
-pre.diff-highlight
- > code
- .token.token.deleted:not(.prefix)
- *::-moz-selection,
-pre
- > code.diff-highlight
- .token.token.deleted:not(.prefix)::-moz-selection,
-pre
- > code.diff-highlight
- .token.token.deleted:not(.prefix)
- *::-moz-selection {
+pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
+pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection,
+pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
+pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection {
background-color: hsla(353, 95%, 66%, 0.25);
}
-pre.diff-highlight
- > code
- .token.token.deleted:not(.prefix)::selection,
-pre.diff-highlight
- > code
- .token.token.deleted:not(.prefix)
- *::selection,
-pre
- > code.diff-highlight
- .token.token.deleted:not(.prefix)::selection,
-pre
- > code.diff-highlight
- .token.token.deleted:not(.prefix)
- *::selection {
+pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
+pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
+pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
+pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
background-color: hsla(353, 95%, 66%, 0.25);
}
-pre.diff-highlight
- > code
- .token.token.inserted:not(.prefix),
-pre
- > code.diff-highlight
- .token.token.inserted:not(.prefix) {
+pre.diff-highlight > code .token.token.inserted:not(.prefix),
+pre > code.diff-highlight .token.token.inserted:not(.prefix) {
background-color: hsla(137, 100%, 55%, 0.15);
}
-pre.diff-highlight
- > code
- .token.token.inserted:not(.prefix)::-moz-selection,
-pre.diff-highlight
- > code
- .token.token.inserted:not(.prefix)
- *::-moz-selection,
-pre
- > code.diff-highlight
- .token.token.inserted:not(.prefix)::-moz-selection,
-pre
- > code.diff-highlight
- .token.token.inserted:not(.prefix)
- *::-moz-selection {
+pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection,
+pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection,
+pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection,
+pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection {
background-color: hsla(135, 73%, 55%, 0.25);
}
-pre.diff-highlight
- > code
- .token.token.inserted:not(.prefix)::selection,
-pre.diff-highlight
- > code
- .token.token.inserted:not(.prefix)
- *::selection,
-pre
- > code.diff-highlight
- .token.token.inserted:not(.prefix)::selection,
-pre
- > code.diff-highlight
- .token.token.inserted:not(.prefix)
- *::selection {
+pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
+pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
+pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
+pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
background-color: hsla(135, 73%, 55%, 0.25);
}
diff --git a/src/styles/markdown.css b/src/styles/markdown.css
index 97b0b01..c247657 100644
--- a/src/styles/markdown.css
+++ b/src/styles/markdown.css
@@ -31,15 +31,15 @@
.markdown h1,
.markdown h2,
.markdown h3 {
- @apply my-4 border-b-2 border-b-primary-700 first-letter:capitalize;
+ @apply border-b-primary-700 my-4 border-b-2 first-letter:capitalize;
}
.markdown code:not(pre code) {
- @apply rounded-md bg-primary-800 px-2 py-0.5 leading-normal;
+ @apply bg-primary-800 rounded-md px-2 py-0.5 leading-normal;
}
.markdown a {
- @apply text-accent-500 opacity-80 transition-smooth hover:opacity-100;
+ @apply text-accent-500 transition-smooth opacity-80 hover:opacity-100;
}
.markdown p {
@@ -68,7 +68,7 @@
.markdown table th,
.markdown table td {
- @apply border border-primary-700 px-2 py-1;
+ @apply border-primary-700 border px-2 py-1;
}
.markdown table th {
@@ -93,15 +93,15 @@
.markdown input[type="checkbox"] {
@apply mr-2 aspect-square h-4 w-4 rounded-full p-2;
- @apply appearance-none border border-primary-500;
+ @apply border-primary-500 appearance-none border;
@apply disabled:bg-primary-600 disabled:checked:bg-accent-400;
}
.markdown blockquote {
- @apply my-2 rounded-r-md border-l-4 border-l-info-500 bg-primary-800 py-4 pl-2;
+ @apply border-l-info-500 bg-primary-800 my-2 rounded-r-md border-l-4 py-4 pl-2;
}
.markdown hr {
- @apply my-4 border-b border-t-0 border-b-primary-500;
+ @apply border-b-primary-500 my-4 border-b border-t-0;
}
}
diff --git a/src/utils.ts b/src/utils.ts
index d084cca..365058c 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,6 +1,6 @@
-import { type ClassValue, clsx } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
+ return twMerge(clsx(inputs));
}
diff --git a/src/utils/apiHelper/getSearchParams.ts b/src/utils/apiHelper/getSearchParams.ts
deleted file mode 100644
index 3e67593..0000000
--- a/src/utils/apiHelper/getSearchParams.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export default function getSearchParams(
- url: string,
- key: string | string[],
-): Record {
- const { searchParams } = new URL(url);
- if (typeof key === "string") {
- return { [key]: searchParams.get(key) };
- } else {
- const result: Record = {};
- for (const k of key) {
- result[k] = searchParams.get(k);
- }
- return result;
- }
-}
diff --git a/src/utils/apiHelper/privateIndexProtection.ts b/src/utils/apiHelper/privateIndexProtection.ts
deleted file mode 100644
index f11ada6..0000000
--- a/src/utils/apiHelper/privateIndexProtection.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { NextRequest } from "next/server";
-
-import ExtendedError from "utils/extendedError";
-import { checkPathPassword } from "utils/passwordHelper";
-
-import { Constant } from "types/constant";
-
-export default async function privateIndexProtection(
- request: NextRequest,
-) {
- const cookies = request.cookies.get(
- Constant.cookies_SitePassword,
- );
- if (!cookies) {
- throw new ExtendedError(
- "Unauthorized",
- 401,
- "No password found",
- );
- }
-
- const password = cookies.value;
- const sitePassword =
- process.env.NEXT_PUBLIC_SITE_PASSWORD;
- console.log(sitePassword);
- if (!sitePassword) {
- throw new ExtendedError(
- "Internal Server Error",
- 500,
- "Can't find site password. Please check your environment variable",
- );
- }
- const valid = checkPathPassword(
- "site-wide",
- password,
- sitePassword,
- );
- if (!valid) {
- throw new ExtendedError(
- "Unauthorized",
- 401,
- "Invalid password",
- );
- }
-}
diff --git a/src/utils/bytesFormat.ts b/src/utils/bytesFormat.ts
index 89e3326..6689bf2 100644
--- a/src/utils/bytesFormat.ts
+++ b/src/utils/bytesFormat.ts
@@ -2,7 +2,5 @@ export default function bytesToReadable(bytes: number) {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
- return (
- Math.round(bytes / Math.pow(1024, i)) + " " + sizes[i]
- );
+ return Math.round(bytes / Math.pow(1024, i)) + " " + sizes[i];
}
diff --git a/src/utils/extendedError.ts b/src/utils/extendedError.ts
deleted file mode 100644
index 283ccfd..0000000
--- a/src/utils/extendedError.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-class ExtendedError extends Error {
- public extendedMessage?: string;
- public code?: number;
- public reason?: string;
-
- constructor(msg: string, code: number, reason: string) {
- super(msg);
- this.extendedMessage = msg;
- this.code = code;
- this.reason = reason;
- }
-}
-
-export default ExtendedError;
diff --git a/src/utils/filesHelper.ts b/src/utils/filesHelper.ts
deleted file mode 100644
index 6e4d34a..0000000
--- a/src/utils/filesHelper.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export function stripExtension(
- filename: string,
- ext: string,
-): string {
- // Make sure the extension is lowercase and also remove the dot
- const extension = ext.toLowerCase().replace(/^\./, "");
- return filename.toLowerCase().endsWith(extension)
- ? filename.slice(0, -extension.length - 1)
- : filename;
-}
diff --git a/src/utils/gdrive.ts b/src/utils/gdrive.ts
deleted file mode 100644
index 55ef4e7..0000000
--- a/src/utils/gdrive.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import gIndexConfig from "config";
-import { GaxiosResponse } from "gaxios";
-import type { drive_v3 } from "googleapis";
-
-import gdrive from "utils/gdriveInstance";
-
-interface ListFiles extends drive_v3.Params$Resource$Files$List {}
-
-export async function gdriveFilesList({
- q,
- fields,
- orderBy,
- pageSize,
- pageToken,
-}: ListFiles): Promise> {
- const result = await gdrive.files.list({
- q,
- fields,
- orderBy,
- pageSize,
- pageToken,
- ...(gIndexConfig.apiConfig.isTeamDrive && {
- supportsAllDrives: true,
- includeItemsFromAllDrives: true,
- driveId: gIndexConfig.apiConfig.rootFolder,
- corpora: "drive",
- }),
- });
- return result;
-}
diff --git a/src/utils/gdriveInstance.ts b/src/utils/gdriveInstance.ts
index c6da766..fe8a25f 100644
--- a/src/utils/gdriveInstance.ts
+++ b/src/utils/gdriveInstance.ts
@@ -53,13 +53,13 @@ if (!gdrive) {
gdrive = google.drive({
version: "v3",
auth: serviceAccountAuth,
- fetchImplementation: (url, init) =>
- fetch(url, {
- ...init,
- next: {
- revalidate: 3600,
- },
- }),
+ // fetchImplementation: (url, init) =>
+ // fetch(url, {
+ // ...init,
+ // next: {
+ // revalidate: 3600,
+ // },
+ // }),
});
}
diff --git a/src/utils/generateDownloadLink.ts b/src/utils/generateDownloadLink.ts
deleted file mode 100644
index 4479541..0000000
--- a/src/utils/generateDownloadLink.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { IGDriveFiles } from "types/api/files";
-
-import { encryptData } from "./encryptionHelper/hash";
-
-/**
- * Generate download link for the file, so I don't have to repeat the code.
- * Since it's contains encrypted object, I hope this function will make it easier to read and maintain.
- *
- * @param {IGDriveFiles} file - File object from GDrive API response.
- * @param {boolean} [isProtected = false] - Is the file inside password protected folder or not.
- * @param {boolean} [includeHost = false] - Include host name or not.
- * @param {string} [token] - Download token for protected file.
- * @param {string} [disableLimit] - Disable file size limit.
- * @returns {string} URL to download the file.
- */
-export function generatedDownloadLink(
- file: IGDriveFiles,
- isProtected: boolean = false,
- includeHost: boolean = false,
- token?: string | null,
- disableLimit?: boolean,
-): string {
- const data: Record<"id" | "isProtected", string | boolean> = {
- id: file.encryptedId,
- isProtected,
- };
- const encryptedData = encryptData(JSON.stringify(data, null, 0));
- // let url = `/api/download/${encryptedData}/${file.name}`;
-
- const url = new URL(`/api/download/${encryptedData}/${file.name}`, window.location.origin);
- if (token) url.searchParams.append("token", token);
- if (disableLimit) url.searchParams.append("disableLimit", "true");
-
- // if (includeHost) url = `${window.location.origin}${url}`;
- // if (token) url = `${url}?token=${token}`;
- // if (disableLimit) url = `${url}&disableLimit=true`;
-
- return url.toString();
-}
diff --git a/src/utils/hexToRGB.ts b/src/utils/hexToRGB.ts
deleted file mode 100644
index ed4de09..0000000
--- a/src/utils/hexToRGB.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Converts a hex color code to an RGB value.
- *
- * @param {string} hex - The hex color code to convert.
- * @returns {string} The RGB value of the hex color code.
- */
-export default function hexToRgb(hex: string): string {
- // Remove the # character from the beginning of the hex code
- hex = hex.replace("#", "");
-
- // Convert the hex code to RGB values
- const r = parseInt(hex.substring(0, 2), 16);
- const g = parseInt(hex.substring(2, 4), 16);
- const b = parseInt(hex.substring(4, 6), 16);
-
- // Return the RGB value as a string
- return `${r} ${g} ${b}`;
-}
diff --git a/src/utils/passwordHelper.ts b/src/utils/passwordHelper.ts
deleted file mode 100644
index b8d66ca..0000000
--- a/src/utils/passwordHelper.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Constant } from "types/constant";
-
-import {
- decryptData,
- encryptData,
-} from "./encryptionHelper/hash";
-
-/**
- * Check if the password is correct for the given path
- * @param cookies - cookies value
- * @param path - The path to check
- * @param password - The password to check
- * @returns true if the password is correct, false otherwise
- */
-export function checkPathPassword(
- path: string,
- cookies: string,
- password: string,
-): boolean {
- try {
- const validCookieToken = decryptData(cookies);
- const cookieTokenJSON = JSON.parse(validCookieToken);
- if (!cookieTokenJSON[path]) return false;
- return cookieTokenJSON[path] === password;
- } catch (error: any) {
- console.error(error.message);
- return false;
- }
-}
-
-/**
- * Create a new password for the given path
- * @param cookies - cookies value
- * @param path - The path to add
- * @param password - The password to add
- * @returns The new cookies token
- */
-export function addNewPassword(
- path: string,
- password: string,
- cookies?: string,
- server: boolean = false,
-): string | void {
- try {
- let cookieTokenJSON: any = {};
- if (cookies) {
- const validCookieToken = decryptData(cookies);
- cookieTokenJSON = JSON.parse(validCookieToken);
- }
- cookieTokenJSON[path] = password;
- const encryptedCookieToken = encryptData(
- JSON.stringify(cookieTokenJSON),
- );
-
- // Set cookie for 5 years
- const expires = new Date(
- Date.now() + 5 * 365 * 24 * 60 * 60 * 1000,
- ).toUTCString();
- if (!server) {
- document.cookie = `${Constant.cookies_SitePassword}=${encryptedCookieToken}; expires=${expires}; path=/; SameSite=Strict;`;
- } else {
- return `${Constant.cookies_SitePassword}=${encryptedCookieToken}; expires=${expires}; path=/; SameSite=Strict;`;
- }
- } catch (error: any) {
- removeAllPassword();
- throw new Error(
- "Failed to add new password, it's likely because the encryption key are changed. Please refresh the page and try again.",
- );
- }
-}
-
-/**
- * Remove all password
- */
-export function removeAllPassword() {
- document.cookie = `${Constant.cookies_SitePassword}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
-}
diff --git a/src/utils/previewHelper.ts b/src/utils/previewHelper.ts
index dd3ab34..f0ad7f7 100644
--- a/src/utils/previewHelper.ts
+++ b/src/utils/previewHelper.ts
@@ -29,7 +29,7 @@ const extensionsMap: Record = {
audio: ["mp3", "ogg", "wav", "flac", "m4a", "wma", "aac"],
markdown: ["md"],
pdf: ["pdf"],
- document: ["doc", "docx", "xls", "xlsx", "ppt", "pptx"],
+ document: ["odt", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv"],
executable: ["exe", "msi"],
code: [
"html",
@@ -110,6 +110,24 @@ const iconMap: Record = {
// return previewMap[type as keyof typeof previewMap] ?? previewMap["unknown"];
// }
+export function getFileType(
+ fileExtension: string,
+ mimeType: string,
+): FileTypes | "unknown" {
+ if (fileExtension === "ts") {
+ if (mimeType.includes("video")) {
+ return "video";
+ } else {
+ return "code";
+ }
+ }
+ const type = Object.keys(extensionsMap).find((key) =>
+ extensionsMap[key as keyof typeof extensionsMap].includes(fileExtension),
+ );
+
+ return type ? (type as FileTypes) : "unknown";
+}
+
export function getPreviewIcon(fileExtension: string, mimeType: string) {
if (fileExtension === "ts") {
if (mimeType.includes("video")) {
diff --git a/src/utils/tokenHelper.ts b/src/utils/tokenHelper.ts
deleted file mode 100644
index 73e147f..0000000
--- a/src/utils/tokenHelper.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import gIndexConfig from "config";
-
-import { decryptData, encryptData } from "./encryptionHelper/hash";
-
-export function createDownloadToken(durationH: number = gIndexConfig.apiConfig.temporaryTokenDuration) {
- const duration = durationH * 60 * 60 * 1000;
- const expiredAt = Date.now() + duration;
- return encryptData(expiredAt.toString());
-}
-
-export function isDownloadTokenValid(token: string): boolean {
- try {
- const expiredAt = Number(decryptData(token));
- return expiredAt > Date.now();
- } catch (e) {
- return false;
- }
-}
diff --git a/tsconfig.json b/tsconfig.json
index b03b845..6fa7fe8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "target": "es5",
+ "target": "es2021",
"lib": ["dom", "dom.iterable", "esnext", "es2019"],
"allowJs": true,
"skipLibCheck": true,
@@ -35,6 +35,18 @@
}
]
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", ".next/types/**/*.ts"],
- "exclude": ["node_modules", ".next", "**/*/_legacy/**/*", "**/*/_app/**/*"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ "**/*.js",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ ".next",
+ "**/*/_legacy/**/*",
+ "**/*/_app/**/*",
+ "**/*/_pages/**/*"
+ ]
}
diff --git a/yarn.lock b/yarn.lock
index 317fbef..c4c833f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3290,31 +3290,31 @@ __metadata:
languageName: node
linkType: hard
-"embla-carousel-react@npm:^8.0.0":
- version: 8.0.0
- resolution: "embla-carousel-react@npm:8.0.0"
+"embla-carousel-react@npm:^8.0.1":
+ version: 8.0.1
+ resolution: "embla-carousel-react@npm:8.0.1"
dependencies:
- embla-carousel: "npm:8.0.0"
- embla-carousel-reactive-utils: "npm:8.0.0"
+ embla-carousel: "npm:8.0.1"
+ embla-carousel-reactive-utils: "npm:8.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0
- checksum: 10c0/43d1f551be6119ba62f29fc281b5f89fabfc731fa7a8a008cb5520639be2775377580ad218c23171877f5812167b2a3bb72fac82918ad78b40ebf6f9214dfb4a
+ checksum: 10c0/a16af76be911133f00ff38491b0ed12f09571949234b22bfe83cbd2d8b0d3cf43b666ffc4fc6aaf17e04691495fdad69fc1bc33db3eeec9ba7f67fe8db056a25
languageName: node
linkType: hard
-"embla-carousel-reactive-utils@npm:8.0.0":
- version: 8.0.0
- resolution: "embla-carousel-reactive-utils@npm:8.0.0"
+"embla-carousel-reactive-utils@npm:8.0.1":
+ version: 8.0.1
+ resolution: "embla-carousel-reactive-utils@npm:8.0.1"
peerDependencies:
- embla-carousel: 8.0.0
- checksum: 10c0/d88fb25a59be6bbbb11a9b012bdc42bc1570ae82fd363bda0224cc3131852d469855bc9068bd5aa85e4c8546311de2dd1eae43ea0556dd1443d9b12ff138034c
+ embla-carousel: 8.0.1
+ checksum: 10c0/c511dbbcd869f11f102e826aea600f1668f8097792b4e185678f44240466fe6a224b68833c408bd9eb6c9b26e40321b6f18f70f45bd19df3cd403d964e1fba95
languageName: node
linkType: hard
-"embla-carousel@npm:8.0.0":
- version: 8.0.0
- resolution: "embla-carousel@npm:8.0.0"
- checksum: 10c0/51634c07816a5eef142b6bdfa1104da8232015de3260ff7c4b21a952abeac4035d2d02ea87bb5cec06290ec4429e606543e898b50aa9ccf6e636e74baf2951a6
+"embla-carousel@npm:8.0.1":
+ version: 8.0.1
+ resolution: "embla-carousel@npm:8.0.1"
+ checksum: 10c0/9ce30759a77e75ff4ce490102c429794fd46f03bbcc4a6af4ecefbe55a5de5289b7ac0f7607d1774a9b23c241d5781bf3d45459590768b15679a9da5b56ef6df
languageName: node
linkType: hard
@@ -6516,7 +6516,7 @@ __metadata:
clsx: "npm:^2.1.0"
cmdk: "npm:^1.0.0"
date-fns: "npm:^3.6.0"
- embla-carousel-react: "npm:^8.0.0"
+ embla-carousel-react: "npm:^8.0.1"
encoding: "npm:^0.1.13"
eslint: "npm:8.38.0"
eslint-config-next: "npm:^14.1.4"
@@ -6529,8 +6529,8 @@ __metadata:
next-themes: "npm:^0.3.0"
nextjs-toploader: "npm:^1.6.11"
postcss: "npm:^8.4.38"
- prettier: "npm:^2.8.7"
- prettier-plugin-tailwindcss: "npm:^0.2.7"
+ prettier: "npm:3.0.0"
+ prettier-plugin-tailwindcss: "npm:0.5.12"
react: "npm:^18.2.0"
react-day-picker: "npm:^8.10.0"
react-dom: "npm:^18.2.0"
@@ -7159,25 +7159,25 @@ __metadata:
languageName: node
linkType: hard
-"prettier-plugin-tailwindcss@npm:^0.2.7":
- version: 0.2.8
- resolution: "prettier-plugin-tailwindcss@npm:0.2.8"
+"prettier-plugin-tailwindcss@npm:0.5.12":
+ version: 0.5.12
+ resolution: "prettier-plugin-tailwindcss@npm:0.5.12"
peerDependencies:
"@ianvs/prettier-plugin-sort-imports": "*"
"@prettier/plugin-pug": "*"
"@shopify/prettier-plugin-liquid": "*"
- "@shufo/prettier-plugin-blade": "*"
"@trivago/prettier-plugin-sort-imports": "*"
- prettier: ">=2.2.0"
+ prettier: ^3.0
prettier-plugin-astro: "*"
prettier-plugin-css-order: "*"
prettier-plugin-import-sort: "*"
prettier-plugin-jsdoc: "*"
+ prettier-plugin-marko: "*"
prettier-plugin-organize-attributes: "*"
prettier-plugin-organize-imports: "*"
+ prettier-plugin-sort-imports: "*"
prettier-plugin-style-order: "*"
prettier-plugin-svelte: "*"
- prettier-plugin-twig-melody: "*"
peerDependenciesMeta:
"@ianvs/prettier-plugin-sort-imports":
optional: true
@@ -7185,8 +7185,6 @@ __metadata:
optional: true
"@shopify/prettier-plugin-liquid":
optional: true
- "@shufo/prettier-plugin-blade":
- optional: true
"@trivago/prettier-plugin-sort-imports":
optional: true
prettier-plugin-astro:
@@ -7197,26 +7195,30 @@ __metadata:
optional: true
prettier-plugin-jsdoc:
optional: true
+ prettier-plugin-marko:
+ optional: true
prettier-plugin-organize-attributes:
optional: true
prettier-plugin-organize-imports:
optional: true
+ prettier-plugin-sort-imports:
+ optional: true
prettier-plugin-style-order:
optional: true
prettier-plugin-svelte:
optional: true
prettier-plugin-twig-melody:
optional: true
- checksum: 10c0/b025983a88ecfd53e8d7cf601b54feb2583c62e8f8b87533200f5c4291fee4a60801e1463caa639fd56e3c7a0cf28fea00feed4e93c1fe27a1136f1385907ba6
+ checksum: 10c0/984b79fd33aed89f09bb19c3659079eb19dad6c38a78150f8b1a3082d7331f4faf46e6c561e0e54f47954679c105ef8e674e9c56e22db318e1493b468aa29f5b
languageName: node
linkType: hard
-"prettier@npm:^2.8.7":
- version: 2.8.8
- resolution: "prettier@npm:2.8.8"
+"prettier@npm:3.0.0":
+ version: 3.0.0
+ resolution: "prettier@npm:3.0.0"
bin:
- prettier: bin-prettier.js
- checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a
+ prettier: bin/prettier.cjs
+ checksum: 10c0/113e2828c6487f10e19e7c30598e8810684b827f2a1b7747f113728d1049da15583b77cc5e907f37b371b08c49cfdd900aebe5c2a2077be3f0cccb73cca1133b
languageName: node
linkType: hard