Merge with main
This commit is contained in:
commit
da8e7415dc
95 changed files with 5205 additions and 1587 deletions
24
README.md
24
README.md
|
@ -15,12 +15,32 @@ You can run `yarn dev` in either / both folders to start the server and client w
|
|||
|
||||
### Production
|
||||
|
||||
**Note: Drift is not yet ready for production usage and should not be used seriously until the database has been setup, which I'll get to when the server API is semi stable.**
|
||||
**Note: Drift is not yet ready for production usage and should not be used too seriously. I'll make every effort to not lose data, but I won't make any guarantees until the project is further along.**
|
||||
|
||||
`yarn build` in both `client/` and `server/` will produce production code for the client and server respectively. The client and server each also have Dockerfiles which you can use with a docker-compose (an example compose will be provided in the near future).
|
||||
|
||||
If you're deploying the front-end to something like Vercel, you'll need to set the root folder to `client/`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can change these to your liking.
|
||||
|
||||
`client/.env`:
|
||||
|
||||
- `API_URL`: defaults to localhost:3001, but allows you to host the front-end separately from the backend on a service like Vercel or Netlify
|
||||
- `WELCOME_CONTENT`: a markdown string (with \n newlines) that's rendered on the home page
|
||||
- `WELCOME_TITLE`: the file title for the post on the homepage.
|
||||
- `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser
|
||||
|
||||
`server/.env`:
|
||||
|
||||
- `PORT`: the default port to start the server on (3000 by default)
|
||||
- `ENV`: can be `production` or `debug`, toggles logging
|
||||
- `JWT_SECRET`: a secure token for JWT tokens. You can generate one [here](https://www.grc.com/passwords.htm).
|
||||
- `MEMORY_DB`: if `true`, a sqlite database will not be created and changes will only exist in memory. Mainly for the demo.
|
||||
- `REGISTRATION_PASSWORD`: if MEMORY_DB is not `true`, the user will be required to provide this password to sign-up, in addition to their username and account password. If it's not set, no password will be required.
|
||||
- `SECRET_KEY`: the same secret key as the client
|
||||
|
||||
## Current status
|
||||
|
||||
Drift is a major work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist.
|
||||
|
@ -34,7 +54,7 @@ Drift is a major work in progress. Below is a (rough) list of completed and envi
|
|||
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
|
||||
- [x] downloading files (individually and entire posts)
|
||||
- [ ] password protected posts
|
||||
- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development)
|
||||
- [x] sqlite database
|
||||
- [ ] non-node backend
|
||||
- [ ] administrator account / settings
|
||||
- [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13))
|
||||
|
|
|
@ -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
7
client/.prettierrc
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": false,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"useTabs": true
|
||||
}
|
|
@ -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) => {
|
||||
|
|
45
client/components/app/index.tsx
Normal file
45
client/components/app/index.tsx
Normal 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
|
|
@ -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}>
|
||||
|
|
26
client/components/button-dropdown/dropdown.module.css
Normal file
26
client/components/button-dropdown/dropdown.module.css
Normal 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;
|
||||
}
|
116
client/components/button-dropdown/index.tsx
Normal file
116
client/components/button-dropdown/index.tsx
Normal 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
|
53
client/components/button/button.module.css
Normal file
53
client/components/button/button.module.css
Normal 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);
|
||||
}
|
28
client/components/button/index.tsx
Normal file
28
client/components/button/index.tsx
Normal 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
|
|
@ -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;
|
||||
}
|
35
client/components/edit-document-list/index.tsx
Normal file
35
client/components/edit-document-list/index.tsx
Normal 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)
|
48
client/components/edit-document/document.module.css
Normal file
48
client/components/edit-document/document.module.css
Normal 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;
|
||||
}
|
|
@ -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])
|
||||
|
123
client/components/edit-document/index.tsx
Normal file
123
client/components/edit-document/index.tsx
Normal 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)
|
27
client/components/head/index.tsx
Normal file
27
client/components/head/index.tsx
Normal 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;
|
|
@ -1,18 +1,23 @@
|
|||
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}>
|
||||
|
|
179
client/components/header/header.tsx
Normal file
179
client/components/header/header.tsx
Normal 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
|
|
@ -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 > * /}
|
24
client/components/input/index.tsx
Normal file
24
client/components/input/index.tsx
Normal 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
|
57
client/components/input/input.module.css
Normal file
57
client/components/input/input.module.css
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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])
|
||||
|
||||
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)
|
|
@ -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()
|
||||
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)
|
||||
if (json.id)
|
||||
router.push(`/post/${json.id}`)
|
||||
else {
|
||||
setToast({ text: json.error.message, type: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const updateTitle = useCallback((title: string, id: string) => {
|
||||
setDocs(docs.map((doc) => doc.id === id ? { ...doc, title } : doc))
|
||||
}, [docs])
|
||||
}, [docs, router, setToast, title])
|
||||
|
||||
const updateContent = useCallback((content: string, id: string) => {
|
||||
setDocs(docs.map((doc) => doc.id === id ? { ...doc, content } : doc))
|
||||
}, [docs])
|
||||
const [isSubmitting, setSubmitting] = useState(false)
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
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 onClosePasswordModal = () => {
|
||||
setPasswordModalVisible(false)
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
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 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,7 +154,9 @@ 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>
|
||||
)
|
||||
|
|
52
client/components/new-post/password/index.tsx
Normal file
52
client/components/new-post/password/index.tsx
Normal 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'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
|
|
@ -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"
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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,17 +32,17 @@ 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}>
|
||||
<Grid.Container>
|
||||
<Grid md={14} xs={14}>
|
||||
<Text h3 paddingLeft={1 / 2} >
|
||||
<NextLink passHref={true} href={`/post/${post.id}`}>
|
||||
<NextLink passHref={true} href={getPostPath(post.visibility, post.id)}>
|
||||
<Link color>{post.title}
|
||||
<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} />
|
||||
|
|
71
client/components/post-page/index.tsx
Normal file
71
client/components/post-page/index.tsx
Normal 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
|
23
client/components/post-page/post-page.module.css
Normal file
23
client/components/post-page/post-page.module.css
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}, [type, content])
|
||||
return (<ReactMarkdownPreview height={height} content={contentToRender} />)
|
||||
} 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)
|
||||
}
|
||||
fetchPost()
|
||||
}, [content, fileId, title])
|
||||
return (<>
|
||||
{isLoading ? <div>Loading...</div> : <article className={styles.markdownPreview} dangerouslySetInnerHTML={{ __html: preview }} style={{
|
||||
height
|
||||
}} />}
|
||||
</>)
|
||||
|
||||
}
|
||||
|
||||
export default memo(MarkdownPreview)
|
||||
|
|
|
@ -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 */
|
||||
|
@ -36,11 +55,11 @@
|
|||
.markdownPreview h4 a:hover::after,
|
||||
.markdownPreview h5 a:hover::after,
|
||||
.markdownPreview h6 a:hover::after {
|
||||
content: "🔗";
|
||||
filter: grayscale(100%);
|
||||
content: "#";
|
||||
font-size: 0.7em;
|
||||
margin-left: 0.25em;
|
||||
font-weight: normal;
|
||||
filter: opacity(0.5);
|
||||
}
|
||||
|
||||
.markdownPreview h1 {
|
||||
|
@ -60,11 +79,11 @@
|
|||
}
|
||||
|
||||
.markdownPreview h5 {
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.markdownPreview h6 {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.markdownPreview ul {
|
||||
|
@ -75,24 +94,19 @@
|
|||
content: "";
|
||||
}
|
||||
|
||||
.markdownPreview ul ul {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
.markdownPreview ul ul li {
|
||||
margin-left: var(--gap);
|
||||
}
|
||||
|
||||
.markdownPreview code {
|
||||
border-radius: 3px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: initial;
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.markdownPreview code::before,
|
||||
.markdownPreview code::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.markdownPreview img {
|
||||
max-width: 100%;
|
||||
max-height: 350px;
|
||||
}
|
||||
|
|
|
@ -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
|
22
client/components/view-document-list/index.tsx
Normal file
22
client/components/view-document-list/index.tsx
Normal 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)
|
40
client/components/view-document/document.module.css
Normal file
40
client/components/view-document/document.module.css
Normal 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;
|
||||
}
|
|
@ -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 >
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -1,30 +1,39 @@
|
|||
export default function generateUUID() {
|
||||
if (typeof crypto === 'object') {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
if (typeof crypto === "object") {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
|
||||
return crypto.randomUUID();
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
|
||||
if (
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
random = (timestamp + random) % 16 | 0
|
||||
timestamp = Math.floor(timestamp / 16)
|
||||
} else {
|
||||
random = (perforNow + random) % 16 | 0;
|
||||
perforNow = Math.floor(perforNow / 16);
|
||||
random = (perforNow + random) % 16 | 0
|
||||
perforNow = Math.floor(perforNow / 16)
|
||||
}
|
||||
return (c === "x" ? random : (random & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
13
client/lib/get-post-path.ts
Normal file
13
client/lib/get-post-path.ts
Normal 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}`
|
||||
}
|
||||
}
|
18
client/lib/hooks/use-debounce.ts
Normal file
18
client/lib/hooks/use-debounce.ts
Normal 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
|
||||
}
|
|
@ -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)
|
||||
} else {
|
||||
setSignedIn(false)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(true)
|
||||
checkToken()
|
||||
}, [setSignedIn, token])
|
||||
|
||||
const interval = setInterval(() => {
|
||||
checkToken()
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [setLoading, setSignedIn])
|
||||
|
||||
return { isSignedIn, isLoading, signout }
|
||||
return { signedIn, signin, token, signout }
|
||||
}
|
||||
|
||||
export default useSignedIn
|
||||
|
|
19
client/lib/hooks/use-trace-route.ts
Normal file
19
client/lib/hooks/use-trace-route.ts
Normal 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
|
152
client/lib/render-markdown.tsx
Normal file
152
client/lib/render-markdown.tsx
Normal 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">​<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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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);
|
||||
const interval = Math.floor(timeAgoInSeconds / seconds)
|
||||
|
||||
if (interval >= 1) {
|
||||
return {
|
||||
interval: interval,
|
||||
epoch: name
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interval: 0,
|
||||
epoch: 'second'
|
||||
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
24
client/lib/types.d.ts
vendored
Normal 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
|
||||
}
|
|
@ -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
39
client/next.config.mjs
Normal 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
|
||||
)
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
46
client/pages/_middleware.tsx
Normal file
46
client/pages/_middleware.tsx
Normal 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()
|
||||
}
|
58
client/pages/api/markdown/[id].ts
Normal file
58
client/pages/api/markdown/[id].ts
Normal 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
|
|
@ -2,20 +2,30 @@ 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}`)
|
||||
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.setHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
res.setHeader("Cache-Control", "s-maxage=86400")
|
||||
if (file.ok) {
|
||||
const data = await file.json()
|
||||
const json = await file.json()
|
||||
const data = 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}"`)
|
||||
}
|
||||
|
||||
res.status(200).send(content)
|
||||
res.status(200).write(content, "utf-8")
|
||||
res.end()
|
||||
} else {
|
||||
res.status(404).send("File not found")
|
||||
}
|
||||
|
|
42
client/pages/api/render-markdown.ts
Normal file
42
client/pages/api/render-markdown.ts
Normal 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
|
|
@ -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`}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
export type PostProps = {
|
||||
post: Post
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPost() {
|
||||
setIsLoading(true);
|
||||
if (router.query.id) {
|
||||
const post = await fetch(`/server-api/posts/${router.query.id}`, {
|
||||
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",
|
||||
"Authorization": `Bearer ${Cookies.get("drift-token")}`
|
||||
"x-secret-key": process.env.SECRET_KEY || "",
|
||||
}
|
||||
})
|
||||
|
||||
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 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 }
|
||||
}))
|
||||
|
||||
const download = async () => {
|
||||
const clientZip = require("client-zip")
|
||||
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 || "",
|
||||
}
|
||||
})
|
||||
|
||||
const blob = await clientZip.downloadZip(post.files.map((file: any) => {
|
||||
return {
|
||||
name: file.title,
|
||||
input: file.content,
|
||||
lastModified: new Date(file.updatedAt)
|
||||
props: {
|
||||
post: await post.json()
|
||||
},
|
||||
}
|
||||
})).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'}
|
||||
/>
|
||||
)}
|
||||
export default PostView
|
||||
|
||||
<Page.Header>
|
||||
<Header theme={theme} changeTheme={changeTheme} />
|
||||
</Page.Header>
|
||||
<Page.Content width={"var(--main-content-width)"} margin="auto">
|
||||
{error && <Text type="error">{error}</Text>}
|
||||
{/* {!error && (isLoading || !post?.files) && <Loading />} */}
|
||||
{!error && isLoading && <><Text h2><Skeleton width={400} /></Text>
|
||||
<Document skeleton={true} />
|
||||
</>}
|
||||
{!isLoading && post && <>
|
||||
<div className={styles.header}>
|
||||
<Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
|
||||
<Button auto onClick={download}>
|
||||
Download as ZIP archive
|
||||
</Button>
|
||||
</div>
|
||||
{post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => (
|
||||
<Document
|
||||
key={id}
|
||||
id={id}
|
||||
content={content}
|
||||
title={title}
|
||||
editable={false}
|
||||
initialTab={'preview'}
|
||||
/>
|
||||
))}
|
||||
</>}
|
||||
</Page.Content>
|
||||
</Page >
|
||||
)
|
||||
}
|
||||
|
||||
export default Post
|
||||
|
|
58
client/pages/post/private/[id].tsx
Normal file
58
client/pages/post/private/[id].tsx
Normal 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
|
||||
|
80
client/pages/post/protected/[id].tsx
Normal file
80
client/pages/post/protected/[id].tsx
Normal 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
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
18
client/postcss.config.json
Normal file
18
client/postcss.config.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
|
@ -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%;
|
||||
}
|
||||
max-width: var(--main-content) !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 0 0 !important;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
body {
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-sans);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
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
100
client/styles/inter.css
Normal 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
130
client/styles/markdown.css
Normal 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
24
client/styles/syntax.css
Normal 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);
|
||||
}
|
|
@ -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",
|
||||
|
|
2422
client/yarn.lock
2422
client/yarn.lock
File diff suppressed because it is too large
Load diff
1
server/.gitignore
vendored
1
server/.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
.env
|
||||
node_modules/
|
||||
dist/
|
||||
drift.sqlite
|
18
server/config/config.json
Normal file
18
server/config/config.json
Normal 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"
|
||||
}
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
import {Sequelize} from 'sequelize-typescript';
|
||||
|
||||
export const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
database: 'movies',
|
||||
storage: ':memory:',
|
||||
models: [__dirname + '/models']
|
||||
});
|
27
server/migrations/20220323033259-postAddHtmlColumn.js
Normal file
27
server/migrations/20220323033259-postAddHtmlColumn.js
Normal 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,
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
14
server/src/lib/middleware/secret-key.ts
Normal file
14
server/src/lib/middleware/secret-key.ts
Normal 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()
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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,
|
|
@ -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,
|
152
server/src/lib/render-markdown.tsx
Normal file
152
server/src/lib/render-markdown.tsx
Normal 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">​<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>
|
||||
</>
|
||||
)
|
||||
}
|
9
server/src/lib/sequelize.ts
Normal file
9
server/src/lib/sequelize.ts
Normal 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',
|
||||
});
|
|
@ -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) => {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
);
|
||||
)
|
||||
|
|
|
@ -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
|
||||
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(),
|
||||
});
|
||||
sha: crypto.createHash('sha256').update(file.content).digest('hex').toString(),
|
||||
html
|
||||
})
|
||||
|
||||
await newFile.$set("user", req.body.userId);
|
||||
await newFile.$set("post", newPost.id);
|
||||
await newFile.save();
|
||||
return newFile;
|
||||
})
|
||||
);
|
||||
}))
|
||||
|
||||
await Promise.all(
|
||||
newFiles.map((file) => {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
// }
|
||||
// });
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
112
server/yarn.lock
112
server/yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue