Merge with main

This commit is contained in:
Max Leiter 2022-03-24 14:53:57 -07:00
commit da8e7415dc
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
95 changed files with 5205 additions and 1587 deletions

View file

@ -15,12 +15,32 @@ You can run `yarn dev` in either / both folders to start the server and client w
### Production ### 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). `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/`. 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 ## 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. 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)) - [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
- [x] downloading files (individually and entire posts) - [x] downloading files (individually and entire posts)
- [ ] password protected 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 - [ ] non-node backend
- [ ] administrator account / settings - [ ] administrator account / settings
- [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13)) - [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13))

View file

@ -1,3 +1,4 @@
API_URL=http://localhost:3000 API_URL=http://localhost:3000
WELCOME_TITLE="Welcome to Drift" 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." 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

7
client/.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"semi": false,
"trailingComma": "none",
"singleQuote": false,
"printWidth": 80,
"useTabs": true
}

View file

@ -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"; import { useRouter } from "next/router";
const Link = (props: LinkProps) => { const Link = (props: LinkProps) => {

View file

@ -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<NextPageContext, any, any>
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 (<GeistProvider themes={[customTheme]} themeType={"custom"} >
<SkeletonTheme baseColor={skeletonBaseColor} highlightColor={skeletonHighlightColor}>
<CssBaseline />
<Component {...pageProps} />
</SkeletonTheme>
</GeistProvider >)
}
export default App

View file

@ -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 { Button, Input, Text, Note } from '@geist-ui/core'
import styles from './auth.module.css' import styles from './auth.module.css'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Link from '../Link' import Link from '../Link'
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import useSignedIn from '@lib/hooks/use-signed-in'
const NO_EMPTY_SPACE_REGEX = /^\S*$/; const NO_EMPTY_SPACE_REGEX = /^\S*$/;
const ERROR_MESSAGE = "Provide a non empty username and a password with at least 6 characters"; 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 [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [serverPassword, setServerPassword] = useState('');
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
const [requiresServerPassword, setRequiresServerPassword] = useState(false);
const signingIn = page === 'signin' 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) => { const handleJson = (json: any) => {
Cookies.set('drift-token', json.token); signin(json.token)
Cookies.set('drift-userid', json.userId); Cookies.set('drift-userid', json.userId);
router.push('/') router.push('/')
} }
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault() 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(''); else setErrorMsg('');
const reqOpts = { const reqOpts = {
@ -35,19 +55,17 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password, serverPassword })
} }
try { try {
const signUrl = signingIn ? '/server-api/auth/signin' : '/server-api/auth/signup'; const signUrl = signingIn ? '/server-api/auth/signin' : '/server-api/auth/signup';
const resp = await fetch(signUrl, reqOpts); const resp = await fetch(signUrl, reqOpts);
const json = await resp.json(); const json = await resp.json();
console.log(json)
if (!resp.ok) throw new Error(json.error.message); if (!resp.ok) throw new Error(json.error.message);
handleJson(json) handleJson(json)
} catch (err: any) { } catch (err: any) {
console.log(err)
setErrorMsg(err.message ?? "Something went wrong") setErrorMsg(err.message ?? "Something went wrong")
} }
} }
@ -78,6 +96,16 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
required required
scale={4 / 3} scale={4 / 3}
/> />
{requiresServerPassword && <Input
htmlType='password'
id="server-password"
value={serverPassword}
onChange={(event) => setServerPassword(event.target.value)}
placeholder="Server Password"
required
scale={4 / 3}
/>}
<Button type="success" htmlType="submit">{signingIn ? 'Sign In' : 'Sign Up'}</Button> <Button type="success" htmlType="submit">{signingIn ? 'Sign In' : 'Sign Up'}</Button>
</div> </div>
<div className={styles.formContentSpace}> <div className={styles.formContentSpace}>

View file

@ -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;
}

View file

@ -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<React.HTMLAttributes<any>, keyof Props>
type ButtonDropdownProps = Props & Attrs
const ButtonDropdown: React.FC<React.PropsWithChildren<ButtonDropdownProps>> = ({
type,
className,
disabled,
loading,
iconHeight = 24,
...props
}) => {
const [visible, setVisible] = useState(false)
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null)
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setVisible(!visible)
}
const onBlur = () => {
setVisible(false)
}
const onMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}
const onMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}
const onMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setVisible(false)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
setVisible(false)
}
}
const onClickOutside = useCallback(() => (e: React.MouseEvent<HTMLDivElement>) => {
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 (
<div
className={`${styles.main} ${className}`}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
onKeyDown={onKeyDown}
onBlur={onBlur}
>
<div style={{ display: 'flex', flexDirection: 'row', justifyContent: 'flex-end' }}>
{props.children[0]}
<Button style={{ height: iconHeight, width: iconHeight }} className={styles.icon} onClick={() => setVisible(!visible)}><DownIcon /></Button>
</div>
{
visible && (
<div
className={`${styles.dropdown}`}
>
<div
className={`${styles.dropdownContent}`}
>
{props.children.slice(1)}
</div>
</div>
)
}
</div >
)
}
export default ButtonDropdown

View file

@ -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);
}

View file

@ -0,0 +1,28 @@
import styles from './button.module.css'
import { forwardRef, Ref } from 'react'
type Props = React.HTMLProps<HTMLButtonElement> & {
children: React.ReactNode
buttonType?: 'primary' | 'secondary'
className?: string
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
}
// eslint-disable-next-line react/display-name
const Button = forwardRef<HTMLButtonElement, Props>(
({ children, onClick, className, buttonType = 'primary', type = 'button', disabled = false, ...props }, ref) => {
return (
<button
ref={ref}
className={`${styles.button} ${styles[type]} ${className}`}
disabled={disabled}
onClick={onClick}
{...props}
>
{children}
</button>
)
}
)
export default Button

View file

@ -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;
}

View file

@ -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<HTMLTextAreaElement>) => {
updateDocContent(i)(e.target.value)
}, [updateDocContent])
return (<>{
docs.map(({ content, id, title }, i) => {
return (
<DocumentComponent
onPaste={onPaste}
key={id}
remove={removeDoc(i)}
setContent={updateDocContent(i)}
setTitle={updateDocTitle(i)}
handleOnContentChange={handleOnChange(i)}
content={content}
title={title}
/>
)
})
}
</>)
}
export default memo(DocumentList)

View file

@ -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;
}

View file

@ -1,7 +1,10 @@
import { ButtonGroup, Button } from "@geist-ui/core" import Bold from '@geist-ui/icons/bold'
import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons' 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 { RefObject, useCallback, useMemo } from "react"
import styles from '../document.module.css' import styles from '../document.module.css'
import { Button, ButtonGroup } from "@geist-ui/core"
// TODO: clean up // TODO: clean up
@ -20,7 +23,7 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
// return { textBefore: '', textAfter: '' } // return { textBefore: '', textAfter: '' }
// }, [textareaRef,]) // }, [textareaRef,])
const handleBoldClick = useCallback((e) => { const handleBoldClick = useCallback(() => {
if (textareaRef?.current && setText) { if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd const selectionEnd = textareaRef.current.selectionEnd
@ -31,13 +34,10 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
const newText = `${before}**${selectedText}**${after}` const newText = `${before}**${selectedText}**${after}`
setText(newText) setText(newText)
// TODO; fails because settext async
textareaRef.current.setSelectionRange(before.length + 2, before.length + 2 + selectedText.length)
} }
}, [setText, textareaRef]) }, [setText, textareaRef])
const handleItalicClick = useCallback((e) => { const handleItalicClick = useCallback(() => {
if (textareaRef?.current && setText) { if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd const selectionEnd = textareaRef.current.selectionEnd
@ -47,12 +47,10 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
const selectedText = text.substring(selectionStart, selectionEnd) const selectedText = text.substring(selectionStart, selectionEnd)
const newText = `${before}*${selectedText}*${after}` const newText = `${before}*${selectedText}*${after}`
setText(newText) setText(newText)
textareaRef.current.focus()
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
} }
}, [setText, textareaRef]) }, [setText, textareaRef])
const handleLinkClick = useCallback((e) => { const handleLinkClick = useCallback(() => {
if (textareaRef?.current && setText) { if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd const selectionEnd = textareaRef.current.selectionEnd
@ -68,12 +66,10 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
} }
const newText = `${before}${formattedText}${after}` const newText = `${before}${formattedText}${after}`
setText(newText) setText(newText)
textareaRef.current.focus()
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
} }
}, [setText, textareaRef]) }, [setText, textareaRef])
const handleImageClick = useCallback((e) => { const handleImageClick = useCallback(() => {
if (textareaRef?.current && setText) { if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd const selectionEnd = textareaRef.current.selectionEnd
@ -89,8 +85,6 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
} }
const newText = `${before}${formattedText}${after}` const newText = `${before}${formattedText}${after}`
setText(newText) setText(newText)
textareaRef.current.focus()
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
} }
}, [setText, textareaRef]) }, [setText, textareaRef])

View file

@ -0,0 +1,123 @@
import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react"
import styles from './document.module.css'
import Trash from '@geist-ui/icons/trash'
import Download from '@geist-ui/icons/download'
import ExternalLink from '@geist-ui/icons/externalLink'
import FormattingIcons from "./formatting-icons"
import Skeleton from "react-loading-skeleton"
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
import Preview from "@components/preview"
// import Link from "next/link"
type Props = {
title?: string
content?: string
setTitle?: (title: string) => void
setContent?: (content: string) => void
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => 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<HTMLTextAreaElement>(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<HTMLInputElement>) => 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 <>
<Spacer height={1} />
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Skeleton width={275} height={36} />
{remove && <Skeleton width={36} height={36} />}
</div>
<div className={styles.descriptionContainer}>
<div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
<Skeleton width={'100%'} height={350} />
</div >
</div>
</>
}
return (
<>
<Spacer height={1} />
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Input
placeholder="MyFile.md"
value={title}
onChange={onTitleChange}
marginTop="var(--gap-double)"
size={1.2}
font={1.2}
label="Filename"
width={"100%"}
id={title}
/>
{remove && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />}
</div>
<div className={styles.descriptionContainer}>
{tab === 'edit' && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
<Tabs.Item label={"Edit"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
<Textarea
onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef}
placeholder=""
value={content}
onChange={handleOnContentChange}
width="100%"
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
resize="vertical"
className={styles.textarea}
/>
</div>
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: 'var(--gap-half)', }}>
<Preview height={height} title={title} content={content} />
</div>
</Tabs.Item>
</Tabs>
</div >
</div >
</>
)
}
export default memo(Document)

View file

@ -0,0 +1,27 @@
import Head from "next/head";
import React from "react";
type PageSeoProps = {
title?: string;
description?: string;
isLoading?: boolean;
isPrivate?: boolean
};
const PageSeo = ({
title = 'Drift',
description = "A self-hostable clone of GitHub Gist",
isPrivate = false
}: PageSeoProps) => {
return (
<>
<Head>
<title>{title}</title>
{!isPrivate && <meta name="description" content={description} />}
</Head>
</>
);
};
export default PageSeo;

View file

