Merge with main

This commit is contained in:
Max Leiter 2022-03-24 14:53:57 -07:00
commit da8e7415dc
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
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
**Note: Drift is not yet ready for production usage and should not be used seriously until the database has been setup, which I'll get to when the server API is semi stable.**
**Note: Drift is not yet ready for production usage and should not be used too seriously. I'll make every effort to not lose data, but I won't make any guarantees until the project is further along.**
`yarn build` in both `client/` and `server/` will produce production code for the client and server respectively. The client and server each also have Dockerfiles which you can use with a docker-compose (an example compose will be provided in the near future).
If you're deploying the front-end to something like Vercel, you'll need to set the root folder to `client/`.
### Environment Variables
You can change these to your liking.
`client/.env`:
- `API_URL`: defaults to localhost:3001, but allows you to host the front-end separately from the backend on a service like Vercel or Netlify
- `WELCOME_CONTENT`: a markdown string (with \n newlines) that's rendered on the home page
- `WELCOME_TITLE`: the file title for the post on the homepage.
- `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser
`server/.env`:
- `PORT`: the default port to start the server on (3000 by default)
- `ENV`: can be `production` or `debug`, toggles logging
- `JWT_SECRET`: a secure token for JWT tokens. You can generate one [here](https://www.grc.com/passwords.htm).
- `MEMORY_DB`: if `true`, a sqlite database will not be created and changes will only exist in memory. Mainly for the demo.
- `REGISTRATION_PASSWORD`: if MEMORY_DB is not `true`, the user will be required to provide this password to sign-up, in addition to their username and account password. If it's not set, no password will be required.
- `SECRET_KEY`: the same secret key as the client
## Current status
Drift is a major work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist.
@ -34,7 +54,7 @@ Drift is a major work in progress. Below is a (rough) list of completed and envi
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
- [x] downloading files (individually and entire posts)
- [ ] password protected posts
- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development)
- [x] sqlite database
- [ ] non-node backend
- [ ] administrator account / settings
- [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13))

View file

@ -1,3 +1,4 @@
API_URL=http://localhost:3000
WELCOME_TITLE="Welcome to Drift"
WELCOME_CONTENT="### Drift is a self-hostable clone of GitHub Gist. \nIt is a simple way to share code and text snippets with your friends, with support for the following:\n \n - Render GitHub Extended Markdown (including images)\n - User authentication\n - Private, public, and secret posts\n \n If you want to signup, you can join at [/signup](/signup) as long as you have a passcode provided by the administrator (which you don\'t need for this demo).\n **This demo is on a memory-only database, so accounts and pastes can be deleted at any time.**\n You can find the source code on [GitHub](https://github.com/MaxLeiter/drift).\n \n Drift was inspired by [this tweet](https://twitter.com/emilyst/status/1499858264346935297):\n > What is the absolute closest thing to GitHub Gist that can be self-hosted?\n In terms of design and functionality. Hosts images and markdown, rendered. Creates links that can be private or public. Uses/requires registration. I have looked at dozens of pastebin-like things."
SECRET_KEY=secret

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";
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 styles from './auth.module.css'
import { useRouter } from 'next/router'
import Link from '../Link'
import Cookies from "js-cookie";
import useSignedIn from '@lib/hooks/use-signed-in'
const NO_EMPTY_SPACE_REGEX = /^\S*$/;
const ERROR_MESSAGE = "Provide a non empty username and a password with at least 6 characters";
@ -13,21 +14,40 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [serverPassword, setServerPassword] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const [requiresServerPassword, setRequiresServerPassword] = useState(false);
const signingIn = page === 'signin'
const { signin } = useSignedIn();
useEffect(() => {
async function fetchRequiresPass() {
if (!signingIn) {
const resp = await fetch("/server-api/auth/requires-passcode", {
method: "GET",
})
if (resp.ok) {
const res = await resp.json()
setRequiresServerPassword(res.requiresPasscode)
} else {
setErrorMsg("Something went wrong.")
}
}
}
fetchRequiresPass()
}, [page, signingIn])
const handleJson = (json: any) => {
Cookies.set('drift-token', json.token);
signin(json.token)
Cookies.set('drift-userid', json.userId);
router.push('/')
}
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (page === "signup" && (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)) return setErrorMsg(ERROR_MESSAGE)
if (!signingIn && (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)) return setErrorMsg(ERROR_MESSAGE)
if (!signingIn && requiresServerPassword && !NO_EMPTY_SPACE_REGEX.test(serverPassword)) return setErrorMsg(ERROR_MESSAGE)
else setErrorMsg('');
const reqOpts = {
@ -35,19 +55,17 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
body: JSON.stringify({ username, password, serverPassword })
}
try {
const signUrl = signingIn ? '/server-api/auth/signin' : '/server-api/auth/signup';
const resp = await fetch(signUrl, reqOpts);
const json = await resp.json();
console.log(json)
if (!resp.ok) throw new Error(json.error.message);
handleJson(json)
} catch (err: any) {
console.log(err)
setErrorMsg(err.message ?? "Something went wrong")
}
}
@ -78,6 +96,16 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
required
scale={4 / 3}
/>
{requiresServerPassword && <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>
</div>
<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, Italic, Link, Image as ImageIcon } from '@geist-ui/icons'
import Bold from '@geist-ui/icons/bold'
import Italic from '@geist-ui/icons/italic'
import Link from '@geist-ui/icons/link'
import ImageIcon from '@geist-ui/icons/image'
import { RefObject, useCallback, useMemo } from "react"
import styles from '../document.module.css'
import { Button, ButtonGroup } from "@geist-ui/core"
// TODO: clean up
@ -20,7 +23,7 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
// return { textBefore: '', textAfter: '' }
// }, [textareaRef,])
const handleBoldClick = useCallback((e) => {
const handleBoldClick = useCallback(() => {
if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd
@ -31,13 +34,10 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
const newText = `${before}**${selectedText}**${after}`
setText(newText)
// TODO; fails because settext async
textareaRef.current.setSelectionRange(before.length + 2, before.length + 2 + selectedText.length)
}
}, [setText, textareaRef])
const handleItalicClick = useCallback((e) => {
const handleItalicClick = useCallback(() => {
if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd
@ -47,12 +47,10 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
const selectedText = text.substring(selectionStart, selectionEnd)
const newText = `${before}*${selectedText}*${after}`
setText(newText)
textareaRef.current.focus()
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
}
}, [setText, textareaRef])
const handleLinkClick = useCallback((e) => {
const handleLinkClick = useCallback(() => {
if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd
@ -68,12 +66,10 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
}
const newText = `${before}${formattedText}${after}`
setText(newText)
textareaRef.current.focus()
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
}
}, [setText, textareaRef])
const handleImageClick = useCallback((e) => {
const handleImageClick = useCallback(() => {
if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd
@ -89,8 +85,6 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
}
const newText = `${before}${formattedText}${after}`
setText(newText)
textareaRef.current.focus()
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
}
}, [setText, textareaRef])
@ -134,4 +128,4 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
}
export default FormattingIcons
export default FormattingIcons

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 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 styles from './header.module.css'
import { Select } from '@geist-ui/core'
import { useTheme } from 'next-themes'
const Controls = ({ changeTheme, theme }: ThemeProps) => {
const switchThemes = (type: string | string[]) => {
changeTheme()
if (typeof window === 'undefined' || !window.localStorage) return
window.localStorage.setItem('drift-theme', Array.isArray(type) ? type[0] : type)
const Controls = () => {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => setMounted(true), [])
if (!mounted) return null
const switchThemes = () => {
if (theme === 'dark') {
setTheme('light')
} else {
setTheme('dark')
}
}
return (
<div className={styles.wrapper}>
<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 { 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'
import dynamic from 'next/dynamic'
type Tab = {
name: string
icon: JSX.Element
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 >
)
}
const Header = dynamic(import('./header'), {
ssr: false,
// loading: () => <MenuSkeleton />,
})
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 Cookies from "js-cookie"
const fetcher = (url: string) => fetch(url, {
headers: {
'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} />
const MyPosts = ({ posts, error }: { posts: any, error: any }) => {
return <PostList posts={posts} error={error} />
}
export default MyPosts

View file

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

View file

@ -1,10 +1,9 @@
import { Button, Text, useTheme, useToasts } from '@geist-ui/core'
import { useCallback, useEffect } from 'react'
import { Text, useTheme, useToasts } from '@geist-ui/core'
import { memo } from 'react'
import { useDropzone } from 'react-dropzone'
import styles from './drag-and-drop.module.css'
import { Document } from '../'
import type { Document } from '@lib/types'
import generateUUID from '@lib/generate-uuid'
import { XCircle } from '@geist-ui/icons'
const allowedFileTypes = [
'application/json',
'application/x-javascript',
@ -92,14 +91,16 @@ const allowedFileExtensions = [
'sql',
'xml',
'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 { setToast } = useToasts()
const onDrop = useCallback(async (acceptedFiles) => {
const newDocs = await Promise.all(acceptedFiles.map((file: File) => {
return new Promise((resolve, reject) => {
const onDrop = async (acceptedFiles: File[]) => {
const newDocs = await Promise.all(acceptedFiles.map((file) => {
return new Promise<Document>((resolve) => {
const reader = new FileReader()
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) {
if (docs[0].content === '') {
setDocs(newDocs)
return
}
}
setDocs((oldDocs) => [...oldDocs, ...newDocs])
}, [setDocs, setToast, docs])
setDocs(newDocs)
}
const validator = (file: File) => {
// TODO: make this configurable
@ -170,11 +164,11 @@ function FileDropzone({ setDocs, docs }: { setDocs: React.Dispatch<React.SetStat
</div>
{fileRejections.length > 0 && <ul className={styles.error}>
{/* <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}
</ul>}
</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 { useCallback, useState } from 'react'
import generateUUID from '@lib/generate-uuid';
import Document from '../document';
import FileDropzone from './drag-and-drop';
import styles from './post.module.css'
import Title from './title';
import Cookies from 'js-cookie'
export type Document = {
title: string
content: string
id: string
}
import type { PostVisibility, Document as DocumentType } from '@lib/types';
import PasswordModal from './password';
import getPostPath from '@lib/get-post-path';
import EditDocumentList from '@components/edit-document-list';
import { ChangeEvent } from 'react';
const Post = () => {
const { setToast } = useToasts()
const router = useRouter();
const [title, setTitle] = useState<string>()
const [docs, setDocs] = useState<Document[]>([{
const [docs, setDocs] = useState<DocumentType[]>([{
title: '',
content: '',
id: generateUUID()
}])
const [isSubmitting, setSubmitting] = useState(false)
const remove = (id: string) => {
setDocs(docs.filter((doc) => doc.id !== id))
}
const onSubmit = async (visibility: string) => {
setSubmitting(true)
const response = await fetch('/server-api/posts/create', {
method: 'POST',
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const sendRequest = useCallback(async (url: string, data: { visibility?: PostVisibility, title?: string, files?: DocumentType[], password?: string, userId: string }) => {
const res = await fetch(url, {
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${Cookies.get("drift-token")}`
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get('drift-token')}`
},
body: JSON.stringify({
title,
files: docs,
visibility,
userId: Cookies.get("drift-userid"),
...data,
})
})
const json = await response.json()
setSubmitting(false)
if (json.id)
router.push(`/post/${json.id}`)
else {
setToast({ text: json.error.message, type: "error" })
if (res.ok) {
const json = await res.json()
router.push(getPostPath(json.visibility, json.id))
} else {
const json = await res.json()
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) => {
setDocs(docs.map((doc) => doc.id === id ? { ...doc, title } : doc))
}, [docs])
const onClosePasswordModal = () => {
setPasswordModalVisible(false)
setSubmitting(false)
}
const updateContent = useCallback((content: string, id: string) => {
setDocs(docs.map((doc) => doc.id === id ? { ...doc, content } : doc))
}, [docs])
const onChangeTitle = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
}, [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 (
<div>
<Title title={title} setTitle={setTitle} />
<FileDropzone docs={docs} setDocs={setDocs} />
{
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 style={{ marginBottom: 150 }}>
<Title title={title} onChange={onChangeTitle} />
<FileDropzone setDocs={uploadDocs} />
<EditDocumentList onPaste={onPaste} docs={docs} updateDocTitle={updateDocTitle} updateDocContent={updateDocContent} removeDoc={removeDoc} />
<div className={styles.buttons}>
<Button
className={styles.button}
@ -104,10 +154,12 @@ const Post = () => {
<ButtonDropdown.Item main onClick={() => onSubmit('private')}>Create Private</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('public')} >Create Public</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('unlisted')} >Create Unlisted</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('protected')} >Create with Password</ButtonDropdown.Item>
</ButtonDropdown>
<PasswordModal isOpen={passwordModalVisible} onClose={onClosePasswordModal} onSubmit={(password) => onSubmit('protected', password)} />
</div>
</div >
</div>
)
}
export default Post
export default Post

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 { memo } from 'react'
import { ChangeEvent, memo, useEffect, useState } from 'react'
import { Text } from '@geist-ui/core'
import ShiftBy from '@components/shift-by'
import styles from '../post.module.css'
import { Input } from '@geist-ui/core'
const titlePlaceholders = [
"How to...",
@ -14,18 +16,23 @@ const titlePlaceholders = [
]
type props = {
setTitle: (title: string) => void
onChange: (e: ChangeEvent<HTMLInputElement>) => void
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}>
<Text h1 width={"150px"} className={styles.drift}>Drift</Text>
<ShiftBy y={-3}>
<Input
placeholder={titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)]}
placeholder={placeholder}
value={title || ""}
onChange={(event) => setTitle(event.target.value)}
onChange={onChange}
height={"55px"}
font={1.5}
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 { Card, Divider, Grid, Spacer } from "@geist-ui/core";
const ListItemSkeleton = () => (<Card>
<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 { useEffect, useMemo, useState } from "react"
import timeAgo from "@lib/time-ago"
import ShiftBy from "../shift-by"
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
value={title}
marginTop="var(--gap-double)"
marginTop="var(--gap)"
size={1.2}
font={1.2}
label="Filename"
@ -30,22 +32,22 @@ const ListItem = ({ post }: { post: any }) => {
return (<li key={post.id}>
<Card style={{ overflowY: 'scroll' }}>
<Spacer height={1 / 2} />
<Grid.Container justify={'space-between'}>
<Grid xs={8}>
<Text h3 paddingLeft={1 / 2}>
<NextLink passHref={true} href={`/post/${post.id}`}>
<Grid.Container>
<Grid md={14} xs={14}>
<Text h3 paddingLeft={1 / 2} >
<NextLink passHref={true} href={getPostPath(post.visibility, post.id)}>
<Link color>{post.title}
<ShiftBy y={-1}><VisibilityBadge visibility={post.visibility} /></ShiftBy>
</Link>
</NextLink>
</Text></Grid>
<Grid xs={7}><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={9}><Text type="secondary" h5><Tooltip text={formattedTime}>{time}</Tooltip></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>
<Divider h="1px" my={0} />
<Card.Content >
<Card.Content>
{post.files.map((file: any) => {
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 ReactMarkdownPreview from "./react-markdown-preview"
import styles from './preview.module.css'
type Props = {
content?: string
height?: number | string
fileId?: string
content?: string
title?: string
// file extensions we can highlight
type?: string
}
const MarkdownPreview = ({ content = '', height = 500, type = 'markdown' }: Props) => {
const [contentToRender, setContent] = useState(content)
const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
const [preview, setPreview] = useState<string>(content || "")
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
// 'm' so it doesn't flash code when you change the type to md
const renderAsMarkdown = ['m', 'markdown', 'md', 'mdown', 'mkdn', 'mkd', 'mdwn', 'mdtxt', 'mdtext', 'text', '']
if (!renderAsMarkdown.includes(type)) {
setContent(`~~~${type}
${content}
~~~
`)
} else {
setContent(content)
async function fetchPost() {
if (fileId) {
const resp = await fetch(`/server-api/files/html/${fileId}`, {
method: "GET",
})
if (resp.ok) {
const res = await resp.text()
setPreview(res)
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])
return (<ReactMarkdownPreview height={height} content={contentToRender} />)
fetchPost()
}, [content, fileId, title])
return (<>
{isLoading ? <div>Loading...</div> : <article className={styles.markdownPreview} dangerouslySetInnerHTML={{ __html: preview }} style={{
height
}} />}
</>)
}
export default memo(MarkdownPreview)

View file

@ -1,12 +1,12 @@
.markdownPreview pre {
border-radius: 3px;
font-family: "Courier New", Courier, monospace;
font-size: 14px;
line-height: 1.42857143;
margin: 0;
padding: 10px;
white-space: pre-wrap;
word-wrap: break-word;
border-radius: 3px;
font-family: "Courier New", Courier, monospace;
font-size: 14px;
line-height: 1.42857143;
margin: 0;
padding: 10px;
white-space: pre-wrap;
word-wrap: break-word;
}
.markdownPreview h1,
@ -15,8 +15,27 @@
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
margin-top: 0;
margin-bottom: 0.5rem;
margin-top: var(--gap);
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 */
@ -26,7 +45,7 @@
.markdownPreview h4 a,
.markdownPreview h5 a,
.markdownPreview h6 a {
color: inherit;
color: inherit;
}
/* Auto-linked headers */
@ -36,63 +55,58 @@
.markdownPreview h4 a:hover::after,
.markdownPreview h5 a:hover::after,
.markdownPreview h6 a:hover::after {
content: "🔗";
filter: grayscale(100%);
font-size: 0.7em;
margin-left: 0.25em;
font-weight: normal;
content: "#";
font-size: 0.7em;
margin-left: 0.25em;
font-weight: normal;
filter: opacity(0.5);
}
.markdownPreview h1 {
font-size: 2rem;
font-size: 2rem;
}
.markdownPreview h2 {
font-size: 1.5rem;
font-size: 1.5rem;
}
.markdownPreview h3 {
font-size: 1.25rem;
font-size: 1.25rem;
}
.markdownPreview h4 {
font-size: 1rem;
font-size: 1rem;
}
.markdownPreview h5 {
font-size: 0.875rem;
font-size: 1rem;
}
.markdownPreview h6 {
font-size: 0.75rem;
font-size: 0.875rem;
}
.markdownPreview ul {
list-style: inside;
list-style: inside;
}
.markdownPreview ul li::before {
content: "";
}
.markdownPreview ul ul {
list-style: circle;
}
.markdownPreview ul ul li {
margin-left: var(--gap);
content: "";
}
.markdownPreview code {
border-radius: 3px;
white-space: pre-wrap;
word-wrap: break-word;
color: initial;
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
white-space: pre-wrap;
word-wrap: break-word;
color: inherit !important;
}
.markdownPreview code::before,
.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 MarkdownPreview from '../preview'
import { Trash, Download, ExternalLink } from '@geist-ui/icons'
import FormattingIcons from "./formatting-icons"
import Download from '@geist-ui/icons/download'
import ExternalLink from '@geist-ui/icons/externalLink'
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"
type Props = {
editable?: boolean
remove?: () => void
title?: string
content?: string
setTitle?: (title: string) => void
setContent?: (content: string) => void
title: string
initialTab?: "edit" | "preview"
skeleton?: boolean
id?: string
id: string
content: 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 [tab, setTab] = useState(initialTab)
const height = editable ? "500px" : '100%'
// const height = editable ? "500px" : '100%'
const height = "100%";
const handleTabChange = (newTab: string) => {
if (newTab === 'edit') {
@ -58,79 +59,55 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
setTab(newTab as 'edit' | 'preview')
}
const getType = useMemo(() => {
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(() => {
const rawLink = () => {
if (id) {
return `/file/raw/${id}`
}
}, [id])
}
if (skeleton) {
return <>
<Spacer height={1} />
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}>
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Skeleton width={275} height={36} />
{editable && <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 >
</Card>
</div>
</>
}
return (
<>
<Spacer height={1} />
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}>
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Input
placeholder="MyFile.md"
value={title}
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle ? setTitle(event.target.value) : null}
readOnly
marginTop="var(--gap-double)"
size={1.2}
font={1.2}
label="Filename"
disabled={!editable}
width={"100%"}
id={title}
/>
{remove && editable && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />}
</div>
<div className={styles.descriptionContainer}>
{tab === 'edit' && editable && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
{rawLink && <DownloadButton rawLink={rawLink} />}
<DownloadButton rawLink={rawLink()} />
<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> */}
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
<Textarea
readOnly
ref={codeEditorRef}
placeholder="Type some contents..."
value={content}
onChange={(event) => setContent ? setContent(event.target.value) : null}
width="100%"
disabled={!editable}
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
resize="vertical"
@ -139,13 +116,15 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
</div>
</Tabs.Item>
<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>
</div >
</Card >
<Spacer height={1} />
</div >
</>
)
}

View file

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

View file

@ -1,30 +1,39 @@
export default function generateUUID() {
if (typeof crypto === 'object') {
if (typeof crypto.randomUUID === 'function') {
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
return crypto.randomUUID();
}
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
const callback = (c: string) => {
const num = Number(c);
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16);
};
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback);
}
}
let timestamp = new Date().getTime();
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 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);
});
};
if (typeof crypto === "object") {
if (typeof crypto.randomUUID === "function") {
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
return crypto.randomUUID()
}
if (
typeof crypto.getRandomValues === "function" &&
typeof Uint8Array === "function"
) {
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
const callback = (c: string) => {
const num = Number(c)
return (
num ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
).toString(16)
}
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, callback)
}
}
let timestamp = new Date().getTime()
let perforNow =
(typeof performance !== "undefined" &&
performance.now &&
performance.now() * 1000) ||
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
const useSharedState = <T>(key: string, initial?: T) => {
const { data: state, mutate: setState } = useSWR(key, {
fallbackData: initial
})
return [state, setState] as const
const { data: state, mutate: setState } = useSWR(key, {
fallbackData: initial
})
return [state, setState] as const
}
export default useSharedState

View file

@ -1,45 +1,35 @@
import { useRouter } from "next/router";
import { useCallback, useEffect } from "react"
import useSharedState from "./use-shared-state";
import Cookies from 'js-cookie'
import Cookies from "js-cookie"
import { useRouter } from "next/router"
import { useEffect, useState } from "react"
import useSharedState from "./use-shared-state"
const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: boolean }) => {
const [isSignedIn, setSignedIn] = useSharedState('isSignedIn', false)
const [isLoading, setLoading] = useSharedState('isLoading', true)
const signout = useCallback(() => setSignedIn(false), [setSignedIn])
const useSignedIn = () => {
const [signedIn, setSignedIn] = useSharedState(
"signedIn",
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();
if (redirectIfNotAuthed && !isLoading && isSignedIn === false) {
router.push('/signin')
}
const signout = () => {
setSignedIn(false)
Cookies.remove("drift-token")
router.push("/")
}
useEffect(() => {
async function checkToken() {
const token = Cookies.get('drift-token')
if (token) {
const response = await fetch('/server-api/auth/verify-token', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
setSignedIn(true)
}
}
setLoading(false)
}
setLoading(true)
checkToken()
useEffect(() => {
if (token) {
setSignedIn(true)
} else {
setSignedIn(false)
}
}, [setSignedIn, token])
const interval = setInterval(() => {
checkToken()
}, 10000);
return () => clearInterval(interval);
}, [setLoading, setSignedIn])
return { isSignedIn, isLoading, signout }
return { signedIn, signin, token, signout }
}
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
const epochs = [
['year', 31536000],
['month', 2592000],
['day', 86400],
['hour', 3600],
['minute', 60],
['second', 1]
] as const;
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
["second", 1]
] as const
// Get duration
const getDuration = (timeAgoInSeconds: number) => {
for (let [name, seconds] of epochs) {
const interval = Math.floor(timeAgoInSeconds / seconds);
for (let [name, seconds] of epochs) {
const interval = Math.floor(timeAgoInSeconds / seconds)
if (interval >= 1) {
return {
interval: interval,
epoch: name
};
}
}
if (interval >= 1) {
return {
interval: interval,
epoch: name
}
}
}
return {
interval: 0,
epoch: 'second'
}
};
return {
interval: 0,
epoch: "second"
}
}
// Calculate
const timeAgo = (date: Date) => {
const timeAgoInSeconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000);
const { interval, epoch } = getDuration(timeAgoInSeconds);
const suffix = interval === 1 ? '' : 's';
const timeAgoInSeconds = Math.floor(
(new Date().getTime() - new Date(date).getTime()) / 1000
)
const { interval, epoch } = getDuration(timeAgoInSeconds)
const suffix = interval === 1 ? "" : "s"
return `${interval} ${epoch}${suffix} ago`;
};
return `${interval} ${epoch}${suffix} ago`
}
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",
"build": "next build",
"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": {
"@fec/remark-a11y-emoji": "^3.1.0",
"@geist-ui/core": "^2.3.5",
"@geist-ui/icons": "^1.0.1",
"@types/cookie": "^0.4.1",
"@types/js-cookie": "^3.0.1",
"client-zip": "^2.0.0",
"comlink": "^4.3.1",
"cookie": "^0.4.2",
"dotenv": "^16.0.0",
"js-cookie": "^3.0.1",
"next": "12.1.0",
"prismjs": "^1.27.0",
"lodash.debounce": "^4.0.8",
"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-debounce-render": "^8.0.2",
"react-dom": "17.0.2",
"react-dropzone": "^12.0.4",
"react-loading-skeleton": "^3.0.3",
"react-markdown": "^8.0.0",
"react-syntax-highlighter": "^15.4.5",
"react-syntax-highlighter-virtualized-renderer": "^1.1.0",
"rehype-autolink-headings": "^6.1.1",
"rehype-katex": "^6.0.2",
"rehype-raw": "^6.1.1",
"rehype-slug": "^5.0.1",
"rehype-stringify": "^9.0.3",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"swr": "^1.2.2"
},
"devDependencies": {
"@next/bundle-analyzer": "^12.1.0",
"@types/lodash.debounce": "^4.0.6",
"@types/marked": "^4.0.3",
"@types/node": "17.0.21",
"@types/nprogress": "^0.2.0",
"@types/react": "17.0.39",
"@types/react-dom": "^17.0.14",
"@types/react-syntax-highlighter": "^13.5.2",
"eslint": "8.10.0",
"eslint-config-next": "12.1.0",
"typescript": "4.6.2"
"eslint-config-next": "^12.1.1-canary.16",
"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 { GeistProvider, CssBaseline, useTheme } from '@geist-ui/core'
import { useEffect, useMemo, useState } from 'react'
import type { AppProps as NextAppProps } from "next/app";
import useSharedState from '@lib/hooks/use-shared-state';
import 'react-loading-skeleton/dist/skeleton.css'
import { SkeletonTheme } from 'react-loading-skeleton';
import Head from 'next/head';
export type ThemeProps = {
theme: "light" | "dark" | string,
changeTheme: () => void
}
import { CssBaseline, GeistProvider, Themes } from '@geist-ui/core';
import { useTheme, ThemeProvider } from 'next-themes'
import { useEffect } from 'react';
import App from '@components/app';
type AppProps<P = any> = {
pageProps: P;
} & 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 (
<>
<div>
<Head>
<meta charSet="utf-8" />
<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" />
<title>Drift</title>
</Head>
<GeistProvider themeType={themeType} >
<SkeletonTheme baseColor={skeletonBaseColor} highlightColor={skeletonHighlightColor}>
<CssBaseline />
<Component {...pageProps} theme={themeType || 'light'} changeTheme={changeTheme} />
</SkeletonTheme>
</GeistProvider>
</>
<ThemeProvider defaultTheme="system" disableTransitionOnChange>
<App Component={Component} pageProps={pageProps} />
</ThemeProvider>
</div>
)
}

View file

@ -1,5 +1,5 @@
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
import { CssBaseline } from '@geist-ui/core'
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
class MyDocument extends Document {
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"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
const { id, download } = req.query
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`)
if (file.ok) {
const data = await file.json()
const { title, content } = data
// serve the file raw as plain text
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}"`)
}
const { id, download } = 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"]}`
}
})
res.status(200).send(content)
} else {
res.status(404).send("File not found")
}
res.setHeader("Content-Type", "text/plain; charset=utf-8")
res.setHeader("Cache-Control", "s-maxage=86400")
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

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

