Add editor toolbar to choose and embed markets (#702)
* Embed markets using the "add markets" template * Override dev domain * Improve market modal style - contract searchbar now sticky - entire card clickable to select (if quickbet is hidden) - adjust selected card styles * remove extra export * Hide pills * Fix browser redirect warning * Insert all markets instead of just one * fix type error * fixup "Insert all markets instead of just one" Co-authored-by: Sinclair Chen <abc.sinclair@gmail.com>
This commit is contained in:
parent
4e8b94a28c
commit
dc95587cca
|
@ -2,6 +2,7 @@ import { EnvConfig, PROD_CONFIG } from './prod'
|
|||
|
||||
export const DEV_CONFIG: EnvConfig = {
|
||||
...PROD_CONFIG,
|
||||
domain: 'dev.manifold.markets',
|
||||
firebaseConfig: {
|
||||
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
|
||||
authDomain: 'dev-mantic-markets.firebaseapp.com',
|
||||
|
|
|
@ -3,14 +3,17 @@ import algoliasearch from 'algoliasearch/lite'
|
|||
|
||||
import { Contract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
|
||||
import {
|
||||
QuerySortOptions,
|
||||
Sort,
|
||||
useQueryAndSortParams,
|
||||
} from '../hooks/use-sort-and-query-params'
|
||||
import {
|
||||
ContractHighlightOptions,
|
||||
ContractsGrid,
|
||||
} from './contract/contracts-grid'
|
||||
import { Row } from './layout/row'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import { track, trackCallback } from 'web/lib/service/analytics'
|
||||
|
@ -21,6 +24,7 @@ import { PillButton } from './buttons/pill-button'
|
|||
import { range, sortBy } from 'lodash'
|
||||
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
||||
import { Col } from './layout/col'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
'GJQPAYENIF',
|
||||
|
@ -45,12 +49,8 @@ export const DEFAULT_SORT = 'score'
|
|||
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
|
||||
|
||||
export function ContractSearch(props: {
|
||||
user: User | null | undefined
|
||||
querySortOptions?: {
|
||||
defaultSort: Sort
|
||||
defaultFilter?: filter
|
||||
shouldLoadFromStorage?: boolean
|
||||
}
|
||||
user?: User | null
|
||||
querySortOptions?: { defaultFilter?: filter } & QuerySortOptions
|
||||
additionalFilter?: {
|
||||
creatorId?: string
|
||||
tag?: string
|
||||
|
@ -66,6 +66,7 @@ export function ContractSearch(props: {
|
|||
hideGroupLink?: boolean
|
||||
hideQuickBet?: boolean
|
||||
}
|
||||
headerClassName?: string
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
|
@ -77,6 +78,7 @@ export function ContractSearch(props: {
|
|||
showPlaceHolder,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
headerClassName,
|
||||
} = props
|
||||
|
||||
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
|
||||
|
@ -99,11 +101,8 @@ export function ContractSearch(props: {
|
|||
|
||||
const follows = useFollows(user?.id)
|
||||
|
||||
const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {}
|
||||
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
|
||||
defaultSort,
|
||||
shouldLoadFromStorage,
|
||||
})
|
||||
const { query, setQuery, sort, setSort } =
|
||||
useQueryAndSortParams(querySortOptions)
|
||||
|
||||
const [filter, setFilter] = useState<filter>(
|
||||
querySortOptions?.defaultFilter ?? 'open'
|
||||
|
@ -257,7 +256,13 @@ export function ContractSearch(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<Col className="h-full">
|
||||
<Col
|
||||
className={clsx(
|
||||
'bg-base-200 sticky top-0 z-20 gap-3 pb-3',
|
||||
headerClassName
|
||||
)}
|
||||
>
|
||||
<Row className="gap-1 sm:gap-2">
|
||||
<input
|
||||
type="text"
|
||||
|
@ -294,8 +299,6 @@ export function ContractSearch(props: {
|
|||
)}
|
||||
</Row>
|
||||
|
||||
<Spacer h={3} />
|
||||
|
||||
{pillsEnabled && (
|
||||
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
|
||||
<PillButton
|
||||
|
@ -336,8 +339,7 @@ export function ContractSearch(props: {
|
|||
})}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Spacer h={3} />
|
||||
</Col>
|
||||
|
||||
{filter === 'personal' &&
|
||||
(follows ?? []).length === 0 &&
|
||||
|
|
|
@ -76,7 +76,8 @@ export function ContractCard(props: {
|
|||
<Col className="relative flex-1 gap-3 pr-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'peer absolute -left-6 -top-4 -bottom-4 right-0 z-10'
|
||||
'peer absolute -left-6 -top-4 -bottom-4 z-10',
|
||||
hideQuickBet ? '-right-20' : 'right-0'
|
||||
)}
|
||||
>
|
||||
{onClick ? (
|
||||
|
|
|
@ -13,7 +13,7 @@ import { TextEditor, useTextEditor } from 'web/components/editor'
|
|||
import { Button } from '../button'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { Editor, Content as ContentType } from '@tiptap/react'
|
||||
import { appendToEditor } from '../editor/utils'
|
||||
import { insertContent } from '../editor/utils'
|
||||
|
||||
export function ContractDescription(props: {
|
||||
contract: Contract
|
||||
|
@ -95,7 +95,8 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
|
|||
size="xs"
|
||||
onClick={() => {
|
||||
setEditing(true)
|
||||
appendToEditor(editor, `<p>${editTimestamp()}</p>`)
|
||||
editor?.commands.focus('end')
|
||||
insertContent(editor, `<p>${editTimestamp()}</p>`)
|
||||
}}
|
||||
>
|
||||
Edit description
|
||||
|
@ -127,7 +128,7 @@ function EditQuestion(props: {
|
|||
|
||||
function joinContent(oldContent: ContentType, newContent: string) {
|
||||
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
|
||||
appendToEditor(editor, newContent)
|
||||
insertContent(editor, newContent)
|
||||
return editor.getJSON()
|
||||
}
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ import { Col } from 'web/components/layout/col'
|
|||
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { appendToEditor } from '../editor/utils'
|
||||
import { insertContent } from '../editor/utils'
|
||||
|
||||
export type ShowTime = 'resolve-date' | 'close-date'
|
||||
|
||||
|
@ -283,7 +283,8 @@ function EditableCloseDate(props: {
|
|||
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
|
||||
|
||||
const editor = new Editor({ content, extensions: exhibitExts })
|
||||
appendToEditor(
|
||||
editor.commands.focus('end')
|
||||
insertContent(
|
||||
editor,
|
||||
`<br><p>Close date updated to ${formattedCloseDate}</p>`
|
||||
)
|
||||
|
|
|
@ -21,12 +21,18 @@ import { useUsers } from 'web/hooks/use-users'
|
|||
import { mentionSuggestion } from './editor/mention-suggestion'
|
||||
import { DisplayMention } from './editor/mention'
|
||||
import Iframe from 'common/util/tiptap-iframe'
|
||||
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
|
||||
import {
|
||||
CodeIcon,
|
||||
PhotographIcon,
|
||||
PresentationChartLineIcon,
|
||||
} from '@heroicons/react/solid'
|
||||
import { Modal } from './layout/modal'
|
||||
import { Col } from './layout/col'
|
||||
import { Button } from './button'
|
||||
import { Row } from './layout/row'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { MarketModal } from './editor/market-modal'
|
||||
import { insertContent } from './editor/utils'
|
||||
|
||||
const DisplayImage = Image.configure({
|
||||
HTMLAttributes: {
|
||||
|
@ -105,7 +111,7 @@ export function useTextEditor(props: {
|
|||
// If the pasted content is iframe code, directly inject it
|
||||
const text = event.clipboardData?.getData('text/plain').trim() ?? ''
|
||||
if (isValidIframe(text)) {
|
||||
editor.chain().insertContent(text).run()
|
||||
insertContent(editor, text)
|
||||
return true // Prevent the code from getting pasted as text
|
||||
}
|
||||
|
||||
|
@ -139,6 +145,7 @@ export function TextEditor(props: {
|
|||
}) {
|
||||
const { editor, upload, children } = props
|
||||
const [iframeOpen, setIframeOpen] = useState(false)
|
||||
const [marketOpen, setMarketOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -148,16 +155,15 @@ export function TextEditor(props: {
|
|||
<EditorContent editor={editor} />
|
||||
{/* Toolbar, with buttons for images and embeds */}
|
||||
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
||||
<div className="flex items-center">
|
||||
<div className="tooltip flex items-center" data-tip="Add image">
|
||||
<FileUploadButton
|
||||
onFiles={upload.mutate}
|
||||
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">Upload an image</span>
|
||||
</FileUploadButton>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="tooltip flex items-center" data-tip="Add embed">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIframeOpen(true)}
|
||||
|
@ -169,7 +175,23 @@ export function TextEditor(props: {
|
|||
setOpen={setIframeOpen}
|
||||
/>
|
||||
<CodeIcon className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">Embed an iframe</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="tooltip flex items-center" data-tip="Add market">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMarketOpen(true)}
|
||||
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<MarketModal
|
||||
editor={editor}
|
||||
open={marketOpen}
|
||||
setOpen={setMarketOpen}
|
||||
/>
|
||||
<PresentationChartLineIcon
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Spacer that also focuses editor on click */}
|
||||
|
@ -229,7 +251,7 @@ function IframeModal(props: {
|
|||
disabled={!valid}
|
||||
onClick={() => {
|
||||
if (editor && valid) {
|
||||
editor.chain().insertContent(embedCode).run()
|
||||
insertContent(editor, embedCode)
|
||||
setInput('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
|
86
web/components/editor/market-modal.tsx
Normal file
86
web/components/editor/market-modal.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { Editor } from '@tiptap/react'
|
||||
import { Contract } from 'common/contract'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../button'
|
||||
import { ContractSearch } from '../contract-search'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Row } from '../layout/row'
|
||||
import { LoadingIndicator } from '../loading-indicator'
|
||||
import { embedCode } from '../share-embed-button'
|
||||
import { insertContent } from './utils'
|
||||
|
||||
export function MarketModal(props: {
|
||||
editor: Editor | null
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { editor, open, setOpen } = props
|
||||
|
||||
const [contracts, setContracts] = useState<Contract[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function addContract(contract: Contract) {
|
||||
if (contracts.map((c) => c.id).includes(contract.id)) {
|
||||
setContracts(contracts.filter((c) => c.id !== contract.id))
|
||||
} else setContracts([...contracts, contract])
|
||||
}
|
||||
|
||||
async function doneAddingContracts() {
|
||||
setLoading(true)
|
||||
insertContent(editor, ...contracts.map(embedCode))
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
setContracts([])
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
|
||||
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
|
||||
<Row className="p-8 pb-0">
|
||||
<div className={'text-xl text-indigo-700'}>Embed a market</div>
|
||||
|
||||
{!loading && (
|
||||
<Row className="grow justify-end gap-4">
|
||||
{contracts.length > 0 && (
|
||||
<Button onClick={doneAddingContracts} color={'indigo'}>
|
||||
Embed {contracts.length} question
|
||||
{contracts.length > 1 && 's'}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setContracts([])} color="gray">
|
||||
Cancel
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{loading && (
|
||||
<div className="w-full justify-center">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-scroll sm:px-8">
|
||||
<ContractSearch
|
||||
hideOrderSelector
|
||||
onContractClick={addContract}
|
||||
overrideGridClassName={
|
||||
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
|
||||
}
|
||||
showPlaceHolder
|
||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||
querySortOptions={{ disableQueryString: true }}
|
||||
highlightOptions={{
|
||||
contractIds: contracts.map((c) => c.id),
|
||||
highlightClassName:
|
||||
'!bg-indigo-100 outline outline-2 outline-indigo-300',
|
||||
}}
|
||||
additionalFilter={{}} /* hide pills */
|
||||
headerClassName="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -1,10 +1,13 @@
|
|||
import { Editor, Content } from '@tiptap/react'
|
||||
|
||||
export function appendToEditor(editor: Editor | null, content: Content) {
|
||||
editor
|
||||
?.chain()
|
||||
.focus('end')
|
||||
.createParagraphNear()
|
||||
.insertContent(content)
|
||||
.run()
|
||||
export function insertContent(editor: Editor | null, ...contents: Content[]) {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
let e = editor.chain()
|
||||
for (const content of contents) {
|
||||
e = e.createParagraphNear().insertContent(content)
|
||||
}
|
||||
e.run()
|
||||
}
|
||||
|
|
|
@ -9,13 +9,11 @@ import { copyToClipboard } from 'web/lib/util/copy'
|
|||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
function copyEmbedCode(contract: Contract) {
|
||||
export function embedCode(contract: Contract) {
|
||||
const title = contract.question
|
||||
const src = `https://${DOMAIN}/embed${contractPath(contract)}`
|
||||
|
||||
const embedCode = `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>`
|
||||
|
||||
copyToClipboard(embedCode)
|
||||
return `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>`
|
||||
}
|
||||
|
||||
export function ShareEmbedButton(props: {
|
||||
|
@ -29,7 +27,7 @@ export function ShareEmbedButton(props: {
|
|||
as="div"
|
||||
className="relative z-10 flex-shrink-0"
|
||||
onMouseUp={() => {
|
||||
copyEmbedCode(contract)
|
||||
copyToClipboard(embedCode(contract))
|
||||
track('copy embed code')
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -25,12 +25,18 @@ export function getSavedSort() {
|
|||
}
|
||||
}
|
||||
|
||||
export function useQueryAndSortParams(options?: {
|
||||
export interface QuerySortOptions {
|
||||
defaultSort?: Sort
|
||||
shouldLoadFromStorage?: boolean
|
||||
}) {
|
||||
const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } =
|
||||
options ?? {}
|
||||
/** Use normal react state instead of url query string */
|
||||
disableQueryString?: boolean
|
||||
}
|
||||
|
||||
export function useQueryAndSortParams({
|
||||
defaultSort = DEFAULT_SORT,
|
||||
shouldLoadFromStorage = true,
|
||||
disableQueryString,
|
||||
}: QuerySortOptions = {}) {
|
||||
const router = useRouter()
|
||||
|
||||
const { s: sort, q: query } = router.query as {
|
||||
|
@ -68,8 +74,10 @@ export function useQueryAndSortParams(options?: {
|
|||
|
||||
const setQuery = (query: string | undefined) => {
|
||||
setQueryState(query)
|
||||
if (!disableQueryString) {
|
||||
pushQuery(query)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// If there's no sort option, then set the one from localstorage
|
||||
|
@ -86,10 +94,13 @@ export function useQueryAndSortParams(options?: {
|
|||
}
|
||||
})
|
||||
|
||||
// use normal state if querydisableQueryString
|
||||
const [sortState, setSortState] = useState(defaultSort)
|
||||
|
||||
return {
|
||||
sort: sort ?? defaultSort,
|
||||
sort: disableQueryString ? sortState : sort ?? defaultSort,
|
||||
query: queryState ?? '',
|
||||
setSort,
|
||||
setSort: disableQueryString ? setSortState : setSort,
|
||||
setQuery,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { sortBy } from 'lodash'
|
|||
import { ContractsGrid } from 'web/components/contract/contracts-grid'
|
||||
import { useContracts } from 'web/hooks/use-contracts'
|
||||
import {
|
||||
QuerySortOptions,
|
||||
Sort,
|
||||
useQueryAndSortParams,
|
||||
} from 'web/hooks/use-sort-and-query-params'
|
||||
|
@ -11,10 +12,7 @@ import {
|
|||
const MAX_CONTRACTS_RENDERED = 100
|
||||
|
||||
export default function ContractSearchFirestore(props: {
|
||||
querySortOptions?: {
|
||||
defaultSort: Sort
|
||||
shouldLoadFromStorage?: boolean
|
||||
}
|
||||
querySortOptions?: QuerySortOptions
|
||||
additionalFilter?: {
|
||||
creatorId?: string
|
||||
tag?: string
|
||||
|
|
Loading…
Reference in New Issue
Block a user