@ -1,19 +1,24 @@
import React from 'react' import React, { useEffect, useState } from 'react'
import MoonIcon from '@geist-ui/icons/moon' import MoonIcon from '@geist-ui/icons/moon'
import SunIcon from '@geist-ui/icons/sun' import SunIcon from '@geist-ui/icons/sun'
import { Select } from '@geist-ui/core'
import { ThemeProps } from '../../pages/_app'
// import { useAllThemes, useTheme } from '@geist-ui/core' // import { useAllThemes, useTheme } from '@geist-ui/core'
import styles from './header.module.css' import styles from './header.module.css'
import { Select } from '@geist-ui/core'
import { useTheme } from 'next-themes'
const Controls = ({ changeTheme, theme }: ThemeProps) => { const Controls = () => {
const switchThemes = (type: string | string[]) => { const [mounted, setMounted] = useState(false)
changeTheme() const { theme, setTheme } = useTheme()
if (typeof window === 'undefined' || !window.localStorage) return useEffect(() => setMounted(true), [])
window.localStorage.setItem('drift-theme', Array.isArray(type) ? type[0] : type) if (!mounted) return null
const switchThemes = () => {
if (theme === 'dark') {
setTheme('light')
} else {
setTheme('dark')
}
} }
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<Select <Select

View file

@ -0,0 +1,179 @@
import { ButtonGroup, Page, Spacer, Tabs, useBodyScroll, useMediaQuery, } from "@geist-ui/core";
import { useCallback, useEffect, useState } from "react";
import styles from './header.module.css';
import { useRouter } from "next/router";
import useSignedIn from "../../lib/hooks/use-signed-in";
import HomeIcon from '@geist-ui/icons/home';
import MenuIcon from '@geist-ui/icons/menu';
import GitHubIcon from '@geist-ui/icons/github';
import SignOutIcon from '@geist-ui/icons/userX';
import SignInIcon from '@geist-ui/icons/user';
import SignUpIcon from '@geist-ui/icons/userPlus';
import NewIcon from '@geist-ui/icons/plusCircle';
import YourIcon from '@geist-ui/icons/list'
import MoonIcon from '@geist-ui/icons/moon';
import SunIcon from '@geist-ui/icons/sun';
import { useTheme } from "next-themes"
import { Button } from "@geist-ui/core";
type Tab = {
name: string
icon: JSX.Element
condition?: boolean
value: string
onClick?: () => void
href?: string
}
const Header = () => {
const router = useRouter();
const [selectedTab, setSelectedTab] = useState<string>(router.pathname === '/' ? 'home' : router.pathname.split('/')[1]);
const [expanded, setExpanded] = useState<boolean>(false)
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
const isMobile = useMediaQuery('xs', { match: 'down' })
const { signedIn: isSignedIn, signout } = useSignedIn()
const [pages, setPages] = useState<Tab[]>([])
const { setTheme, theme } = useTheme()
useEffect(() => {
setBodyHidden(expanded)
}, [expanded, setBodyHidden])
useEffect(() => {
if (!isMobile) {
setExpanded(false)
}
}, [isMobile])
useEffect(() => {
const defaultPages: Tab[] = [
{
name: isMobile ? "GitHub" : "",
href: "https://github.com/maxleiter/drift",
icon: <GitHubIcon />,
condition: true,
value: "github"
},
{
name: isMobile ? "Change theme" : "",
onClick: function () {
if (typeof window !== 'undefined')
setTheme(theme === 'light' ? 'dark' : 'light');
},
icon: theme === 'light' ? <MoonIcon /> : <SunIcon />,
condition: true,
value: "theme",
}
]
if (isSignedIn)
setPages([
{
name: 'new',
icon: <NewIcon />,
value: 'new',
href: '/'
},
{
name: 'yours',
icon: <YourIcon />,
value: 'yours',
href: '/mine'
},
{
name: 'sign out',
icon: <SignOutIcon />,
value: 'signout',
onClick: signout
},
...defaultPages
])
else
setPages([
{
name: 'home',
icon: <HomeIcon />,
value: 'home',
href: '/'
},
{
name: 'Sign in',
icon: <SignInIcon />,
value: 'signin',
href: '/signin'
},
{
name: 'Sign up',
icon: <SignUpIcon />,
value: 'signup',
href: '/signup'
},
...defaultPages
])
// TODO: investigate deps causing infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile, isSignedIn, theme])
const onTabChange = useCallback((tab: string) => {
if (typeof window === 'undefined') return
const match = pages.find(page => page.value === tab)
if (match?.onClick) {
match.onClick()
} else {
router.push(match?.href || '/')
}
}, [pages, router])
return (
<Page.Header height={'var(--page-nav-height)'} margin={0} paddingBottom={0} paddingTop={"var(--gap)"}>
<div className={styles.tabs}>
<Tabs
value={selectedTab}
leftSpace={0}
align="center"
hideDivider
hideBorder
onChange={onTabChange}>
{pages.map((tab) => {
return <Tabs.Item
font="14px"
label={<>{tab.icon} {tab.name}</>}
value={tab.value}
key={`${tab.value}`}
/>
})}
</Tabs>
</div>
<div className={styles.controls}>
<Button
auto
type="abort"
onClick={() => setExpanded(!expanded)}
aria-label="Menu"
>
<Spacer height={5 / 6} width={0} />
<MenuIcon />
</Button>
</div>
{isMobile && expanded && (<div className={styles.mobile}>
<ButtonGroup vertical>
{pages.map((tab, index) => {
return <Button
key={`${tab.name}-${index}`}
onClick={() => onTabChange(tab.value)}
icon={tab.icon}
>
{tab.name}
</Button>
})}
</ButtonGroup>
</div>)}
</Page.Header >
)
}
export default Header

View file

@ -1,223 +1,8 @@
import { Page, ButtonGroup, Button, useBodyScroll, useMediaQuery, Tabs, Spacer } from "@geist-ui/core"; import dynamic from 'next/dynamic'
import { Github as GitHubIcon, UserPlus as SignUpIcon, User as SignInIcon, Home as HomeIcon, Menu as MenuIcon, Tool as SettingsIcon, UserX as SignoutIcon, PlusCircle as NewIcon, List as YourIcon, Moon, Sun } from "@geist-ui/icons";
import { DriftProps } from "../../pages/_app";
import { useEffect, useMemo, useState } from "react";
import styles from './header.module.css';
import { useRouter } from "next/router";
import useSignedIn from "../../lib/hooks/use-signed-in";
import Cookies from 'js-cookie'
type Tab = { const Header = dynamic(import('./header'), {
name: string ssr: false,
icon: JSX.Element // loading: () => <MenuSkeleton />,
condition?: boolean })
value: string
onClick?: () => void
href?: string
}
const Header = ({ changeTheme, theme }: DriftProps) => {
const router = useRouter();
const [selectedTab, setSelectedTab] = useState<string>();
const [expanded, setExpanded] = useState<boolean>(false)
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
const isMobile = useMediaQuery('xs', { match: 'down' })
const { isLoading, isSignedIn, signout } = useSignedIn({ redirectIfNotAuthed: false })
const [pages, setPages] = useState<Tab[]>([])
useEffect(() => {
setBodyHidden(expanded)
}, [expanded, setBodyHidden])
useEffect(() => {
if (!isMobile) {
setExpanded(false)
}
}, [isMobile])
useEffect(() => {
const pageList: Tab[] = [
{
name: "Home",
href: "/",
icon: <HomeIcon />,
condition: true,
value: "home"
},
{
name: "New",
href: "/new",
icon: <NewIcon />,
condition: isSignedIn,
value: "new"
},
{
name: "Yours",
href: "/mine",
icon: <YourIcon />,
condition: isSignedIn,
value: "mine"
},
// {
// name: "Settings",
// href: "/settings",
// icon: <SettingsIcon />,
// condition: isSignedIn
// },
{
name: "Sign out",
onClick: () => {
if (typeof window !== 'undefined') {
localStorage.clear();
// // send token to API blacklist
// fetch('/api/auth/signout', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json'
// },
// body: JSON.stringify({
// token: Cookies.get("drift-token")
// })
// })
signout();
router.push("/signin");
}
},
href: "#signout",
icon: <SignoutIcon />,
condition: isSignedIn,
value: "signout"
},
{
name: "Sign in",
href: "/signin",
icon: <SignInIcon />,
condition: !isSignedIn,
value: "signin"
},
{
name: "Sign up",
href: "/signup",
icon: <SignUpIcon />,
condition: !isSignedIn,
value: "signup"
},
{
name: isMobile ? "GitHub" : "",
href: "https://github.com/maxleiter/drift",
icon: <GitHubIcon />,
condition: true,
value: "github"
},
{
name: isMobile ? "Change theme" : "",
onClick: function () {
if (typeof window !== 'undefined') {
changeTheme();
setSelectedTab(undefined);
}
},
icon: theme === 'light' ? <Moon /> : <Sun />,
condition: true,
value: "theme",
}
]
if (isLoading) {
return setPages([])
}
setPages(pageList.filter(page => page.condition))
}, [changeTheme, isLoading, isMobile, isSignedIn, router, signout, theme])
// useEffect(() => {
// setSelectedTab(pages.find((page) => {
// console.log(page.href, router.asPath)
// if (page.href && page.href === router.asPath) {
// return true
// }
// })?.href)
// }, [pages, router, router.pathname])
const onTabChange = (tab: string) => {
const match = pages.find(page => page.value === tab)
if (match?.onClick) {
match.onClick()
} else if (match?.href) {
router.push(`${match.href}`)
}
}
return (
<Page.Header height={'var(--page-nav-height)'} margin={0} paddingBottom={0} paddingTop={"var(--gap)"}>
<div className={styles.tabs}>
<Tabs
value={selectedTab}
leftSpace={0}
align="center"
hideDivider
hideBorder
onChange={onTabChange}>
{!isLoading && pages.map((tab) => {
return <Tabs.Item
font="14px"
label={<>{tab.icon} {tab.name}</>}
value={tab.value}
key={`${tab.value}`}
/>
})}
</Tabs>
</div>
<div className={styles.controls}>
<Button
auto
type="abort"
onClick={() => setExpanded(!expanded)}
aria-label="Menu"
>
<Spacer height={5 / 6} width={0} />
<MenuIcon />
</Button>
</div>
{isMobile && expanded && (<div className={styles.mobile}>
<ButtonGroup vertical>
{pages.map((tab, index) => {
return <Button
key={`${tab.name}-${index}`}
onClick={() => onTabChange(tab.value)}
icon={tab.icon}
>
{tab.name}
</Button>
})}
</ButtonGroup>
</div>)}
</Page.Header >
)
}
export default Header export default Header
// {/* {/* <ButtonGroup>
// <Button onClick={() => {
// }}><Link href="/signin">Sign out</Link></Button>
// <Button>
// <Link href="/mine">
// Yours
// </Link>
// </Button>
// <Button>
// {/* TODO: Link outside Button, but seems to break ButtonGroup */}
// <Link href="/new">
// New
// </Link>
// </Button >
// <Button onClick={() => changeTheme()}>
// <ShiftBy y={6}>{theme.type === 'light' ? <Moon /> : <Sun />}</ShiftBy>
// </Button>
// </ButtonGroup > * /}

View file

@ -0,0 +1,24 @@
import React from 'react'
import styles from './input.module.css'
type Props = React.HTMLProps<HTMLInputElement> & {
label?: string
fontSize?: number | string
}
// eslint-disable-next-line react/display-name
const Input = React.forwardRef<HTMLInputElement, Props>(({ label, className, ...props }, ref) => {
return (<div className={styles.wrapper}>
{label && <label className={styles.label}>{label}</label>}
<input
ref={ref}
className={className ? `${styles.input} ${className}` : styles.input}
{...props}
/>
</div>
)
})
export default Input

View file

@ -0,0 +1,57 @@
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: 100%;
font-size: 1rem;
}
.input {
height: 2.5rem;
border-radius: var(--inline-radius);
background: var(--bg);
color: var(--fg);
border: 1px solid var(--light-gray);
padding: 0 var(--gap-half);
outline: none;
transition: border-color var(--transition);
display: flex;
justify-content: center;
margin: 0;
width: 100%;
}
.input::placeholder {
font-size: 1.5rem;
}
.input:focus {
border-color: var(--input-border-focus);
}
.label {
display: inline-flex;
width: initial;
height: 100%;
align-items: center;
pointer-events: none;
margin: 0;
padding: 0 var(--gap-half);
color: var(--fg);
background-color: var(--light-gray);
border-top-left-radius: var(--radius);
border-bottom-left-radius: var(--radius);
border-top: 1px solid var(--input-border);
border-left: 1px solid var(--input-border);
border-bottom: 1px solid var(--input-border);
font-size: inherit;
line-height: 1;
white-space: nowrap;
}
@media screen and (max-width: 768px) {
.wrapper {
margin-bottom: var(--gap);
}
}

View file

@ -1,17 +1,7 @@
import useSWR from "swr"
import PostList from "../post-list" import PostList from "../post-list"
import Cookies from "js-cookie"
const fetcher = (url: string) => fetch(url, { const MyPosts = ({ posts, error }: { posts: any, error: any }) => {
headers: { return <PostList posts={posts} error={error} />
'Content-Type': 'application/json',
'Authorization': `Bearer ${Cookies.get("drift-token")}`
},
}).then(r => r.json())
const MyPosts = () => {
const { data, error } = useSWR('/server-api/users/mine', fetcher)
return <PostList posts={data} error={error} />
} }
export default MyPosts export default MyPosts

View file

@ -18,10 +18,14 @@
border-radius: 2px; border-radius: 2px;
border-style: dashed; border-style: dashed;
outline: none; outline: none;
transition: border 0.24s ease-in-out; transition: all 0.24s ease-in-out;
cursor: pointer; cursor: pointer;
} }
.dropzone:focus {
box-shadow: 0 0 4px 1px rgba(124, 124, 124, 0.5);
}
.error { .error {
color: red; color: red;
font-size: 0.8rem; font-size: 0.8rem;
@ -31,10 +35,6 @@
padding: 20px; padding: 20px;
} }
.error > li:before {
content: "";
}
.error ul { .error ul {
margin: 0; margin: 0;
padding-left: var(--gap-double); padding-left: var(--gap-double);

View file

@ -1,10 +1,9 @@
import { Button, Text, useTheme, useToasts } from '@geist-ui/core' import { Text, useTheme, useToasts } from '@geist-ui/core'
import { useCallback, useEffect } from 'react' import { memo } from 'react'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import styles from './drag-and-drop.module.css' import styles from './drag-and-drop.module.css'
import { Document } from '../' import type { Document } from '@lib/types'
import generateUUID from '@lib/generate-uuid' import generateUUID from '@lib/generate-uuid'
import { XCircle } from '@geist-ui/icons'
const allowedFileTypes = [ const allowedFileTypes = [
'application/json', 'application/json',
'application/x-javascript', 'application/x-javascript',
@ -92,14 +91,16 @@ const allowedFileExtensions = [
'sql', 'sql',
'xml', 'xml',
'webmanifest', 'webmanifest',
'vue',
'vuex',
] ]
function FileDropzone({ setDocs, docs }: { setDocs: React.Dispatch<React.SetStateAction<Document[]>>, docs: Document[] }) { function FileDropzone({ setDocs }: { setDocs: ((docs: Document[]) => void) }) {
const { palette } = useTheme() const { palette } = useTheme()
const { setToast } = useToasts() const { setToast } = useToasts()
const onDrop = useCallback(async (acceptedFiles) => { const onDrop = async (acceptedFiles: File[]) => {
const newDocs = await Promise.all(acceptedFiles.map((file: File) => { const newDocs = await Promise.all(acceptedFiles.map((file) => {
return new Promise((resolve, reject) => { return new Promise<Document>((resolve) => {
const reader = new FileReader() const reader = new FileReader()
reader.onabort = () => setToast({ text: 'File reading was aborted', type: 'error' }) reader.onabort = () => setToast({ text: 'File reading was aborted', type: 'error' })
@ -116,15 +117,8 @@ function FileDropzone({ setDocs, docs }: { setDocs: React.Dispatch<React.SetStat
}) })
})) }))
if (docs.length === 1) { setDocs(newDocs)
if (docs[0].content === '') { }
setDocs(newDocs)
return
}
}
setDocs((oldDocs) => [...oldDocs, ...newDocs])
}, [setDocs, setToast, docs])
const validator = (file: File) => { const validator = (file: File) => {
// TODO: make this configurable // TODO: make this configurable
@ -170,11 +164,11 @@ function FileDropzone({ setDocs, docs }: { setDocs: React.Dispatch<React.SetStat
</div> </div>
{fileRejections.length > 0 && <ul className={styles.error}> {fileRejections.length > 0 && <ul className={styles.error}>
{/* <Button style={{ float: 'right' }} type="abort" onClick={() => fileRejections.splice(0, fileRejections.length)} auto iconRight={<XCircle />}></Button> */} {/* <Button style={{ float: 'right' }} type="abort" onClick={() => fileRejections.splice(0, fileRejections.length)} auto iconRight={<XCircle />}></Button> */}
<Text h5>There was a problem with some of your files.</Text> <Text h5>There was a problem with one or more of your files.</Text>
{fileRejectionItems} {fileRejectionItems}
</ul>} </ul>}
</div> </div>
) )
} }
export default FileDropzone export default memo(FileDropzone)

View file

@ -1,89 +1,139 @@
import { Button, ButtonDropdown, useToasts } from '@geist-ui/core' import { Button, useToasts, ButtonDropdown } from '@geist-ui/core'
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import generateUUID from '@lib/generate-uuid'; import generateUUID from '@lib/generate-uuid';
import Document from '../document';
import FileDropzone from './drag-and-drop'; import FileDropzone from './drag-and-drop';
import styles from './post.module.css' import styles from './post.module.css'
import Title from './title'; import Title from './title';
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import type { PostVisibility, Document as DocumentType } from '@lib/types';
export type Document = { import PasswordModal from './password';
title: string import getPostPath from '@lib/get-post-path';
content: string import EditDocumentList from '@components/edit-document-list';
id: string import { ChangeEvent } from 'react';
}
const Post = () => { const Post = () => {
const { setToast } = useToasts() const { setToast } = useToasts()
const router = useRouter(); const router = useRouter();
const [title, setTitle] = useState<string>() const [title, setTitle] = useState<string>()
const [docs, setDocs] = useState<Document[]>([{ const [docs, setDocs] = useState<DocumentType[]>([{
title: '', title: '',
content: '', content: '',
id: generateUUID() id: generateUUID()
}]) }])
const [isSubmitting, setSubmitting] = useState(false)
const remove = (id: string) => { const [passwordModalVisible, setPasswordModalVisible] = useState(false)
setDocs(docs.filter((doc) => doc.id !== id)) const sendRequest = useCallback(async (url: string, data: { visibility?: PostVisibility, title?: string, files?: DocumentType[], password?: string, userId: string }) => {
} const res = await fetch(url, {
method: "POST",
const onSubmit = async (visibility: string) => {
setSubmitting(true)
const response = await fetch('/server-api/posts/create', {
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'Authorization': `Bearer ${Cookies.get("drift-token")}` "Authorization": `Bearer ${Cookies.get('drift-token')}`
}, },
body: JSON.stringify({ body: JSON.stringify({
title, title,
files: docs, files: docs,
visibility, ...data,
userId: Cookies.get("drift-userid"),
}) })
}) })
const json = await response.json() if (res.ok) {
setSubmitting(false) const json = await res.json()
if (json.id) router.push(getPostPath(json.visibility, json.id))
router.push(`/post/${json.id}`) } else {
else { const json = await res.json()
setToast({ text: json.error.message, type: "error" }) setToast({
text: json.error.message,
type: 'error'
})
setPasswordModalVisible(false)
setSubmitting(false)
} }
}, [docs, router, setToast, title])
const [isSubmitting, setSubmitting] = useState(false)
const onSubmit = async (visibility: PostVisibility, password?: string) => {
if (visibility === 'protected' && !password) {
setPasswordModalVisible(true)
return
}
setSubmitting(true)
await sendRequest('/server-api/posts/create', {
title,
files: docs,
visibility,
password,
userId: Cookies.get('drift-userid') || ''
})
} }
const updateTitle = useCallback((title: string, id: string) => { const onClosePasswordModal = () => {
setDocs(docs.map((doc) => doc.id === id ? { ...doc, title } : doc)) setPasswordModalVisible(false)
}, [docs]) setSubmitting(false)
}
const updateContent = useCallback((content: string, id: string) => { const onChangeTitle = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setDocs(docs.map((doc) => doc.id === id ? { ...doc, content } : doc)) setTitle(e.target.value)
}, [docs]) }, [setTitle])
const updateDocTitle = useCallback((i: number) => (title: string) => {
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, title } : doc))
}, [setDocs])
const updateDocContent = useCallback((i: number) => (content: string) => {
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, content } : doc))
}, [setDocs])
const removeDoc = useCallback((i: number) => () => {
setDocs((docs) => docs.filter((_, index) => i !== index))
}, [setDocs])
const uploadDocs = useCallback((files: DocumentType[]) => {
// if no title is set and the only document is empty,
const isFirstDocEmpty = docs.length <= 1 && (docs.length ? docs[0].title === '' : true)
const shouldSetTitle = !title && isFirstDocEmpty
if (shouldSetTitle) {
if (files.length === 1) {
setTitle(files[0].title)
} else if (files.length > 1) {
setTitle('Uploaded files')
}
}
if (isFirstDocEmpty) setDocs(files)
else setDocs((docs) => [...docs, ...files])
}, [docs, title])
// pasted files
// const files = e.clipboardData.files as File[]
// if (files.length) {
// const docs = Array.from(files).map((file) => ({
// title: file.name,
// content: '',
// id: generateUUID()
// }))
// }
const onPaste = useCallback((e: any) => {
const pastedText = (e.clipboardData).getData('text')
if (pastedText) {
if (!title) {
setTitle("Pasted text")
}
}
}, [title])
return ( return (
<div> <div style={{ marginBottom: 150 }}>
<Title title={title} setTitle={setTitle} /> <Title title={title} onChange={onChangeTitle} />
<FileDropzone docs={docs} setDocs={setDocs} /> <FileDropzone setDocs={uploadDocs} />
{ <EditDocumentList onPaste={onPaste} docs={docs} updateDocTitle={updateDocTitle} updateDocContent={updateDocContent} removeDoc={removeDoc} />
docs.map(({ id }) => {
const doc = docs.find((doc) => doc.id === id)
return (
<Document
remove={() => remove(id)}
key={id}
editable={true}
setContent={(content) => updateContent(content, id)}
setTitle={(title) => updateTitle(title, id)}
content={doc?.content}
title={doc?.title}
/>
)
})
}
<div className={styles.buttons}> <div className={styles.buttons}>
<Button <Button
className={styles.button} className={styles.button}
@ -104,9 +154,11 @@ const Post = () => {
<ButtonDropdown.Item main onClick={() => onSubmit('private')}>Create Private</ButtonDropdown.Item> <ButtonDropdown.Item main onClick={() => onSubmit('private')}>Create Private</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('public')} >Create Public</ButtonDropdown.Item> <ButtonDropdown.Item onClick={() => onSubmit('public')} >Create Public</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('unlisted')} >Create Unlisted</ButtonDropdown.Item> <ButtonDropdown.Item onClick={() => onSubmit('unlisted')} >Create Unlisted</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('protected')} >Create with Password</ButtonDropdown.Item>
</ButtonDropdown> </ButtonDropdown>
<PasswordModal isOpen={passwordModalVisible} onClose={onClosePasswordModal} onSubmit={(password) => onSubmit('protected', password)} />
</div> </div>
</div > </div>
) )
} }

View file

@ -0,0 +1,52 @@
import { Modal, Note, Spacer, Input } from "@geist-ui/core"
import { useState } from "react"
type Props = {
creating?: boolean
isOpen: boolean
onClose: () => void
onSubmit: (password: string) => void
}
const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify, creating }: Props) => {
const [password, setPassword] = useState<string>()
const [confirmPassword, setConfirmPassword] = useState<string>()
const [error, setError] = useState<string>()
const onSubmit = () => {
if (!password || (creating && !confirmPassword)) {
setError('Please enter a password')
return
}
if (password !== confirmPassword && creating) {
setError("Passwords do not match")
return
}
onSubmitAfterVerify(password)
}
return (<>
{<Modal visible={isOpen} >
<Modal.Title>Enter a password</Modal.Title>
<Modal.Content>
{!error && creating && <Note type="warning" label='Warning'>
This doesn&apos;t protect your post from the server administrator.
</Note>}
{error && <Note type="error" label='Error'>
{error}
</Note>}
<Spacer />
<Input width={"100%"} label="Password" marginBottom={1} htmlType="password" placeholder="Password" onChange={(e) => setPassword(e.target.value)} />
{creating && <Input width={"100%"} label="Confirm" htmlType="password" placeholder="Confirm Password" onChange={(e) => setConfirmPassword(e.target.value)} />}
</Modal.Content>
<Modal.Action passive onClick={onClose}>Cancel</Modal.Action>
<Modal.Action onClick={onSubmit}>Submit</Modal.Action>
</Modal>}
</>)
}
export default PasswordModal

View file

