Let users edit descriptions and questions (#654)

* Use rich text editor on the description

* Write a new line to description when the question is changed

* Stop showing categories

* Allow anyone to edit their own question
This commit is contained in:
Austin Chen 2022-07-17 22:22:44 -07:00 committed by GitHub
parent 281b712258
commit f393246e4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 119 additions and 98 deletions

View File

@ -2,16 +2,17 @@ import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useState } from 'react' import { useState } from 'react'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { CATEGORY_LIST } from '../../../common/categories'
import { Contract } from 'common/contract' import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
import { parseTags, exhibitExts } from 'common/util/parse' import { exhibitExts, parseTags } from 'common/util/parse'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { updateContract } from 'web/lib/firebase/contracts' import { updateContract } from 'web/lib/firebase/contracts'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { TagsList } from '../tags-list'
import { Content } from '../editor' import { Content } from '../editor'
import { Editor } from '@tiptap/react' import { TextEditor, useTextEditor } from 'web/components/editor'
import { Button } from '../button'
import { Spacer } from '../layout/spacer'
import { Editor, Content as ContentType } from '@tiptap/react'
export function ContractDescription(props: { export function ContractDescription(props: {
contract: Contract contract: Contract
@ -19,20 +20,39 @@ export function ContractDescription(props: {
className?: string className?: string
}) { }) {
const { contract, isCreator, className } = props const { contract, isCreator, className } = props
const descriptionTimestamp = () => `${dayjs().format('MMM D, h:mma')}: `
const isAdmin = useAdmin() const isAdmin = useAdmin()
return (
<div className={clsx('mt-2 text-gray-700', className)}>
{isCreator || isAdmin ? (
<RichEditContract contract={contract} />
) : (
<Content content={contract.description} />
)}
{isAdmin && !isCreator && (
<div className="mt-2 text-red-400">(👆 admin powers)</div>
)}
</div>
)
}
const desc = contract.description ?? '' function editTimestamp() {
return `${dayjs().format('MMM D, h:mma')}: `
}
// Append the new description (after a newline) function RichEditContract(props: { contract: Contract }) {
async function saveDescription(newText: string) { const { contract } = props
const editor = new Editor({ content: desc, extensions: exhibitExts }) const [editing, setEditing] = useState(false)
editor const [editingQ, setEditingQ] = useState(false)
.chain() const [isSubmitting, setIsSubmitting] = useState(false)
.focus('end')
.insertContent('<br /><br />') const { editor, upload } = useTextEditor({
.insertContent(newText.trim()) max: MAX_DESCRIPTION_LENGTH,
.run() defaultValue: contract.description,
disabled: isSubmitting,
})
async function saveDescription() {
if (!editor) return
const tags = parseTags( const tags = parseTags(
`${editor.getText()} ${contract.tags.map((tag) => `#${tag}`).join(' ')}` `${editor.getText()} ${contract.tags.map((tag) => `#${tag}`).join(' ')}`
@ -46,76 +66,92 @@ export function ContractDescription(props: {
}) })
} }
const { tags } = contract return editing ? (
const categories = tags.filter((tag) => <>
CATEGORY_LIST.includes(tag.toLowerCase()) <TextEditor editor={editor} upload={upload} />
) <Spacer h={2} />
<Row className="gap-2">
return ( <Button
<div onClick={async () => {
className={clsx( setIsSubmitting(true)
'mt-2 whitespace-pre-line break-words text-gray-700', await saveDescription()
className setEditing(false)
)} setIsSubmitting(false)
}}
> >
<Content content={desc} /> Save
</Button>
{categories.length > 0 && ( <Button color="gray" onClick={() => setEditing(false)}>
<div className="mt-4"> Cancel
<TagsList tags={categories} noLabel /> </Button>
</div> </Row>
)} </>
) : (
<br /> <>
<Content content={contract.description} />
{isCreator && ( <Spacer h={2} />
<EditContract <Row className="gap-2">
// Note: Because descriptionTimestamp is called once, later edits use <Button
// a stale timestamp. Ideally this is a function that gets called when color="gray"
// isEditing is set to true. onClick={() => {
text={descriptionTimestamp()} setEditing(true)
onSave={saveDescription} editor
buttonText="Add to description" ?.chain()
.setContent(contract.description)
.focus('end')
.insertContent(`<p>${editTimestamp()}</p>`)
.run()
}}
>
Edit description
</Button>
<Button color="gray" onClick={() => setEditingQ(true)}>
Edit question
</Button>
</Row>
<EditQuestion
contract={contract}
editing={editingQ}
setEditing={setEditingQ}
/> />
)} </>
{isAdmin && (
<EditContract
text={contract.question}
onSave={(question) => updateContract(contract.id, { question })}
buttonText="ADMIN: Edit question"
/>
)}
{/* {isAdmin && (
<EditContract
text={contract.createdTime.toString()}
onSave={(time) =>
updateContract(contract.id, { createdTime: Number(time) })
}
buttonText="ADMIN: Edit createdTime"
/>
)} */}
</div>
) )
} }
function EditContract(props: { function EditQuestion(props: {
text: string contract: Contract
onSave: (newText: string) => void editing: boolean
buttonText: string setEditing: (editing: boolean) => void
}) { }) {
const [text, setText] = useState(props.text) const { contract, editing, setEditing } = props
const [editing, setEditing] = useState(false) const [text, setText] = useState(contract.question)
const onSave = (newText: string) => {
function questionChanged(oldQ: string, newQ: string) {
return `<p>${editTimestamp()}<s>${oldQ}</s> → ${newQ}</p>`
}
function joinContent(oldContent: ContentType, newContent: string) {
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
editor.chain().focus('end').insertContent(newContent).run()
return editor.getJSON()
}
const onSave = async (newText: string) => {
setEditing(false) setEditing(false)
setText(props.text) // Reset to original text await updateContract(contract.id, {
props.onSave(newText) question: newText,
description: joinContent(
contract.description,
questionChanged(contract.question, newText)
),
})
} }
return editing ? ( return editing ? (
<div className="mt-4"> <div className="mt-4">
<Textarea <Textarea
className="textarea textarea-bordered mb-1 h-24 w-full resize-none" className="textarea textarea-bordered mb-1 h-24 w-full resize-none"
rows={3} rows={2}
value={text} value={text}
onChange={(e) => setText(e.target.value || '')} onChange={(e) => setText(e.target.value || '')}
autoFocus autoFocus
@ -130,28 +166,11 @@ function EditContract(props: {
}} }}
/> />
<Row className="gap-2"> <Row className="gap-2">
<button <Button onClick={() => onSave(text)}>Save</Button>
className="btn btn-neutral btn-outline btn-sm" <Button color="gray" onClick={() => setEditing(false)}>
onClick={() => onSave(text)}
>
Save
</button>
<button
className="btn btn-error btn-outline btn-sm"
onClick={() => setEditing(false)}
>
Cancel Cancel
</button> </Button>
</Row> </Row>
</div> </div>
) : ( ) : null
<Row>
<button
className="btn btn-neutral btn-outline btn-xs mt-4"
onClick={() => setEditing(true)}
>
{props.buttonText}
</button>
</Row>
)
} }

View File

@ -21,7 +21,7 @@ import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link' import { linkClass } from './site-link'
const proseClass = clsx( const proseClass = clsx(
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless font-light' 'prose prose-p:my-2 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless font-light'
) )
export function useTextEditor(props: { export function useTextEditor(props: {
@ -155,7 +155,9 @@ function RichContent(props: { content: JSONContent }) {
export function Content(props: { content: JSONContent | string }) { export function Content(props: { content: JSONContent | string }) {
const { content } = props const { content } = props
return typeof content === 'string' ? ( return typeof content === 'string' ? (
<div className="whitespace-pre-line break-words">
<Linkify text={content} /> <Linkify text={content} />
</div>
) : ( ) : (
<RichContent content={content} /> <RichContent content={content} />
) )