mirror of https://github.com/MaxLeiter/Drift
server: store and render markdown on server
parent
30e32e33cf
commit
19988e49ed
@ -0,0 +1,20 @@
|
||||
import useTheme from "@lib/hooks/use-theme"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import styles from './preview.module.css'
|
||||
|
||||
type Props = {
|
||||
height?: number | string
|
||||
html: string
|
||||
// file extensions we can highlight
|
||||
}
|
||||
|
||||
const HtmlPreview = ({ height = 500, html }: Props) => {
|
||||
const { theme } = useTheme()
|
||||
return (<article
|
||||
data-theme={theme}
|
||||
className={styles.markdownPreview}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
style={{ height }} />)
|
||||
}
|
||||
|
||||
export default HtmlPreview
|
||||
@ -0,0 +1,22 @@
|
||||
import type { Document } from "@lib/types"
|
||||
import DocumentComponent from "@components/edit-document"
|
||||
import { ChangeEvent, memo, useCallback } 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,41 @@
|
||||
.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,133 @@
|
||||
|
||||
|
||||
import { memo, useRef, useState } from "react"
|
||||
import styles from './document.module.css'
|
||||
import Download from '@geist-ui/icons/download'
|
||||
import ExternalLink from '@geist-ui/icons/externalLink'
|
||||
import Skeleton from "react-loading-skeleton"
|
||||
|
||||
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
|
||||
import Preview from "@components/preview"
|
||||
import HtmlPreview from "@components/preview/html"
|
||||
|
||||
// import Link from "next/link"
|
||||
type Props = {
|
||||
title: string
|
||||
html: string
|
||||
initialTab?: "edit" | "preview"
|
||||
skeleton?: boolean
|
||||
id: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
|
||||
return (<div className={styles.actionWrapper}>
|
||||
<ButtonGroup className={styles.actions}>
|
||||
<Tooltip text="Download">
|
||||
<a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
scale={2 / 3} px={0.6}
|
||||
icon={<Download />}
|
||||
auto
|
||||
aria-label="Download"
|
||||
/>
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip text="Open raw in new tab">
|
||||
<a href={rawLink} target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
scale={2 / 3} px={0.6}
|
||||
icon={<ExternalLink />}
|
||||
auto
|
||||
aria-label="Open raw file in new tab"
|
||||
/>
|
||||
</a>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
const Document = ({ content, title, html, initialTab = 'edit', skeleton, id }: 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 rawLink = () => {
|
||||
if (id) {
|
||||
return `/file/raw/${id}`
|
||||
}
|
||||
}
|
||||
|
||||
if (skeleton) {
|
||||
return <>
|
||||
<Spacer height={1} />
|
||||
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}>
|
||||
<div className={styles.fileNameContainer}>
|
||||
<Skeleton width={275} height={36} />
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
<div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
|
||||
<Skeleton width={'100%'} height={350} />
|
||||
</div >
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spacer height={1} />
|
||||
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}>
|
||||
<div className={styles.fileNameContainer}>
|
||||
<Input
|
||||
value={title}
|
||||
readOnly
|
||||
marginTop="var(--gap-double)"
|
||||
size={1.2}
|
||||
font={1.2}
|
||||
label="Filename"
|
||||
width={"100%"}
|
||||
id={title}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
<DownloadButton rawLink={rawLink()} />
|
||||
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
|
||||
<Tabs.Item label={"Raw"} value="edit">
|
||||
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
||||
<div style={{ marginTop: 'var(--gap)', display: 'flex', flexDirection: 'column' }}>
|
||||
<Textarea
|
||||
readOnly
|
||||
ref={codeEditorRef}
|
||||
value={content}
|
||||
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">
|
||||
<HtmlPreview height={height} html={html} />
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
|
||||
</div >
|
||||
</Card >
|
||||
<Spacer height={1} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default memo(Document)
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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,141 @@
|
||||
import { marked } from 'marked'
|
||||
import Highlight, { defaultProps, Language, } from 'prism-react-renderer'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
//@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" }} >
|
||||
{text}
|
||||
</a>
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
renderer.link = (href, _, text) => {
|
||||
const isHrefLocal = href?.startsWith('/') || href?.startsWith('#')
|
||||
if (isHrefLocal) {
|
||||
return renderToStaticMarkup(
|
||||
<a href={href || ''}>
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue