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
|
||||||
|
</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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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} />
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user