diff --git a/README.md b/README.md index c28b727b..cb12ef79 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,32 @@ You can run `yarn dev` in either / both folders to start the server and client w ### Production -**Note: Drift is not yet ready for production usage and should not be used seriously until the database has been setup, which I'll get to when the server API is semi stable.** +**Note: Drift is not yet ready for production usage and should not be used too seriously. I'll make every effort to not lose data, but I won't make any guarantees until the project is further along.** `yarn build` in both `client/` and `server/` will produce production code for the client and server respectively. The client and server each also have Dockerfiles which you can use with a docker-compose (an example compose will be provided in the near future). If you're deploying the front-end to something like Vercel, you'll need to set the root folder to `client/`. +### Environment Variables + +You can change these to your liking. + +`client/.env`: + +- `API_URL`: defaults to localhost:3001, but allows you to host the front-end separately from the backend on a service like Vercel or Netlify +- `WELCOME_CONTENT`: a markdown string (with \n newlines) that's rendered on the home page +- `WELCOME_TITLE`: the file title for the post on the homepage. +- `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser + +`server/.env`: + +- `PORT`: the default port to start the server on (3000 by default) +- `ENV`: can be `production` or `debug`, toggles logging +- `JWT_SECRET`: a secure token for JWT tokens. You can generate one [here](https://www.grc.com/passwords.htm). +- `MEMORY_DB`: if `true`, a sqlite database will not be created and changes will only exist in memory. Mainly for the demo. +- `REGISTRATION_PASSWORD`: if MEMORY_DB is not `true`, the user will be required to provide this password to sign-up, in addition to their username and account password. If it's not set, no password will be required. +- `SECRET_KEY`: the same secret key as the client + ## Current status Drift is a major work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist. @@ -34,7 +54,7 @@ Drift is a major work in progress. Below is a (rough) list of completed and envi - [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11)) - [x] downloading files (individually and entire posts) - [ ] password protected posts -- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development) +- [x] sqlite database - [ ] non-node backend - [ ] administrator account / settings - [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13)) diff --git a/client/.env.local b/client/.env.local index 3bcbece7..a4e28f86 100644 --- a/client/.env.local +++ b/client/.env.local @@ -1,3 +1,4 @@ API_URL=http://localhost:3000 WELCOME_TITLE="Welcome to Drift" WELCOME_CONTENT="### Drift is a self-hostable clone of GitHub Gist. \nIt is a simple way to share code and text snippets with your friends, with support for the following:\n \n - Render GitHub Extended Markdown (including images)\n - User authentication\n - Private, public, and secret posts\n \n If you want to signup, you can join at [/signup](/signup) as long as you have a passcode provided by the administrator (which you don\'t need for this demo).\n **This demo is on a memory-only database, so accounts and pastes can be deleted at any time.**\n You can find the source code on [GitHub](https://github.com/MaxLeiter/drift).\n \n Drift was inspired by [this tweet](https://twitter.com/emilyst/status/1499858264346935297):\n > What is the absolute closest thing to GitHub Gist that can be self-hosted?\n In terms of design and functionality. Hosts images and markdown, rendered. Creates links that can be private or public. Uses/requires registration. I have looked at dozens of pastebin-like things." +SECRET_KEY=secret \ No newline at end of file diff --git a/client/.prettierrc b/client/.prettierrc new file mode 100644 index 00000000..cda1eceb --- /dev/null +++ b/client/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "trailingComma": "none", + "singleQuote": false, + "printWidth": 80, + "useTabs": true +} diff --git a/client/components/Link.tsx b/client/components/Link.tsx index 97a285ec..e96f89bb 100644 --- a/client/components/Link.tsx +++ b/client/components/Link.tsx @@ -1,4 +1,5 @@ -import { Link as GeistLink, LinkProps } from "@geist-ui/core" +import type { LinkProps } from "@geist-ui/core" +import { Link as GeistLink } from "@geist-ui/core" import { useRouter } from "next/router"; const Link = (props: LinkProps) => { diff --git a/client/components/app/index.tsx b/client/components/app/index.tsx new file mode 100644 index 00000000..66f85cba --- /dev/null +++ b/client/components/app/index.tsx @@ -0,0 +1,45 @@ +import { GeistProvider, CssBaseline, Themes } from "@geist-ui/core" +import type { NextComponentType, NextPageContext } from "next" +import { SkeletonTheme } from "react-loading-skeleton" +import { useTheme } from 'next-themes' +const App = ({ + Component, + pageProps, +}: { + Component: NextComponentType + pageProps: any +}) => { + const skeletonBaseColor = 'var(--light-gray)' + const skeletonHighlightColor = 'var(--lighter-gray)' + + const customTheme = Themes.createFromLight( + { + type: "custom", + palette: { + background: 'var(--bg)', + foreground: 'var(--fg)', + accents_1: 'var(--lightest-gray)', + accents_2: 'var(--lighter-gray)', + accents_3: 'var(--light-gray)', + accents_4: 'var(--gray)', + accents_5: 'var(--darker-gray)', + accents_6: 'var(--darker-gray)', + accents_7: 'var(--darkest-gray)', + accents_8: 'var(--darkest-gray)', + border: 'var(--light-gray)', + }, + font: { + mono: 'var(--font-mono)', + sans: 'var(--font-sans)', + } + } + ) + return ( + + + + + ) +} + +export default App \ No newline at end of file diff --git a/client/components/auth/index.tsx b/client/components/auth/index.tsx index 1db9c549..5f48f3cf 100644 --- a/client/components/auth/index.tsx +++ b/client/components/auth/index.tsx @@ -1,9 +1,10 @@ -import { FormEvent, useState } from 'react' +import { FormEvent, useEffect, useState } from 'react' import { Button, Input, Text, Note } from '@geist-ui/core' import styles from './auth.module.css' import { useRouter } from 'next/router' import Link from '../Link' import Cookies from "js-cookie"; +import useSignedIn from '@lib/hooks/use-signed-in' const NO_EMPTY_SPACE_REGEX = /^\S*$/; const ERROR_MESSAGE = "Provide a non empty username and a password with at least 6 characters"; @@ -13,21 +14,40 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [serverPassword, setServerPassword] = useState(''); const [errorMsg, setErrorMsg] = useState(''); - + const [requiresServerPassword, setRequiresServerPassword] = useState(false); const signingIn = page === 'signin' + const { signin } = useSignedIn(); + useEffect(() => { + async function fetchRequiresPass() { + if (!signingIn) { + const resp = await fetch("/server-api/auth/requires-passcode", { + method: "GET", + }) + if (resp.ok) { + const res = await resp.json() + setRequiresServerPassword(res.requiresPasscode) + } else { + setErrorMsg("Something went wrong.") + } + } + } + fetchRequiresPass() + }, [page, signingIn]) + const handleJson = (json: any) => { - Cookies.set('drift-token', json.token); + signin(json.token) Cookies.set('drift-userid', json.userId); router.push('/') } - const handleSubmit = async (e: FormEvent) => { e.preventDefault() - if (page === "signup" && (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)) return setErrorMsg(ERROR_MESSAGE) + if (!signingIn && (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)) return setErrorMsg(ERROR_MESSAGE) + if (!signingIn && requiresServerPassword && !NO_EMPTY_SPACE_REGEX.test(serverPassword)) return setErrorMsg(ERROR_MESSAGE) else setErrorMsg(''); const reqOpts = { @@ -35,19 +55,17 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }) + body: JSON.stringify({ username, password, serverPassword }) } try { const signUrl = signingIn ? '/server-api/auth/signin' : '/server-api/auth/signup'; const resp = await fetch(signUrl, reqOpts); const json = await resp.json(); - console.log(json) if (!resp.ok) throw new Error(json.error.message); handleJson(json) } catch (err: any) { - console.log(err) setErrorMsg(err.message ?? "Something went wrong") } } @@ -78,6 +96,16 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => { required scale={4 / 3} /> + {requiresServerPassword && setServerPassword(event.target.value)} + placeholder="Server Password" + required + scale={4 / 3} + />} +
diff --git a/client/components/button-dropdown/dropdown.module.css b/client/components/button-dropdown/dropdown.module.css new file mode 100644 index 00000000..dd03da08 --- /dev/null +++ b/client/components/button-dropdown/dropdown.module.css @@ -0,0 +1,26 @@ +.main { + margin-bottom: 2rem; +} + +.dropdown { + position: relative; + display: inline-block; + vertical-align: middle; + cursor: pointer; + padding: 0; + border: 0; + background: transparent; +} + +.dropdownContent { + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.25rem; + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15); +} + +.icon { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/client/components/button-dropdown/index.tsx b/client/components/button-dropdown/index.tsx new file mode 100644 index 00000000..7000059b --- /dev/null +++ b/client/components/button-dropdown/index.tsx @@ -0,0 +1,116 @@ +import Button from "@components/button" +import React, { useCallback, useEffect } from "react" +import { useState } from "react" +import styles from './dropdown.module.css' +import DownIcon from '@geist-ui/icons/arrowDown' +type Props = { + type?: "primary" | "secondary" + loading?: boolean + disabled?: boolean + className?: string + iconHeight?: number +} + +type Attrs = Omit, keyof Props> +type ButtonDropdownProps = Props & Attrs + +const ButtonDropdown: React.FC> = ({ + type, + className, + disabled, + loading, + iconHeight = 24, + ...props +}) => { + const [visible, setVisible] = useState(false) + const [dropdown, setDropdown] = useState(null) + + const onClick = (e: React.MouseEvent) => { + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + setVisible(!visible) + } + + const onBlur = () => { + setVisible(false) + } + + const onMouseDown = (e: React.MouseEvent) => { + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + } + + const onMouseUp = (e: React.MouseEvent) => { + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + } + + const onMouseLeave = (e: React.MouseEvent) => { + e.stopPropagation() + e.nativeEvent.stopImmediatePropagation() + setVisible(false) + } + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setVisible(false) + } + } + + const onClickOutside = useCallback(() => (e: React.MouseEvent) => { + if (dropdown && !dropdown.contains(e.target as Node)) { + setVisible(false) + } + }, [dropdown]) + + useEffect(() => { + if (visible) { + document.addEventListener("mousedown", onClickOutside) + } else { + document.removeEventListener("mousedown", onClickOutside) + } + + return () => { + document.removeEventListener("mousedown", onClickOutside) + } + }, [visible, onClickOutside]) + + if (!Array.isArray(props.children)) { + return null + } + + return ( +
+
+ {props.children[0]} + +
+ { + visible && ( +
+
+ {props.children.slice(1)} + +
+
+ ) + } +
+ ) + + + +} + +export default ButtonDropdown \ No newline at end of file diff --git a/client/components/button/button.module.css b/client/components/button/button.module.css new file mode 100644 index 00000000..35690899 --- /dev/null +++ b/client/components/button/button.module.css @@ -0,0 +1,53 @@ +.button { + user-select: none; + cursor: pointer; + border-radius: var(--radius); + color: var(--input-fg); + font-weight: 400; + font-size: 1.1rem; + background: var(--input-bg); + border: var(--input-border); + height: 2rem; + display: flex; + align-items: center; + padding: var(--gap-quarter) var(--gap-half); + transition: background-color var(--transition), color var(--transition); + width: 100%; + height: var(--input-height); +} + +.button:hover, +.button:focus { + outline: none; + background: var(--input-bg-hover); + border: var(--input-border-focus); +} + +.button[disabled] { + cursor: not-allowed; + background: var(--lighter-gray); + color: var(--gray); +} + +.secondary { + background: var(--bg); + color: var(--fg); +} + +/* +--bg: #131415; + --fg: #fafbfc; + --gray: #666; + --light-gray: #444; + --lighter-gray: #222; + --lightest-gray: #1a1a1a; + --article-color: #eaeaea; + --header-bg: rgba(19, 20, 21, 0.45); + --gray-alpha: rgba(255, 255, 255, 0.5); + --selection: rgba(255, 255, 255, 0.99); + */ + +.primary { + background: var(--fg); + color: var(--bg); +} diff --git a/client/components/button/index.tsx b/client/components/button/index.tsx new file mode 100644 index 00000000..0e85a79a --- /dev/null +++ b/client/components/button/index.tsx @@ -0,0 +1,28 @@ +import styles from './button.module.css' +import { forwardRef, Ref } from 'react' + +type Props = React.HTMLProps & { + children: React.ReactNode + buttonType?: 'primary' | 'secondary' + className?: string + onClick?: (e: React.MouseEvent) => void +} + +// eslint-disable-next-line react/display-name +const Button = forwardRef( + ({ children, onClick, className, buttonType = 'primary', type = 'button', disabled = false, ...props }, ref) => { + return ( + + ) + } +) + +export default Button diff --git a/client/components/document/document.module.css b/client/components/document/document.module.css deleted file mode 100644 index 135f89c8..00000000 --- a/client/components/document/document.module.css +++ /dev/null @@ -1,41 +0,0 @@ -.input { - background: #efefef; -} - -.descriptionContainer { - display: flex; - flex-direction: column; - min-height: 400px; - overflow: auto; -} - -.fileNameContainer { - display: flex; - justify-content: space-between; - align-items: center; - height: 36px; -} - -.fileNameContainer { - display: flex; - align-content: center; -} - -.fileNameContainer > div { - /* Override geist-ui styling */ - margin: 0 !important; -} - -.textarea { - height: 100%; -} - -.actionWrapper { - position: relative; - z-index: 1; -} - -.actionWrapper .actions { - position: absolute; - right: 0; -} diff --git a/client/components/edit-document-list/index.tsx b/client/components/edit-document-list/index.tsx new file mode 100644 index 00000000..9822b916 --- /dev/null +++ b/client/components/edit-document-list/index.tsx @@ -0,0 +1,35 @@ +import type { Document } from "@lib/types" +import DocumentComponent from "@components/edit-document" +import { ChangeEvent, memo, useCallback } from "react" + +const DocumentList = ({ docs, removeDoc, updateDocContent, updateDocTitle, onPaste }: { + docs: Document[], + updateDocTitle: (i: number) => (title: string) => void + updateDocContent: (i: number) => (content: string) => void + removeDoc: (i: number) => () => void + onPaste: (e: any) => void +}) => { + const handleOnChange = useCallback((i) => (e: ChangeEvent) => { + updateDocContent(i)(e.target.value) + }, [updateDocContent]) + + return (<>{ + docs.map(({ content, id, title }, i) => { + return ( + + ) + }) + } + ) +} + +export default memo(DocumentList) diff --git a/client/components/edit-document/document.module.css b/client/components/edit-document/document.module.css new file mode 100644 index 00000000..61927f6f --- /dev/null +++ b/client/components/edit-document/document.module.css @@ -0,0 +1,48 @@ +.card { + margin: var(--gap) auto; + padding: var(--gap); + border: 1px solid var(--light-gray); + border-radius: var(--radius); +} + +.input { + background: #efefef; +} + +.descriptionContainer { + display: flex; + flex-direction: column; + min-height: 400px; + overflow: auto; +} + +.fileNameContainer { + display: flex; + justify-content: space-between; + align-items: center; + height: 36px; +} + +.fileNameContainer { + display: flex; + align-content: center; +} + +.fileNameContainer > div { + /* Override geist-ui styling */ + margin: 0 !important; +} + +.textarea { + height: 100%; +} + +.actionWrapper { + position: relative; + z-index: 1; +} + +.actionWrapper .actions { + position: absolute; + right: 0; +} diff --git a/client/components/document/formatting-icons/index.tsx b/client/components/edit-document/formatting-icons/index.tsx similarity index 83% rename from client/components/document/formatting-icons/index.tsx rename to client/components/edit-document/formatting-icons/index.tsx index d5e15fe1..5224466b 100644 --- a/client/components/document/formatting-icons/index.tsx +++ b/client/components/edit-document/formatting-icons/index.tsx @@ -1,7 +1,10 @@ -import { ButtonGroup, Button } from "@geist-ui/core" -import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons' +import Bold from '@geist-ui/icons/bold' +import Italic from '@geist-ui/icons/italic' +import Link from '@geist-ui/icons/link' +import ImageIcon from '@geist-ui/icons/image' import { RefObject, useCallback, useMemo } from "react" import styles from '../document.module.css' +import { Button, ButtonGroup } from "@geist-ui/core" // TODO: clean up @@ -20,7 +23,7 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject { + const handleBoldClick = useCallback(() => { if (textareaRef?.current && setText) { const selectionStart = textareaRef.current.selectionStart const selectionEnd = textareaRef.current.selectionEnd @@ -31,13 +34,10 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject { + const handleItalicClick = useCallback(() => { if (textareaRef?.current && setText) { const selectionStart = textareaRef.current.selectionStart const selectionEnd = textareaRef.current.selectionEnd @@ -47,12 +47,10 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject { + const handleLinkClick = useCallback(() => { if (textareaRef?.current && setText) { const selectionStart = textareaRef.current.selectionStart const selectionEnd = textareaRef.current.selectionEnd @@ -68,12 +66,10 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject { + const handleImageClick = useCallback(() => { if (textareaRef?.current && setText) { const selectionStart = textareaRef.current.selectionStart const selectionEnd = textareaRef.current.selectionEnd @@ -89,8 +85,6 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject void + setContent?: (content: string) => void + handleOnContentChange?: (e: ChangeEvent) => void + initialTab?: "edit" | "preview" + skeleton?: boolean + remove?: () => void + onPaste?: (e: any) => void +} + +const Document = ({ onPaste, remove, title, content, setTitle, setContent, initialTab = 'edit', skeleton, handleOnContentChange }: Props) => { + const codeEditorRef = useRef(null) + const [tab, setTab] = useState(initialTab) + // const height = editable ? "500px" : '100%' + const height = "100%"; + + const handleTabChange = (newTab: string) => { + if (newTab === 'edit') { + codeEditorRef.current?.focus() + } + setTab(newTab as 'edit' | 'preview') + } + + const onTitleChange = useCallback((event: ChangeEvent) => setTitle ? setTitle(event.target.value) : null, [setTitle]) + + const removeFile = useCallback((remove?: () => void) => { + if (remove) { + if (content && content.trim().length > 0) { + const confirmed = window.confirm("Are you sure you want to remove this file?") + if (confirmed) { + remove() + } + } else { + remove() + } + } + }, [content]) + + if (skeleton) { + return <> + +
+
+ + {remove && } +
+
+
+ +
+
+ + } + + return ( + <> + +
+
+ + {remove &&
+
+ {tab === 'edit' && } + + + {/* */} +
+ */} -
+