View file

@ -1,20 +1,60 @@
import styles from '@styles/Home.module.css'
import { Page } from '@geist-ui/core'
import Header from '@components/header'
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 (
<Page className={styles.container} width="100%">
<Page className={styles.container}>
<Page.Header>
<Header theme={theme} changeTheme={changeTheme} />
<Header />
</Page.Header>
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}>
<MyPosts />
<Page.Content className={styles.main}>
<MyPosts error={error} posts={posts} />
</Page.Content>
</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

View file

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

View file

@ -1,111 +1,49 @@
import { Button, Page, Text } from "@geist-ui/core";
import Skeleton from 'react-loading-skeleton';
import type { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
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";
import type { Post } from "@lib/types";
import PostPage from "@components/post-page";
const Post = ({ theme, changeTheme }: ThemeProps) => {
const [post, setPost] = useState<any>()
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 type PostProps = {
post: Post
}
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 Auth from "@components/auth";
import Header from "@components/header";
import { ThemeProps } from "./_app";
const SignIn = ({ theme, changeTheme }: ThemeProps) => (
import Header from "@components/header/header";
import styles from '@styles/Home.module.css'
const SignIn = () => (
<Page width={"100%"}>
<PageSeo title="Drift - Sign In" />
<Page.Header>
<Header theme={theme} changeTheme={changeTheme} />
<Header />
</Page.Header>
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="auto">
<Page.Content className={styles.main}>
<Auth page="signin" />
</Page.Content>
</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 Header from "@components/header";
import Header from "@components/header/header";
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%">
<PageSeo title="Drift - Sign Up" />
<Page.Header>
<Header theme={theme} changeTheme={changeTheme} />
<Header />
</Page.Header>
<Page.Content width={"var(--main-content-width)"} paddingTop={"var(--gap)"} margin="auto">
<Page.Content className={styles.main}>
<Auth page="signup" />
</Page.Content>
</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 {
min-height: 100vh;
flex: 1;
display: flex;
flex-direction: column;
margin: 0 auto;
width: var(--main-content-width);
}
.container {
.wrapper {
height: 100% !important;
padding-bottom: var(--small-gap) !important;
width: 100% !important;
}
@media screen and (max-width: 768px) {
.container {
width: 100%;
margin: 0 auto !important;
padding: 0;
}
.container h1 {
font-size: 2rem;
}
.main {
width: 100%;
}
.main {
max-width: var(--main-content) !important;
margin: 0 auto !important;
padding: 0 0 !important;
}

View file

@ -1,32 +1,154 @@
@import "./syntax.css";
@import "./markdown.css";
@import "./inter.css";
:root {
--main-content-width: 800px;
--page-nav-height: 60px;
--gap: 8px;
--gap-half: calc(var(--gap) / 2);
--gap-double: calc(var(--gap) * 2);
--border-radius: 4px;
--font-size: 16px;
/* Spacing */
--gap-quarter: 0.25rem;
--gap-half: 0.5rem;
--gap: 1rem;
--gap-double: 2rem;
--small-gap: 4rem;
--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) {
:root {
--main-content-width: 100%;
}
[data-theme="light"] {
--token: #666;
--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,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
padding: 0;
margin: 0;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
body {
min-height: 100vh;
font-family: var(--font-sans);
display: flex;
flex-direction: column;
}
* {
box-sizing: border-box;
p {
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": {
"target": "es5",
"plugins": [{ "name": "typescript-plugin-css-modules" }],
"target": "es2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",

File diff suppressed because it is too large Load diff

3
server/.gitignore vendored
View file

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

View file

@ -2,7 +2,8 @@ import * as express from 'express';
import * as bodyParser from 'body-parser';
import * as errorhandler from 'strong-error-handler';
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();
@ -19,7 +20,10 @@ app.use("/posts", posts)
app.use("/users", users)
app.use("/files", files)
app.use(errors());
app.use(errorhandler({
debug: process.env.ENV !== 'production',
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 { User } from './User';
@ -20,6 +20,7 @@ import { User } from './User';
export class File extends Model {
@IsUUID(4)
@PrimaryKey
@Unique
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
@ -35,6 +36,9 @@ export class File extends Model {
@Column
sha!: string;
@Column
html!: string;
@ForeignKey(() => User)
@BelongsTo(() => User, 'userId')
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 { User } from './User';
import { File } from './File';
@ -26,6 +26,7 @@ import { File } from './File';
export class Post extends Model {
@IsUUID(4)
@PrimaryKey
@Unique
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
@ -38,7 +39,7 @@ export class Post extends Model {
@BelongsToMany(() => User, () => PostAuthor)
users?: User[];
@HasMany(() => File)
@HasMany(() => File, { constraints: false })
files?: File[];
@CreatedAt
@ -48,6 +49,9 @@ export class Post extends Model {
@Column
visibility!: string;
@Column
password?: string;
@UpdatedAt
@Column
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 { User } from "./User";
@ -6,6 +6,7 @@ import { User } from "./User";
export class PostAuthor extends Model {
@IsUUID(4)
@PrimaryKey
@Unique
@Column({
type: DataType.UUID,
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 { PostAuthor } from "./PostAuthor";
@ -22,6 +22,7 @@ import { PostAuthor } from "./PostAuthor";
export class User extends Model {
@IsUUID(4)
@PrimaryKey
@Unique
@Column({
type: DataType.UUID,
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 { genSalt, hash, compare } from "bcrypt";
import { User } from "../../lib/models/User";
import { sign } from "jsonwebtoken";
import config from "../../lib/config";
import jwt from "../../lib/middleware/jwt";
import { celebrate, Joi } from "celebrate";
import { Router } from 'express'
import { genSalt, hash, compare } from "bcrypt"
import { User } from '@lib/models/User'
import { sign } from 'jsonwebtoken'
import config from '@lib/config'
import jwt from '@lib/middleware/jwt'
import { celebrate, Joi } from 'celebrate'
const NO_EMPTY_SPACE_REGEX = /^\S*$/;
const NO_EMPTY_SPACE_REGEX = /^\S*$/
export const requiresServerPassword =
(process.env.MEMORY_DB || process.env.ENV === "production") &&
!!process.env.REGISTRATION_PASSWORD;
console.log(`Registration password required: ${requiresServerPassword}`);
export const requiresServerPassword = (process.env.MEMORY_DB || process.env.ENV === 'production') && !!process.env.REGISTRATION_PASSWORD
console.log(`Registration password required: ${requiresServerPassword}`)
export const auth = Router();
export const auth = Router()
const validateAuthPayload = (
username: string,
password: string,
serverPassword?: string
): void => {
const validateAuthPayload = (username: string, password: string, serverPassword?: string): void => {
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 (
!serverPassword ||
process.env.REGISTRATION_PASSWORD !== serverPassword
) {
throw new Error(
"Server password is incorrect. Please contact the server administrator."
);
if (!serverPassword || process.env.REGISTRATION_PASSWORD !== serverPassword) {
throw new Error("Server password is incorrect. Please contact the server administrator.")
}
}
};
}
auth.post(
"/signup",
auth.post('/signup',
celebrate({
body: {
username: Joi.string().required(),
password: Joi.string().required(),
serverPassword: Joi.string(),
},
serverPassword: Joi.string().required().allow('', null),
}
}),
async (req, res, next) => {
try {
validateAuthPayload(
req.body.username,
req.body.password,
req.body.serverPassword
);
validateAuthPayload(req.body.username, req.body.password, req.body.serverPassword)
const username = req.body.username.toLowerCase();
const existingUser = await User.findOne({
@ -85,6 +68,7 @@ auth.post(
body: {
username: Joi.string().required(),
password: Joi.string().required(),
serverPassword: Joi.string().required().allow('', null),
},
}),
async (req, res, next) => {

View file

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

View file

@ -1,14 +1,23 @@
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 { 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();
const postVisibilitySchema = (value: string) => {
if (value === 'public' || value === 'private') {
return value;
} else {
throw new Error('Invalid post visibility');
}
}
posts.post(
"/create",
jwt,
@ -16,46 +25,45 @@ posts.post(
body: {
title: Joi.string().required(),
files: Joi.any().required(),
visibility: Joi.string().required(),
visibility: Joi.string().custom(postVisibilitySchema, 'valid visibility').required(),
userId: Joi.string().required(),
password: Joi.string().optional(),
},
}),
async (req, res, next) => {
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({
title: req.body.title,
visibility: req.body.visibility,
});
password: hashedPassword,
})
await newPost.save();
await newPost.$add("users", req.body.userId);
const newFiles = await Promise.all(
req.body.files.map(async (file) => {
// Establish a "file" for each file in the request
const newFile = new File({
title: file.title,
content: file.content,
sha: crypto
.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 newPost.save()
await newPost.$add('users', req.body.userId);
const newFiles = await Promise.all(req.body.files.map(async (file) => {
const html = getHtmlFromFile(file);
const newFile = new File({
title: file.title,
content: file.content,
sha: crypto.createHash('sha256').update(file.content).digest('hex').toString(),
html
})
);
await Promise.all(
newFiles.map((file) => {
newPost.$add("files", file.id);
newPost.save();
})
);
await newFile.$set("user", req.body.userId);
await newFile.$set("post", newPost.id);
await newFile.save();
return newFile;
}))
await Promise.all(newFiles.map((file) => {
newPost.$add("files", file.id);
newPost.save();
}))
res.json(newPost);
} 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(
"/:id",
celebrate({
@ -98,18 +147,63 @@ posts.get(
],
});
if (post?.visibility === "public" || post?.visibility === "unlisted") {
res.setHeader("Cache-Control", "public, max-age=86400");
res.json(post);
} else {
// TODO: should this be `private, `?
res.setHeader("Cache-Control", "max-age=86400");
jwt(req, res, () => {
res.json(post);
});
if (!post) {
return res.status(404).json({ error: "Post not found" })
}
} 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);
}
}
);
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 { User } from "../../lib/models/User";
import { File } from "../../lib/models/File";
import jwt, { UserJwtRequest } from "../../lib/middleware/jwt";
import { Post } from "../../lib/models/Post";
// import jwt from "@lib/middleware/jwt";
// import { User } from "@lib/models/User";
export const users = Router();
users.get("/", jwt, async (req, res, next) => {
try {
const allUsers = await User.findAll();
res.json(allUsers);
} catch (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);
}
});
// users.get("/", jwt, async (req, res, next) => {
// try {
// const allUsers = await User.findAll();
// res.json(allUsers);
// } catch (error) {
// next(error);
// }
// });

View file

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

View file

@ -4,6 +4,7 @@
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"jsx": "react-jsxdev",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"noUnusedLocals": true,
@ -13,8 +14,17 @@
"strictNullChecks": true,
"skipLibCheck": 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"]
}

View file

@ -179,6 +179,11 @@
"@types/qs" "*"
"@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":
version "8.5.8"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.8.tgz#01b39711eb844777b7af1d1f2b4cf22fda1c0c44"
@ -186,6 +191,11 @@
dependencies:
"@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":
version "1.3.2"
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"
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@*":
version "6.9.7"
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"
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@*":
version "1.13.10"
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"
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:
version "1.14.1"
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/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"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
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"
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:
version "8.5.1"
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"
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:
version "1.0.1"
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:
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:
version "2.3.0"
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"
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:
version "3.1.6"
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"
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:
version "2.0.1"
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"
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:
version "2.3.7"
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"
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:
version "3.1.1"
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:
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:
version "2.0.0"
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"
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:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"