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:
		
							parent
							
								
									281b712258
								
							
						
					
					
						commit
						f393246e4f
					
				| 
						 | 
					@ -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
 | 
				
			||||||
      {categories.length > 0 && (
 | 
					        </Button>
 | 
				
			||||||
        <div className="mt-4">
 | 
					        <Button color="gray" onClick={() => setEditing(false)}>
 | 
				
			||||||
          <TagsList tags={categories} noLabel />
 | 
					          Cancel
 | 
				
			||||||
        </div>
 | 
					        </Button>
 | 
				
			||||||
      )}
 | 
					      </Row>
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
      <br />
 | 
					  ) : (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
      {isCreator && (
 | 
					      <Content content={contract.description} />
 | 
				
			||||||
        <EditContract
 | 
					      <Spacer h={2} />
 | 
				
			||||||
          // Note: Because descriptionTimestamp is called once, later edits use
 | 
					      <Row className="gap-2">
 | 
				
			||||||
          // a stale timestamp. Ideally this is a function that gets called when
 | 
					        <Button
 | 
				
			||||||
          // isEditing is set to true.
 | 
					          color="gray"
 | 
				
			||||||
          text={descriptionTimestamp()}
 | 
					          onClick={() => {
 | 
				
			||||||
          onSave={saveDescription}
 | 
					            setEditing(true)
 | 
				
			||||||
          buttonText="Add to description"
 | 
					            editor
 | 
				
			||||||
        />
 | 
					              ?.chain()
 | 
				
			||||||
      )}
 | 
					              .setContent(contract.description)
 | 
				
			||||||
      {isAdmin && (
 | 
					              .focus('end')
 | 
				
			||||||
        <EditContract
 | 
					              .insertContent(`<p>${editTimestamp()}</p>`)
 | 
				
			||||||
          text={contract.question}
 | 
					              .run()
 | 
				
			||||||
          onSave={(question) => updateContract(contract.id, { question })}
 | 
					          }}
 | 
				
			||||||
          buttonText="ADMIN: Edit question"
 | 
					        >
 | 
				
			||||||
        />
 | 
					          Edit description
 | 
				
			||||||
      )}
 | 
					        </Button>
 | 
				
			||||||
      {/* {isAdmin && (
 | 
					        <Button color="gray" onClick={() => setEditingQ(true)}>
 | 
				
			||||||
        <EditContract
 | 
					          Edit question
 | 
				
			||||||
          text={contract.createdTime.toString()}
 | 
					        </Button>
 | 
				
			||||||
          onSave={(time) =>
 | 
					      </Row>
 | 
				
			||||||
            updateContract(contract.id, { createdTime: Number(time) })
 | 
					      <EditQuestion
 | 
				
			||||||
          }
 | 
					        contract={contract}
 | 
				
			||||||
          buttonText="ADMIN: Edit createdTime"
 | 
					        editing={editingQ}
 | 
				
			||||||
        />
 | 
					        setEditing={setEditingQ}
 | 
				
			||||||
      )} */}
 | 
					      />
 | 
				
			||||||
    </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>
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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' ? (
 | 
				
			||||||
    <Linkify text={content} />
 | 
					    <div className="whitespace-pre-line break-words">
 | 
				
			||||||
 | 
					      <Linkify text={content} />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
  ) : (
 | 
					  ) : (
 | 
				
			||||||
    <RichContent content={content} />
 | 
					    <RichContent content={content} />
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user