diff --git a/.eslintrc.json b/.eslintrc.json index bffb357..cd0edd9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,6 @@ { - "extends": "next/core-web-vitals" -} + "extends": "next/core-web-vitals", + "rules": { + "@next/next/no-img-element": "off" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ca8ffb..c5a346f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ /.pnp .pnp.js +.env + # testing /coverage diff --git a/README.md b/README.md index 5efa7e6..91a76d2 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,29 @@ You need `NodeJs` and `yarn` installed on your machine. npm install --global yarn ``` +### Firebase prerequisites (optional) + +Firebase is used in this project for authentications and to store snippets. In order to contribute in the part requiring Firebase, create a file called `.env` inside the root folder and add the following credentials in it once you create a Firebase app. + +```.env +NEXT_PUBLIC_FIREBASE_API_KEY= + +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= + +NEXT_PUBLIC_FIREBASE_PROJECT_ID= + +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= + +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= + +NEXT_PUBLIC_FIREBASE_APP_ID= + +NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= + +``` + +It does not matter what credentials you add to your `.env` file, as the app won't crash while developing since the error is taken care of for the Firebase services that are unavailable. + ### Installation 1. Clone the repo diff --git a/components/ErrorText.tsx b/components/ErrorText.tsx new file mode 100644 index 0000000..43935a2 --- /dev/null +++ b/components/ErrorText.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import Button, { SnippngButtonType } from "./form/Button"; +import { FolderPlusIcon } from "@heroicons/react/24/outline"; + +interface Props { + errorTitle: string; + errorSubTitle?: string; + errorActionProps?: SnippngButtonType; + ErrorIcon?: ((props: React.SVGProps) => JSX.Element) | null; +} + +const ErrorText: React.FC = ({ + errorTitle, + errorSubTitle, + errorActionProps, + ErrorIcon, +}) => { + return ( +
+ {ErrorIcon ? ( + + ) : ( + + )} +

+ {errorTitle} +

+ {errorSubTitle ? ( +

+ {errorSubTitle} +

+ ) : null} + {errorActionProps ? ( +
+ +
+ ) : null} +
+ ); +}; + +export default ErrorText; diff --git a/components/Loader.tsx b/components/Loader.tsx new file mode 100644 index 0000000..defef59 --- /dev/null +++ b/components/Loader.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +const Loader = () => { + return ( +
+
+ + + + +
+
+ ); +}; + +export default Loader; diff --git a/components/SigninButton.tsx b/components/SigninButton.tsx new file mode 100644 index 0000000..24af357 --- /dev/null +++ b/components/SigninButton.tsx @@ -0,0 +1,17 @@ +import { useAuth } from "@/context/AuthContext"; +import React from "react"; +import Button from "./form/Button"; +import GithubIcon from "./icons/GithubIcon"; + +const SigninButton = () => { + const { loginWithGithub } = useAuth(); + + return ( + + ); +}; + +export default SigninButton; diff --git a/components/editor/SnippngCodeArea.tsx b/components/editor/SnippngCodeArea.tsx index 77aa2cf..7f7fca9 100644 --- a/components/editor/SnippngCodeArea.tsx +++ b/components/editor/SnippngCodeArea.tsx @@ -1,24 +1,36 @@ -import { createRef, useContext, useState } from "react"; +import { createRef } from "react"; -import { DEFAULT_BASE_SETUP, DEFAULT_CODE_SNIPPET } from "@/lib/constants"; +import { DEFAULT_BASE_SETUP } from "@/lib/constants"; import { clsx, getEditorWrapperBg, getLanguage, getTheme } from "@/utils"; import { langs, loadLanguage } from "@uiw/codemirror-extensions-langs"; import * as themes from "@uiw/codemirror-themes-all"; import CodeMirror from "@uiw/react-codemirror"; -import { SnippngEditorContext } from "@/context/SnippngEditorContext"; +import { useSnippngEditor } from "@/context/SnippngEditorContext"; import { WidthHandler } from "@/lib/width-handler"; +import Button from "../form/Button"; +import Input from "../form/Input"; import NoSSRWrapper from "../NoSSRWrapper"; import SnippngControlHeader from "./SnippngControlHeader"; import SnippngWindowControls from "./SnippngWindowControls"; +import { db } from "@/config/firebase"; +import { useAuth } from "@/context/AuthContext"; +import { + ArrowDownOnSquareStackIcon, + ArrowPathIcon, +} from "@heroicons/react/24/outline"; +import { addDoc, collection, doc, updateDoc } from "firebase/firestore"; + const SnippngCodeArea = () => { - const [code, setCode] = useState(DEFAULT_CODE_SNIPPET); const editorRef = createRef(); - const { editorConfig, handleConfigChange } = useContext(SnippngEditorContext); + const { editorConfig, handleConfigChange } = useSnippngEditor(); + const { user } = useAuth(); const { + code, + snippetsName, selectedLang, selectedTheme, wrapperBg, @@ -34,8 +46,36 @@ const SnippngCodeArea = () => { gradients, gradientAngle, editorWidth, + uid, } = editorConfig; + const saveSnippet = async () => { + if (!db) return console.log(Error("Firebase is not configured")); // This is to handle error when there is no `.env` file. So, that app doesn't crash while developing without `.env` file. + + if (!user) return; + try { + const docRef = await addDoc( + collection(db, "user", user.uid, "snippets"), + editorConfig + ); + } catch (e) { + console.error("Error adding document: ", e); + } + }; + + const updateSnippet = async () => { + if (!db) return console.log(Error("Firebase is not configured")); // This is to handle error when there is no `.env` file. So, that app doesn't crash while developing without `.env` file. + + if (!user || !uid) return; + try { + await updateDoc(doc(db, "user", user.uid, "snippets", uid), { + ...editorConfig, + }); + } catch (e) { + console.error("Error adding document: ", e); + } + }; + return ( <>
{ // @ts-ignore theme={themes[getTheme(selectedTheme.id)]} indentWithTab - onChange={(value) => { - setCode(value); - }} + onChange={(value) => handleConfigChange("code")(value)} >
{showFileName ? ( + handleConfigChange("fileName")(e.target.value) + } className="absolute bg-transparent w-72 text-center top-2 -translate-x-1/2 left-1/2 text-xs font-extralight text-zinc-400 focus:border-b-[0.1px] border-zinc-500 outline-none ring-0" spellCheck={false} contentEditable @@ -118,6 +159,41 @@ const SnippngCodeArea = () => {
+
+
+ + handleConfigChange("snippetsName")(e.target.value) + } + placeholder="Snippet name..." + /> +
+
+ + {uid ? ( + + ) : null} +
+
diff --git a/components/editor/SnippngControlHeader.tsx b/components/editor/SnippngControlHeader.tsx index 750b101..085c867 100644 --- a/components/editor/SnippngControlHeader.tsx +++ b/components/editor/SnippngControlHeader.tsx @@ -1,4 +1,4 @@ -import { SnippngEditorContext } from "@/context/SnippngEditorContext"; +import { useSnippngEditor } from "@/context/SnippngEditorContext"; import { ColorPicker } from "@/lib/color-picker"; import { LANGUAGES, THEMES } from "@/lib/constants"; import { getEditorWrapperBg } from "@/utils"; @@ -11,7 +11,7 @@ import { SparklesIcon, } from "@heroicons/react/24/outline"; import * as htmlToImage from "html-to-image"; -import React, { Fragment, useContext, useState } from "react"; +import React, { Fragment, useState } from "react"; import Button from "../form/Button"; import Checkbox from "../form/Checkbox"; import Range from "../form/Range"; @@ -20,7 +20,7 @@ import Select from "../form/Select"; const SnippngControlHeader = () => { const [downloadingSnippet, setDownloadingSnippet] = useState(false); - const { editorConfig, handleConfigChange } = useContext(SnippngEditorContext); + const { editorConfig, handleConfigChange } = useSnippngEditor(); const { selectedLang, diff --git a/components/form/Button.tsx b/components/form/Button.tsx index d09b838..414627c 100644 --- a/components/form/Button.tsx +++ b/components/form/Button.tsx @@ -1,12 +1,12 @@ import { clsx } from "@/utils"; import React from "react"; -const Button: React.FC< - React.ButtonHTMLAttributes & { - StartIcon?: ((props: React.SVGProps) => JSX.Element) | null; - EndIcon?: ((props: React.SVGProps) => JSX.Element) | null; - } -> = ({ StartIcon, EndIcon, ...props }) => { +interface Props extends React.ButtonHTMLAttributes { + StartIcon?: ((props: React.SVGProps) => JSX.Element) | null; + EndIcon?: ((props: React.SVGProps) => JSX.Element) | null; +} + +const Button: React.FC = ({ StartIcon, EndIcon, ...props }) => { return ( ); }; export default Button; + +export type SnippngButtonType = Props; diff --git a/components/form/Input.tsx b/components/form/Input.tsx new file mode 100644 index 0000000..46b9e96 --- /dev/null +++ b/components/form/Input.tsx @@ -0,0 +1,31 @@ +import { clsx } from "@/utils"; +import React from "react"; + +interface Props extends React.InputHTMLAttributes { + label?: string; + containerClassName?: string; +} + +const Input: React.FC = ({ label, containerClassName, ...props }) => { + return ( +
+ {label ? ( + + ) : null} + +
+ ); +}; + +export default Input; diff --git a/components/icons/GithubIcon.tsx b/components/icons/GithubIcon.tsx new file mode 100644 index 0000000..07a2091 --- /dev/null +++ b/components/icons/GithubIcon.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +const GithubIcon: React.FC> = ({ + ...props +}) => { + return ( + + + + ); +}; + +export default GithubIcon; diff --git a/components/index.tsx b/components/index.tsx index aaf4198..62be17e 100644 --- a/components/index.tsx +++ b/components/index.tsx @@ -1,12 +1,15 @@ import SnippngCodeArea from "./editor/SnippngCodeArea"; import SnippngControlHeader from "./editor/SnippngControlHeader"; import SnippngWindowControls from "./editor/SnippngWindowControls"; +import ErrorText from "./ErrorText"; import Button from "./form/Button"; import Checkbox from "./form/Checkbox"; import Range from "./form/Range"; import Select from "./form/Select"; +import Loader from "./Loader"; import Logo from "./Logo"; import NoSSRWrapper from "./NoSSRWrapper"; +import SigninButton from "./SigninButton"; import ThemeToggle from "./ThemeToggle"; export { @@ -20,4 +23,7 @@ export { Checkbox, SnippngControlHeader, Logo, + ErrorText, + SigninButton, + Loader, }; diff --git a/config/firebase.ts b/config/firebase.ts new file mode 100644 index 0000000..2913ad0 --- /dev/null +++ b/config/firebase.ts @@ -0,0 +1,31 @@ +import { initializeApp } from "firebase/app"; +import { Firestore, getFirestore } from "firebase/firestore"; +import { Auth, getAuth } from "firebase/auth"; + +let auth: Auth | undefined; +let db: Firestore | undefined; + +try { + const firebaseConfig = { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, + measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID, + }; + + const app = initializeApp(firebaseConfig); + + auth = getAuth(app); + db = getFirestore(app); +} catch (error) { + console.log( + Error( + "Error while setting up firebase. Please add .env file with required credentials for firebase setup. Refer README.md for required credentials" + ) + ); +} + +export { auth, db }; diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx new file mode 100644 index 0000000..2f62a6a --- /dev/null +++ b/context/AuthContext.tsx @@ -0,0 +1,65 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; +import { onAuthStateChanged } from "firebase/auth"; +import { + User, + GithubAuthProvider, + signInWithPopup, + signOut, + browserLocalPersistence, + setPersistence, +} from "firebase/auth"; +import { auth } from "@/config/firebase"; + +const provider = new GithubAuthProvider(); + +const AuthContext = createContext<{ + user: User | null; + loginWithGithub: () => Promise; + logout: () => Promise; +}>({ + user: null, + loginWithGithub: async () => {}, + logout: async () => {}, +}); + +const useAuth = () => useContext(AuthContext); + +const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [user, setUser] = useState(null); + + const loginWithGithub = async () => { + if (!auth) return console.log(Error("Firebase is not configured")); // This is to handle error when there is no `.env` file. So, that app doesn't crash while developing without `.env` file. + await setPersistence(auth, browserLocalPersistence) + .then(() => { + return signInWithPopup(auth!, provider); + }) + .catch((error) => { + alert(error.message); + }); + }; + + const logout = async () => { + if (!auth) return console.log(Error("Firebase is not configured")); // This is to handle error when there is no `.env` file. So, that app doesn't crash while developing without `.env` file. + await signOut(auth); + }; + + useEffect(() => { + if (!auth) return console.log(Error("Firebase is not configured")); // This is to handle error when there is no `.env` file. So, that app doesn't crash while developing without `.env` file. + const unsubscribe = onAuthStateChanged(auth, (fbUser) => { + setUser(fbUser); + }); + return () => { + unsubscribe(); + }; + }, []); + + return ( + + {children} + + ); +}; + +export { useAuth, AuthProvider }; diff --git a/context/SnippngEditorContext.tsx b/context/SnippngEditorContext.tsx index c1655ab..4c9212a 100644 --- a/context/SnippngEditorContext.tsx +++ b/context/SnippngEditorContext.tsx @@ -3,10 +3,17 @@ import { SnippngEditorConfigInterface, SnippngEditorContextInterface, } from "@/types"; -import React, { createContext, useCallback, useEffect, useState } from "react"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; const SnippngEditorContext = createContext({ editorConfig: { ...defaultEditorConfig }, + setEditorConfig: (config: SnippngEditorConfigInterface) => {}, handleConfigChange: < K extends keyof SnippngEditorConfigInterface, @@ -17,6 +24,8 @@ const SnippngEditorContext = createContext({ (value: V) => {}, }); +const useSnippngEditor = () => useContext(SnippngEditorContext); + const SnippngContextProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { @@ -53,10 +62,12 @@ const SnippngContextProvider: React.FC<{ children: React.ReactNode }> = ({ }, [editorConfig.lineHeight, handleLineHeight]); return ( - + {children} ); }; -export { SnippngContextProvider, SnippngEditorContext }; +export { SnippngContextProvider, useSnippngEditor }; diff --git a/docker-compose.yml b/docker-compose.yml index 40e6cbb..1d5e8bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,5 +10,5 @@ services: command: yarn dev ports: - "3000:3000" - environment: - NODE_ENV: development + env_file: + - ./.env diff --git a/jest.config.js b/jest.config.js index 8a2d28d..88deea2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,6 +14,7 @@ const customJestConfig = { "^@/utils(.*)$": "/utils/$1", "^@/types(.*)$": "/types/$1", "^@/lib(.*)$": "/lib/$1", + "^@/config(.*)$": "/config/$1", }, moduleDirectories: ["node_modules", __dirname], testEnvironment: "jest-environment-jsdom", diff --git a/layout/Header.tsx b/layout/Header.tsx index 7620824..ece9dfe 100644 --- a/layout/Header.tsx +++ b/layout/Header.tsx @@ -1,14 +1,43 @@ -import { Logo, ThemeToggle } from "@/components"; +import { Button, Logo, SigninButton, ThemeToggle } from "@/components"; +import { useAuth } from "@/context/AuthContext"; +import { ArrowLeftOnRectangleIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; +import { useRouter } from "next/router"; const Header = () => { + const { user, logout } = useAuth(); + const router = useRouter(); return (
diff --git a/lib/color-picker/components/ColorPicker.tsx b/lib/color-picker/components/ColorPicker.tsx index 3b49c99..684b17b 100644 --- a/lib/color-picker/components/ColorPicker.tsx +++ b/lib/color-picker/components/ColorPicker.tsx @@ -1,3 +1,4 @@ +import Input from "@/components/form/Input"; import { Menu, Transition } from "@headlessui/react"; import React, { Fragment, useCallback, useMemo } from "react"; import { @@ -142,75 +143,48 @@ export const ColorPicker: React.FC = ({ }} />
-
- - -
+
-
- - handleRgbChange("r", event.target.value)} - inputMode="numeric" - pattern="[0-9]*" - /> -
-
- - handleRgbChange("g", event.target.value)} - inputMode="numeric" - pattern="[0-9]*" - /> -
-
- - handleRgbChange("b", event.target.value)} - inputMode="numeric" - pattern="[0-9]*" - /> -
+ handleRgbChange("r", event.target.value)} + inputMode="numeric" + pattern="[0-9]*" + /> + + handleRgbChange("g", event.target.value)} + inputMode="numeric" + pattern="[0-9]*" + /> + + handleRgbChange("b", event.target.value)} + inputMode="numeric" + pattern="[0-9]*" + />
any>(func: F, waitFor: number) => { + let timeout: NodeJS.Timeout | null = null; + + const debounced = (...args: Parameters) => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + timeout = setTimeout(() => func(...args), waitFor); + }; + + return debounced as (...args: Parameters) => ReturnType; +};`; + export const defaultEditorConfig: SnippngEditorConfigInterface = { + code: DEFAULT_CODE_SNIPPET, + snippetsName: "", editorFontSize: 16, editorWindowControlsType: "mac-left", fileName: "@utils/debounce.ts", @@ -501,17 +517,3 @@ export const DEFAULT_WIDTHS = { minWidth: 320, maxWidth: 1200, }; - -export const DEFAULT_CODE_SNIPPET = `export const debounce = any>(func: F, waitFor: number) => { - let timeout: NodeJS.Timeout | null = null; - - const debounced = (...args: Parameters) => { - if (timeout !== null) { - clearTimeout(timeout); - timeout = null; - } - timeout = setTimeout(() => func(...args), waitFor); - }; - - return debounced as (...args: Parameters) => ReturnType; -};`; diff --git a/package.json b/package.json index 17a6acc..e7ca3df 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@uiw/react-codemirror": "^4.19.7", "eslint": "8.32.0", "eslint-config-next": "13.1.5", + "firebase": "^9.16.0", "html-to-image": "^1.11.4", "next": "13.1.5", "react": "18.2.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index a46519f..001b099 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,11 +1,14 @@ +import { AuthProvider } from "@/context/AuthContext"; import { SnippngContextProvider } from "@/context/SnippngEditorContext"; import "@/styles/globals.css"; import type { AppProps } from "next/app"; export default function App({ Component, pageProps }: AppProps) { return ( - - - + + + + + ); } diff --git a/pages/_document.tsx b/pages/_document.tsx index 36d8ba7..0aca846 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -1,4 +1,5 @@ import { Html, Head, Main, NextScript } from "next/document"; +import Script from "next/script"; const modeScript = ` let darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)') @@ -55,6 +56,10 @@ const faviconScript = ` onUpdate(); `; +const adSenseScript = ` +(adsbygoogle = (window.adsbygoogle || [])).push({}); +`; + export default function Document() { return ( @@ -75,6 +80,18 @@ export default function Document() { /> +