@ -1,7 +1,9 @@
import { Text, Input } from '@geist-ui/core' import { ChangeEvent, memo, useEffect, useState } from 'react'
import { memo } from 'react' import { Text } from '@geist-ui/core'
import ShiftBy from '@components/shift-by' import ShiftBy from '@components/shift-by'
import styles from '../post.module.css' import styles from '../post.module.css'
import { Input } from '@geist-ui/core'
const titlePlaceholders = [ const titlePlaceholders = [
"How to...", "How to...",
@ -14,18 +16,23 @@ const titlePlaceholders = [
] ]
type props = { type props = {
setTitle: (title: string) => void onChange: (e: ChangeEvent<HTMLInputElement>) => void
title?: string title?: string
} }
const Title = ({ setTitle, title }: props) => { const Title = ({ onChange, title }: props) => {
const [placeholder, setPlaceholder] = useState(titlePlaceholders[0])
useEffect(() => {
// set random placeholder on load
setPlaceholder(titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)])
}, [])
return (<div className={styles.title}> return (<div className={styles.title}>
<Text h1 width={"150px"} className={styles.drift}>Drift</Text> <Text h1 width={"150px"} className={styles.drift}>Drift</Text>
<ShiftBy y={-3}> <ShiftBy y={-3}>
<Input <Input
placeholder={titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)]} placeholder={placeholder}
value={title || ""} value={title || ""}
onChange={(event) => setTitle(event.target.value)} onChange={onChange}
height={"55px"} height={"55px"}
font={1.5} font={1.5}
label="Post title" label="Post title"

View file

@ -1,5 +1,7 @@
import { Card, Spacer, Grid, Divider } from "@geist-ui/core";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import { Card, Divider, Grid, Spacer } from "@geist-ui/core";
const ListItemSkeleton = () => (<Card> const ListItemSkeleton = () => (<Card>
<Spacer height={1 / 2} /> <Spacer height={1 / 2} />

View file

@ -1,13 +1,15 @@
import { Card, Spacer, Grid, Divider, Link, Text, Input, Tooltip } from "@geist-ui/core"
import NextLink from "next/link" import NextLink from "next/link"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import timeAgo from "@lib/time-ago" import timeAgo from "@lib/time-ago"
import ShiftBy from "../shift-by" import ShiftBy from "../shift-by"
import VisibilityBadge from "../visibility-badge" import VisibilityBadge from "../visibility-badge"
import getPostPath from "@lib/get-post-path"
import { Input, Link, Text, Card, Spacer, Grid, Tooltip, Divider } from "@geist-ui/core"
const FilenameInput = ({ title }: { title: string }) => <Input const FilenameInput = ({ title }: { title: string }) => <Input
value={title} value={title}
marginTop="var(--gap-double)" marginTop="var(--gap)"
size={1.2} size={1.2}
font={1.2} font={1.2}
label="Filename" label="Filename"
@ -30,22 +32,22 @@ const ListItem = ({ post }: { post: any }) => {
return (<li key={post.id}> return (<li key={post.id}>
<Card style={{ overflowY: 'scroll' }}> <Card style={{ overflowY: 'scroll' }}>
<Spacer height={1 / 2} /> <Spacer height={1 / 2} />
<Grid.Container justify={'space-between'}> <Grid.Container>
<Grid xs={8}> <Grid md={14} xs={14}>
<Text h3 paddingLeft={1 / 2}> <Text h3 paddingLeft={1 / 2} >
<NextLink passHref={true} href={`/post/${post.id}`}> <NextLink passHref={true} href={getPostPath(post.visibility, post.id)}>
<Link color>{post.title} <Link color>{post.title}
<ShiftBy y={-1}><VisibilityBadge visibility={post.visibility} /></ShiftBy> <ShiftBy y={-1}><VisibilityBadge visibility={post.visibility} /></ShiftBy>
</Link> </Link>
</NextLink> </NextLink>
</Text></Grid> </Text></Grid>
<Grid xs={7}><Text type="secondary" h5><Tooltip text={formattedTime}>{time}</Tooltip></Text></Grid> <Grid paddingLeft={1 / 2} md={5} xs={9}><Text type="secondary" h5><Tooltip text={formattedTime}>{time}</Tooltip></Text></Grid>
<Grid xs={4}><Text type="secondary" h5>{post.files.length === 1 ? "1 file" : `${post.files.length} files`}</Text></Grid> <Grid paddingLeft={1 / 2} md={5} xs={4}><Text type="secondary" h5>{post.files.length === 1 ? "1 file" : `${post.files.length} files`}</Text></Grid>
</Grid.Container> </Grid.Container>
<Divider h="1px" my={0} /> <Divider h="1px" my={0} />
<Card.Content > <Card.Content>
{post.files.map((file: any) => { {post.files.map((file: any) => {
return <FilenameInput key={file.id} title={file.title} /> return <FilenameInput key={file.id} title={file.title} />
})} })}

View file

@ -0,0 +1,71 @@
import Header from "@components/header/header"
import PageSeo from "@components/page-seo"
import VisibilityBadge from "@components/visibility-badge"
import DocumentComponent from '@components/view-document'
import styles from './post-page.module.css'
import homeStyles from '@styles/Home.module.css'
import type { File, Post } from "@lib/types"
import { Page, Button, Text } from "@geist-ui/core"
import ShiftBy from "@components/shift-by"
type Props = {
post: Post
}
const PostPage = ({ post }: Props) => {
const download = async () => {
const downloadZip = (await import("client-zip")).downloadZip
const blob = await downloadZip(post.files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip`
link.click()
link.remove()
}
return (
<Page width={"100%"}>
<PageSeo
title={`${post.title} - Drift`}
description={post.description}
isPrivate={false}
/>
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={homeStyles.main}>
{/* {!isLoading && <PostFileExplorer files={post.files} />} */}
<div className={styles.header}>
<div className={styles.titleAndBadge}>
<Text h2>{post.title}</Text>
<ShiftBy y={-5}>
<VisibilityBadge visibility={post.visibility} />
</ShiftBy>
</div>
<Button auto onClick={download}>
Download as ZIP archive
</Button>
</div>
{post.files.map(({ id, content, title }: File) => (
<DocumentComponent
key={id}
title={title}
initialTab={'preview'}
id={id}
content={content}
/>
))}
</Page.Content>
</Page >
)
}
export default PostPage

View file

@ -0,0 +1,23 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header .titleAndBadge {
display: flex;
text-align: center;
justify-content: space-between;
align-items: center;
}
@media screen and (max-width: 650px) {
.header {
flex-direction: column;
}
.header .titleAndBadge {
flex-direction: column;
padding-bottom: var(--gap-double);
}
}

View file

@ -1,28 +1,55 @@
import { memo, useEffect, useState } from "react" import { memo, useEffect, useState } from "react"
import ReactMarkdownPreview from "./react-markdown-preview" import styles from './preview.module.css'
type Props = { type Props = {
content?: string
height?: number | string height?: number | string
fileId?: string
content?: string
title?: string
// file extensions we can highlight // file extensions we can highlight
type?: string
} }
const MarkdownPreview = ({ content = '', height = 500, type = 'markdown' }: Props) => { const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
const [contentToRender, setContent] = useState(content) const [preview, setPreview] = useState<string>(content || "")
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => { useEffect(() => {
// 'm' so it doesn't flash code when you change the type to md async function fetchPost() {
const renderAsMarkdown = ['m', 'markdown', 'md', 'mdown', 'mkdn', 'mkd', 'mdwn', 'mdtxt', 'mdtext', 'text', ''] if (fileId) {
if (!renderAsMarkdown.includes(type)) { const resp = await fetch(`/server-api/files/html/${fileId}`, {
setContent(`~~~${type} method: "GET",
${content} })
~~~ if (resp.ok) {
`) const res = await resp.text()
} else { setPreview(res)
setContent(content) setIsLoading(false)
}
} else if (content) {
const resp = await fetch(`/api/render-markdown`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
content,
}),
})
if (resp.ok) {
const res = await resp.text()
setPreview(res)
setIsLoading(false)
}
}
setIsLoading(false)
} }
}, [type, content]) fetchPost()
return (<ReactMarkdownPreview height={height} content={contentToRender} />) }, [content, fileId, title])
return (<>
{isLoading ? <div>Loading...</div> : <article className={styles.markdownPreview} dangerouslySetInnerHTML={{ __html: preview }} style={{
height
}} />}
</>)
} }
export default memo(MarkdownPreview) export default memo(MarkdownPreview)

View file

@ -1,12 +1,12 @@
.markdownPreview pre { .markdownPreview pre {
border-radius: 3px; border-radius: 3px;
font-family: "Courier New", Courier, monospace; font-family: "Courier New", Courier, monospace;
font-size: 14px; font-size: 14px;
line-height: 1.42857143; line-height: 1.42857143;
margin: 0; margin: 0;
padding: 10px; padding: 10px;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
.markdownPreview h1, .markdownPreview h1,
@ -15,8 +15,27 @@
.markdownPreview h4, .markdownPreview h4,
.markdownPreview h5, .markdownPreview h5,
.markdownPreview h6 { .markdownPreview h6 {
margin-top: 0; margin-top: var(--gap);
margin-bottom: 0.5rem; margin-bottom: var(--gap-half);
}
.markdownPreview h1 {
color: var(--fg);
}
.markdownPreview h2 {
color: var(--darkest-gray);
}
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
color: var(--darker-gray);
}
.markdownPreview a {
color: #0070f3;
} }
/* Auto-linked headers */ /* Auto-linked headers */
@ -26,7 +45,7 @@
.markdownPreview h4 a, .markdownPreview h4 a,
.markdownPreview h5 a, .markdownPreview h5 a,
.markdownPreview h6 a { .markdownPreview h6 a {
color: inherit; color: inherit;
} }
/* Auto-linked headers */ /* Auto-linked headers */
@ -36,63 +55,58 @@
.markdownPreview h4 a:hover::after, .markdownPreview h4 a:hover::after,
.markdownPreview h5 a:hover::after, .markdownPreview h5 a:hover::after,
.markdownPreview h6 a:hover::after { .markdownPreview h6 a:hover::after {
content: "🔗"; content: "#";
filter: grayscale(100%); font-size: 0.7em;
font-size: 0.7em; margin-left: 0.25em;
margin-left: 0.25em; font-weight: normal;
font-weight: normal; filter: opacity(0.5);
} }
.markdownPreview h1 { .markdownPreview h1 {
font-size: 2rem; font-size: 2rem;
} }
.markdownPreview h2 { .markdownPreview h2 {
font-size: 1.5rem; font-size: 1.5rem;
} }
.markdownPreview h3 { .markdownPreview h3 {
font-size: 1.25rem; font-size: 1.25rem;
} }
.markdownPreview h4 { .markdownPreview h4 {
font-size: 1rem; font-size: 1rem;
} }
.markdownPreview h5 { .markdownPreview h5 {
font-size: 0.875rem; font-size: 1rem;
} }
.markdownPreview h6 { .markdownPreview h6 {
font-size: 0.75rem; font-size: 0.875rem;
} }
.markdownPreview ul { .markdownPreview ul {
list-style: inside; list-style: inside;
} }
.markdownPreview ul li::before { .markdownPreview ul li::before {
content: ""; content: "";
}
.markdownPreview ul ul {
list-style: circle;
}
.markdownPreview ul ul li {
margin-left: var(--gap);
} }
.markdownPreview code { .markdownPreview code {
border-radius: 3px; border-radius: 3px;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
color: initial; color: inherit !important;
background-color: #f5f5f5;
padding: 2px 4px;
} }
.markdownPreview code::before, .markdownPreview code::before,
.markdownPreview code::after { .markdownPreview code::after {
content: ""; content: "";
}
.markdownPreview img {
max-width: 100%;
max-height: 350px;
} }

View file

@ -1,59 +0,0 @@
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter';
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
// @ts-ignore because of no types in remark-a11y-emoji
import a11yEmoji from '@fec/remark-a11y-emoji';
import styles from './preview.module.css'
import { vscDarkPlus as dark, vs as light } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import useSharedState from "@lib/hooks/use-shared-state";
type Props = {
content: string | undefined
height: number | string
}
const ReactMarkdownPreview = ({ content, height }: Props) => {
const [themeType] = useSharedState<string>('theme')
return (<div style={{ height }}>
<ReactMarkdown className={styles.markdownPreview}
remarkPlugins={[remarkGfm, a11yEmoji]}
rehypePlugins={[rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'wrap' }]]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<SyntaxHighlighter
lineNumberStyle={{
minWidth: "2.25rem"
}}
customStyle={{
padding: 0,
margin: 0,
background: 'transparent'
}}
codeTagProps={{
style: { background: 'transparent' }
}}
style={themeType === 'dark' ? dark : light}
showLineNumbers={true}
language={match[1]}
PreTag="div"
{...props}
>{String(children).replace(/\n$/, '')}</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}>
{content || ""}
</ReactMarkdown></div>)
}
export default ReactMarkdownPreview

View file

@ -0,0 +1,22 @@
import type { Document } from "@lib/types"
import DocumentComponent from "@components/edit-document"
import { memo, } from "react"
const DocumentList = ({ docs }: {
docs: Document[],
}) => {
return (<>{
docs.map(({ content, id, title }) => {
return (
<DocumentComponent
key={id}
content={content}
title={title}
/>
)
})
}
</>)
}
export default memo(DocumentList)

View file

@ -0,0 +1,40 @@
.card {
margin: var(--gap) auto;
padding: var(--gap);
border: 1px solid var(--light-gray);
border-radius: var(--radius);
}
.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;
}
.actionWrapper {
position: relative;
z-index: 1;
}
.actionWrapper .actions {
position: absolute;
right: 0;
}

View file

@ -1,21 +1,21 @@
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react"
import { memo, useRef, useState } from "react"
import styles from './document.module.css' import styles from './document.module.css'
import MarkdownPreview from '../preview' import Download from '@geist-ui/icons/download'
import { Trash, Download, ExternalLink } from '@geist-ui/icons' import ExternalLink from '@geist-ui/icons/externalLink'
import FormattingIcons from "./formatting-icons"
import Skeleton from "react-loading-skeleton" import Skeleton from "react-loading-skeleton"
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
import HtmlPreview from "@components/preview"
// import Link from "next/link" // import Link from "next/link"
type Props = { type Props = {
editable?: boolean title: string
remove?: () => void
title?: string
content?: string
setTitle?: (title: string) => void
setContent?: (content: string) => void
initialTab?: "edit" | "preview" initialTab?: "edit" | "preview"
skeleton?: boolean skeleton?: boolean
id?: string id: string
content: string
} }
const DownloadButton = ({ rawLink }: { rawLink?: string }) => { const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
@ -46,10 +46,11 @@ const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
} }
const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton, id }: Props) => { const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null) const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab) const [tab, setTab] = useState(initialTab)
const height = editable ? "500px" : '100%' // const height = editable ? "500px" : '100%'
const height = "100%";
const handleTabChange = (newTab: string) => { const handleTabChange = (newTab: string) => {
if (newTab === 'edit') { if (newTab === 'edit') {
@ -58,79 +59,55 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
setTab(newTab as 'edit' | 'preview') setTab(newTab as 'edit' | 'preview')
} }
const getType = useMemo(() => { const rawLink = () => {
if (!title) return
const pathParts = title.split(".")
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : ""
return language
}, [title])
const removeFile = (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()
}
}
}
const rawLink = useMemo(() => {
if (id) { if (id) {
return `/file/raw/${id}` return `/file/raw/${id}`
} }
}, [id]) }
if (skeleton) { if (skeleton) {
return <> return <>
<Spacer height={1} /> <Spacer height={1} />
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}> <div className={styles.card}>
<div className={styles.fileNameContainer}> <div className={styles.fileNameContainer}>
<Skeleton width={275} height={36} /> <Skeleton width={275} height={36} />
{editable && <Skeleton width={36} height={36} />}
</div> </div>
<div className={styles.descriptionContainer}> <div className={styles.descriptionContainer}>
<div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div> <div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
<Skeleton width={'100%'} height={350} /> <Skeleton width={'100%'} height={350} />
</div > </div >
</Card> </div>
</> </>
} }
return ( return (
<> <>
<Spacer height={1} /> <Spacer height={1} />
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}> <div className={styles.card}>
<div className={styles.fileNameContainer}> <div className={styles.fileNameContainer}>
<Input <Input
placeholder="MyFile.md"
value={title} value={title}
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle ? setTitle(event.target.value) : null} readOnly
marginTop="var(--gap-double)" marginTop="var(--gap-double)"
size={1.2} size={1.2}
font={1.2} font={1.2}
label="Filename" label="Filename"
disabled={!editable}
width={"100%"} width={"100%"}
id={title}
/> />
{remove && editable && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />}
</div> </div>
<div className={styles.descriptionContainer}> <div className={styles.descriptionContainer}>
{tab === 'edit' && editable && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />} <DownloadButton rawLink={rawLink()} />
{rawLink && <DownloadButton rawLink={rawLink} />}
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}> <Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
<Tabs.Item label={editable ? "Edit" : "Raw"} value="edit"> <Tabs.Item label={"Raw"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */} {/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
<Textarea <Textarea
readOnly
ref={codeEditorRef} ref={codeEditorRef}
placeholder="Type some contents..."
value={content} value={content}
onChange={(event) => setContent ? setContent(event.target.value) : null}
width="100%" width="100%"
disabled={!editable}
// TODO: Textarea should grow to fill parent if height == 100% // TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }} style={{ flex: 1, minHeight: 350 }}
resize="vertical" resize="vertical"
@ -139,13 +116,15 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
</div> </div>
</Tabs.Item> </Tabs.Item>
<Tabs.Item label="Preview" value="preview"> <Tabs.Item label="Preview" value="preview">
<MarkdownPreview height={height} content={content} type={getType} /> <div style={{ marginTop: 'var(--gap-half)', }}>
<HtmlPreview height={height} fileId={id} />
</div>
</Tabs.Item> </Tabs.Item>
</Tabs> </Tabs>
</div > </div >
</Card > </div >
<Spacer height={1} />
</> </>
) )
} }

View file

@ -1,9 +1,8 @@
import { Badge } from "@geist-ui/core" import { Badge } from "@geist-ui/core"
import type { PostVisibility } from "@lib/types"
type Visibility = "unlisted" | "private" | "public"
type Props = { type Props = {
visibility: Visibility visibility: PostVisibility
} }
const VisibilityBadge = ({ visibility }: Props) => { const VisibilityBadge = ({ visibility }: Props) => {

View file

@ -1,30 +1,39 @@
export default function generateUUID() { export default function generateUUID() {
if (typeof crypto === 'object') { if (typeof crypto === "object") {
if (typeof crypto.randomUUID === 'function') { if (typeof crypto.randomUUID === "function") {
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID // https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
return crypto.randomUUID(); return crypto.randomUUID()
} }
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') { if (
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid typeof crypto.getRandomValues === "function" &&
const callback = (c: string) => { typeof Uint8Array === "function"
const num = Number(c); ) {
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16); // https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
}; const callback = (c: string) => {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback); const num = Number(c)
} return (
} num ^
let timestamp = new Date().getTime(); (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0; ).toString(16)
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { }
let random = Math.random() * 16; return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, callback)
if (timestamp > 0) { }
random = (timestamp + random) % 16 | 0; }
timestamp = Math.floor(timestamp / 16); let timestamp = new Date().getTime()
} else { let perforNow =
random = (perforNow + random) % 16 | 0; (typeof performance !== "undefined" &&
perforNow = Math.floor(perforNow / 16); performance.now &&
} performance.now() * 1000) ||
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16); 0
}); return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
}; let random = Math.random() * 16
if (timestamp > 0) {
random = (timestamp + random) % 16 | 0
timestamp = Math.floor(timestamp / 16)
} else {
random = (perforNow + random) % 16 | 0
perforNow = Math.floor(perforNow / 16)
}
return (c === "x" ? random : (random & 0x3) | 0x8).toString(16)
})
}

View file

@ -0,0 +1,13 @@
import type { PostVisibility } from "./types"
export default function getPostPath(visibility: PostVisibility, id: string) {
switch (visibility) {
case "private":
return `/post/private/${id}`
case "protected":
return `/post/protected/${id}`
case "unlisted":
case "public":
return `/post/${id}`
}
}

View file

@ -0,0 +1,18 @@
// useDebounce.js
import { useState, useEffect } from "react"
export default function useDebounce(value: any, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}

View file

@ -2,10 +2,10 @@ import useSWR from "swr"
// https://2020.paco.me/blog/shared-hook-state-with-swr // https://2020.paco.me/blog/shared-hook-state-with-swr
const useSharedState = <T>(key: string, initial?: T) => { const useSharedState = <T>(key: string, initial?: T) => {
const { data: state, mutate: setState } = useSWR(key, { const { data: state, mutate: setState } = useSWR(key, {
fallbackData: initial fallbackData: initial
}) })
return [state, setState] as const return [state, setState] as const
} }
export default useSharedState export default useSharedState

View file

@ -1,45 +1,35 @@
import { useRouter } from "next/router"; import Cookies from "js-cookie"
import { useCallback, useEffect } from "react" import { useRouter } from "next/router"
import useSharedState from "./use-shared-state"; import { useEffect, useState } from "react"
import Cookies from 'js-cookie' import useSharedState from "./use-shared-state"
const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: boolean }) => { const useSignedIn = () => {
const [isSignedIn, setSignedIn] = useSharedState('isSignedIn', false) const [signedIn, setSignedIn] = useSharedState(
const [isLoading, setLoading] = useSharedState('isLoading', true) "signedIn",
const signout = useCallback(() => setSignedIn(false), [setSignedIn]) typeof window === "undefined" ? false : !!Cookies.get("drift-token")
)
const token = Cookies.get("drift-token")
const router = useRouter()
const signin = (token: string) => {
setSignedIn(true)
Cookies.set("drift-token", token)
}
const router = useRouter(); const signout = () => {
if (redirectIfNotAuthed && !isLoading && isSignedIn === false) { setSignedIn(false)
router.push('/signin') Cookies.remove("drift-token")
} router.push("/")
}
useEffect(() => { useEffect(() => {
async function checkToken() { if (token) {
const token = Cookies.get('drift-token') setSignedIn(true)
if (token) { } else {
const response = await fetch('/server-api/auth/verify-token', { setSignedIn(false)
method: 'GET', }
headers: { }, [setSignedIn, token])
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
setSignedIn(true)
}
}
setLoading(false)
}
setLoading(true)
checkToken()
const interval = setInterval(() => { return { signedIn, signin, token, signout }
checkToken()
}, 10000);
return () => clearInterval(interval);
}, [setLoading, setSignedIn])
return { isSignedIn, isLoading, signout }
} }
export default useSignedIn export default useSignedIn

View file

@ -0,0 +1,19 @@
import { useRef, useEffect } from "react"
function useTraceUpdate(props: { [key: string]: any }) {
const prev = useRef(props)
useEffect(() => {
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
if (prev.current[k] !== v) {
ps[k] = [prev.current[k], v]
}
return ps
}, {} as { [key: string]: any })
if (Object.keys(changedProps).length > 0) {
console.log("Changed props:", changedProps)
}
prev.current = props
})
}
export default useTraceUpdate

View file

@ -0,0 +1,152 @@
import { marked } from 'marked'
import Highlight, { defaultProps, Language, } from 'prism-react-renderer'
import { renderToStaticMarkup } from 'react-dom/server'
// // image sizes. DDoS Safe?
// const imageSizeLink = /^!?\[((?:\[[^\[\]]*\]|\\[\[\]]?|`[^`]*`|[^\[\]\\])*?)\]\(\s*(<(?:\\[<>]?|[^\s<>\\])*>|(?:\\[()]?|\([^\s\x00-\x1f()\\]*\)|[^\s\x00-\x1f()\\])*?(?:\s+=(?:[\w%]+)?x(?:[\w%]+)?)?)(?:\s+("(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)))?\s*\)/;
// //@ts-ignore
// Lexer.rules.inline.normal.link = imageSizeLink;
// //@ts-ignore
// Lexer.rules.inline.gfm.link = imageSizeLink;
// //@ts-ignore
// Lexer.rules.inline.breaks.link = imageSizeLink;
//@ts-ignore
delete defaultProps.theme
// import linkStyles from '../components/link/link.module.css'
const renderer = new marked.Renderer()
renderer.heading = (text, level, _, slugger) => {
const id = slugger.slug(text)
const Component = `h${level}`
return renderToStaticMarkup(
//@ts-ignore
<Component>
<a href={`#${id}`} id={id} style={{ color: "inherit" }} dangerouslySetInnerHTML={{ __html: (text) }} >
</a>
</Component>
)
}
// renderer.link = (href, _, text) => {
// const isHrefLocal = href?.startsWith('/') || href?.startsWith('#')
// if (isHrefLocal) {
// return renderToStaticMarkup(
// <a href={href || ''}>
// {text}
// </a>
// )
// }
// // dirty hack
// // if text contains elements, render as html
// return <a href={href || ""} target="_blank" rel="noopener noreferrer" dangerouslySetInnerHTML={{ __html: convertHtmlEntities(text) }} ></a>
// }
renderer.image = function (href, _, text) {
return `<Image loading="lazy" src="${href}" alt="${text}" layout="fill" />`
}
renderer.checkbox = () => ''
renderer.listitem = (text, task, checked) => {
if (task) {
return `<li class="reset"><span class="check">&#8203;<input type="checkbox" disabled ${checked ? 'checked' : ''
} /></span><span>${text}</span></li>`
}
return `<li>${text}</li>`
}
renderer.code = (code: string, language: string) => {
return renderToStaticMarkup(
<pre>
{/* {title && <code>{title} </code>} */}
{/* {language && title && <code style={{}}> {language} </code>} */}
<Code
language={language}
// title={title}
code={code}
// highlight={highlight}
/>
</pre>
)
}
marked.setOptions({
gfm: true,
breaks: true,
headerIds: true,
renderer,
})
const markdown = (markdown: string) => marked(markdown)
export default markdown
const Code = ({ code, language, highlight, title, ...props }: {
code: string,
language: string,
highlight?: string,
title?: string,
}) => {
if (!language)
return (
<>
<code {...props} dangerouslySetInnerHTML={{ __html: code }} />
</>
)
const highlightedLines = highlight
//@ts-ignore
? highlight.split(',').reduce((lines, h) => {
if (h.includes('-')) {
// Expand ranges like 3-5 into [3,4,5]
const [start, end] = h.split('-').map(Number)
const x = Array(end - start + 1)
.fill(undefined)
.map((_, i) => i + start)
return [...lines, ...x]
}
return [...lines, Number(h)]
}, [])
: ''
// https://mdxjs.com/guides/syntax-harkedighlighting#all-together
return (
<>
<Highlight {...defaultProps} code={code.trim()} language={language as Language} >
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<code className={className} style={{ ...style }}>
{
tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line, key: i })}
style={
//@ts-ignore
highlightedLines.includes((i + 1).toString())
? {
background: 'var(--highlight)',
margin: '0 -1rem',
padding: '0 1rem',
}
: undefined
}
>
{
line.map((token, key) => (
<span key={key} {...getTokenProps({ token, key })} />
))
}
</div>
))}
</code>
)}
</Highlight>
</>
)
}

View file

@ -2,40 +2,42 @@
// which is based on https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site // which is based on https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
const epochs = [ const epochs = [
['year', 31536000], ["year", 31536000],
['month', 2592000], ["month", 2592000],
['day', 86400], ["day", 86400],
['hour', 3600], ["hour", 3600],
['minute', 60], ["minute", 60],
['second', 1] ["second", 1]
] as const; ] as const
// Get duration // Get duration
const getDuration = (timeAgoInSeconds: number) => { const getDuration = (timeAgoInSeconds: number) => {
for (let [name, seconds] of epochs) { for (let [name, seconds] of epochs) {
const interval = Math.floor(timeAgoInSeconds / seconds); const interval = Math.floor(timeAgoInSeconds / seconds)
if (interval >= 1) { if (interval >= 1) {
return { return {
interval: interval, interval: interval,
epoch: name epoch: name
}; }
} }
} }
return { return {
interval: 0, interval: 0,
epoch: 'second' epoch: "second"
} }
}; }
// Calculate // Calculate
const timeAgo = (date: Date) => { const timeAgo = (date: Date) => {
const timeAgoInSeconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000); const timeAgoInSeconds = Math.floor(
const { interval, epoch } = getDuration(timeAgoInSeconds); (new Date().getTime() - new Date(date).getTime()) / 1000
const suffix = interval === 1 ? '' : 's'; )
const { interval, epoch } = getDuration(timeAgoInSeconds)
const suffix = interval === 1 ? "" : "s"
return `${interval} ${epoch}${suffix} ago`; return `${interval} ${epoch}${suffix} ago`
}; }
export default timeAgo export default timeAgo

24
client/lib/types.d.ts vendored Normal file
View file

@ -0,0 +1,24 @@
export type PostVisibility = "unlisted" | "private" | "public" | "protected"
export type Document = {
title: string
content: string
id: string
}
export type File = {
id: string
title: string
content: string
html: string
}
type Files = File[]
export type Post = {
id: string
title: string
description: string
visibility: PostVisibility
files: Files
}

View file

@ -1,24 +0,0 @@
const dotenv = require("dotenv");
dotenv.config();
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
outputStandalone: true,
},
async rewrites() {
return [
{
source: "/server-api/:path*",
destination: `${process.env.API_URL}/:path*`,
},
{
source: "/file/raw/:id",
destination: `/api/raw/:id`,
},
];
},
};
module.exports = nextConfig;

