mirror of https://github.com/MaxLeiter/Drift
Merge with main
commit
da8e7415dc
@ -1,3 +1,4 @@
|
|||||||
API_URL=http://localhost:3000
|
API_URL=http://localhost:3000
|
||||||
WELCOME_TITLE="Welcome to Drift"
|
WELCOME_TITLE="Welcome to Drift"
|
||||||
WELCOME_CONTENT="### Drift is a self-hostable clone of GitHub Gist. \nIt is a simple way to share code and text snippets with your friends, with support for the following:\n \n - Render GitHub Extended Markdown (including images)\n - User authentication\n - Private, public, and secret posts\n \n If you want to signup, you can join at [/signup](/signup) as long as you have a passcode provided by the administrator (which you don\'t need for this demo).\n **This demo is on a memory-only database, so accounts and pastes can be deleted at any time.**\n You can find the source code on [GitHub](https://github.com/MaxLeiter/drift).\n \n Drift was inspired by [this tweet](https://twitter.com/emilyst/status/1499858264346935297):\n > What is the absolute closest thing to GitHub Gist that can be self-hosted?\n In terms of design and functionality. Hosts images and markdown, rendered. Creates links that can be private or public. Uses/requires registration. I have looked at dozens of pastebin-like things."
|
WELCOME_CONTENT="### Drift is a self-hostable clone of GitHub Gist. \nIt is a simple way to share code and text snippets with your friends, with support for the following:\n \n - Render GitHub Extended Markdown (including images)\n - User authentication\n - Private, public, and secret posts\n \n If you want to signup, you can join at [/signup](/signup) as long as you have a passcode provided by the administrator (which you don\'t need for this demo).\n **This demo is on a memory-only database, so accounts and pastes can be deleted at any time.**\n You can find the source code on [GitHub](https://github.com/MaxLeiter/drift).\n \n Drift was inspired by [this tweet](https://twitter.com/emilyst/status/1499858264346935297):\n > What is the absolute closest thing to GitHub Gist that can be self-hosted?\n In terms of design and functionality. Hosts images and markdown, rendered. Creates links that can be private or public. Uses/requires registration. I have looked at dozens of pastebin-like things."
|
||||||
|
SECRET_KEY=secret
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 80,
|
||||||
|
"useTabs": true
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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)
|
||||||
@ -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;
|
||||||
@ -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 dynamic from 'next/dynamic'
|
||||||
import { Github as GitHubIcon, UserPlus as SignUpIcon, User as SignInIcon, Home as HomeIcon, Menu as MenuIcon, Tool as SettingsIcon, UserX as SignoutIcon, PlusCircle as NewIcon, List as YourIcon, Moon, Sun } from "@geist-ui/icons";
|
|
||||||
import { DriftProps } from "../../pages/_app";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import styles from './header.module.css';
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import useSignedIn from "../../lib/hooks/use-signed-in";
|
|
||||||
import Cookies from 'js-cookie'
|
|
||||||
|
|
||||||
type Tab = {
|
const Header = dynamic(import('./header'), {
|
||||||
name: string
|
ssr: false,
|
||||||
icon: JSX.Element
|
// loading: () => <MenuSkeleton />,
|
||||||
condition?: boolean
|
})
|
||||||
value: string
|
|
||||||
onClick?: () => void
|
|
||||||
href?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const Header = ({ changeTheme, theme }: DriftProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [selectedTab, setSelectedTab] = useState<string>();
|
|
||||||
const [expanded, setExpanded] = useState<boolean>(false)
|
|
||||||
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
|
|
||||||
const isMobile = useMediaQuery('xs', { match: 'down' })
|
|
||||||
const { isLoading, isSignedIn, signout } = useSignedIn({ redirectIfNotAuthed: false })
|
|
||||||
const [pages, setPages] = useState<Tab[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setBodyHidden(expanded)
|
|
||||||
}, [expanded, setBodyHidden])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMobile) {
|
|
||||||
setExpanded(false)
|
|
||||||
}
|
|
||||||
}, [isMobile])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const pageList: Tab[] = [
|
|
||||||
{
|
|
||||||
name: "Home",
|
|
||||||
href: "/",
|
|
||||||
icon: <HomeIcon />,
|
|
||||||
condition: true,
|
|
||||||
value: "home"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "New",
|
|
||||||
href: "/new",
|
|
||||||
icon: <NewIcon />,
|
|
||||||
condition: isSignedIn,
|
|
||||||
value: "new"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Yours",
|
|
||||||
href: "/mine",
|
|
||||||
icon: <YourIcon />,
|
|
||||||
condition: isSignedIn,
|
|
||||||
value: "mine"
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// name: "Settings",
|
|
||||||
// href: "/settings",
|
|
||||||
// icon: <SettingsIcon />,
|
|
||||||
// condition: isSignedIn
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
name: "Sign out",
|
|
||||||
onClick: () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.clear();
|
|
||||||
|
|
||||||
// // send token to API blacklist
|
|
||||||
// fetch('/api/auth/signout', {
|
|
||||||
// method: 'POST',
|
|
||||||
// headers: {
|
|
||||||
// 'Content-Type': 'application/json'
|
|
||||||
// },
|
|
||||||
// body: JSON.stringify({
|
|
||||||
// token: Cookies.get("drift-token")
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
|
|
||||||
signout();
|
|
||||||
router.push("/signin");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
href: "#signout",
|
|
||||||
icon: <SignoutIcon />,
|
|
||||||
condition: isSignedIn,
|
|
||||||
value: "signout"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Sign in",
|
|
||||||
href: "/signin",
|
|
||||||
icon: <SignInIcon />,
|
|
||||||
condition: !isSignedIn,
|
|
||||||
value: "signin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Sign up",
|
|
||||||
href: "/signup",
|
|
||||||
icon: <SignUpIcon />,
|
|
||||||
condition: !isSignedIn,
|
|
||||||
value: "signup"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: isMobile ? "GitHub" : "",
|
|
||||||
href: "https://github.com/maxleiter/drift",
|
|
||||||
icon: <GitHubIcon />,
|
|
||||||
condition: true,
|
|
||||||
value: "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: isMobile ? "Change theme" : "",
|
|
||||||
onClick: function () {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
changeTheme();
|
|
||||||
setSelectedTab(undefined);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: theme === 'light' ? <Moon /> : <Sun />,
|
|
||||||
condition: true,
|
|
||||||
value: "theme",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return setPages([])
|
|
||||||
}
|
|
||||||
|
|
||||||
setPages(pageList.filter(page => page.condition))
|
|
||||||
}, [changeTheme, isLoading, isMobile, isSignedIn, router, signout, theme])
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// setSelectedTab(pages.find((page) => {
|
|
||||||
// console.log(page.href, router.asPath)
|
|
||||||
// if (page.href && page.href === router.asPath) {
|
|
||||||
// return true
|
|
||||||
// }
|
|
||||||
// })?.href)
|
|
||||||
// }, [pages, router, router.pathname])
|
|
||||||
|
|
||||||
const onTabChange = (tab: string) => {
|
|
||||||
const match = pages.find(page => page.value === tab)
|
|
||||||
if (match?.onClick) {
|
|
||||||
match.onClick()
|
|
||||||
} else if (match?.href) {
|
|
||||||
router.push(`${match.href}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page.Header height={'var(--page-nav-height)'} margin={0} paddingBottom={0} paddingTop={"var(--gap)"}>
|
|
||||||
<div className={styles.tabs}>
|
|
||||||
<Tabs
|
|
||||||
value={selectedTab}
|
|
||||||
leftSpace={0}
|
|
||||||
align="center"
|
|
||||||
hideDivider
|
|
||||||
hideBorder
|
|
||||||
onChange={onTabChange}>
|
|
||||||
{!isLoading && pages.map((tab) => {
|
|
||||||
return <Tabs.Item
|
|
||||||
font="14px"
|
|
||||||
label={<>{tab.icon} {tab.name}</>}
|
|
||||||
value={tab.value}
|
|
||||||
key={`${tab.value}`}
|
|
||||||
/>
|
|
||||||
})}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
<div className={styles.controls}>
|
|
||||||
<Button
|
|
||||||
auto
|
|
||||||
type="abort"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
aria-label="Menu"
|
|
||||||
>
|
|
||||||
<Spacer height={5 / 6} width={0} />
|
|
||||||
<MenuIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{isMobile && expanded && (<div className={styles.mobile}>
|
|
||||||
<ButtonGroup vertical>
|
|
||||||
{pages.map((tab, index) => {
|
|
||||||
return <Button
|
|
||||||
key={`${tab.name}-${index}`}
|
|
||||||
onClick={() => onTabChange(tab.value)}
|
|
||||||
icon={tab.icon}
|
|
||||||
>
|
|
||||||
{tab.name}
|
|
||||||
</Button>
|
|
||||||
})}
|
|
||||||
</ButtonGroup>
|
|
||||||
</div>)}
|
|
||||||
</Page.Header >
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header
|
export default Header
|
||||||
|
|
||||||
|
|
||||||
// {/* {/* <ButtonGroup>
|
|
||||||
// <Button onClick={() => {
|
|
||||||
|
|
||||||
// }}><Link href="/signin">Sign out</Link></Button>
|
|
||||||
// <Button>
|
|
||||||
// <Link href="/mine">
|
|
||||||
// Yours
|
|
||||||
// </Link>
|
|
||||||
// </Button>
|
|
||||||
// <Button>
|
|
||||||
// {/* TODO: Link outside Button, but seems to break ButtonGroup */}
|
|
||||||
// <Link href="/new">
|
|
||||||
// New
|
|
||||||
// </Link>
|
|
||||||
// </Button >
|
|
||||||
// <Button onClick={() => changeTheme()}>
|
|
||||||
// <ShiftBy y={6}>{theme.type === 'light' ? <Moon /> : <Sun />}</ShiftBy>
|
|
||||||
// </Button>
|
|
||||||
// </ButtonGroup > * /}
|
|
||||||
@ -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
|
||||||
@ -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 PostList from "../post-list"
|
||||||
import Cookies from "js-cookie"
|
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url, {
|
const MyPosts = ({ posts, error }: { posts: any, error: any }) => {
|
||||||
headers: {
|
return <PostList posts={posts} error={error} />
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${Cookies.get("drift-token")}`
|
|
||||||
},
|
|
||||||
}).then(r => r.json())
|
|
||||||
|
|
||||||
const MyPosts = () => {
|
|
||||||
const { data, error } = useSWR('/server-api/users/mine', fetcher)
|
|
||||||
return <PostList posts={data} error={error} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MyPosts
|
export default MyPosts
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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 { memo, useEffect, useState } from "react"
|
||||||
import ReactMarkdownPreview from "./react-markdown-preview"
|
import styles from './preview.module.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content?: string
|
|
||||||
height?: number | string
|
height?: number | string
|
||||||
|
fileId?: string
|
||||||
|
content?: string
|
||||||
|
title?: string
|
||||||
// file extensions we can highlight
|
// file extensions we can highlight
|
||||||
type?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownPreview = ({ content = '', height = 500, type = 'markdown' }: Props) => {
|
const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
|
||||||
const [contentToRender, setContent] = useState(content)
|
const [preview, setPreview] = useState<string>(content || "")
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 'm' so it doesn't flash code when you change the type to md
|
async function fetchPost() {
|
||||||
const renderAsMarkdown = ['m', 'markdown', 'md', 'mdown', 'mkdn', 'mkd', 'mdwn', 'mdtxt', 'mdtext', 'text', '']
|
if (fileId) {
|
||||||
if (!renderAsMarkdown.includes(type)) {
|
const resp = await fetch(`/server-api/files/html/${fileId}`, {
|
||||||
setContent(`~~~${type}
|
method: "GET",
|
||||||
${content}
|
})
|
||||||
~~~
|
if (resp.ok) {
|
||||||
`)
|
const res = await resp.text()
|
||||||
} else {
|
setPreview(res)
|
||||||
setContent(content)
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
} else if (content) {
|
||||||
|
const resp = await fetch(`/api/render-markdown`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (resp.ok) {
|
||||||
|
const res = await resp.text()
|
||||||
|
setPreview(res)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [type, content])
|
fetchPost()
|
||||||
return (<ReactMarkdownPreview height={height} content={contentToRender} />)
|
}, [content, fileId, title])
|
||||||
|
return (<>
|
||||||
|
{isLoading ? <div>Loading...</div> : <article className={styles.markdownPreview} dangerouslySetInnerHTML={{ __html: preview }} style={{
|
||||||
|
height
|
||||||
|
}} />}
|
||||||
|
</>)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(MarkdownPreview)
|
export default memo(MarkdownPreview)
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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)
|
||||||
@ -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,30 +1,39 @@
|
|||||||
export default function generateUUID() {
|
export default function generateUUID() {
|
||||||
if (typeof crypto === 'object') {
|
if (typeof crypto === "object") {
|
||||||
if (typeof crypto.randomUUID === 'function') {
|
if (typeof crypto.randomUUID === "function") {
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
|
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID()
|
||||||
}
|
}
|
||||||
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
|
if (
|
||||||
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
|
typeof crypto.getRandomValues === "function" &&
|
||||||
const callback = (c: string) => {
|
typeof Uint8Array === "function"
|
||||||
const num = Number(c);
|
) {
|
||||||
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16);
|
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
|
||||||
};
|
const callback = (c: string) => {
|
||||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback);
|
const num = Number(c)
|
||||||
}
|
return (
|
||||||
}
|
num ^
|
||||||
let timestamp = new Date().getTime();
|
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
|
||||||
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0;
|
).toString(16)
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
}
|
||||||
let random = Math.random() * 16;
|
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, callback)
|
||||||
if (timestamp > 0) {
|
}
|
||||||
random = (timestamp + random) % 16 | 0;
|
}
|
||||||
timestamp = Math.floor(timestamp / 16);
|
let timestamp = new Date().getTime()
|
||||||
} else {
|
let perforNow =
|
||||||
random = (perforNow + random) % 16 | 0;
|
(typeof performance !== "undefined" &&
|
||||||
perforNow = Math.floor(perforNow / 16);
|
performance.now &&
|
||||||
}
|
performance.now() * 1000) ||
|
||||||
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16);
|
0
|
||||||
});
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||||
};
|
let random = Math.random() * 16
|
||||||
|
if (timestamp > 0) {
|
||||||
|
random = (timestamp + random) % 16 | 0
|
||||||
|
timestamp = Math.floor(timestamp / 16)
|
||||||
|
} else {
|
||||||
|
random = (perforNow + random) % 16 | 0
|
||||||
|
perforNow = Math.floor(perforNow / 16)
|
||||||
|
}
|
||||||
|
return (c === "x" ? random : (random & 0x3) | 0x8).toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 Cookies from "js-cookie"
|
||||||
import { useCallback, useEffect } from "react"
|
import { useRouter } from "next/router"
|
||||||
import useSharedState from "./use-shared-state";
|
import { useEffect, useState } from "react"
|
||||||
import Cookies from 'js-cookie'
|
import useSharedState from "./use-shared-state"
|
||||||
|
|
||||||
const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: boolean }) => {
|
const useSignedIn = () => {
|
||||||
const [isSignedIn, setSignedIn] = useSharedState('isSignedIn', false)
|
const [signedIn, setSignedIn] = useSharedState(
|
||||||
const [isLoading, setLoading] = useSharedState('isLoading', true)
|
"signedIn",
|
||||||
const signout = useCallback(() => setSignedIn(false), [setSignedIn])
|
typeof window === "undefined" ? false : !!Cookies.get("drift-token")
|
||||||
|
)
|
||||||
|
const token = Cookies.get("drift-token")
|
||||||
|
const router = useRouter()
|
||||||
|
const signin = (token: string) => {
|
||||||
|
setSignedIn(true)
|
||||||
|
Cookies.set("drift-token", token)
|
||||||
|
}
|
||||||
|
|
||||||
const router = useRouter();
|
const signout = () => {
|
||||||
if (redirectIfNotAuthed && !isLoading && isSignedIn === false) {
|
setSignedIn(false)
|
||||||
router.push('/signin')
|
Cookies.remove("drift-token")
|
||||||
}
|
router.push("/")
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function checkToken() {
|
if (token) {
|
||||||
const token = Cookies.get('drift-token')
|
setSignedIn(true)
|
||||||
if (token) {
|
} else {
|
||||||
const response = await fetch('/server-api/auth/verify-token', {
|
setSignedIn(false)
|
||||||
method: 'GET',
|
}
|
||||||
headers: {
|
}, [setSignedIn, token])
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
setSignedIn(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
setLoading(true)
|
|
||||||
checkToken()
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
return { signedIn, signin, token, signout }
|
||||||
checkToken()
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [setLoading, setSignedIn])
|
|
||||||
|
|
||||||
return { isSignedIn, isLoading, signout }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useSignedIn
|
export default useSignedIn
|
||||||
|
|||||||
@ -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
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -1,24 +1,34 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from "next"
|
import { NextApiRequest, NextApiResponse } from "next"
|
||||||
|
|
||||||
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
|
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const { id, download } = req.query
|
const { id, download } = req.query
|
||||||
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`)
|
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`, {
|
||||||
if (file.ok) {
|
headers: {
|
||||||
const data = await file.json()
|
Accept: "text/plain",
|
||||||
const { title, content } = data
|
"x-secret-key": process.env.SECRET_KEY || "",
|
||||||
// serve the file raw as plain text
|
Authorization: `Bearer ${req.cookies["drift-token"]}`
|
||||||
res.setHeader("Content-Type", "text/plain")
|
}
|
||||||
res.setHeader('Cache-Control', 's-maxage=86400');
|
})
|
||||||
if (download) {
|
|
||||||
res.setHeader("Content-Disposition", `attachment; filename="${title}"`)
|
|
||||||
} else {
|
|
||||||
res.setHeader("Content-Disposition", `inline; filename="${title}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).send(content)
|
res.setHeader("Content-Type", "text/plain; charset=utf-8")
|
||||||
} else {
|
res.setHeader("Cache-Control", "s-maxage=86400")
|
||||||
res.status(404).send("File not found")
|
if (file.ok) {
|
||||||
}
|
const json = await file.json()
|
||||||
|
const data = json
|
||||||
|
const { title, content } = data
|
||||||
|
// serve the file raw as plain text
|
||||||
|
|
||||||
|
if (download) {
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${title}"`)
|
||||||
|
} else {
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${title}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).write(content, "utf-8")
|
||||||
|
res.end()
|
||||||
|
} else {
|
||||||
|
res.status(404).send("File not found")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default getRawFile
|
export default getRawFile
|
||||||
|
|||||||
@ -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,20 +1,60 @@
|
|||||||
import styles from '@styles/Home.module.css'
|
import styles from '@styles/Home.module.css'
|
||||||
import { Page } from '@geist-ui/core'
|
|
||||||
|
|
||||||
import Header from '@components/header'
|
import Header from '@components/header'
|
||||||
import MyPosts from '@components/my-posts'
|
import MyPosts from '@components/my-posts'
|
||||||
|
import cookie from "cookie";
|
||||||
|
import type { GetServerSideProps } from 'next';
|
||||||
|
import { Post } from '@lib/types';
|
||||||
|
import { Page } from '@geist-ui/core';
|
||||||
|
|
||||||
const Home = ({ theme, changeTheme }: { theme: "light" | "dark", changeTheme: () => void }) => {
|
const Home = ({ posts, error }: { posts: Post[]; error: any; }) => {
|
||||||
return (
|
return (
|
||||||
<Page className={styles.container} width="100%">
|
<Page className={styles.container}>
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Header theme={theme} changeTheme={changeTheme} />
|
<Header />
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
<MyPosts />
|
<MyPosts error={error} posts={posts} />
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page >
|
</Page >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// get server side props
|
||||||
|
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
|
||||||
|
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`]
|
||||||
|
if (!driftToken) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: '/',
|
||||||
|
permanent: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = await fetch(process.env.API_URL + `/posts/mine`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${driftToken}`,
|
||||||
|
"x-secret-key": process.env.SECRET_KEY || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!posts.ok || posts.status !== 200) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: '/',
|
||||||
|
permanent: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
posts: await posts.json(),
|
||||||
|
error: posts.status !== 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default Home
|
export default Home
|
||||||
|
|||||||
@ -1,31 +1,23 @@
|
|||||||
import styles from '@styles/Home.module.css'
|
import styles from '@styles/Home.module.css'
|
||||||
import NewPost from '@components/new-post'
|
import NewPost from '@components/new-post'
|
||||||
import { Page } from '@geist-ui/core'
|
|
||||||
import useSignedIn from '@lib/hooks/use-signed-in'
|
|
||||||
import Header from '@components/header'
|
import Header from '@components/header'
|
||||||
import { ThemeProps } from './_app'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import PageSeo from '@components/page-seo'
|
import PageSeo from '@components/page-seo'
|
||||||
|
import { Page } from '@geist-ui/core'
|
||||||
|
|
||||||
const Home = ({ theme, changeTheme }: ThemeProps) => {
|
const New = () => {
|
||||||
const router = useRouter()
|
|
||||||
const { isSignedIn, isLoading } = useSignedIn({ redirectIfNotAuthed: true })
|
|
||||||
if (!isSignedIn && !isLoading) {
|
|
||||||
router.push("/signin")
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Page className={styles.container} width="100%">
|
<Page className={styles.container} width="100%">
|
||||||
<PageSeo title="Drift - New" />
|
<PageSeo title="Drift - New" />
|
||||||
|
|
||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Header theme={theme} changeTheme={changeTheme} />
|
<Header />
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|
||||||
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}>
|
<Page.Content className={styles.main}>
|
||||||
{isSignedIn && <NewPost />}
|
<NewPost />
|
||||||
</Page.Content>
|
</Page.Content>
|
||||||
</Page >
|
</Page >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Home
|
export default New
|
||||||
|
|||||||
@ -1,111 +1,49 @@
|
|||||||
import { Button, Page, Text } from "@geist-ui/core";
|
import type { GetStaticPaths, GetStaticProps } from "next";
|
||||||
import Skeleton from 'react-loading-skeleton';
|
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
import type { Post } from "@lib/types";
|
||||||
import { useEffect, useState } from "react";
|
import PostPage from "@components/post-page";
|
||||||
import Document from '../../components/document'
|
|
||||||
import Header from "../../components/header";
|
|
||||||
import VisibilityBadge from "../../components/visibility-badge";
|
|
||||||
import { ThemeProps } from "../_app";
|
|
||||||
import PageSeo from "components/page-seo";
|
|
||||||
import Head from "next/head";
|
|
||||||
import styles from './styles.module.css';
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
|
|
||||||
const Post = ({ theme, changeTheme }: ThemeProps) => {
|
export type PostProps = {
|
||||||
const [post, setPost] = useState<any>()
|
post: Post
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
}
|
||||||
const [error, setError] = useState<string>()
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
const PostView = ({ post }: PostProps) => {
|
||||||
async function fetchPost() {
|
return <PostPage post={post} />
|
||||||
setIsLoading(true);
|
}
|
||||||
if (router.query.id) {
|
|
||||||
const post = await fetch(`/server-api/posts/${router.query.id}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `Bearer ${Cookies.get("drift-token")}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (post.ok) {
|
export const getStaticPaths: GetStaticPaths = async () => {
|
||||||
const res = await post.json()
|
const posts = await fetch(process.env.API_URL + `/posts/`, {
|
||||||
if (res)
|
method: "GET",
|
||||||
setPost(res)
|
headers: {
|
||||||
else
|
"Content-Type": "application/json",
|
||||||
setError("Post not found")
|
"x-secret-key": process.env.SECRET_KEY || "",
|
||||||
} else {
|
|
||||||
if (post.status.toString().startsWith("4")) {
|
|
||||||
router.push("/signin")
|
|
||||||
} else {
|
|
||||||
setError(post.statusText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fetchPost()
|
})
|
||||||
}, [router, router.query.id])
|
|
||||||
|
|
||||||
const download = async () => {
|
const json = await posts.json()
|
||||||
const clientZip = require("client-zip")
|
const filtered = json.filter((post: Post) => post.visibility === "public" || post.visibility === "unlisted")
|
||||||
|
const paths = filtered.map((post: Post) => ({
|
||||||
|
params: { id: post.id }
|
||||||
|
}))
|
||||||
|
|
||||||
const blob = await clientZip.downloadZip(post.files.map((file: any) => {
|
return { paths, fallback: 'blocking' }
|
||||||
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 (
|
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
||||||
<Page width={"100%"}>
|
const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, {
|
||||||
{!isLoading && (
|
method: "GET",
|
||||||
<PageSeo
|
headers: {
|
||||||
title={`${post.title} - Drift`}
|
"Content-Type": "application/json",
|
||||||
description={post.description}
|
"x-secret-key": process.env.SECRET_KEY || "",
|
||||||
isPrivate={post.visibility === 'private'}
|
}
|
||||||
/>
|
})
|
||||||
)}
|
|
||||||
|
|
||||||
<Page.Header>
|
return {
|
||||||
<Header theme={theme} changeTheme={changeTheme} />
|
props: {
|
||||||
</Page.Header>
|
post: await post.json()
|
||||||
<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
|
export default PostView
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 {
|
.wrapper {
|
||||||
min-height: 100vh;
|
height: 100% !important;
|
||||||
flex: 1;
|
padding-bottom: var(--small-gap) !important;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: var(--main-content-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
.main {
|
||||||
.container {
|
max-width: var(--main-content) !important;
|
||||||
width: 100%;
|
margin: 0 auto !important;
|
||||||
margin: 0 auto !important;
|
padding: 0 0 !important;
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,154 @@
|
|||||||
|
@import "./syntax.css";
|
||||||
|
@import "./markdown.css";
|
||||||
|
@import "./inter.css";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--main-content-width: 800px;
|
/* Spacing */
|
||||||
--page-nav-height: 60px;
|
--gap-quarter: 0.25rem;
|
||||||
--gap: 8px;
|
--gap-half: 0.5rem;
|
||||||
--gap-half: calc(var(--gap) / 2);
|
--gap: 1rem;
|
||||||
--gap-double: calc(var(--gap) * 2);
|
--gap-double: 2rem;
|
||||||
--border-radius: 4px;
|
--small-gap: 4rem;
|
||||||
--font-size: 16px;
|
--big-gap: 4rem;
|
||||||
|
--main-content: 55rem;
|
||||||
|
--radius: 8px;
|
||||||
|
--inline-radius: 5px;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
|
||||||
|
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
|
--font-mono: ui-monospace, "SFMono-Regular", "Consolas", "Liberation Mono",
|
||||||
|
"Menlo", monospace;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition: 0.1s ease-in-out;
|
||||||
|
--transition-slow: 0.3s ease-in-out;
|
||||||
|
|
||||||
|
--page-nav-height: 64px;
|
||||||
|
--token: #999;
|
||||||
|
--comment: #999;
|
||||||
|
--keyword: #fff;
|
||||||
|
--name: #fff;
|
||||||
|
--highlight: #2e2e2e;
|
||||||
|
|
||||||
|
/* Dark Mode Colors */
|
||||||
|
--bg: #000;
|
||||||
|
--fg: #fafbfc;
|
||||||
|
--gray: #666;
|
||||||
|
--light-gray: #444;
|
||||||
|
--lighter-gray: #222;
|
||||||
|
--lightest-gray: #1a1a1a;
|
||||||
|
--darker-gray: #b4b4b4;
|
||||||
|
--darkest-gray: #efefef;
|
||||||
|
--article-color: #eaeaea;
|
||||||
|
--header-bg: rgba(19, 20, 21, 0.45);
|
||||||
|
--gray-alpha: rgba(255, 255, 255, 0.5);
|
||||||
|
--selection: rgba(255, 255, 255, 0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
* {
|
||||||
:root {
|
box-sizing: border-box;
|
||||||
--main-content-width: 100%;
|
}
|
||||||
}
|
|
||||||
|
::selection {
|
||||||
|
text-shadow: none;
|
||||||
|
background: var(--selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
font-size: 15px;
|
||||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
body {
|
||||||
color: inherit;
|
min-height: 100vh;
|
||||||
text-decoration: none;
|
font-family: var(--font-sans);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
p {
|
||||||
box-sizing: border-box;
|
overflow-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 3px solid var(--light-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.reset {
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
:root {
|
||||||
|
--bg: #fff;
|
||||||
|
--fg: #000;
|
||||||
|
--gray: #888;
|
||||||
|
--light-gray: #dedede;
|
||||||
|
--lighter-gray: #f5f5f5;
|
||||||
|
--lightest-gray: #fafafa;
|
||||||
|
--article-color: #212121;
|
||||||
|
--header-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--gray-alpha: rgba(19, 20, 21, 0.5);
|
||||||
|
--selection: rgba(0, 0, 0, 0.99);
|
||||||
|
|
||||||
|
--token: #666;
|
||||||
|
--comment: #999;
|
||||||
|
--keyword: #000;
|
||||||
|
--name: #333;
|
||||||
|
--highlight: #eaeaea;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#root,
|
||||||
|
#__next {
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
drift.sqlite
|
||||||
@ -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']
|
|
||||||
});
|
|
||||||
@ -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,
|
||||||
|
};
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,36 +1,72 @@
|
|||||||
import { celebrate, Joi } from "celebrate";
|
import { celebrate, Joi } from "celebrate";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
// import { Movie } from '../models/Post'
|
import { File } from "@lib/models/File";
|
||||||
import { File } from "../../lib/models/File";
|
import secretKey from "@lib/middleware/secret-key";
|
||||||
|
|
||||||
export const files = Router();
|
export const files = Router();
|
||||||
|
|
||||||
files.get(
|
files.get("/raw/:id",
|
||||||
"/raw/:id",
|
|
||||||
celebrate({
|
celebrate({
|
||||||
params: {
|
params: {
|
||||||
id: Joi.string().required(),
|
id: Joi.string().required(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
secretKey,
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const file = await File.findOne({
|
const file = await File.findOne({
|
||||||
where: {
|
where: {
|
||||||
id: req.params.id,
|
id: req.params.id
|
||||||
},
|
},
|
||||||
attributes: ["title", "content"],
|
attributes: ["title", "content"],
|
||||||
});
|
})
|
||||||
// TODO: fix post inclusion
|
|
||||||
// if (file?.post.visibility === 'public' || file?.post.visibility === 'unlisted') {
|
if (!file) {
|
||||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
return res.status(404).json({ error: "File not found" })
|
||||||
res.json(file);
|
}
|
||||||
// } else {
|
|
||||||
// TODO: should this be `private, `?
|
// TODO: JWT-checkraw files
|
||||||
// res.setHeader("Cache-Control", "max-age=86400");
|
if (file?.post?.visibility === "private") {
|
||||||
// res.json(file);
|
// jwt(req as UserJwtRequest, res, () => {
|
||||||
// }
|
// res.json(file);
|
||||||
} catch (e) {
|
// })
|
||||||
|
res.json(file);
|
||||||
|
} else {
|
||||||
|
res.json(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
next(e);
|
next(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
|
|
||||||
|
files.get("/html/:id",
|
||||||
|
celebrate({
|
||||||
|
params: {
|
||||||
|
id: Joi.string().required(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const file = await File.findOne({
|
||||||
|
where: {
|
||||||
|
id: req.params.id
|
||||||
|
},
|
||||||
|
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,47 +1,14 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { User } from "../../lib/models/User";
|
// import jwt from "@lib/middleware/jwt";
|
||||||
import { File } from "../../lib/models/File";
|
// import { User } from "@lib/models/User";
|
||||||
import jwt, { UserJwtRequest } from "../../lib/middleware/jwt";
|
|
||||||
import { Post } from "../../lib/models/Post";
|
|
||||||
|
|
||||||
export const users = Router();
|
export const users = Router();
|
||||||
|
|
||||||
users.get("/", jwt, async (req, res, next) => {
|
// users.get("/", jwt, async (req, res, next) => {
|
||||||
try {
|
// try {
|
||||||
const allUsers = await User.findAll();
|
// const allUsers = await User.findAll();
|
||||||
res.json(allUsers);
|
// res.json(allUsers);
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
next(error);
|
// next(error);
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
|
|
||||||
users.get("/mine", jwt, async (req: UserJwtRequest, res, next) => {
|
|
||||||
if (!req.user) {
|
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await User.findByPk(req.user.id, {
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: Post,
|
|
||||||
as: "posts",
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: File,
|
|
||||||
as: "files",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
return res.status(404).json({ error: "User not found" });
|
|
||||||
}
|
|
||||||
return res.json(
|
|
||||||
user.posts?.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
Loading…
Reference in New Issue