39
client/next.config.mjs Normal file
View file

@ -0,0 +1,39 @@
import dotenv from "dotenv"
import bundleAnalyzer from "@next/bundle-analyzer"
dotenv.config()
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
outputStandalone: true,
esmExternals: true
},
webpack: (config, { dev, isServer }) => {
if (!dev && !isServer) {
Object.assign(config.resolve.alias, {
react: "preact/compat",
"react-dom/test-utils": "preact/test-utils",
"react-dom": "preact/compat"
})
}
return config
},
async rewrites() {
return [
{
source: "/server-api/:path*",
destination: `${process.env.API_URL}/:path*`
},
{
source: "/file/raw/:id",
destination: `/api/raw/:id`
}
]
}
}
export default bundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(
nextConfig
)

View file

@ -6,41 +6,66 @@
"dev": "next dev --port 3001", "dev": "next dev --port 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint && prettier --config .prettierrc '{components,lib,pages}/**/*.ts' --write",
"analyze": "ANALYZE=true next build",
"find:unused": "next-unused"
}, },
"dependencies": { "dependencies": {
"@fec/remark-a11y-emoji": "^3.1.0",
"@geist-ui/core": "^2.3.5", "@geist-ui/core": "^2.3.5",
"@geist-ui/icons": "^1.0.1", "@geist-ui/icons": "^1.0.1",
"@types/cookie": "^0.4.1",
"@types/js-cookie": "^3.0.1", "@types/js-cookie": "^3.0.1",
"client-zip": "^2.0.0", "client-zip": "^2.0.0",
"comlink": "^4.3.1", "cookie": "^0.4.2",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"next": "12.1.0", "lodash.debounce": "^4.0.8",
"prismjs": "^1.27.0", "marked": "^4.0.12",
"next": "^12.1.1-canary.15",
"next-themes": "^0.1.1",
"postcss": "^8.4.12",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-hover-media-feature": "^1.0.2",
"postcss-preset-env": "^7.4.3",
"preact": "^10.6.6",
"prism-react-renderer": "^1.3.1",
"react": "17.0.2", "react": "17.0.2",
"react-debounce-render": "^8.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-dropzone": "^12.0.4", "react-dropzone": "^12.0.4",
"react-loading-skeleton": "^3.0.3", "react-loading-skeleton": "^3.0.3",
"react-markdown": "^8.0.0", "react-markdown": "^8.0.0",
"react-syntax-highlighter": "^15.4.5", "react-syntax-highlighter": "^15.4.5",
"react-syntax-highlighter-virtualized-renderer": "^1.1.0",
"rehype-autolink-headings": "^6.1.1", "rehype-autolink-headings": "^6.1.1",
"rehype-katex": "^6.0.2", "rehype-raw": "^6.1.1",
"rehype-slug": "^5.0.1", "rehype-slug": "^5.0.1",
"rehype-stringify": "^9.0.3",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"swr": "^1.2.2" "swr": "^1.2.2"
}, },
"devDependencies": { "devDependencies": {
"@next/bundle-analyzer": "^12.1.0",
"@types/lodash.debounce": "^4.0.6",
"@types/marked": "^4.0.3",
"@types/node": "17.0.21", "@types/node": "17.0.21",
"@types/nprogress": "^0.2.0",
"@types/react": "17.0.39", "@types/react": "17.0.39",
"@types/react-dom": "^17.0.14",
"@types/react-syntax-highlighter": "^13.5.2", "@types/react-syntax-highlighter": "^13.5.2",
"eslint": "8.10.0", "eslint": "8.10.0",
"eslint-config-next": "12.1.0", "eslint-config-next": "^12.1.1-canary.16",
"typescript": "4.6.2" "next-unused": "^0.0.6",
"prettier": "^2.6.0",
"typescript": "4.6.2",
"typescript-plugin-css-modules": "^3.4.0"
},
"next-unused": {
"alias": {
"@components": "components/",
"@lib": "lib/",
"@styles": "styles/"
},
"include": [
"components",
"lib"
]
} }
} }

View file

@ -1,51 +1,22 @@
import '@styles/globals.css' import '@styles/globals.css'
import { GeistProvider, CssBaseline, useTheme } from '@geist-ui/core'
import { useEffect, useMemo, useState } from 'react'
import type { AppProps as NextAppProps } from "next/app"; import type { AppProps as NextAppProps } from "next/app";
import useSharedState from '@lib/hooks/use-shared-state';
import 'react-loading-skeleton/dist/skeleton.css' import 'react-loading-skeleton/dist/skeleton.css'
import { SkeletonTheme } from 'react-loading-skeleton'; import { SkeletonTheme } from 'react-loading-skeleton';
import Head from 'next/head'; import Head from 'next/head';
import { CssBaseline, GeistProvider, Themes } from '@geist-ui/core';
export type ThemeProps = { import { useTheme, ThemeProvider } from 'next-themes'
theme: "light" | "dark" | string, import { useEffect } from 'react';
changeTheme: () => void import App from '@components/app';
}
type AppProps<P = any> = { type AppProps<P = any> = {
pageProps: P; pageProps: P;
} & Omit<NextAppProps<P>, "pageProps">; } & Omit<NextAppProps<P>, "pageProps">;
export type DriftProps = ThemeProps
function MyApp({ Component, pageProps }: AppProps<ThemeProps>) {
const [themeType, setThemeType] = useSharedState<string>('theme', 'light')
const theme = useTheme();
useEffect(() => {
if (typeof window === 'undefined' || !window.localStorage) return
const storedTheme = window.localStorage.getItem('drift-theme')
if (storedTheme) setThemeType(storedTheme)
// TODO: useReducer?
}, [setThemeType, themeType])
const changeTheme = () => {
const newTheme = themeType === 'dark' ? 'light' : 'dark'
localStorage.setItem('drift-theme', newTheme)
setThemeType(last => (last === 'dark' ? 'light' : 'dark'))
}
const skeletonBaseColor = useMemo(() => {
if (themeType === 'dark') return '#333'
return '#eee'
}, [themeType])
const skeletonHighlightColor = useMemo(() => {
if (themeType === 'dark') return '#555'
return '#ddd'
}, [themeType])
function MyApp({ Component, pageProps }: AppProps) {
return ( return (
<> <div>
<Head> <Head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
@ -60,13 +31,10 @@ function MyApp({ Component, pageProps }: AppProps<ThemeProps>) {
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<title>Drift</title> <title>Drift</title>
</Head> </Head>
<GeistProvider themeType={themeType} > <ThemeProvider defaultTheme="system" disableTransitionOnChange>
<SkeletonTheme baseColor={skeletonBaseColor} highlightColor={skeletonHighlightColor}> <App Component={Component} pageProps={pageProps} />
<CssBaseline /> </ThemeProvider>
<Component {...pageProps} theme={themeType || 'light'} changeTheme={changeTheme} /> </div>
</SkeletonTheme>
</GeistProvider>
</>
) )
} }

View file

@ -1,5 +1,5 @@
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
import { CssBaseline } from '@geist-ui/core' import { CssBaseline } from '@geist-ui/core'
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
class MyDocument extends Document { class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) { static async getInitialProps(ctx: DocumentContext) {

View file

@ -0,0 +1,46 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server'
const PUBLIC_FILE = /.(.*)$/
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname
const signedIn = req.cookies['drift-token']
const getURL = (pageName: string) => new URL(`/${pageName}`, req.url).href
// const isPageRequest =
// !PUBLIC_FILE.test(req.nextUrl.pathname) &&
// !req.nextUrl.pathname.startsWith('/api') &&
// // header added when next/link pre-fetches a route
// !req.headers.get('x-middleware-preflight')
if (pathname === '/signout') {
// If you're signed in we remove the cookie and redirect to the home page
// If you're not signed in we redirect to the home page
if (signedIn) {
const resp = NextResponse.redirect(getURL(''));
resp.clearCookie('drift-token');
resp.clearCookie('drift-userid');
return resp
}
} else if (pathname === '/') {
if (signedIn) {
return NextResponse.rewrite(getURL('new'))
}
// If you're not signed in we redirect the new post page to the home page
} else if (pathname === '/new') {
if (!signedIn) {
return NextResponse.redirect(getURL(''))
}
// If you're signed in we redirect the sign in page to the home page (which is the new page)
} else if (pathname === '/signin' || pathname === '/signup') {
if (signedIn) {
return NextResponse.redirect(getURL(''))
}
} else if (pathname === '/new') {
if (!signedIn) {
return NextResponse.redirect(getURL('/signin'))
}
}
return NextResponse.next()
}

View file

@ -0,0 +1,58 @@
import type { NextApiHandler } from "next"
import markdown from "@lib/render-markdown"
const renderMarkdown: NextApiHandler = async (req, res) => {
const { id } = req.query
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`, {
headers: {
Accept: "text/plain",
"x-secret-key": process.env.SECRET_KEY || "",
Authorization: `Bearer ${req.cookies["drift-token"]}`
}
})
if (file.status
!== 200) {
return res.status(404).json({ error: "File not found" })
}
const json = await file.json()
const { content, title } = json
const renderAsMarkdown = [
"markdown",
"md",
"mdown",
"mkdn",
"mkd",
"mdwn",
"mdtxt",
"mdtext",
"text",
""
]
const fileType = () => {
const pathParts = title.split(".")
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : ""
return language
}
const type = fileType()
let contentToRender: string = "\n" + content
if (!renderAsMarkdown.includes(type)) {
contentToRender = `~~~${type}
${content}
~~~`
}
if (typeof contentToRender !== "string") {
res.status(400).send("content must be a string")
return
}
res.setHeader("Content-Type", "text/plain")
res.setHeader("Cache-Control", "public, max-age=4800")
res.status(200).write(markdown(contentToRender))
res.end()
}
export default renderMarkdown

View file

@ -1,24 +1,34 @@
import { NextApiRequest, NextApiResponse } from "next" import { NextApiRequest, NextApiResponse } from "next"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => { const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
const { id, download } = req.query const { id, download } = req.query
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`) const file = await fetch(`${process.env.API_URL}/files/raw/${id}`, {
if (file.ok) { headers: {
const data = await file.json() Accept: "text/plain",
const { title, content } = data "x-secret-key": process.env.SECRET_KEY || "",
// serve the file raw as plain text Authorization: `Bearer ${req.cookies["drift-token"]}`
res.setHeader("Content-Type", "text/plain") }
res.setHeader('Cache-Control', 's-maxage=86400'); })
if (download) {
res.setHeader("Content-Disposition", `attachment; filename="${title}"`)
} else {
res.setHeader("Content-Disposition", `inline; filename="${title}"`)
}
res.status(200).send(content) res.setHeader("Content-Type", "text/plain; charset=utf-8")
} else { res.setHeader("Cache-Control", "s-maxage=86400")
res.status(404).send("File not found") if (file.ok) {
} const json = await file.json()
const data = json
const { title, content } = data
// serve the file raw as plain text
if (download) {
res.setHeader("Content-Disposition", `attachment; filename="${title}"`)
} else {
res.setHeader("Content-Disposition", `inline; filename="${title}"`)
}
res.status(200).write(content, "utf-8")
res.end()
} else {
res.status(404).send("File not found")
}
} }
export default getRawFile export default getRawFile

View file

@ -0,0 +1,42 @@
import type { NextApiHandler } from "next"
import markdown from "@lib/render-markdown"
const renderMarkdown: NextApiHandler = async (req, res) => {
const { content, title } = req.body
const renderAsMarkdown = [
"markdown",
"md",
"mdown",
"mkdn",
"mkd",
"mdwn",
"mdtxt",
"mdtext",
"text",
""
]
const fileType = () => {
const pathParts = title.split(".")
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : ""
return language
}
const type = fileType()
let contentToRender: string = content || ""
if (!renderAsMarkdown.includes(type)) {
contentToRender = `~~~${type}
${content}
~~~`
} else {
contentToRender = "\n" + content
}
if (typeof contentToRender !== "string") {
res.status(400).send("content must be a string")
return
}
res.status(200).write(markdown(contentToRender))
res.end()
}
export default renderMarkdown

View file

@ -1,12 +1,10 @@
import styles from '@styles/Home.module.css' import styles from '@styles/Home.module.css'
import { Page, Spacer, Text } from '@geist-ui/core'
import Header from '@components/header' import Header from '@components/header'
import { ThemeProps } from './_app' import Document from '@components/edit-document'
import Document from '@components/document'
import Image from 'next/image' import Image from 'next/image'
import ShiftBy from '@components/shift-by' import ShiftBy from '@components/shift-by'
import PageSeo from '@components/page-seo' import PageSeo from '@components/page-seo'
import { Page, Text, Spacer } from '@geist-ui/core'
export function getStaticProps() { export function getStaticProps() {
const introDoc = process.env.WELCOME_CONTENT const introDoc = process.env.WELCOME_CONTENT
@ -18,27 +16,25 @@ export function getStaticProps() {
} }
} }
type Props = ThemeProps & { type Props = {
introContent: string introContent: string
} }
const Home = ({ theme, changeTheme, introContent }: Props) => { const Home = ({ introContent }: Props) => {
return ( return (
<Page className={styles.container} width="100%"> <Page className={styles.container}>
<PageSeo /> <PageSeo />
<Page.Header> <Page.Header>
<Header theme={theme} changeTheme={changeTheme} /> <Header />
</Page.Header> </Page.Header>
<Page.Content width={"var(--main-content-width)"} margin="auto" paddingTop={"var(--gap)"}> <Page.Content className={styles.main}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}> <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<ShiftBy y={-2}><Image src={'/assets/logo-optimized.svg'} width={'48px'} height={'48px'} alt="" /></ShiftBy> <ShiftBy y={-2}><Image src={'/assets/logo-optimized.svg'} width={'48px'} height={'48px'} alt="" /></ShiftBy>
<Spacer /> <Spacer />
<Text style={{ display: 'inline' }} h1> Welcome to Drift</Text> <Text style={{ display: 'inline' }} h1> Welcome to Drift</Text>
</div> </div>
<Document <Document
editable={false}
content={introContent} content={introContent}
title={`Welcome to Drift.md`} title={`Welcome to Drift.md`}
initialTab={`preview`} initialTab={`preview`}

View file

@ -1,20 +1,60 @@
import styles from '@styles/Home.module.css' import styles from '@styles/Home.module.css'
import { Page } from '@geist-ui/core'
import Header from '@components/header' import Header from '@components/header'
import MyPosts from '@components/my-posts' import MyPosts from '@components/my-posts'
import cookie from "cookie";
import type { GetServerSideProps } from 'next';
import { Post } from '@lib/types';
import { Page } from '@geist-ui/core';
const Home = ({ theme, changeTheme }: { theme: "light" | "dark", changeTheme: () => void }) => { const Home = ({ posts, error }: { posts: Post[]; error: any; }) => {
return ( return (
<Page className={styles.container} width="100%"> <Page className={styles.container}>
<Page.Header> <Page.Header>
<Header theme={theme} changeTheme={changeTheme} /> <Header />
</Page.Header> </Page.Header>
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}> <Page.Content className={styles.main}>
<MyPosts /> <MyPosts error={error} posts={posts} />
</Page.Content> </Page.Content>
</Page > </Page >
) )
} }
// get server side props
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`]
if (!driftToken) {
return {
redirect: {
destination: '/',
permanent: false,
}
}
}
const posts = await fetch(process.env.API_URL + `/posts/mine`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || ''
}
})
if (!posts.ok || posts.status !== 200) {
return {
redirect: {
destination: '/',
permanent: false,
}
}
}
return {
props: {
posts: await posts.json(),
error: posts.status !== 200,
}
}
}
export default Home export default Home

View file

@ -1,31 +1,23 @@
import styles from '@styles/Home.module.css' import styles from '@styles/Home.module.css'
import NewPost from '@components/new-post' import NewPost from '@components/new-post'
import { Page } from '@geist-ui/core'
import useSignedIn from '@lib/hooks/use-signed-in'
import Header from '@components/header' import Header from '@components/header'
import { ThemeProps } from './_app'
import { useRouter } from 'next/router'
import PageSeo from '@components/page-seo' import PageSeo from '@components/page-seo'
import { Page } from '@geist-ui/core'
const Home = ({ theme, changeTheme }: ThemeProps) => { const New = () => {
const router = useRouter()
const { isSignedIn, isLoading } = useSignedIn({ redirectIfNotAuthed: true })
if (!isSignedIn && !isLoading) {
router.push("/signin")
}
return ( return (
<Page className={styles.container} width="100%"> <Page className={styles.container} width="100%">
<PageSeo title="Drift - New" /> <PageSeo title="Drift - New" />
<Page.Header> <Page.Header>
<Header theme={theme} changeTheme={changeTheme} /> <Header />
</Page.Header> </Page.Header>
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}> <Page.Content className={styles.main}>
{isSignedIn && <NewPost />} <NewPost />
</Page.Content> </Page.Content>
</Page > </Page >
) )
} }
export default Home export default New

View file

@ -1,111 +1,49 @@
import { Button, Page, Text } from "@geist-ui/core"; import type { GetStaticPaths, GetStaticProps } from "next";
import Skeleton from 'react-loading-skeleton';
import { useRouter } from "next/router"; import type { Post } from "@lib/types";
import { useEffect, useState } from "react"; import PostPage from "@components/post-page";
import Document from '../../components/document'
import Header from "../../components/header";
import VisibilityBadge from "../../components/visibility-badge";
import { ThemeProps } from "../_app";
import PageSeo from "components/page-seo";
import Head from "next/head";
import styles from './styles.module.css';
import Cookies from "js-cookie";
const Post = ({ theme, changeTheme }: ThemeProps) => { export type PostProps = {
const [post, setPost] = useState<any>() post: Post
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string>()
const router = useRouter();
useEffect(() => {
async function fetchPost() {
setIsLoading(true);
if (router.query.id) {
const post = await fetch(`/server-api/posts/${router.query.id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token")}`
}
})
if (post.ok) {
const res = await post.json()
if (res)
setPost(res)
else
setError("Post not found")
} else {
if (post.status.toString().startsWith("4")) {
router.push("/signin")
} else {
setError(post.statusText)
}
}
setIsLoading(false)
}
}
fetchPost()
}, [router, router.query.id])
const download = async () => {
const clientZip = require("client-zip")
const blob = await clientZip.downloadZip(post.files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip`
link.click()
link.remove()
}
return (
<Page width={"100%"}>
{!isLoading && (
<PageSeo
title={`${post.title} - Drift`}
description={post.description}
isPrivate={post.visibility === 'private'}
/>
)}
<Page.Header>
<Header theme={theme} changeTheme={changeTheme} />
</Page.Header>
<Page.Content width={"var(--main-content-width)"} margin="auto">
{error && <Text type="error">{error}</Text>}
{/* {!error && (isLoading || !post?.files) && <Loading />} */}
{!error && isLoading && <><Text h2><Skeleton width={400} /></Text>
<Document skeleton={true} />
</>}
{!isLoading && post && <>
<div className={styles.header}>
<Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
<Button auto onClick={download}>
Download as ZIP archive
</Button>
</div>
{post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => (
<Document
key={id}
id={id}
content={content}
title={title}
editable={false}
initialTab={'preview'}
/>
))}
</>}
</Page.Content>
</Page >
)
} }
export default Post const PostView = ({ post }: PostProps) => {
return <PostPage post={post} />
}
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await fetch(process.env.API_URL + `/posts/`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-secret-key": process.env.SECRET_KEY || "",
}
})
const json = await posts.json()
const filtered = json.filter((post: Post) => post.visibility === "public" || post.visibility === "unlisted")
const paths = filtered.map((post: Post) => ({
params: { id: post.id }
}))
return { paths, fallback: 'blocking' }
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-secret-key": process.env.SECRET_KEY || "",
}
})
return {
props: {
post: await post.json()
},
}
}
export default PostView

View file

@ -0,0 +1,58 @@
import cookie from "cookie";
import type { GetServerSideProps } from "next";
import { Post } from "@lib/types";
import PostPage from "@components/post-page";
export type PostProps = {
post: Post
}
const Post = ({ post, }: PostProps) => {
return (<PostPage post={post} />)
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const headers = context.req.headers
const host = headers.host
const driftToken = cookie.parse(headers.cookie || '')[`drift-token`]
if (context.query.id) {
const post = await fetch('http://' + host + `/server-api/posts/${context.query.id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || "",
}
})
if (!post.ok || post.status !== 200) {
return {
redirect: {
destination: '/',
permanent: false,
},
}
}
try {
const json = await post.json();
return {
props: {
post: json
}
}
} catch (e) {
console.log(e)
}
}
return {
props: {
post: null
}
}
}
export default Post

View file

@ -0,0 +1,80 @@
import { Page, useToasts } from '@geist-ui/core';
import type { Post } from "@lib/types";
import PasswordModal from "@components/new-post/password";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import Cookies from "js-cookie";
import PostPage from "@components/post-page";
const Post = () => {
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true);
const [post, setPost] = useState<Post>()
const router = useRouter()
const { setToast } = useToasts()
useEffect(() => {
if (router.isReady) {
const fetchPostWithAuth = async () => {
const resp = await fetch(`/server-api/posts/${router.query.id}`, {
headers: {
Authorization: `Bearer ${Cookies.get('drift-token')}`
}
})
if (!resp.ok) return
const post = await resp.json()
if (!post) return
setPost(post)
}
fetchPostWithAuth()
}
}, [router.isReady, router.query.id])
const onSubmit = async (password: string) => {
const res = await fetch(`/server-api/posts/${router.query.id}?password=${password}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
}
})
if (!res.ok) {
setToast({
type: "error",
text: "Wrong password"
})
return
}
const data = await res.json()
if (data) {
if (data.error) {
setToast({
text: data.error,
type: "error"
})
} else {
setPost(data)
setIsPasswordModalOpen(false)
}
}
}
const onClose = () => {
setIsPasswordModalOpen(false);
}
if (!router.isReady) {
return <></>
}
if (!post) {
return <Page><PasswordModal creating={false} onClose={onClose} onSubmit={onSubmit} isOpen={isPasswordModalOpen} /></Page>
}
return (<PostPage post={post} />)
}
export default Post

View file

@ -1,11 +0,0 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
@media screen and (max-width: 650px) {
.header {
flex-direction: column;
}
}

View file

@ -1,17 +1,16 @@
import { Page } from "@geist-ui/core"; import { Page } from '@geist-ui/core';
import PageSeo from "@components/page-seo"; import PageSeo from "@components/page-seo";
import Auth from "@components/auth"; import Auth from "@components/auth";
import Header from "@components/header"; import Header from "@components/header/header";
import { ThemeProps } from "./_app"; import styles from '@styles/Home.module.css'
const SignIn = () => (
const SignIn = ({ theme, changeTheme }: ThemeProps) => (
<Page width={"100%"}> <Page width={"100%"}>
<PageSeo title="Drift - Sign In" /> <PageSeo title="Drift - Sign In" />
<Page.Header> <Page.Header>
<Header theme={theme} changeTheme={changeTheme} /> <Header />
</Page.Header> </Page.Header>
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="auto"> <Page.Content className={styles.main}>
<Auth page="signin" /> <Auth page="signin" />
</Page.Content> </Page.Content>
</Page> </Page>

View file

@ -1,17 +1,17 @@
import { Page } from "@geist-ui/core"; import { Page } from '@geist-ui/core';
import Auth from "@components/auth"; import Auth from "@components/auth";
import Header from "@components/header"; import Header from "@components/header/header";
import PageSeo from '@components/page-seo'; import PageSeo from '@components/page-seo';
import { ThemeProps } from "./_app"; import styles from '@styles/Home.module.css'
const SignUp = ({ theme, changeTheme }: ThemeProps) => ( const SignUp = () => (
<Page width="100%"> <Page width="100%">
<PageSeo title="Drift - Sign Up" /> <PageSeo title="Drift - Sign Up" />
<Page.Header> <Page.Header>
<Header theme={theme} changeTheme={changeTheme} /> <Header />
</Page.Header> </Page.Header>
<Page.Content width={"var(--main-content-width)"} paddingTop={"var(--gap)"} margin="auto"> <Page.Content className={styles.main}>
<Auth page="signup" /> <Auth page="signup" />
</Page.Content> </Page.Content>
</Page> </Page>

View file

@ -0,0 +1,18 @@
{
"plugins": [
"postcss-flexbugs-fixes",
"postcss-hover-media-feature",
[
"postcss-preset-env",
{
"autoprefixer": {
"flexbox": "no-2009"
},
"stage": 3,
"features": {
"custom-properties": false
}
}
]
]
}

View file

@ -1,28 +1,11 @@
.main { .wrapper {
min-height: 100vh; height: 100% !important;
flex: 1; padding-bottom: var(--small-gap) !important;
display: flex;
flex-direction: column;
margin: 0 auto;
width: var(--main-content-width);
}
.container {
width: 100% !important; width: 100% !important;
} }
@media screen and (max-width: 768px) { .main {
.container { max-width: var(--main-content) !important;
width: 100%; margin: 0 auto !important;
margin: 0 auto !important; padding: 0 0 !important;
padding: 0;
}
.container h1 {
font-size: 2rem;
}
.main {
width: 100%;
}
} }

View file

@ -1,32 +1,154 @@
@import "./syntax.css";
@import "./markdown.css";
@import "./inter.css";
:root { :root {
--main-content-width: 800px; /* Spacing */
--page-nav-height: 60px; --gap-quarter: 0.25rem;
--gap: 8px; --gap-half: 0.5rem;
--gap-half: calc(var(--gap) / 2); --gap: 1rem;
--gap-double: calc(var(--gap) * 2); --gap-double: 2rem;
--border-radius: 4px; --small-gap: 4rem;
--font-size: 16px; --big-gap: 4rem;
--main-content: 55rem;
--radius: 8px;
--inline-radius: 5px;
/* Typography */
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--font-mono: ui-monospace, "SFMono-Regular", "Consolas", "Liberation Mono",
"Menlo", monospace;
/* Transitions */
--transition: 0.1s ease-in-out;
--transition-slow: 0.3s ease-in-out;
--page-nav-height: 64px;
--token: #999;
--comment: #999;
--keyword: #fff;
--name: #fff;
--highlight: #2e2e2e;
/* Dark Mode Colors */
--bg: #000;
--fg: #fafbfc;
--gray: #666;
--light-gray: #444;
--lighter-gray: #222;
--lightest-gray: #1a1a1a;
--darker-gray: #b4b4b4;
--darkest-gray: #efefef;
--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);
} }
@media screen and (max-width: 768px) { [data-theme="light"] {
:root { --token: #666;
--main-content-width: 100%; --comment: #999;
} --keyword: #000;
--name: #333;
--highlight: #eaeaea;
--bg: #fff;
--fg: #000;
--gray: #888;
--light-gray: #dedede;
--lighter-gray: #f5f5f5;
--lightest-gray: #fafafa;
--darker-gray: #555;
--darkest-gray: #222;
--article-color: #212121;
--header-bg: rgba(255, 255, 255, 0.8);
--gray-alpha: rgba(19, 20, 21, 0.5);
--selection: rgba(0, 0, 0, 0.99);
}
* {
box-sizing: border-box;
}
::selection {
text-shadow: none;
background: var(--selection);
} }
html, html,
body { body {
padding: 0; padding: 0;
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, font-size: 15px;
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
a { body {
color: inherit; min-height: 100vh;
text-decoration: none; font-family: var(--font-sans);
display: flex;
flex-direction: column;
} }
* { p {
box-sizing: border-box; overflow-wrap: break-word;
hyphens: auto;
}
input,
button,
textarea,
select {
font-size: 1rem;
}
blockquote {
font-style: italic;
margin: 0;
padding-left: 1rem;
border-left: 3px solid var(--light-gray);
}
a.reset {
outline: none;
text-decoration: none;
}
pre,
code {
font-family: var(--font-mono) !important;
}
@media print {
:root {
--bg: #fff;
--fg: #000;
--gray: #888;
--light-gray: #dedede;
--lighter-gray: #f5f5f5;
--lightest-gray: #fafafa;
--article-color: #212121;
--header-bg: rgba(255, 255, 255, 0.8);
--gray-alpha: rgba(19, 20, 21, 0.5);
--selection: rgba(0, 0, 0, 0.99);
--token: #666;
--comment: #999;
--keyword: #000;
--name: #333;
--highlight: #eaeaea;
}
* {
text-shadow: none !important;
}
}
#root,
#__next {
isolation: isolate;
} }

100
client/styles/inter.css Normal file
View file

@ -0,0 +1,100 @@
/* latin */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 200;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 300;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 700;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 800;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 900;
font-display: block;
src: url(https://assets.vercel.com/raw/upload/v1587415301/fonts/2/inter-var-latin.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}

130
client/styles/markdown.css Normal file
View file

@ -0,0 +1,130 @@
article {
max-width: var(--main-content);
margin: 0 auto;
line-height: 1.9;
}
article > * + * {
margin-top: 2em;
}
article img {
max-width: 100%;
margin: auto;
}
article [id]::before {
content: "";
display: block;
height: var(--gap-half);
margin-top: calc(var(--gap-half) * -1);
visibility: hidden;
}
/* Lists */
article ul {
padding: 0;
list-style-position: inside;
list-style-type: circle;
}
article ol {
padding: 0;
list-style-position: inside;
}
article ul li.reset {
display: flex;
align-items: flex-start;
list-style-type: none;
margin-left: -0.5rem;
}
article ul li.reset .check {
display: flex;
align-items: center;
margin-right: 0.51rem;
}
/* Checkbox */
input[type="checkbox"] {
vertical-align: middle;
appearance: none;
display: inline-block;
background-origin: border-box;
user-select: none;
flex-shrink: 0;
height: 1rem;
width: 1rem;
background-color: var(--bg);
color: var(--fg);
border: 1px solid var(--fg);
border-radius: 3px;
}
input[type="checkbox"]:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='black' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.707 7.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L7 8.586 5.707 7.293z'/%3e%3c/svg%3e");
border-color: transparent;
background-color: currentColor;
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat;
}
html[data-theme="light"] input[type="checkbox"]:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.707 7.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L7 8.586 5.707 7.293z'/%3e%3c/svg%3e");
}
input[type="checkbox"]:focus {
outline: none;
border-color: var(--fg);
}
/* Code Snippets */
.token-line:not(:last-child) {
min-height: 1.4rem;
}
article *:not(pre) > code {
font-weight: 500;
font-family: var(--font-sans);
}
article li > p {
display: inline-block;
padding: 0;
margin: 0;
}
article code > * {
font-family: var(--font-mono);
}
article pre {
overflow-x: auto;
border-radius: var(--inline-radius);
line-height: 1.8;
padding: 1rem;
font-size: 0.875rem;
}
/* Linkable Headers */
.header-link {
color: inherit;
text-decoration: none;
}
.header-link::after {
opacity: 0;
content: "#";
margin-left: var(--gap-half);
}
.header-link:hover::after {
opacity: 1;
}

24
client/styles/syntax.css Normal file
View file

@ -0,0 +1,24 @@
.keyword {
font-weight: bold;
color: var(--keyword);
}
.token.operator,
.token.punctuation,
.token.string,
.token.number,
.token.builtin,
.token.variable {
color: var(--token);
}
.token.comment {
color: var(--comment);
}
.token.class-name,
.token.function,
.token.tag,
.token.attr-name {
color: var(--name);
}

View file

@ -1,15 +1,26 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "plugins": [{ "name": "typescript-plugin-css-modules" }],
"target": "es2020",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",

File diff suppressed because it is too large Load diff

1
server/.gitignore vendored
View file

@ -1,3 +1,4 @@
.env .env
node_modules/ node_modules/
dist/ dist/
drift.sqlite

18
server/config/config.json Normal file
View file

@ -0,0 +1,18 @@
{
"production": {
"database": "../drift.sqlite",
"host": "127.0.0.1",
"port": "3306",
"user": "root",
"password": "root",
"dialect": "sqlite"
},
"development": {
"database": "../drift.sqlite",
"host": "127.0.0.1",
"port": "3306",
"user": "root",
"password": "root",
"dialect": "sqlite"
}
}

View file

@ -1,8 +0,0 @@
import {Sequelize} from 'sequelize-typescript';
export const sequelize = new Sequelize({
dialect: 'sqlite',
database: 'movies',
storage: ':memory:',
models: [__dirname + '/models']
});

View file

@ -0,0 +1,27 @@
const { DataTypes } = require("sequelize");
async function up(qi) {
try {
await qi.addColumn("Posts", "html", {
allowNull: true,
type: DataTypes.STRING,
});
} catch (e) {
console.error(e);
throw e;
}
}
async function down(qi) {
try {
await qi.removeColumn("Posts", "html");
} catch (e) {
console.error(e);
throw e;
}
}
module.exports = {
up,
down,
};

View file

@ -6,7 +6,8 @@
"scripts": { "scripts": {
"start": "ts-node index.ts", "start": "ts-node index.ts",
"dev": "nodemon index.ts", "dev": "nodemon index.ts",
"build": "tsc -p ." "build": "tsc -p .",
"migrate": "sequelize db:migrate"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
@ -19,7 +20,11 @@
"express": "^4.16.2", "express": "^4.16.2",
"express-jwt": "^6.1.1", "express-jwt": "^6.1.1",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"marked": "^4.0.12",
"nodemon": "^2.0.15", "nodemon": "^2.0.15",
"prism-react-renderer": "^1.3.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"reflect-metadata": "^0.1.10", "reflect-metadata": "^0.1.10",
"sequelize": "^6.17.0", "sequelize": "^6.17.0",
"sequelize-typescript": "^2.1.3", "sequelize-typescript": "^2.1.3",
@ -32,8 +37,11 @@
"@types/express": "^4.0.39", "@types/express": "^4.0.39",
"@types/express-jwt": "^6.0.4", "@types/express-jwt": "^6.0.4",
"@types/jsonwebtoken": "^8.5.8", "@types/jsonwebtoken": "^8.5.8",
"@types/marked": "^4.0.3",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/react-dom": "^17.0.14",
"ts-node": "^10.6.0", "ts-node": "^10.6.0",
"tsconfig-paths": "^3.14.1",
"tslint": "^6.1.3", "tslint": "^6.1.3",
"typescript": "^4.6.2" "typescript": "^4.6.2"
} }

View file

@ -2,7 +2,8 @@ import * as express from 'express';
import * as bodyParser from 'body-parser'; import * as bodyParser from 'body-parser';
import * as errorhandler from 'strong-error-handler'; import * as errorhandler from 'strong-error-handler';
import * as cors from 'cors'; import * as cors from 'cors';
import { posts, users, auth, files } from './routes'; import { posts, users, auth, files } from '@routes/index';
import { errors } from 'celebrate'
export const app = express(); export const app = express();
@ -19,7 +20,10 @@ app.use("/posts", posts)
app.use("/users", users) app.use("/users", users)
app.use("/files", files) app.use("/files", files)
app.use(errors());
app.use(errorhandler({ app.use(errorhandler({
debug: process.env.ENV !== 'production', debug: process.env.ENV !== 'production',
log: true, log: true,
})); }));

View file

@ -0,0 +1,14 @@
import { NextFunction, Request, Response } from 'express';
const key = process.env.SECRET_KEY;
if (!key) {
throw new Error('SECRET_KEY is not set.');
}
export default function authenticateToken(req: Request, res: Response, next: NextFunction) {
const requestKey = req.headers['x-secret-key']
if (requestKey !== key) {
return res.sendStatus(401)
}
next()
}

View file

@ -1,4 +1,4 @@
import { BelongsTo, Column, CreatedAt, DataType, ForeignKey, IsUUID, Model, PrimaryKey, Scopes, Table } from 'sequelize-typescript'; import { BelongsTo, Column, CreatedAt, DataType, ForeignKey, IsUUID, Model, PrimaryKey, Scopes, Table, Unique } from 'sequelize-typescript';
import { Post } from './Post'; import { Post } from './Post';
import { User } from './User'; import { User } from './User';
@ -20,6 +20,7 @@ import { User } from './User';
export class File extends Model { export class File extends Model {
@IsUUID(4) @IsUUID(4)
@PrimaryKey @PrimaryKey
@Unique
@Column({ @Column({
type: DataType.UUID, type: DataType.UUID,
defaultValue: DataType.UUIDV4, defaultValue: DataType.UUIDV4,
@ -35,6 +36,9 @@ export class File extends Model {
@Column @Column
sha!: string; sha!: string;
@Column
html!: string;
@ForeignKey(() => User) @ForeignKey(() => User)
@BelongsTo(() => User, 'userId') @BelongsTo(() => User, 'userId')
user!: User; user!: User;

View file

@ -1,4 +1,4 @@
import { BelongsToMany, Column, CreatedAt, DataType, HasMany, IsUUID, Model, PrimaryKey, Scopes, Table, UpdatedAt } from 'sequelize-typescript'; import { BelongsToMany, Column, CreatedAt, DataType, HasMany, IsUUID, Model, PrimaryKey, Scopes, Table, Unique, UpdatedAt } from 'sequelize-typescript';
import { PostAuthor } from './PostAuthor'; import { PostAuthor } from './PostAuthor';
import { User } from './User'; import { User } from './User';
import { File } from './File'; import { File } from './File';
@ -26,6 +26,7 @@ import { File } from './File';
export class Post extends Model { export class Post extends Model {
@IsUUID(4) @IsUUID(4)
@PrimaryKey @PrimaryKey
@Unique
@Column({ @Column({
type: DataType.UUID, type: DataType.UUID,
defaultValue: DataType.UUIDV4, defaultValue: DataType.UUIDV4,
@ -38,7 +39,7 @@ export class Post extends Model {
@BelongsToMany(() => User, () => PostAuthor) @BelongsToMany(() => User, () => PostAuthor)
users?: User[]; users?: User[];
@HasMany(() => File) @HasMany(() => File, { constraints: false })
files?: File[]; files?: File[];
@CreatedAt @CreatedAt
@ -48,6 +49,9 @@ export class Post extends Model {
@Column @Column
visibility!: string; visibility!: string;
@Column
password?: string;
@UpdatedAt @UpdatedAt
@Column @Column
updatedAt!: Date; updatedAt!: Date;

View file

@ -1,4 +1,4 @@
import { Model, Column, Table, ForeignKey, IsUUID, PrimaryKey, DataType } from "sequelize-typescript"; import { Model, Column, Table, ForeignKey, IsUUID, PrimaryKey, DataType, Unique } from "sequelize-typescript";
import { Post } from "./Post"; import { Post } from "./Post";
import { User } from "./User"; import { User } from "./User";
@ -6,6 +6,7 @@ import { User } from "./User";
export class PostAuthor extends Model { export class PostAuthor extends Model {
@IsUUID(4) @IsUUID(4)
@PrimaryKey @PrimaryKey
@Unique
@Column({ @Column({
type: DataType.UUID, type: DataType.UUID,
defaultValue: DataType.UUIDV4, defaultValue: DataType.UUIDV4,

View file

@ -1,4 +1,4 @@
import { Model, Column, Table, BelongsToMany, Scopes, CreatedAt, UpdatedAt, IsUUID, PrimaryKey, DataType } from "sequelize-typescript"; import { Model, Column, Table, BelongsToMany, Scopes, CreatedAt, UpdatedAt, IsUUID, PrimaryKey, DataType, Unique } from "sequelize-typescript";
import { Post } from "./Post"; import { Post } from "./Post";
import { PostAuthor } from "./PostAuthor"; import { PostAuthor } from "./PostAuthor";
@ -22,6 +22,7 @@ import { PostAuthor } from "./PostAuthor";
export class User extends Model { export class User extends Model {
@IsUUID(4) @IsUUID(4)
@PrimaryKey @PrimaryKey
@Unique
@Column({ @Column({
type: DataType.UUID, type: DataType.UUID,
defaultValue: DataType.UUIDV4, defaultValue: DataType.UUIDV4,

View file

@ -0,0 +1,152 @@
import { marked } from 'marked'
import Highlight, { defaultProps, Language, } from 'prism-react-renderer'
import { renderToStaticMarkup } from 'react-dom/server'
// // image sizes. DDoS Safe?
// const imageSizeLink = /^!?\[((?:\[[^\[\]]*\]|\\[\[\]]?|`[^`]*`|[^\[\]\\])*?)\]\(\s*(<(?:\\[<>]?|[^\s<>\\])*>|(?:\\[()]?|\([^\s\x00-\x1f()\\]*\)|[^\s\x00-\x1f()\\])*?(?:\s+=(?:[\w%]+)?x(?:[\w%]+)?)?)(?:\s+("(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)))?\s*\)/;
// //@ts-ignore
// Lexer.rules.inline.normal.link = imageSizeLink;
// //@ts-ignore
// Lexer.rules.inline.gfm.link = imageSizeLink;
// //@ts-ignore
// Lexer.rules.inline.breaks.link = imageSizeLink;
//@ts-ignore
delete defaultProps.theme
// import linkStyles from '../components/link/link.module.css'
const renderer = new marked.Renderer()
renderer.heading = (text, level, _, slugger) => {
const id = slugger.slug(text)
const Component = `h${level}`
return renderToStaticMarkup(
//@ts-ignore
<Component>
<a href={`#${id}`} id={id} style={{ color: "inherit" }} dangerouslySetInnerHTML={{ __html: (text) }} >
</a>
</Component>
)
}
// renderer.link = (href, _, text) => {
// const isHrefLocal = href?.startsWith('/') || href?.startsWith('#')
// if (isHrefLocal) {
// return renderToStaticMarkup(
// <a href={href || ''}>
// {text}
// </a>
// )
// }
// // dirty hack
// // if text contains elements, render as html
// return <a href={href || ""} target="_blank" rel="noopener noreferrer" dangerouslySetInnerHTML={{ __html: convertHtmlEntities(text) }} ></a>
// }
renderer.image = function (href, _, text) {
return `<Image loading="lazy" src="${href}" alt="${text}" layout="fill" />`
}
renderer.checkbox = () => ''
renderer.listitem = (text, task, checked) => {
if (task) {
return `<li class="reset"><span class="check">&#8203;<input type="checkbox" disabled ${checked ? 'checked' : ''
} /></span><span>${text}</span></li>`
}
return `<li>${text}</li>`
}
renderer.code = (code: string, language: string) => {
return renderToStaticMarkup(
<pre>
{/* {title && <code>{title} </code>} */}
{/* {language && title && <code style={{}}> {language} </code>} */}
<Code
language={language}
// title={title}
code={code}
// highlight={highlight}
/>
</pre>
)
}
marked.setOptions({
gfm: true,
breaks: true,
headerIds: true,
renderer,
})
const markdown = (markdown: string) => marked(markdown)
export default markdown
const Code = ({ code, language, highlight, title, ...props }: {
code: string,
language: string,
highlight?: string,
title?: string,
}) => {
if (!language)
return (
<>
<code {...props} dangerouslySetInnerHTML={{ __html: code }} />
</>
)
const highlightedLines = highlight
//@ts-ignore
? highlight.split(',').reduce((lines, h) => {
if (h.includes('-')) {
// Expand ranges like 3-5 into [3,4,5]
const [start, end] = h.split('-').map(Number)
const x = Array(end - start + 1)
.fill(undefined)
.map((_, i) => i + start)
return [...lines, ...x]
}
return [...lines, Number(h)]
}, [])
: ''
// https://mdxjs.com/guides/syntax-harkedighlighting#all-together
return (
<>
<Highlight {...defaultProps} code={code.trim()} language={language as Language} >
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<code className={className} style={{ ...style }}>
{
tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line, key: i })}
style={
//@ts-ignore
highlightedLines.includes((i + 1).toString())
? {
background: 'var(--highlight)',
margin: '0 -1rem',
padding: '0 1rem',
}
: undefined
}
>
{
line.map((token, key) => (
<span key={key} {...getTokenProps({ token, key })} />
))
}
</div>
))}
</code>
)}
</Highlight>
</>
)
}

View file

@ -0,0 +1,9 @@
import { Sequelize } from 'sequelize-typescript';
export const sequelize = new Sequelize({
dialect: 'sqlite',
database: 'drift',
storage: process.env.MEMORY_DB === "true" ? ":memory:" : __dirname + '/../../drift.sqlite',
models: [__dirname + '/models'],
host: 'localhost',
});

View file

@ -1,58 +1,41 @@
import { Router } from "express"; import { Router } from 'express'
import { genSalt, hash, compare } from "bcrypt"; import { genSalt, hash, compare } from "bcrypt"
import { User } from "../../lib/models/User"; import { User } from '@lib/models/User'
import { sign } from "jsonwebtoken"; import { sign } from 'jsonwebtoken'
import config from "../../lib/config"; import config from '@lib/config'
import jwt from "../../lib/middleware/jwt"; import jwt from '@lib/middleware/jwt'
import { celebrate, Joi } from "celebrate"; import { celebrate, Joi } from 'celebrate'
const NO_EMPTY_SPACE_REGEX = /^\S*$/; const NO_EMPTY_SPACE_REGEX = /^\S*$/
export const requiresServerPassword = export const requiresServerPassword = (process.env.MEMORY_DB || process.env.ENV === 'production') && !!process.env.REGISTRATION_PASSWORD
(process.env.MEMORY_DB || process.env.ENV === "production") && console.log(`Registration password required: ${requiresServerPassword}`)
!!process.env.REGISTRATION_PASSWORD;
console.log(`Registration password required: ${requiresServerPassword}`);
export const auth = Router(); export const auth = Router()
const validateAuthPayload = ( const validateAuthPayload = (username: string, password: string, serverPassword?: string): void => {
username: string,
password: string,
serverPassword?: string
): void => {
if (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6) { if (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6) {
throw new Error("Authentication data does not fulfill requirements"); throw new Error("Authentication data does not fulfill requirements")
} }
if (requiresServerPassword) { if (requiresServerPassword) {
if ( if (!serverPassword || process.env.REGISTRATION_PASSWORD !== serverPassword) {
!serverPassword || throw new Error("Server password is incorrect. Please contact the server administrator.")
process.env.REGISTRATION_PASSWORD !== serverPassword
) {
throw new Error(
"Server password is incorrect. Please contact the server administrator."
);
} }
} }
}; }
auth.post( auth.post('/signup',
"/signup",
celebrate({ celebrate({
body: { body: {
username: Joi.string().required(), username: Joi.string().required(),
password: Joi.string().required(), password: Joi.string().required(),
serverPassword: Joi.string(), serverPassword: Joi.string().required().allow('', null),
}, }
}), }),
async (req, res, next) => { async (req, res, next) => {
try { try {
validateAuthPayload( validateAuthPayload(req.body.username, req.body.password, req.body.serverPassword)
req.body.username,
req.body.password,
req.body.serverPassword
);
const username = req.body.username.toLowerCase(); const username = req.body.username.toLowerCase();
const existingUser = await User.findOne({ const existingUser = await User.findOne({
@ -85,6 +68,7 @@ auth.post(
body: { body: {
username: Joi.string().required(), username: Joi.string().required(),
password: Joi.string().required(), password: Joi.string().required(),
serverPassword: Joi.string().required().allow('', null),
}, },
}), }),
async (req, res, next) => { async (req, res, next) => {

View file

@ -1,12 +1,48 @@
import { celebrate, Joi } from "celebrate"; import { celebrate, Joi } from "celebrate";
import { Router } from "express"; import { Router } from "express";
// import { Movie } from '../models/Post' import { File } from "@lib/models/File";
import { File } from "../../lib/models/File"; import secretKey from "@lib/middleware/secret-key";
export const files = Router(); export const files = Router();
files.get( files.get("/raw/:id",
"/raw/:id", celebrate({
params: {
id: Joi.string().required(),
},
}),
secretKey,
async (req, res, next) => {
try {
const file = await File.findOne({
where: {
id: req.params.id
},
attributes: ["title", "content"],
})
if (!file) {
return res.status(404).json({ error: "File not found" })
}
// TODO: JWT-checkraw files
if (file?.post?.visibility === "private") {
// jwt(req as UserJwtRequest, res, () => {
// res.json(file);
// })
res.json(file);
} else {
res.json(file);
}
}
catch (e) {
next(e);
}
}
)
files.get("/html/:id",
celebrate({ celebrate({
params: { params: {
id: Joi.string().required(), id: Joi.string().required(),
@ -16,21 +52,21 @@ files.get(
try { try {
const file = await File.findOne({ const file = await File.findOne({
where: { where: {
id: req.params.id, id: req.params.id
}, },
attributes: ["title", "content"], attributes: ["html"],
}); })
// TODO: fix post inclusion
// if (file?.post.visibility === 'public' || file?.post.visibility === 'unlisted') { if (!file) {
res.setHeader("Cache-Control", "public, max-age=86400"); return res.status(404).json({ error: "File not found" })
res.json(file); }
// } else {
// TODO: should this be `private, `? res.setHeader('Content-Type', 'text/plain')
// res.setHeader("Cache-Control", "max-age=86400"); res.setHeader('Cache-Control', 'public, max-age=4800')
// res.json(file); res.status(200).write(file.html)
// } res.end()
} catch (e) { } catch (error) {
next(e); next(error)
} }
} }
); )

View file

@ -1,14 +1,23 @@
import { Router } from "express"; import { Router } from "express";
// import { Movie } from '../models/Post'
import { File } from "../../lib/models/File";
import { Post } from "../../lib/models/Post";
import jwt, { UserJwtRequest } from "../../lib/middleware/jwt";
import * as crypto from "crypto";
import { User } from "../../lib/models/User";
import { celebrate, Joi } from "celebrate"; import { celebrate, Joi } from "celebrate";
import { File } from '@lib/models/File'
import { Post } from '@lib/models/Post';
import jwt, { UserJwtRequest } from '@lib/middleware/jwt';
import * as crypto from "crypto";
import { User } from '@lib/models/User';
import secretKey from '@lib/middleware/secret-key';
import markdown from '@lib/render-markdown';
export const posts = Router(); export const posts = Router();
const postVisibilitySchema = (value: string) => {
if (value === 'public' || value === 'private') {
return value;
} else {
throw new Error('Invalid post visibility');
}
}
posts.post( posts.post(
"/create", "/create",
jwt, jwt,
@ -16,46 +25,45 @@ posts.post(
body: { body: {
title: Joi.string().required(), title: Joi.string().required(),
files: Joi.any().required(), files: Joi.any().required(),
visibility: Joi.string().required(), visibility: Joi.string().custom(postVisibilitySchema, 'valid visibility').required(),
userId: Joi.string().required(), userId: Joi.string().required(),
password: Joi.string().optional(),
}, },
}), }),
async (req, res, next) => { async (req, res, next) => {
try { try {
// Create the "post" object let hashedPassword: string = ''
if (req.body.visibility === 'protected') {
hashedPassword = crypto.createHash('sha256').update(req.body.password).digest('hex');
}
const newPost = new Post({ const newPost = new Post({
title: req.body.title, title: req.body.title,
visibility: req.body.visibility, visibility: req.body.visibility,
}); password: hashedPassword,
})
await newPost.save(); await newPost.save()
await newPost.$add("users", req.body.userId); await newPost.$add('users', req.body.userId);
const newFiles = await Promise.all( const newFiles = await Promise.all(req.body.files.map(async (file) => {
req.body.files.map(async (file) => { const html = getHtmlFromFile(file);
// Establish a "file" for each file in the request const newFile = new File({
const newFile = new File({ title: file.title,
title: file.title, content: file.content,
content: file.content, sha: crypto.createHash('sha256').update(file.content).digest('hex').toString(),
sha: crypto html
.createHash("sha256")
.update(file.content)
.digest("hex")
.toString(),
});
await newFile.$set("user", req.body.userId);
await newFile.$set("post", newPost.id);
await newFile.save();
return newFile;
}) })
);
await Promise.all( await newFile.$set("user", req.body.userId);
newFiles.map((file) => { await newFile.$set("post", newPost.id);
newPost.$add("files", file.id); await newFile.save();
newPost.save(); return newFile;
}) }))
);
await Promise.all(newFiles.map((file) => {
newPost.$add("files", file.id);
newPost.save();
}))
res.json(newPost); res.json(newPost);
} catch (e) { } catch (e) {
@ -64,6 +72,47 @@ posts.post(
} }
); );
posts.get("/", secretKey, async (req, res, next) => {
try {
const posts = await Post.findAll({
attributes: ["id", "title", "visibility", "createdAt"],
})
res.json(posts);
} catch (e) {
next(e);
}
});
posts.get("/mine", jwt, secretKey, async (req: UserJwtRequest, res, next) => {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" })
}
try {
const user = await User.findByPk(req.user.id, {
include: [
{
model: Post,
as: "posts",
include: [
{
model: File,
as: "files"
}
]
},
],
})
if (!user) {
return res.status(404).json({ error: "User not found" })
}
return res.json(user.posts?.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()))
} catch (error) {
next(error)
}
})
posts.get( posts.get(
"/:id", "/:id",
celebrate({ celebrate({
@ -98,18 +147,63 @@ posts.get(
], ],
}); });
if (post?.visibility === "public" || post?.visibility === "unlisted") { if (!post) {
res.setHeader("Cache-Control", "public, max-age=86400"); return res.status(404).json({ error: "Post not found" })
res.json(post);
} else {
// TODO: should this be `private, `?
res.setHeader("Cache-Control", "max-age=86400");
jwt(req, res, () => {
res.json(post);
});
} }
} catch (e) {
// if public or unlisted, cache
if (post.visibility === 'public' || post.visibility === 'unlisted') {
res.set('Cache-Control', 'public, max-age=4800')
}
if (post.visibility === 'public' || post?.visibility === 'unlisted') {
secretKey(req, res, () => {
res.json(post);
})
} else if (post.visibility === 'private') {
jwt(req as UserJwtRequest, res, () => {
res.json(post);
})
} else if (post.visibility === 'protected') {
const { password } = req.query
if (!password || typeof password !== 'string') {
return jwt(req as UserJwtRequest, res, () => {
res.json(post);
})
}
const hash = crypto.createHash('sha256').update(password).digest('hex').toString()
if (hash !== post.password) {
return res.status(400).json({ error: "Incorrect password." })
}
res.json(post);
}
}
catch (e) {
next(e); next(e);
} }
} }
); );
function getHtmlFromFile(file: any) {
const renderAsMarkdown = ['markdown', 'md', 'mdown', 'mkdn', 'mkd', 'mdwn', 'mdtxt', 'mdtext', 'text', ''];
const fileType = () => {
const pathParts = file.title.split(".");
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : "";
return language;
};
const type = fileType();
let contentToRender: string = (file.content || '');
if (!renderAsMarkdown.includes(type)) {
contentToRender =
`~~~${type}
${file.content}
~~~`;
} else {
contentToRender = '\n' + file.content;
}
const html = markdown(contentToRender);
return html;
}

View file

@ -1,47 +1,14 @@
import { Router } from "express"; import { Router } from "express";
import { User } from "../../lib/models/User"; // import jwt from "@lib/middleware/jwt";
import { File } from "../../lib/models/File"; // import { User } from "@lib/models/User";
import jwt, { UserJwtRequest } from "../../lib/middleware/jwt";
import { Post } from "../../lib/models/Post";
export const users = Router(); export const users = Router();
users.get("/", jwt, async (req, res, next) => { // users.get("/", jwt, async (req, res, next) => {
try { // try {
const allUsers = await User.findAll(); // const allUsers = await User.findAll();
res.json(allUsers); // res.json(allUsers);
} catch (error) { // } catch (error) {
next(error); // next(error);
} // }
}); // });
users.get("/mine", jwt, async (req: UserJwtRequest, res, next) => {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
try {
const user = await User.findByPk(req.user.id, {
include: [
{
model: Post,
as: "posts",
include: [
{
model: File,
as: "files",
},
],
},
],
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
return res.json(
user.posts?.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
);
} catch (error) {
next(error);
}
});

View file

@ -1,11 +1,10 @@
import { createServer } from 'http'; import { createServer } from 'http';
import { app } from './app'; import { app } from './app';
import config from '../lib/config'; import config from './lib/config';
import { sequelize } from '../lib/sequelize'; import { sequelize } from './lib/sequelize';
(async () => { (async () => {
await sequelize.sync({ force: true }); await sequelize.sync({});
createServer(app) createServer(app)
.listen( .listen(
config.port, config.port,

View file

@ -4,6 +4,7 @@
"target": "es6", "target": "es6",
"module": "commonjs", "module": "commonjs",
"moduleResolution": "node", "moduleResolution": "node",
"jsx": "react-jsxdev",
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"noUnusedLocals": true, "noUnusedLocals": true,
@ -13,8 +14,17 @@
"strictNullChecks": true, "strictNullChecks": true,
"skipLibCheck": true, "skipLibCheck": true,
"strictPropertyInitialization": true, "strictPropertyInitialization": true,
"outDir": "dist" "outDir": "dist",
"baseUrl": ".",
"paths": {
"@routes/*": ["./src/routes/*"],
"@lib/*": ["./src/lib/*"]
}
}, },
"include": ["lib/**/*.ts", "index.ts", "src/**/*.ts"], "ts-node": {
// Do not forget to `npm i -D tsconfig-paths`
"require": ["tsconfig-paths/register"]
},
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View file

@ -179,6 +179,11 @@
"@types/qs" "*" "@types/qs" "*"
"@types/serve-static" "*" "@types/serve-static" "*"
"@types/json5@^0.0.29":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
"@types/jsonwebtoken@^8.5.8": "@types/jsonwebtoken@^8.5.8":
version "8.5.8" version "8.5.8"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.8.tgz#01b39711eb844777b7af1d1f2b4cf22fda1c0c44" resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.8.tgz#01b39711eb844777b7af1d1f2b4cf22fda1c0c44"
@ -186,6 +191,11 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/marked@^4.0.3":
version "4.0.3"
resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.0.3.tgz#2098f4a77adaba9ce881c9e0b6baf29116e5acc4"
integrity sha512-HnMWQkLJEf/PnxZIfbm0yGJRRZYYMhb++O9M36UCTA9z53uPvVoSlAwJr3XOpDEryb7Hwl1qAx/MV6YIW1RXxg==
"@types/mime@^1": "@types/mime@^1":
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
@ -201,6 +211,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.21.tgz#864b987c0c68d07b4345845c3e63b75edd143644"
integrity sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ== integrity sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==
"@types/prop-types@*":
version "15.7.4"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11"
integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==
"@types/qs@*": "@types/qs@*":
version "6.9.7" version "6.9.7"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
@ -211,6 +226,27 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-dom@^17.0.14":
version "17.0.14"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f"
integrity sha512-H03xwEP1oXmSfl3iobtmQ/2dHF5aBHr8aUMwyGZya6OW45G+xtdzmq6HkncefiBt5JU8DVyaWl/nWZbjZCnzAQ==
dependencies:
"@types/react" "*"
"@types/react@*":
version "17.0.41"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.41.tgz#6e179590d276394de1e357b3f89d05d7d3da8b85"
integrity sha512-chYZ9ogWUodyC7VUTRBfblysKLjnohhFY9bGLwvnUFFy48+vB9DikmB3lW0qTFmBcKSzmdglcvkHK71IioOlDA==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.16.2"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
"@types/serve-static@*": "@types/serve-static@*":
version "1.13.10" version "1.13.10"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
@ -709,6 +745,11 @@ crypto-random-string@^2.0.0:
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
csstype@^3.0.2:
version "3.0.11"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.11.tgz#d66700c5eacfac1940deb4e3ee5642792d85cd33"
integrity sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==
dashdash@^1.12.0: dashdash@^1.12.0:
version "1.14.1" version "1.14.1"
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
@ -1426,7 +1467,7 @@ joi@17.x.x:
"@sideway/formula" "^3.0.0" "@sideway/formula" "^3.0.0"
"@sideway/pinpoint" "^2.0.0" "@sideway/pinpoint" "^2.0.0"
js-tokens@^4.0.0: "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
@ -1471,6 +1512,13 @@ json-stringify-safe@~5.0.1:
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
json5@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
dependencies:
minimist "^1.2.0"
jsonwebtoken@^8.1.0, jsonwebtoken@^8.5.1: jsonwebtoken@^8.1.0, jsonwebtoken@^8.5.1:
version "8.5.1" version "8.5.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
@ -1575,6 +1623,13 @@ lodash@4.17.x, lodash@^4.17.20, lodash@^4.17.21:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
loose-envify@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
@ -1611,6 +1666,11 @@ map-age-cleaner@^0.1.3:
dependencies: dependencies:
p-defer "^1.0.0" p-defer "^1.0.0"
marked@^4.0.12:
version "4.0.12"
resolved "https://registry.yarnpkg.com/marked/-/marked-4.0.12.tgz#2262a4e6fd1afd2f13557726238b69a48b982f7d"
integrity sha512-hgibXWrEDNBWgGiK18j/4lkS6ihTe9sxtV4Q1OQppb/0zzyPSzoFANBa5MfsG/zgsWklmNnhm0XACZOH/0HBiQ==
md5@^2.3.0: md5@^2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f"
@ -1688,6 +1748,11 @@ minimist@^1.2.0, minimist@^1.2.5:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
minimist@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
minipass@^3.0.0: minipass@^3.0.0:
version "3.1.6" version "3.1.6"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.6.tgz#3b8150aa688a711a1521af5e8779c1d3bb4f45ee" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.6.tgz#3b8150aa688a711a1521af5e8779c1d3bb4f45ee"
@ -1962,6 +2027,11 @@ prepend-http@^2.0.0:
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
prism-react-renderer@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-1.3.1.tgz#88fc9d0df6bed06ca2b9097421349f8c2f24e30d"
integrity sha512-xUeDMEz074d0zc5y6rxiMp/dlC7C+5IDDlaEUlcBOFE2wddz7hz5PNupb087mPwTt7T9BrFmewObfCBuf/LKwQ==
process-nextick-args@~2.0.0: process-nextick-args@~2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@ -2040,6 +2110,23 @@ rc@^1.2.8:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
react-dom@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler "^0.20.2"
react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.6: readable-stream@^2.0.6:
version "2.3.7" version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
@ -2157,6 +2244,14 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
scheduler@^0.20.2:
version "0.20.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
semver-diff@^3.1.1: semver-diff@^3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b"
@ -2356,6 +2451,11 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1:
dependencies: dependencies:
ansi-regex "^5.0.1" ansi-regex "^5.0.1"
strip-bom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
strip-final-newline@^2.0.0: strip-final-newline@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
@ -2486,6 +2586,16 @@ ts-node@^10.6.0:
v8-compile-cache-lib "^3.0.0" v8-compile-cache-lib "^3.0.0"
yn "3.1.1" yn "3.1.1"
tsconfig-paths@^3.14.1:
version "3.14.1"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a"
integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==
dependencies:
"@types/json5" "^0.0.29"
json5 "^1.0.1"
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@^1.13.0, tslib@^1.8.1: tslib@^1.13.0, tslib@^1.8.1:
version "1.14.1" version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"