Merge branch 'master' into master
|
@ -1,5 +1,5 @@
|
|||
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
|
||||
import { ensureProtocol } from '../../lib/url'
|
||||
import { ensureProtocol, removeTracking } from '../../lib/url'
|
||||
import serialize from './serial'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
|
||||
|
@ -532,6 +532,7 @@ export default {
|
|||
upsertLink: async (parent, args, { me, models }) => {
|
||||
const { id, ...data } = args
|
||||
data.url = ensureProtocol(data.url)
|
||||
data.url = removeTracking(data.url)
|
||||
|
||||
if (id) {
|
||||
return await updateItem(parent, { id, data }, { me, models })
|
||||
|
|
|
@ -43,8 +43,6 @@ export default {
|
|||
GROUP BY time
|
||||
ORDER BY time ASC`, Number(me.id))
|
||||
|
||||
console.log(totalSats)
|
||||
|
||||
return {
|
||||
totalSats,
|
||||
totalReferrals,
|
||||
|
|
|
@ -334,12 +334,29 @@ export default {
|
|||
throw error
|
||||
}
|
||||
},
|
||||
setSettings: async (parent, data, { me, models }) => {
|
||||
setSettings: async (parent, { nostrRelays, ...data }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
|
||||
return await models.user.update({ where: { id: me.id }, data })
|
||||
if (nostrRelays?.length) {
|
||||
const connectOrCreate = []
|
||||
for (const nr of nostrRelays) {
|
||||
await models.nostrRelay.upsert({
|
||||
where: { addr: nr },
|
||||
update: { addr: nr },
|
||||
create: { addr: nr }
|
||||
})
|
||||
connectOrCreate.push({
|
||||
where: { userId_nostrRelayAddr: { userId: me.id, nostrRelayAddr: nr } },
|
||||
create: { nostrRelayAddr: nr }
|
||||
})
|
||||
}
|
||||
|
||||
return await models.user.update({ where: { id: me.id }, data: { ...data, nostrRelays: { deleteMany: {}, connectOrCreate } } })
|
||||
} else {
|
||||
return await models.user.update({ where: { id: me.id }, data: { ...data, nostrRelays: { deleteMany: {} } } })
|
||||
}
|
||||
},
|
||||
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
|
||||
if (!me) {
|
||||
|
@ -533,6 +550,13 @@ export default {
|
|||
}).invites({ take: 1 })
|
||||
|
||||
return invites.length > 0
|
||||
},
|
||||
nostrRelays: async (user, args, { models }) => {
|
||||
const relays = await models.userNostrRelay.findMany({
|
||||
where: { userId: user.id }
|
||||
})
|
||||
|
||||
return relays?.map(r => r.nostrRelayAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ export default gql`
|
|||
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!,
|
||||
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
|
||||
noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, hideFromTopUsers: Boolean!,
|
||||
wildWestMode: Boolean!, greeterMode: Boolean!): User
|
||||
wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!]): User
|
||||
setPhoto(photoId: ID!): Int!
|
||||
upsertBio(bio: String!): User!
|
||||
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
||||
|
@ -52,6 +52,8 @@ export default gql`
|
|||
tipDefault: Int!
|
||||
turboTipping: Boolean!
|
||||
fiatCurrency: String!
|
||||
nostrPubkey: String
|
||||
nostrRelays: [String!]
|
||||
bio: Item
|
||||
bioId: Int
|
||||
photoId: Int
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { gql, useMutation } from '@apollo/client'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import MoreIcon from '../svgs/more-fill.svg'
|
||||
import { useFundError } from './fund-error'
|
||||
import FundError from './fund-error'
|
||||
import { useShowModal } from './modal'
|
||||
|
||||
export default function DontLikeThis ({ id }) {
|
||||
const { setError } = useFundError()
|
||||
const showModal = useShowModal()
|
||||
|
||||
const [dontLikeThis] = useMutation(
|
||||
gql`
|
||||
|
@ -41,7 +42,9 @@ export default function DontLikeThis ({ id }) {
|
|||
})
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
setError(true)
|
||||
showModal(onClose => {
|
||||
return <FundError onClose={onClose} />
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -35,6 +35,11 @@ const COLORS = {
|
|||
brandColor: 'rgba(0, 0, 0, 0.9)',
|
||||
grey: '#707070',
|
||||
link: '#007cbe',
|
||||
toolbarActive: 'rgba(0, 0, 0, 0.10)',
|
||||
toolbarHover: 'rgba(0, 0, 0, 0.20)',
|
||||
toolbar: '#ffffff',
|
||||
quoteBar: 'rgb(206, 208, 212)',
|
||||
quoteColor: 'rgb(101, 103, 107)',
|
||||
linkHover: '#004a72',
|
||||
linkVisited: '#537587'
|
||||
},
|
||||
|
@ -54,6 +59,11 @@ const COLORS = {
|
|||
brandColor: 'var(--primary)',
|
||||
grey: '#969696',
|
||||
link: '#2e99d1',
|
||||
toolbarActive: 'rgba(255, 255, 255, 0.10)',
|
||||
toolbarHover: 'rgba(255, 255, 255, 0.20)',
|
||||
toolbar: '#3e3f3f',
|
||||
quoteBar: 'rgb(158, 159, 163)',
|
||||
quoteColor: 'rgb(141, 144, 150)',
|
||||
linkHover: '#007cbe',
|
||||
linkVisited: '#56798E'
|
||||
}
|
||||
|
@ -126,7 +136,7 @@ export default function Footer ({ noLinks }) {
|
|||
useEffect(() => {
|
||||
setMounted(true)
|
||||
setLightning(localStorage.getItem('lnAnimate') || 'yes')
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleLightning = () => {
|
||||
if (lightning === 'yes') {
|
||||
|
|
|
@ -299,7 +299,7 @@ export function Input ({ label, groupClassName, ...props }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function VariableInput ({ label, groupClassName, name, hint, max, readOnlyLen, ...props }) {
|
||||
export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, ...props }) {
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<FieldArray name={name}>
|
||||
|
@ -307,11 +307,11 @@ export function VariableInput ({ label, groupClassName, name, hint, max, readOnl
|
|||
const options = form.values[name]
|
||||
return (
|
||||
<>
|
||||
{options.map((_, i) => (
|
||||
{options?.map((_, i) => (
|
||||
<div key={i}>
|
||||
<BootstrapForm.Row className='mb-2'>
|
||||
<Col>
|
||||
<InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i > 1 ? 'optional' : undefined} />
|
||||
<InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />
|
||||
</Col>
|
||||
{options.length - 1 === i && options.length !== max
|
||||
? <AddIcon className='fill-grey align-self-center pointer mx-2' onClick={() => fieldArrayHelpers.push('')} />
|
||||
|
|
|
@ -1,48 +1,15 @@
|
|||
import { Button, Modal } from 'react-bootstrap'
|
||||
import React, { useState, useCallback, useContext } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'react-bootstrap'
|
||||
|
||||
export const FundErrorContext = React.createContext({
|
||||
error: null,
|
||||
toggleError: () => {}
|
||||
})
|
||||
|
||||
export function FundErrorProvider ({ children }) {
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const contextValue = {
|
||||
error,
|
||||
setError: useCallback(e => setError(e), [])
|
||||
}
|
||||
|
||||
export default function FundError ({ onClose }) {
|
||||
return (
|
||||
<FundErrorContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FundErrorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useFundError () {
|
||||
const { error, setError } = useContext(FundErrorContext)
|
||||
return { error, setError }
|
||||
}
|
||||
|
||||
export function FundErrorModal () {
|
||||
const { error, setError } = useFundError()
|
||||
return (
|
||||
<Modal
|
||||
show={error}
|
||||
onHide={() => setError(false)}
|
||||
>
|
||||
<div className='modal-close' onClick={() => setError(false)}>X</div>
|
||||
<Modal.Body>
|
||||
<p className='font-weight-bolder'>you need more sats</p>
|
||||
<div className='d-flex justify-content-end'>
|
||||
<Link href='/wallet?type=fund'>
|
||||
<Button variant='success' onClick={() => setError(false)}>fund</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<>
|
||||
<p className='font-weight-bolder'>you need more sats</p>
|
||||
<div className='d-flex justify-content-end'>
|
||||
<Link href='/wallet?type=fund'>
|
||||
<Button variant='success' onClick={onClose}>fund</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Button, Container, NavDropdown } from 'react-bootstrap'
|
|||
import Price from './price'
|
||||
import { useMe } from './me'
|
||||
import Head from 'next/head'
|
||||
import { signOut, signIn } from 'next-auth/client'
|
||||
import { signOut } from 'next-auth/client'
|
||||
import { useLightning } from './lightning'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { randInRange } from '../lib/rand'
|
||||
|
@ -50,7 +50,7 @@ export default function Header ({ sub }) {
|
|||
}
|
||||
setLastCheckedJobs(localStorage.getItem('lastCheckedJobs'))
|
||||
}
|
||||
})
|
||||
}, [sub])
|
||||
|
||||
const Corner = () => {
|
||||
if (me) {
|
||||
|
@ -61,7 +61,7 @@ export default function Header ({ sub }) {
|
|||
</Head>
|
||||
<Link href='/notifications' passHref>
|
||||
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
|
||||
<NoteIcon />
|
||||
<NoteIcon className='theme' />
|
||||
{hasNewNotes?.hasNewNotes &&
|
||||
<span className={styles.notification}>
|
||||
<span className='invisible'>{' '}</span>
|
||||
|
@ -134,18 +134,30 @@ export default function Header ({ sub }) {
|
|||
return () => { isMounted = false }
|
||||
}, [])
|
||||
}
|
||||
return path !== '/login' && !path.startsWith('/invites') &&
|
||||
<Button
|
||||
className='align-items-center d-flex pl-2 pr-3'
|
||||
id='login'
|
||||
onClick={() => signIn(null, { callbackUrl: window.location.origin + router.asPath })}
|
||||
>
|
||||
<LightningIcon
|
||||
width={17}
|
||||
height={17}
|
||||
className='mr-1'
|
||||
/>login
|
||||
</Button>
|
||||
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
|
||||
<div>
|
||||
<Button
|
||||
className='align-items-center px-3 py-1 mr-2'
|
||||
id='signup'
|
||||
style={{ borderWidth: '2px' }}
|
||||
variant='outline-grey-darkmode'
|
||||
onClick={async () => await router.push({ pathname: '/login', query: { callbackUrl: window.location.origin + router.asPath } })}
|
||||
>
|
||||
login
|
||||
</Button>
|
||||
<Button
|
||||
className='align-items-center pl-2 py-1 pr-3'
|
||||
style={{ borderWidth: '2px' }}
|
||||
id='login'
|
||||
onClick={async () => await router.push({ pathname: '/signup', query: { callbackUrl: window.location.origin + router.asPath } })}
|
||||
>
|
||||
<LightningIcon
|
||||
width={17}
|
||||
height={17}
|
||||
className='mr-1'
|
||||
/>sign up
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
.navLinkButton {
|
||||
border: 2px solid;
|
||||
padding: 0.2rem .9rem !important;
|
||||
border-radius: .4rem;
|
||||
}
|
||||
|
||||
|
@ -57,6 +58,6 @@
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.dropdown > a {
|
||||
.dropdown>a {
|
||||
padding-left: 0 !important;
|
||||
}
|
|
@ -4,7 +4,7 @@ import ThumbDown from '../svgs/thumb-down-fill.svg'
|
|||
|
||||
function InvoiceDefaultStatus ({ status }) {
|
||||
return (
|
||||
<div className='d-flex mt-2'>
|
||||
<div className='d-flex mt-2 justify-content-center'>
|
||||
<Moon className='spin fill-grey' />
|
||||
<div className='ml-3 text-muted' style={{ fontWeight: '600' }}>{status}</div>
|
||||
</div>
|
||||
|
@ -13,7 +13,7 @@ function InvoiceDefaultStatus ({ status }) {
|
|||
|
||||
function InvoiceConfirmedStatus ({ status }) {
|
||||
return (
|
||||
<div className='d-flex mt-2'>
|
||||
<div className='d-flex mt-2 justify-content-center'>
|
||||
<Check className='fill-success' />
|
||||
<div className='ml-3 text-success' style={{ fontWeight: '600' }}>{status}</div>
|
||||
</div>
|
||||
|
@ -22,7 +22,7 @@ function InvoiceConfirmedStatus ({ status }) {
|
|||
|
||||
function InvoiceFailedStatus ({ status }) {
|
||||
return (
|
||||
<div className='d-flex mt-2'>
|
||||
<div className='d-flex mt-2 justify-content-center'>
|
||||
<ThumbDown className='fill-danger' />
|
||||
<div className='ml-3 text-danger' style={{ fontWeight: '600' }}>{status}</div>
|
||||
</div>
|
||||
|
|
|
@ -1,105 +1,69 @@
|
|||
import { Button, InputGroup, Modal } from 'react-bootstrap'
|
||||
import React, { useState, useCallback, useContext, useRef, useEffect } from 'react'
|
||||
import { Button, InputGroup } from 'react-bootstrap'
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import * as Yup from 'yup'
|
||||
import { Form, Input, SubmitButton } from './form'
|
||||
import { useMe } from './me'
|
||||
import UpBolt from '../svgs/bolt.svg'
|
||||
|
||||
export const ItemActContext = React.createContext({
|
||||
item: null,
|
||||
setItem: () => {}
|
||||
})
|
||||
|
||||
export function ItemActProvider ({ children }) {
|
||||
const [item, setItem] = useState(null)
|
||||
|
||||
const contextValue = {
|
||||
item,
|
||||
setItem: useCallback(i => setItem(i), [])
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemActContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ItemActContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useItemAct () {
|
||||
const { item, setItem } = useContext(ItemActContext)
|
||||
return { item, setItem }
|
||||
}
|
||||
|
||||
export const ActSchema = Yup.object({
|
||||
amount: Yup.number().typeError('must be a number').required('required')
|
||||
.positive('must be positive').integer('must be whole')
|
||||
})
|
||||
|
||||
export function ItemActModal () {
|
||||
const { item, setItem } = useItemAct()
|
||||
export default function ItemAct ({ onClose, itemId, act, strike }) {
|
||||
const inputRef = useRef(null)
|
||||
const me = useMe()
|
||||
const [oValue, setOValue] = useState()
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [item])
|
||||
}, [onClose, itemId])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={!!item}
|
||||
onHide={() => {
|
||||
setItem(null)
|
||||
<Form
|
||||
initial={{
|
||||
amount: me?.tipDefault,
|
||||
default: false
|
||||
}}
|
||||
schema={ActSchema}
|
||||
onSubmit={async ({ amount }) => {
|
||||
await act({
|
||||
variables: {
|
||||
id: itemId,
|
||||
sats: Number(amount)
|
||||
}
|
||||
})
|
||||
await strike()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div className='modal-close' onClick={() => setItem(null)}>X</div>
|
||||
<Modal.Body>
|
||||
<Form
|
||||
initial={{
|
||||
amount: me?.tipDefault,
|
||||
default: false
|
||||
}}
|
||||
schema={ActSchema}
|
||||
onSubmit={async ({ amount }) => {
|
||||
await item.act({
|
||||
variables: {
|
||||
id: item.itemId,
|
||||
sats: Number(amount)
|
||||
}
|
||||
})
|
||||
await item.strike()
|
||||
setItem(null)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
innerRef={inputRef}
|
||||
overrideValue={oValue}
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<div>
|
||||
{[1, 10, 100, 1000, 10000].map(num =>
|
||||
<Button
|
||||
size='sm'
|
||||
className={`${num > 1 ? 'ml-2' : ''} mb-2`}
|
||||
key={num}
|
||||
onClick={() => { setOValue(num) }}
|
||||
>
|
||||
<UpBolt
|
||||
className='mr-1'
|
||||
width={14}
|
||||
height={14}
|
||||
/>{num}
|
||||
</Button>)}
|
||||
</div>
|
||||
<div className='d-flex'>
|
||||
<SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>tip</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Input
|
||||
label='amount'
|
||||
name='amount'
|
||||
innerRef={inputRef}
|
||||
overrideValue={oValue}
|
||||
required
|
||||
autoFocus
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
/>
|
||||
<div>
|
||||
{[1, 10, 100, 1000, 10000].map(num =>
|
||||
<Button
|
||||
size='sm'
|
||||
className={`${num > 1 ? 'ml-2' : ''} mb-2`}
|
||||
key={num}
|
||||
onClick={() => { setOValue(num) }}
|
||||
>
|
||||
<UpBolt
|
||||
className='mr-1'
|
||||
width={14}
|
||||
height={14}
|
||||
/>{num}
|
||||
</Button>)}
|
||||
</div>
|
||||
<div className='d-flex'>
|
||||
<SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>tip</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import { signIn } from 'next-auth/client'
|
||||
import { useEffect } from 'react'
|
||||
import { Col, Container, Row } from 'react-bootstrap'
|
||||
import AccordianItem from './accordian-item'
|
||||
import LnQR, { LnQRSkeleton } from './lnqr'
|
||||
import styles from './lightning-auth.module.css'
|
||||
import BackIcon from '../svgs/arrow-left-line.svg'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
function LnQRAuth ({ k1, encodedUrl, callbackUrl }) {
|
||||
const query = gql`
|
||||
|
@ -19,16 +24,67 @@ function LnQRAuth ({ k1, encodedUrl, callbackUrl }) {
|
|||
|
||||
// output pubkey and k1
|
||||
return (
|
||||
<>
|
||||
<small className='mb-2'>
|
||||
<a className='text-muted text-underline' href='https://github.com/fiatjaf/lnurl-rfc#lnurl-documents' target='_blank' rel='noreferrer' style={{ textDecoration: 'underline' }}>Does my wallet support lnurl-auth?</a>
|
||||
</small>
|
||||
<LnQR value={encodedUrl} status='waiting for you' />
|
||||
</>
|
||||
<LnQR value={encodedUrl} status='waiting for you' />
|
||||
)
|
||||
}
|
||||
|
||||
export function LightningAuth ({ callbackUrl }) {
|
||||
function LightningExplainer ({ text, children }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Container sm>
|
||||
<div className={styles.login}>
|
||||
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
|
||||
<h3 className='w-100 pb-2'>
|
||||
{text || 'Login'} with Lightning
|
||||
</h3>
|
||||
<div className='font-weight-bold text-muted pb-4'>This is the most private way to use Stacker News. Just open your Lightning wallet and scan the QR code.</div>
|
||||
<Row className='w-100 text-muted'>
|
||||
<Col className='pl-0 mb-4' md>
|
||||
<AccordianItem
|
||||
header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`}
|
||||
body={
|
||||
<>
|
||||
<Row className='mb-3 no-gutters'>
|
||||
You can use any wallet that supports lnurl-auth. These are some wallets you can use:
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs>
|
||||
<ul className='mb-0'>
|
||||
<li>Alby</li>
|
||||
<li>Balance of Satoshis</li>
|
||||
<li>Blixt</li>
|
||||
<li>Breez</li>
|
||||
<li>Blue Wallet</li>
|
||||
<li>Coinos</li>
|
||||
<li>LNBits</li>
|
||||
<li>LNtxtbot</li>
|
||||
</ul>
|
||||
</Col>
|
||||
<Col xs>
|
||||
<ul>
|
||||
<li>Phoenix</li>
|
||||
<li>Simple Bitcoin Wallet</li>
|
||||
<li>Sparrow Wallet</li>
|
||||
<li>ThunderHub</li>
|
||||
<li>Zap Desktop</li>
|
||||
<li>Zeus</li>
|
||||
</ul>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col md className='mx-auto' style={{ maxWidth: '300px' }}>
|
||||
{children}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export function LightningAuth ({ text, callbackUrl }) {
|
||||
// query for challenge
|
||||
const [createAuth, { data, error }] = useMutation(gql`
|
||||
mutation createAuth {
|
||||
|
@ -44,9 +100,9 @@ export function LightningAuth ({ callbackUrl }) {
|
|||
|
||||
if (error) return <div>error</div>
|
||||
|
||||
if (!data) {
|
||||
return <LnQRSkeleton status='generating' />
|
||||
}
|
||||
|
||||
return <LnQRAuth {...data.createAuth} callbackUrl={callbackUrl} />
|
||||
return (
|
||||
<LightningExplainer text={text}>
|
||||
{data ? <LnQRAuth {...data.createAuth} callbackUrl={callbackUrl} /> : <LnQRSkeleton status='generating' />}
|
||||
</LightningExplainer>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.login {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
|
@ -8,11 +8,9 @@ import { ITEM_FIELDS } from '../fragments/items'
|
|||
import Item from './item'
|
||||
import AccordianItem from './accordian-item'
|
||||
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
||||
import { URL_REGEXP } from '../lib/url'
|
||||
import FeeButton, { EditFeeButton } from './fee-button'
|
||||
|
||||
// eslint-disable-next-line
|
||||
const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
|
||||
|
||||
export function LinkForm ({ item, editThreshold }) {
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
|
@ -46,7 +44,7 @@ export function LinkForm ({ item, editThreshold }) {
|
|||
title: Yup.string().required('required').trim()
|
||||
.max(MAX_TITLE_LENGTH,
|
||||
({ max, value }) => `${Math.abs(max - value.length)} too many`),
|
||||
url: Yup.string().matches(URL, 'invalid url').required('required'),
|
||||
url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required'),
|
||||
...AdvPostSchema(client)
|
||||
})
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ export default function LnQR ({ value, webLn, statusVariant, status }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<a className='d-block p-3' style={{ background: 'white' }} href={qrValue}>
|
||||
<a className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }} href={qrValue}>
|
||||
<QRCode
|
||||
className='h-auto mw-100' value={qrValue} renderAs='svg' size={300}
|
||||
/>
|
||||
|
|
|
@ -8,7 +8,6 @@ import { Form, Input, SubmitButton } from '../components/form'
|
|||
import * as Yup from 'yup'
|
||||
import { useState } from 'react'
|
||||
import Alert from 'react-bootstrap/Alert'
|
||||
import LayoutCenter from '../components/layout-center'
|
||||
import { useRouter } from 'next/router'
|
||||
import { LightningAuth } from './lightning-auth'
|
||||
|
||||
|
@ -16,7 +15,7 @@ export const EmailSchema = Yup.object({
|
|||
email: Yup.string().email('email is no good').required('required')
|
||||
})
|
||||
|
||||
export function EmailLoginForm ({ callbackUrl }) {
|
||||
export function EmailLoginForm ({ text, callbackUrl }) {
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
|
@ -34,12 +33,12 @@ export function EmailLoginForm ({ callbackUrl }) {
|
|||
required
|
||||
autoFocus
|
||||
/>
|
||||
<SubmitButton variant='secondary' className={styles.providerButton}>Login with Email</SubmitButton>
|
||||
<SubmitButton variant='secondary' className={styles.providerButton}>{text || 'Login'} with Email</SubmitButton>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Login ({ providers, callbackUrl, error, Header }) {
|
||||
export default function Login ({ providers, callbackUrl, error, text, Header, Footer }) {
|
||||
const errors = {
|
||||
Signin: 'Try signing with a different account.',
|
||||
OAuthSignin: 'Try signing with a different account.',
|
||||
|
@ -56,59 +55,59 @@ export default function Login ({ providers, callbackUrl, error, Header }) {
|
|||
const [errorMessage, setErrorMessage] = useState(error && (errors[error] ?? errors.default))
|
||||
const router = useRouter()
|
||||
|
||||
if (router.query.type === 'lightning') {
|
||||
return <LightningAuth callbackUrl={callbackUrl} text={text} />
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutCenter noFooter>
|
||||
<div className={styles.login}>
|
||||
{Header && <Header />}
|
||||
<div className='text-center font-weight-bold text-muted pb-4'>
|
||||
Join 9,000+ Bitcoiners and start stacking sats today.
|
||||
</div>
|
||||
{errorMessage &&
|
||||
<Alert variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>{errorMessage}</Alert>}
|
||||
{router.query.type === 'lightning'
|
||||
? <LightningAuth callbackUrl={callbackUrl} />
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
className={`mt-2 ${styles.providerButton}`}
|
||||
variant='primary'
|
||||
onClick={() => router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, type: 'lightning' }
|
||||
})}
|
||||
>
|
||||
<LightningIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className='mr-3'
|
||||
/>Login with Lightning
|
||||
</Button>
|
||||
{Object.values(providers).map(provider => {
|
||||
if (provider.name === 'Email' || provider.name === 'Lightning') {
|
||||
return null
|
||||
}
|
||||
const [variant, Icon] =
|
||||
<div className={styles.login}>
|
||||
{Header && <Header />}
|
||||
{errorMessage &&
|
||||
<Alert
|
||||
variant='danger'
|
||||
onClose={() => setErrorMessage(undefined)}
|
||||
dismissible
|
||||
>{errorMessage}
|
||||
</Alert>}
|
||||
<Button
|
||||
className={`mt-2 ${styles.providerButton}`}
|
||||
variant='primary'
|
||||
onClick={() => router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, type: 'lightning' }
|
||||
})}
|
||||
>
|
||||
<LightningIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className='mr-3'
|
||||
/>{text || 'Login'} with Lightning
|
||||
</Button>
|
||||
{providers && Object.values(providers).map(provider => {
|
||||
if (provider.name === 'Email' || provider.name === 'Lightning') {
|
||||
return null
|
||||
}
|
||||
const [variant, Icon] =
|
||||
provider.name === 'Twitter'
|
||||
? ['twitter', TwitterIcon]
|
||||
: ['dark', GithubIcon]
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`mt-2 ${styles.providerButton}`}
|
||||
key={provider.name}
|
||||
variant={variant}
|
||||
onClick={() => signIn(provider.id, { callbackUrl })}
|
||||
>
|
||||
<Icon
|
||||
className='mr-3'
|
||||
/>Login with {provider.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<div className='mt-2 text-center text-muted font-weight-bold'>or</div>
|
||||
<EmailLoginForm callbackUrl={callbackUrl} />
|
||||
</>)}
|
||||
</div>
|
||||
</LayoutCenter>
|
||||
return (
|
||||
<Button
|
||||
className={`mt-2 ${styles.providerButton}`}
|
||||
key={provider.name}
|
||||
variant={variant}
|
||||
onClick={() => signIn(provider.id, { callbackUrl })}
|
||||
>
|
||||
<Icon
|
||||
className='mr-3'
|
||||
/>{text || 'Login'} with {provider.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<div className='mt-2 text-center text-muted font-weight-bold'>or</div>
|
||||
<EmailLoginForm text={text} callbackUrl={callbackUrl} />
|
||||
{Footer && <Footer />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||
import { Modal } from 'react-bootstrap'
|
||||
|
||||
export const ShowModalContext = createContext(() => null)
|
||||
|
||||
export function ShowModalProvider ({ children }) {
|
||||
const [modal, showModal] = useModal()
|
||||
const contextValue = showModal
|
||||
|
||||
return (
|
||||
<ShowModalContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{modal}
|
||||
</ShowModalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useShowModal () {
|
||||
return useContext(ShowModalContext)
|
||||
}
|
||||
|
||||
export default function useModal () {
|
||||
const [modalContent, setModalContent] = useState(null)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setModalContent(null)
|
||||
}, [])
|
||||
|
||||
const modal = useMemo(() => {
|
||||
if (modalContent === null) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Modal onHide={onClose} show={!!modalContent}>
|
||||
<div className='modal-close' onClick={onClose}>X</div>
|
||||
<Modal.Body>
|
||||
{modalContent}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}, [modalContent, onClose])
|
||||
|
||||
const showModal = useCallback(
|
||||
(getContent) => {
|
||||
setModalContent(getContent(onClose))
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
return [modal, showModal]
|
||||
}
|
|
@ -86,6 +86,7 @@ export function PollForm ({ item, editThreshold }) {
|
|||
name='options'
|
||||
readOnlyLen={initialOptions?.length}
|
||||
max={MAX_POLL_NUM_CHOICES}
|
||||
min={2}
|
||||
hint={editThreshold
|
||||
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
|
||||
: null}
|
||||
|
|
|
@ -6,12 +6,13 @@ import { useMe } from './me'
|
|||
import styles from './poll.module.css'
|
||||
import Check from '../svgs/checkbox-circle-fill.svg'
|
||||
import { signIn } from 'next-auth/client'
|
||||
import { useFundError } from './fund-error'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import { useShowModal } from './modal'
|
||||
import FundError from './fund-error'
|
||||
|
||||
export default function Poll ({ item }) {
|
||||
const me = useMe()
|
||||
const { setError } = useFundError()
|
||||
const showModal = useShowModal()
|
||||
const [pollVote] = useMutation(
|
||||
gql`
|
||||
mutation pollVote($id: ID!) {
|
||||
|
@ -60,7 +61,9 @@ export default function Poll ({ item }) {
|
|||
})
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
setError(true)
|
||||
showModal(onClose => {
|
||||
return <FundError onClose={onClose} />
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,7 +78,7 @@ export default function Price () {
|
|||
|
||||
if (asSats === 'yep') {
|
||||
return (
|
||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
||||
<Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'>
|
||||
{fixedDecimal(100000000 / price, 0) + ` sats/${fiatSymbol}`}
|
||||
</Button>
|
||||
)
|
||||
|
@ -86,14 +86,14 @@ export default function Price () {
|
|||
|
||||
if (asSats === '1btc') {
|
||||
return (
|
||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
||||
<Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'>
|
||||
1sat=1sat
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
||||
<Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'>
|
||||
{fiatSymbol + fixedDecimal(price, 0)}
|
||||
</Button>
|
||||
)
|
||||
|
|
|
@ -133,7 +133,7 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ZoomableImage ({ src, topLevel, ...props }) {
|
||||
export function ZoomableImage ({ src, topLevel, ...props }) {
|
||||
if (!src) {
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -59,13 +59,18 @@
|
|||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.text blockquote>*:last-child {
|
||||
.text blockquote:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.text blockquote:has(+ :not(blockquote)) {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.text img {
|
||||
display: block;
|
||||
margin-top: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
border-radius: .4rem;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
|
@ -81,9 +86,19 @@
|
|||
}
|
||||
|
||||
.text blockquote {
|
||||
border-left: 2px solid var(--theme-grey);
|
||||
border-left: 4px solid var(--theme-quoteBar);
|
||||
padding-left: 1rem;
|
||||
margin: 0 0 0.5rem 0.5rem !important;
|
||||
margin-left: 1.25rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text li {
|
||||
margin-top: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.text h1 {
|
||||
|
|
|
@ -2,15 +2,16 @@ import { LightningConsumer } from './lightning'
|
|||
import UpBolt from '../svgs/bolt.svg'
|
||||
import styles from './upvote.module.css'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { signIn } from 'next-auth/client'
|
||||
import { useFundError } from './fund-error'
|
||||
import FundError from './fund-error'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import { useItemAct } from './item-act'
|
||||
import ItemAct from './item-act'
|
||||
import { useMe } from './me'
|
||||
import Rainbow from '../lib/rainbow'
|
||||
import { useRef, useState } from 'react'
|
||||
import LongPressable from 'react-longpressable'
|
||||
import { Overlay, Popover } from 'react-bootstrap'
|
||||
import { useShowModal } from './modal'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const getColor = (meSats) => {
|
||||
if (!meSats || meSats <= 10) {
|
||||
|
@ -63,8 +64,8 @@ const TipPopover = ({ target, show, handleClose }) => (
|
|||
)
|
||||
|
||||
export default function UpVote ({ item, className }) {
|
||||
const { setError } = useFundError()
|
||||
const { setItem } = useItemAct()
|
||||
const showModal = useShowModal()
|
||||
const router = useRouter()
|
||||
const [voteShow, _setVoteShow] = useState(false)
|
||||
const [tipShow, _setTipShow] = useState(false)
|
||||
const ref = useRef()
|
||||
|
@ -123,11 +124,14 @@ export default function UpVote ({ item, className }) {
|
|||
return existingSats + sats
|
||||
},
|
||||
meSats (existingSats = 0) {
|
||||
if (existingSats === 0) {
|
||||
setVoteShow(true)
|
||||
} else {
|
||||
setTipShow(true)
|
||||
if (sats <= me.sats) {
|
||||
if (existingSats === 0) {
|
||||
setVoteShow(true)
|
||||
} else {
|
||||
setTipShow(true)
|
||||
}
|
||||
}
|
||||
|
||||
return existingSats + sats
|
||||
},
|
||||
upvotes (existingUpvotes = 0) {
|
||||
|
@ -183,7 +187,8 @@ export default function UpVote ({ item, className }) {
|
|||
}
|
||||
|
||||
setTipShow(false)
|
||||
setItem({ itemId: item.id, act, strike })
|
||||
showModal(onClose =>
|
||||
<ItemAct onClose={onClose} itemId={item.id} act={act} strike={strike} />)
|
||||
}
|
||||
}
|
||||
onShortPress={
|
||||
|
@ -215,13 +220,18 @@ export default function UpVote ({ item, className }) {
|
|||
})
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
setError(true)
|
||||
showModal(onClose => {
|
||||
return <FundError onClose={onClose} />
|
||||
})
|
||||
return
|
||||
}
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
}
|
||||
: signIn
|
||||
: async () => await router.push({
|
||||
pathname: '/signup',
|
||||
query: { callbackUrl: window.location.origin + router.asPath }
|
||||
})
|
||||
}
|
||||
>
|
||||
<ActionTooltip notForm disable={item?.mine || fwd2me} overlayText={overlayText()}>
|
||||
|
|
|
@ -46,6 +46,8 @@ export const SETTINGS_FIELDS = gql`
|
|||
noteJobIndicator
|
||||
hideInvoiceDesc
|
||||
hideFromTopUsers
|
||||
nostrPubkey
|
||||
nostrRelays
|
||||
wildWestMode
|
||||
greeterMode
|
||||
authMethods {
|
||||
|
@ -70,12 +72,12 @@ ${SETTINGS_FIELDS}
|
|||
mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency: String!, $noteItemSats: Boolean!,
|
||||
$noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
|
||||
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!, $hideFromTopUsers: Boolean!,
|
||||
$wildWestMode: Boolean!, $greeterMode: Boolean!) {
|
||||
$wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!]) {
|
||||
setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency,
|
||||
noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
|
||||
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
|
||||
noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, hideFromTopUsers: $hideFromTopUsers,
|
||||
wildWestMode: $wildWestMode, greeterMode: $greeterMode) {
|
||||
wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays) {
|
||||
...SettingsFields
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,461 @@
|
|||
import {
|
||||
$applyNodeReplacement,
|
||||
$getNodeByKey,
|
||||
$getSelection,
|
||||
$isNodeSelection,
|
||||
$setSelection,
|
||||
CLICK_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, createEditor, DecoratorNode,
|
||||
DRAGSTART_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND,
|
||||
KEY_ESCAPE_COMMAND, SELECTION_CHANGE_COMMAND
|
||||
} from 'lexical'
|
||||
import { useRef, Suspense, useEffect, useCallback } from 'react'
|
||||
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
|
||||
const imageCache = new Set()
|
||||
|
||||
function useSuspenseImage (src) {
|
||||
if (!imageCache.has(src)) {
|
||||
throw new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.src = src
|
||||
img.onload = () => {
|
||||
imageCache.add(src)
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function LazyImage ({
|
||||
altText,
|
||||
className,
|
||||
imageRef,
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
maxWidth
|
||||
}) {
|
||||
useSuspenseImage(src)
|
||||
return (
|
||||
<img
|
||||
className={className || undefined}
|
||||
src={src}
|
||||
alt={altText}
|
||||
ref={imageRef}
|
||||
style={{
|
||||
height,
|
||||
maxHeight: '25vh',
|
||||
// maxWidth,
|
||||
// width,
|
||||
display: 'block',
|
||||
marginBottom: '.5rem',
|
||||
marginTop: '.5rem',
|
||||
borderRadius: '.4rem',
|
||||
width: 'auto',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function convertImageElement (domNode) {
|
||||
if (domNode instanceof HTMLImageElement) {
|
||||
const { alt: altText, src } = domNode
|
||||
const node = $createImageNode({ altText, src })
|
||||
return { node }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export class ImageNode extends DecoratorNode {
|
||||
__src;
|
||||
__altText;
|
||||
__width;
|
||||
__height;
|
||||
__maxWidth;
|
||||
__showCaption;
|
||||
__caption;
|
||||
// Captions cannot yet be used within editor cells
|
||||
__captionsEnabled;
|
||||
|
||||
static getType () {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
static clone (node) {
|
||||
return new ImageNode(
|
||||
node.__src,
|
||||
node.__altText,
|
||||
node.__maxWidth,
|
||||
node.__width,
|
||||
node.__height,
|
||||
node.__showCaption,
|
||||
node.__caption,
|
||||
node.__captionsEnabled,
|
||||
node.__key
|
||||
)
|
||||
}
|
||||
|
||||
static importJSON (serializedNode) {
|
||||
const { altText, height, width, maxWidth, caption, src, showCaption } =
|
||||
serializedNode
|
||||
const node = $createImageNode({
|
||||
altText,
|
||||
height,
|
||||
maxWidth,
|
||||
showCaption,
|
||||
src,
|
||||
width
|
||||
})
|
||||
const nestedEditor = node.__caption
|
||||
const editorState = nestedEditor.parseEditorState(caption.editorState)
|
||||
if (!editorState.isEmpty()) {
|
||||
nestedEditor.setEditorState(editorState)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
exportDOM () {
|
||||
const element = document.createElement('img')
|
||||
element.setAttribute('src', this.__src)
|
||||
element.setAttribute('alt', this.__altText)
|
||||
return { element }
|
||||
}
|
||||
|
||||
static importDOM () {
|
||||
return {
|
||||
img: (node) => ({
|
||||
conversion: convertImageElement,
|
||||
priority: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
constructor (
|
||||
src,
|
||||
altText,
|
||||
maxWidth,
|
||||
width,
|
||||
height,
|
||||
showCaption,
|
||||
caption,
|
||||
captionsEnabled,
|
||||
key
|
||||
) {
|
||||
super(key)
|
||||
this.__src = src
|
||||
this.__altText = altText
|
||||
this.__maxWidth = maxWidth
|
||||
this.__width = width || 'inherit'
|
||||
this.__height = height || 'inherit'
|
||||
this.__showCaption = showCaption || false
|
||||
this.__caption = caption || createEditor()
|
||||
this.__captionsEnabled = captionsEnabled || captionsEnabled === undefined
|
||||
}
|
||||
|
||||
exportJSON () {
|
||||
return {
|
||||
altText: this.getAltText(),
|
||||
caption: this.__caption.toJSON(),
|
||||
height: this.__height === 'inherit' ? 0 : this.__height,
|
||||
maxWidth: this.__maxWidth,
|
||||
showCaption: this.__showCaption,
|
||||
src: this.getSrc(),
|
||||
type: 'image',
|
||||
version: 1,
|
||||
width: this.__width === 'inherit' ? 0 : this.__width
|
||||
}
|
||||
}
|
||||
|
||||
setWidthAndHeight (
|
||||
width,
|
||||
height
|
||||
) {
|
||||
const writable = this.getWritable()
|
||||
writable.__width = width
|
||||
writable.__height = height
|
||||
}
|
||||
|
||||
setShowCaption (showCaption) {
|
||||
const writable = this.getWritable()
|
||||
writable.__showCaption = showCaption
|
||||
}
|
||||
|
||||
// View
|
||||
|
||||
createDOM (config) {
|
||||
const span = document.createElement('span')
|
||||
const theme = config.theme
|
||||
const className = theme.image
|
||||
if (className !== undefined) {
|
||||
span.className = className
|
||||
}
|
||||
return span
|
||||
}
|
||||
|
||||
updateDOM () {
|
||||
return false
|
||||
}
|
||||
|
||||
getSrc () {
|
||||
return this.__src
|
||||
}
|
||||
|
||||
getAltText () {
|
||||
return this.__altText
|
||||
}
|
||||
|
||||
decorate () {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ImageComponent
|
||||
src={this.__src}
|
||||
altText={this.__altText}
|
||||
width={this.__width}
|
||||
height={this.__height}
|
||||
maxWidth={this.__maxWidth}
|
||||
nodeKey={this.getKey()}
|
||||
showCaption={this.__showCaption}
|
||||
caption={this.__caption}
|
||||
captionsEnabled={this.__captionsEnabled}
|
||||
resizable
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function $createImageNode ({
|
||||
altText,
|
||||
height,
|
||||
maxWidth = 500,
|
||||
captionsEnabled,
|
||||
src,
|
||||
width,
|
||||
showCaption,
|
||||
caption,
|
||||
key
|
||||
}) {
|
||||
return $applyNodeReplacement(
|
||||
new ImageNode(
|
||||
src,
|
||||
altText,
|
||||
maxWidth,
|
||||
width,
|
||||
height,
|
||||
showCaption,
|
||||
caption,
|
||||
captionsEnabled,
|
||||
key
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function $isImageNode (
|
||||
node
|
||||
) {
|
||||
return node instanceof ImageNode
|
||||
}
|
||||
|
||||
export default function ImageComponent ({
|
||||
src,
|
||||
altText,
|
||||
nodeKey,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
resizable,
|
||||
showCaption,
|
||||
caption,
|
||||
captionsEnabled
|
||||
}) {
|
||||
const imageRef = useRef(null)
|
||||
const buttonRef = useRef(null)
|
||||
const [isSelected, setSelected, clearSelection] =
|
||||
useLexicalNodeSelection(nodeKey)
|
||||
const [editor] = useLexicalComposerContext()
|
||||
// const [selection, setSelection] = useState(null)
|
||||
const activeEditorRef = useRef(null)
|
||||
|
||||
const onDelete = useCallback(
|
||||
(payload) => {
|
||||
if (isSelected && $isNodeSelection($getSelection())) {
|
||||
const event = payload
|
||||
event.preventDefault()
|
||||
const node = $getNodeByKey(nodeKey)
|
||||
if ($isImageNode(node)) {
|
||||
node.remove()
|
||||
}
|
||||
setSelected(false)
|
||||
}
|
||||
return false
|
||||
},
|
||||
[isSelected, nodeKey, setSelected]
|
||||
)
|
||||
|
||||
const onEnter = useCallback(
|
||||
(event) => {
|
||||
const latestSelection = $getSelection()
|
||||
const buttonElem = buttonRef.current
|
||||
if (
|
||||
isSelected &&
|
||||
$isNodeSelection(latestSelection) &&
|
||||
latestSelection.getNodes().length === 1
|
||||
) {
|
||||
if (showCaption) {
|
||||
// Move focus into nested editor
|
||||
$setSelection(null)
|
||||
event.preventDefault()
|
||||
caption.focus()
|
||||
return true
|
||||
} else if (
|
||||
buttonElem !== null &&
|
||||
buttonElem !== document.activeElement
|
||||
) {
|
||||
event.preventDefault()
|
||||
buttonElem.focus()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
[caption, isSelected, showCaption]
|
||||
)
|
||||
|
||||
const onEscape = useCallback(
|
||||
(event) => {
|
||||
if (
|
||||
activeEditorRef.current === caption ||
|
||||
buttonRef.current === event.target
|
||||
) {
|
||||
$setSelection(null)
|
||||
editor.update(() => {
|
||||
setSelected(true)
|
||||
const parentRootElement = editor.getRootElement()
|
||||
if (parentRootElement !== null) {
|
||||
parentRootElement.focus()
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[caption, editor, setSelected]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
// editor.registerUpdateListener(({ editorState }) => {
|
||||
// setSelection(editorState.read(() => $getSelection()))
|
||||
// }),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_, activeEditor) => {
|
||||
activeEditorRef.current = activeEditor
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
CLICK_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload
|
||||
if (event.target === imageRef.current) {
|
||||
if (event.shiftKey) {
|
||||
setSelected(!isSelected)
|
||||
} else {
|
||||
clearSelection()
|
||||
setSelected(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGSTART_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload
|
||||
if (event.target === imageRef.current) {
|
||||
if (event.shiftKey) {
|
||||
setSelected(!isSelected)
|
||||
} else {
|
||||
clearSelection()
|
||||
setSelected(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGSTART_COMMAND,
|
||||
(event) => {
|
||||
if (event.target === imageRef.current) {
|
||||
// TODO This is just a temporary workaround for FF to behave like other browsers.
|
||||
// Ideally, this handles drag & drop too (and all browsers).
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_DELETE_COMMAND,
|
||||
onDelete,
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
onDelete,
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW),
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
onEscape,
|
||||
COMMAND_PRIORITY_LOW
|
||||
)
|
||||
)
|
||||
}, [
|
||||
clearSelection,
|
||||
editor,
|
||||
isSelected,
|
||||
nodeKey,
|
||||
onDelete,
|
||||
onEnter,
|
||||
onEscape,
|
||||
setSelected
|
||||
])
|
||||
|
||||
// const draggable = isSelected && $isNodeSelection(selection)
|
||||
// const isFocused = isSelected
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<>
|
||||
<div draggable>
|
||||
<LazyImage
|
||||
// className={
|
||||
// isFocused
|
||||
// ? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}`
|
||||
// : null
|
||||
// }
|
||||
src={src}
|
||||
altText={altText}
|
||||
imageRef={imageRef}
|
||||
width={width}
|
||||
height={height}
|
||||
maxWidth={maxWidth}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'
|
||||
|
||||
const URL_MATCHER = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
|
||||
|
||||
const EMAIL_MATCHER = /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/
|
||||
|
||||
const MATCHERS = [
|
||||
(text) => {
|
||||
const match = URL_MATCHER.exec(text)
|
||||
return (
|
||||
match && {
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
text: match[0],
|
||||
url: match[0]
|
||||
}
|
||||
)
|
||||
},
|
||||
(text) => {
|
||||
const match = EMAIL_MATCHER.exec(text)
|
||||
return (
|
||||
match && {
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
text: match[0],
|
||||
url: `mailto:${match[0]}`
|
||||
}
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
export default function PlaygroundAutoLinkPlugin () {
|
||||
return <AutoLinkPlugin matchers={MATCHERS} />
|
||||
}
|
|
@ -0,0 +1,252 @@
|
|||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createRangeSelection,
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isRootOrShadowRoot,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
createCommand,
|
||||
DRAGOVER_COMMAND,
|
||||
DRAGSTART_COMMAND,
|
||||
DROP_COMMAND
|
||||
} from 'lexical'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as Yup from 'yup'
|
||||
import { ensureProtocol, URL_REGEXP } from '../../lib/url'
|
||||
|
||||
import {
|
||||
$createImageNode,
|
||||
$isImageNode,
|
||||
ImageNode
|
||||
} from '../nodes/image'
|
||||
import { Form, Input, SubmitButton } from '../../components/form'
|
||||
import styles from '../styles.module.css'
|
||||
|
||||
const getDOMSelection = (targetWindow) =>
|
||||
typeof window !== 'undefined' ? (targetWindow || window).getSelection() : null
|
||||
|
||||
export const INSERT_IMAGE_COMMAND = createCommand('INSERT_IMAGE_COMMAND')
|
||||
|
||||
const LinkSchema = Yup.object({
|
||||
url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required')
|
||||
})
|
||||
|
||||
export function ImageInsertModal ({ onClose, editor }) {
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
url: '',
|
||||
alt: ''
|
||||
}}
|
||||
schema={LinkSchema}
|
||||
onSubmit={async ({ alt, url }) => {
|
||||
editor.dispatchCommand(INSERT_IMAGE_COMMAND, { src: ensureProtocol(url), altText: alt })
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='url'
|
||||
name='url'
|
||||
innerRef={inputRef}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={<>alt text <small className='text-muted ml-2'>optional</small></>}
|
||||
name='alt'
|
||||
/>
|
||||
<div className='d-flex'>
|
||||
<SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ImageInsertPlugin ({
|
||||
captionsEnabled
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([ImageNode])) {
|
||||
throw new Error('ImagesPlugin: ImageNode not registered on editor')
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_IMAGE_COMMAND,
|
||||
(payload) => {
|
||||
const imageNode = $createImageNode(payload)
|
||||
$insertNodes([imageNode])
|
||||
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
|
||||
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGSTART_COMMAND,
|
||||
(event) => {
|
||||
return onDragStart(event)
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGOVER_COMMAND,
|
||||
(event) => {
|
||||
return onDragover(event)
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
DROP_COMMAND,
|
||||
(event) => {
|
||||
return onDrop(event, editor)
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
)
|
||||
)
|
||||
}, [captionsEnabled, editor])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const TRANSPARENT_IMAGE =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
||||
const img = typeof window !== 'undefined' ? document.createElement('img') : undefined
|
||||
if (img) {
|
||||
img.src = TRANSPARENT_IMAGE
|
||||
}
|
||||
|
||||
function onDragStart (event) {
|
||||
const node = getImageNodeInSelection()
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) {
|
||||
return false
|
||||
}
|
||||
dataTransfer.setData('text/plain', '_')
|
||||
img.src = node.getSrc()
|
||||
dataTransfer.setDragImage(img, 0, 0)
|
||||
dataTransfer.setData(
|
||||
'application/x-lexical-drag',
|
||||
JSON.stringify({
|
||||
data: {
|
||||
altText: node.__altText,
|
||||
caption: node.__caption,
|
||||
height: node.__height,
|
||||
maxHeight: '25vh',
|
||||
key: node.getKey(),
|
||||
maxWidth: node.__maxWidth,
|
||||
showCaption: node.__showCaption,
|
||||
src: node.__src,
|
||||
width: node.__width
|
||||
},
|
||||
type: 'image'
|
||||
})
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function onDragover (event) {
|
||||
const node = getImageNodeInSelection()
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
if (!canDropImage(event)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function onDrop (event, editor) {
|
||||
const node = getImageNodeInSelection()
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
const data = getDragImageData(event)
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
if (canDropImage(event)) {
|
||||
const range = getDragSelection(event)
|
||||
node.remove()
|
||||
const rangeSelection = $createRangeSelection()
|
||||
if (range !== null && range !== undefined) {
|
||||
rangeSelection.applyDOMRange(range)
|
||||
}
|
||||
$setSelection(rangeSelection)
|
||||
editor.dispatchCommand(INSERT_IMAGE_COMMAND, data)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function getImageNodeInSelection () {
|
||||
const selection = $getSelection()
|
||||
const nodes = selection.getNodes()
|
||||
const node = nodes[0]
|
||||
return $isImageNode(node) ? node : null
|
||||
}
|
||||
|
||||
function getDragImageData (event) {
|
||||
const dragData = event.dataTransfer?.getData('application/x-lexical-drag')
|
||||
if (!dragData) {
|
||||
return null
|
||||
}
|
||||
const { type, data } = JSON.parse(dragData)
|
||||
if (type !== 'image') {
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
function canDropImage (event) {
|
||||
const target = event.target
|
||||
return !!(
|
||||
target &&
|
||||
target instanceof HTMLElement &&
|
||||
!target.closest('code, span.editor-image') &&
|
||||
target.parentElement &&
|
||||
target.parentElement.closest(`div.${styles.editorInput}`)
|
||||
)
|
||||
}
|
||||
|
||||
function getDragSelection (event) {
|
||||
let range
|
||||
const target = event.target
|
||||
const targetWindow =
|
||||
target == null
|
||||
? null
|
||||
: target.nodeType === 9
|
||||
? target.defaultView
|
||||
: target.ownerDocument.defaultView
|
||||
const domSelection = getDOMSelection(targetWindow)
|
||||
if (document.caretRangeFromPoint) {
|
||||
range = document.caretRangeFromPoint(event.clientX, event.clientY)
|
||||
} else if (event.rangeParent && domSelection !== null) {
|
||||
domSelection.collapse(event.rangeParent, event.rangeOffset || 0)
|
||||
range = domSelection.getRangeAt(0)
|
||||
} else {
|
||||
throw Error('Cannot get the selection when dragging')
|
||||
}
|
||||
|
||||
return range
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $createTextNode, $getSelection, $insertNodes, $setSelection, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
|
||||
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'
|
||||
import { $createLinkNode, $isLinkNode } from '@lexical/link'
|
||||
import { Modal } from 'react-bootstrap'
|
||||
import React, { useState, useCallback, useContext, useRef, useEffect } from 'react'
|
||||
import * as Yup from 'yup'
|
||||
import { Form, Input, SubmitButton } from '../../components/form'
|
||||
import { ensureProtocol, URL_REGEXP } from '../../lib/url'
|
||||
import { getSelectedNode } from '../utils/selected-node'
|
||||
|
||||
export const INSERT_LINK_COMMAND = createCommand('INSERT_LINK_COMMAND')
|
||||
|
||||
export default function LinkInsertPlugin () {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_LINK_COMMAND,
|
||||
(payload) => {
|
||||
const selection = $getSelection()
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent)) {
|
||||
parent.remove()
|
||||
} else if ($isLinkNode(node)) {
|
||||
node.remove()
|
||||
}
|
||||
const textNode = $createTextNode(payload.text)
|
||||
$insertNodes([textNode])
|
||||
const linkNode = $createLinkNode(payload.url)
|
||||
$wrapNodeInElement(textNode, () => linkNode)
|
||||
$setSelection(textNode.select())
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR
|
||||
)
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const LinkInsertContext = React.createContext({
|
||||
link: null,
|
||||
setLink: () => {}
|
||||
})
|
||||
|
||||
export function LinkInsertProvider ({ children }) {
|
||||
const [link, setLink] = useState(null)
|
||||
|
||||
const contextValue = {
|
||||
link,
|
||||
setLink: useCallback(link => setLink(link), [])
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkInsertContext.Provider value={contextValue}>
|
||||
<LinkInsertModal />
|
||||
{children}
|
||||
</LinkInsertContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLinkInsert () {
|
||||
const { link, setLink } = useContext(LinkInsertContext)
|
||||
return { link, setLink }
|
||||
}
|
||||
|
||||
const LinkSchema = Yup.object({
|
||||
text: Yup.string().required('required'),
|
||||
url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required')
|
||||
})
|
||||
|
||||
export function LinkInsertModal () {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { link, setLink } = useLinkInsert()
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (link) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [link])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={!!link}
|
||||
onHide={() => {
|
||||
setLink(null)
|
||||
setTimeout(() => editor.focus(), 100)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='modal-close' onClick={() => {
|
||||
setLink(null)
|
||||
// I think bootstrap messes with the focus on close so we have to do this ourselves
|
||||
setTimeout(() => editor.focus(), 100)
|
||||
}}
|
||||
>X
|
||||
</div>
|
||||
<Modal.Body>
|
||||
<Form
|
||||
initial={{
|
||||
text: link?.text,
|
||||
url: link?.url
|
||||
}}
|
||||
schema={LinkSchema}
|
||||
onSubmit={async ({ text, url }) => {
|
||||
editor.dispatchCommand(INSERT_LINK_COMMAND, { url: ensureProtocol(url), text })
|
||||
await setLink(null)
|
||||
setTimeout(() => editor.focus(), 100)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='text'
|
||||
name='text'
|
||||
innerRef={inputRef}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label='url'
|
||||
name='url'
|
||||
required
|
||||
/>
|
||||
<div className='d-flex'>
|
||||
<SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,232 @@
|
|||
import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
||||
import styles from '../styles.module.css'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND
|
||||
} from 'lexical'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { getSelectedNode } from '../utils/selected-node'
|
||||
import { setTooltipPosition } from '../utils/tooltip-position'
|
||||
import { useLinkInsert } from './link-insert'
|
||||
import { getLinkFromSelection } from '../utils/link-from-selection'
|
||||
|
||||
function FloatingLinkEditor ({
|
||||
editor,
|
||||
isLink,
|
||||
setIsLink,
|
||||
anchorElem
|
||||
}) {
|
||||
const { setLink } = useLinkInsert()
|
||||
const editorRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isEditMode, setEditMode] = useState(false)
|
||||
|
||||
const updateLinkEditor = useCallback(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent)) {
|
||||
setLinkUrl(parent.getURL())
|
||||
} else if ($isLinkNode(node)) {
|
||||
setLinkUrl(node.getURL())
|
||||
} else {
|
||||
setLinkUrl('')
|
||||
}
|
||||
}
|
||||
const editorElem = editorRef.current
|
||||
const nativeSelection = window.getSelection()
|
||||
const activeElement = document.activeElement
|
||||
|
||||
if (editorElem === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode) &&
|
||||
editor.isEditable()
|
||||
) {
|
||||
const domRange = nativeSelection.getRangeAt(0)
|
||||
let rect
|
||||
if (nativeSelection.anchorNode === rootElement) {
|
||||
let inner = rootElement
|
||||
while (inner.firstElementChild != null) {
|
||||
inner = inner.firstElementChild
|
||||
}
|
||||
rect = inner.getBoundingClientRect()
|
||||
} else {
|
||||
rect = domRange.getBoundingClientRect()
|
||||
}
|
||||
|
||||
setTooltipPosition(rect, editorElem, anchorElem)
|
||||
} else if (!activeElement) {
|
||||
if (rootElement !== null) {
|
||||
setTooltipPosition(null, editorElem, anchorElem)
|
||||
}
|
||||
setEditMode(false)
|
||||
setLinkUrl('')
|
||||
}
|
||||
|
||||
return true
|
||||
}, [anchorElem, editor])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', update)
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update)
|
||||
}
|
||||
}
|
||||
}, [anchorElem.parentElement, editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateLinkEditor()
|
||||
})
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateLinkEditor()
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
() => {
|
||||
if (isLink) {
|
||||
setIsLink(false)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
)
|
||||
)
|
||||
}, [editor, updateLinkEditor, setIsLink, isLink])
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor()
|
||||
})
|
||||
}, [editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isEditMode])
|
||||
|
||||
return (
|
||||
linkUrl &&
|
||||
<div ref={editorRef} className={styles.linkTooltip}>
|
||||
<div className='tooltip-inner d-flex'>
|
||||
<a href={linkUrl} target='_blank' rel='noreferrer' className={`${styles.tooltipUrl} text-reset`}>{linkUrl.replace('https://', '').replace('http://', '')}</a>
|
||||
<span className='px-1'> \ </span>
|
||||
<span
|
||||
className='pointer'
|
||||
onClick={() => {
|
||||
editor.update(() => {
|
||||
// we need to replace the link
|
||||
// their playground simple 'TOGGLE's it with a new url
|
||||
// but we need to potentiallyr replace the text
|
||||
setLink(getLinkFromSelection())
|
||||
})
|
||||
}}
|
||||
>edit
|
||||
</span>
|
||||
<span className='px-1'> \ </span>
|
||||
<span
|
||||
className='pointer'
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
}}
|
||||
>remove
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useFloatingLinkEditorToolbar ({ editor, anchorElem }) {
|
||||
const [activeEditor, setActiveEditor] = useState(editor)
|
||||
const [isLink, setIsLink] = useState(false)
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
const linkParent = $findMatchingParent(node, $isLinkNode)
|
||||
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode)
|
||||
|
||||
// We don't want this menu to open for auto links.
|
||||
if (linkParent != null && autoLinkParent == null) {
|
||||
setIsLink(true)
|
||||
} else {
|
||||
setIsLink(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
updateToolbar()
|
||||
setActiveEditor(newEditor)
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL
|
||||
)
|
||||
}, [editor, updateToolbar])
|
||||
|
||||
return isLink
|
||||
? <FloatingLinkEditor
|
||||
editor={activeEditor}
|
||||
isLink={isLink}
|
||||
anchorElem={anchorElem}
|
||||
setIsLink={setIsLink}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
|
||||
export default function LinkTooltipPlugin ({
|
||||
anchorElem = document.body
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
return useFloatingLinkEditorToolbar({ editor, anchorElem })
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
INDENT_CONTENT_COMMAND,
|
||||
COMMAND_PRIORITY_HIGH
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
function getElementNodesInSelection (selection) {
|
||||
const nodesInSelection = selection.getNodes()
|
||||
|
||||
if (nodesInSelection.length === 0) {
|
||||
return new Set([
|
||||
selection.anchor.getNode().getParentOrThrow(),
|
||||
selection.focus.getNode().getParentOrThrow()
|
||||
])
|
||||
}
|
||||
|
||||
return new Set(
|
||||
nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow()))
|
||||
)
|
||||
}
|
||||
|
||||
function isIndentPermitted (maxDepth) {
|
||||
const selection = $getSelection()
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const elementNodesInSelection = getElementNodesInSelection(selection)
|
||||
|
||||
let totalDepth = 0
|
||||
|
||||
for (const elementNode of elementNodesInSelection) {
|
||||
if ($isListNode(elementNode)) {
|
||||
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth)
|
||||
} else if ($isListItemNode(elementNode)) {
|
||||
const parent = elementNode.getParent()
|
||||
if (!$isListNode(parent)) {
|
||||
throw new Error(
|
||||
'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.'
|
||||
)
|
||||
}
|
||||
|
||||
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth)
|
||||
}
|
||||
}
|
||||
|
||||
return totalDepth <= maxDepth
|
||||
}
|
||||
|
||||
export default function ListMaxIndentLevelPlugin ({ maxDepth }) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
INDENT_CONTENT_COMMAND,
|
||||
() => !isIndentPermitted(maxDepth ?? 7),
|
||||
COMMAND_PRIORITY_HIGH
|
||||
)
|
||||
}, [editor, maxDepth])
|
||||
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,383 @@
|
|||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
INDENT_CONTENT_COMMAND,
|
||||
OUTDENT_CONTENT_COMMAND,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$createParagraphNode
|
||||
} from 'lexical'
|
||||
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||
import {
|
||||
$wrapNodes
|
||||
} from '@lexical/selection'
|
||||
import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
INSERT_ORDERED_LIST_COMMAND,
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
REMOVE_LIST_COMMAND,
|
||||
$isListNode,
|
||||
ListNode
|
||||
} from '@lexical/list'
|
||||
import {
|
||||
$createHeadingNode,
|
||||
$createQuoteNode,
|
||||
$isHeadingNode
|
||||
} from '@lexical/rich-text'
|
||||
// import {
|
||||
// $createCodeNode
|
||||
// } from '@lexical/code'
|
||||
import BoldIcon from '../../svgs/bold.svg'
|
||||
import ItalicIcon from '../../svgs/italic.svg'
|
||||
// import StrikethroughIcon from '../../svgs/strikethrough.svg'
|
||||
import LinkIcon from '../../svgs/link.svg'
|
||||
import ListOrderedIcon from '../../svgs/list-ordered.svg'
|
||||
import ListUnorderedIcon from '../../svgs/list-unordered.svg'
|
||||
import IndentIcon from '../../svgs/indent-increase.svg'
|
||||
import OutdentIcon from '../../svgs/indent-decrease.svg'
|
||||
import ImageIcon from '../../svgs/image-line.svg'
|
||||
import FontSizeIcon from '../../svgs/font-size-2.svg'
|
||||
import QuoteIcon from '../../svgs/double-quotes-r.svg'
|
||||
// import CodeIcon from '../../svgs/code-line.svg'
|
||||
// import CodeBoxIcon from '../../svgs/code-box-line.svg'
|
||||
import ArrowDownIcon from '../../svgs/arrow-down-s-fill.svg'
|
||||
import CheckIcon from '../../svgs/check-line.svg'
|
||||
|
||||
import styles from '../styles.module.css'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { useLinkInsert } from './link-insert'
|
||||
import { getSelectedNode } from '../utils/selected-node'
|
||||
import { getLinkFromSelection } from '../utils/link-from-selection'
|
||||
import { ImageInsertModal } from './image-insert'
|
||||
import useModal from '../utils/modal'
|
||||
|
||||
const LowPriority = 1
|
||||
|
||||
function Divider () {
|
||||
return <div className={styles.divider} />
|
||||
}
|
||||
|
||||
function FontSizeDropdown ({
|
||||
editor,
|
||||
blockType
|
||||
}) {
|
||||
const formatParagraph = () => {
|
||||
if (blockType !== 'paragraph') {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createParagraphNode())
|
||||
}
|
||||
setTimeout(() => editor.focus(), 100)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatLargeHeading = () => {
|
||||
if (blockType !== 'h1') {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createHeadingNode('h1'))
|
||||
}
|
||||
|
||||
setTimeout(() => editor.focus(), 100)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatSmallHeading = () => {
|
||||
if (blockType !== 'h2') {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createHeadingNode('h2'))
|
||||
}
|
||||
|
||||
setTimeout(() => editor.focus(), 100)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown className='pointer' as='span'>
|
||||
<Dropdown.Toggle
|
||||
id='dropdown-basic'
|
||||
as='button' className={styles.toolbarItem} aria-label='Font size'
|
||||
>
|
||||
<FontSizeIcon />
|
||||
<ArrowDownIcon />
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item as='button' className={`${styles.paragraph} my-0`} onClick={formatParagraph}>
|
||||
<CheckIcon className={`mr-1 ${blockType === 'paragraph' ? 'fill-grey' : 'invisible'}`} />
|
||||
<span className={styles.text}>normal</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item as='button' className={`${styles.heading2} my-0`} onClick={formatSmallHeading}>
|
||||
<CheckIcon className={`mr-1 ${['h2', 'h3', 'h4', 'h5', 'h6'].includes(blockType) ? 'fill-grey' : 'invisible'}`} />
|
||||
<span className={styles.text}>subheading</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item as='button' className={`${styles.heading1} my-0`} onClick={formatLargeHeading}>
|
||||
<CheckIcon className={`mr-1 ${blockType === 'h1' ? 'fill-grey' : 'invisible'}`} />
|
||||
<span className={styles.text}>heading</span>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ToolbarPlugin () {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { setLink } = useLinkInsert()
|
||||
const toolbarRef = useRef(null)
|
||||
const [blockType, setBlockType] = useState('paragraph')
|
||||
const [isLink, setIsLink] = useState(false)
|
||||
const [isBold, setIsBold] = useState(false)
|
||||
const [isItalic, setIsItalic] = useState(false)
|
||||
// const [isStrikethrough, setIsStrikethrough] = useState(false)
|
||||
// const [isCode, setIsCode] = useState(false)
|
||||
const [modal, showModal] = useModal()
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const element =
|
||||
anchorNode.getKey() === 'root'
|
||||
? anchorNode
|
||||
: anchorNode.getTopLevelElementOrThrow()
|
||||
const elementKey = element.getKey()
|
||||
const elementDOM = editor.getElementByKey(elementKey)
|
||||
if (elementDOM !== null) {
|
||||
if ($isListNode(element)) {
|
||||
const parentList = $getNearestNodeOfType(anchorNode, ListNode)
|
||||
const type = parentList ? parentList.getTag() : element.getTag()
|
||||
setBlockType(type)
|
||||
} else {
|
||||
const type = $isHeadingNode(element)
|
||||
? element.getTag()
|
||||
: element.getType()
|
||||
setBlockType(type)
|
||||
}
|
||||
}
|
||||
// Update text format
|
||||
setIsBold(selection.hasFormat('bold'))
|
||||
setIsItalic(selection.hasFormat('italic'))
|
||||
// setIsStrikethrough(selection.hasFormat('strikethrough'))
|
||||
// setIsCode(selection.hasFormat('code'))
|
||||
|
||||
// Update links
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
setIsLink(true)
|
||||
} else {
|
||||
setIsLink(false)
|
||||
}
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateToolbar()
|
||||
})
|
||||
}),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
updateToolbar()
|
||||
return false
|
||||
},
|
||||
LowPriority
|
||||
)
|
||||
)
|
||||
}, [editor, updateToolbar])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
if (isLink) {
|
||||
// unlink it
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
} else {
|
||||
editor.update(() => {
|
||||
setLink(getLinkFromSelection())
|
||||
})
|
||||
}
|
||||
}, [editor, isLink])
|
||||
|
||||
const formatBulletList = () => {
|
||||
if (blockType !== 'ul') {
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND)
|
||||
} else {
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND)
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumberedList = () => {
|
||||
if (blockType !== 'ol') {
|
||||
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND)
|
||||
} else {
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND)
|
||||
}
|
||||
}
|
||||
|
||||
const formatQuote = () => {
|
||||
if (blockType !== 'quote') {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createQuoteNode())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
|
||||
if ($isRangeSelection(selection)) {
|
||||
$wrapNodes(selection, () => $createParagraphNode())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// const formatCode = () => {
|
||||
// if (blockType !== 'code') {
|
||||
// editor.update(() => {
|
||||
// const selection = $getSelection()
|
||||
|
||||
// if ($isRangeSelection(selection)) {
|
||||
// $wrapNodes(selection, () => {
|
||||
// const node = $createCodeNode()
|
||||
// node.setLanguage('plain')
|
||||
// return node
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar} ref={toolbarRef}>
|
||||
<FontSizeDropdown editor={editor} blockType={blockType} />
|
||||
<Divider />
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
||||
}}
|
||||
className={`${styles.toolbarItem} ${styles.spaced} ${isBold ? styles.active : ''}`}
|
||||
aria-label='Format Bold'
|
||||
>
|
||||
<BoldIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
||||
}}
|
||||
className={`${styles.toolbarItem} ${styles.spaced} ${isItalic ? styles.active : ''}`}
|
||||
aria-label='Format Italics'
|
||||
>
|
||||
<ItalicIcon />
|
||||
</button>
|
||||
<Divider />
|
||||
<button
|
||||
onClick={formatBulletList}
|
||||
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ul' ? styles.active : ''}`}
|
||||
>
|
||||
<ListUnorderedIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={formatNumberedList}
|
||||
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ol' ? styles.active : ''}`}
|
||||
aria-label='Insert numbered list'
|
||||
>
|
||||
<ListOrderedIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
|
||||
}}
|
||||
className={`${styles.toolbarItem} ${styles.spaced}`}
|
||||
aria-label='Indent'
|
||||
>
|
||||
<IndentIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
|
||||
}}
|
||||
className={`${styles.toolbarItem} ${styles.spaced}`}
|
||||
aria-label='Outdent'
|
||||
>
|
||||
<OutdentIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={formatQuote}
|
||||
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'quote' ? styles.active : ''}`}
|
||||
aria-label='Insert Quote'
|
||||
>
|
||||
<QuoteIcon />
|
||||
</button>
|
||||
{/* <Divider /> */}
|
||||
{/* <button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
||||
}}
|
||||
className={
|
||||
`${styles.toolbarItem} ${styles.spaced} ${isStrikethrough ? styles.active : ''}`
|
||||
}
|
||||
aria-label='Format Strikethrough'
|
||||
>
|
||||
<StrikethroughIcon />
|
||||
</button> */}
|
||||
{/* <button
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
|
||||
}}
|
||||
className={`${styles.toolbarItem} ${styles.spaced} ${isCode ? styles.active : ''}`}
|
||||
aria-label='Insert Code'
|
||||
>
|
||||
<CodeIcon />
|
||||
</button> */}
|
||||
{/* <button
|
||||
onClick={formatCode}
|
||||
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'code' ? styles.active : ''}`}
|
||||
aria-label='Insert Code'
|
||||
>
|
||||
<CodeBoxIcon />
|
||||
</button> */}
|
||||
<Divider />
|
||||
<button
|
||||
onClick={insertLink}
|
||||
className={`${styles.toolbarItem} ${styles.spaced} ${isLink ? styles.active : ''}`}
|
||||
aria-label='Insert Link'
|
||||
>
|
||||
<LinkIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
showModal((onClose) => (
|
||||
<ImageInsertModal
|
||||
editor={editor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
className={`${styles.toolbarItem} ${styles.spaced}`}
|
||||
aria-label='Insert Image'
|
||||
>
|
||||
<ImageIcon />
|
||||
</button>
|
||||
{modal}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,256 @@
|
|||
/* editor */
|
||||
|
||||
.editor {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editorContainer {
|
||||
margin: 20px auto 20px auto;
|
||||
width: 100%;
|
||||
color: var(--theme-color);
|
||||
position: relative;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
border-top-left-radius: .4rem;
|
||||
border-top-right-radius: .4rem;
|
||||
}
|
||||
|
||||
.editorInner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editorInput>hr {
|
||||
border-top: 1px solid var(--theme-clickToContextColor);
|
||||
}
|
||||
|
||||
.editorInput {
|
||||
min-height: 150px;
|
||||
resize: auto;
|
||||
font-size: 15px;
|
||||
caret-color: var(--theme-color);
|
||||
background-color: var(--theme-body);
|
||||
position: relative;
|
||||
tab-size: 1;
|
||||
outline: 0;
|
||||
padding: 15px 10px;
|
||||
border: 1px solid;
|
||||
border-bottom-left-radius: .4rem;
|
||||
border-bottom-right-radius: .4rem;
|
||||
/* border-top: 0px; */
|
||||
}
|
||||
|
||||
.editorPlaceholder {
|
||||
color: var(--theme-grey);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
text-overflow: ellipsis;
|
||||
top: 15px;
|
||||
left: 10px;
|
||||
font-size: 15px;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* blocks */
|
||||
|
||||
.image {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.paragraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.quote {
|
||||
margin: 0;
|
||||
margin-left: 20px;
|
||||
font-size: 15px;
|
||||
color: var(--theme-quoteColor);
|
||||
border-left-color: var(--theme-quoteBar);
|
||||
border-left-width: 4px;
|
||||
border-left-style: solid;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.heading1 {
|
||||
font-size: 24px;
|
||||
color: var(--theme-color);
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.heading2 {
|
||||
font-size: 15px;
|
||||
color: var(--theme-navLink);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
margin-top: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
.code {
|
||||
background-color: rgb(240, 242, 245);
|
||||
font-family: Menlo, Consolas, Monaco, monospace;
|
||||
display: block;
|
||||
padding: 8px 8px 8px 52px;
|
||||
line-height: 1.53;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
tab-size: 2;
|
||||
/* white-space: pre; */
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* inline blocks */
|
||||
|
||||
.link {
|
||||
color: var(--theme-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* lists */
|
||||
|
||||
.listOl {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.listUl {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
margin: 8px 32px 8px 32px;
|
||||
}
|
||||
|
||||
.nestedListItem {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
/* text */
|
||||
|
||||
.textBold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.textItalic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.textUnderline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.textStrikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.textUnderlineStrikethrough {
|
||||
text-decoration: underline line-through;
|
||||
}
|
||||
|
||||
.textCode {
|
||||
background-color: rgb(240, 242, 245);
|
||||
padding: 1px 0.25rem;
|
||||
font-family: Menlo, Consolas, Monaco, monospace;
|
||||
font-size: 94%;
|
||||
}
|
||||
|
||||
/* toolbar */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
background: var(--theme-toolbar);
|
||||
padding: 4px;
|
||||
border-top-left-radius: .4rem;
|
||||
border-top-right-radius: .4rem;
|
||||
vertical-align: middle;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar button.toolbarItem {
|
||||
border: 0;
|
||||
display: flex;
|
||||
background: none;
|
||||
border-radius: .4rem;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.toolbar button.toolbarItem:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar button.toolbarItem.spaced {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.toolbar button.toolbarItem svg {
|
||||
background-size: contain;
|
||||
display: inline-block;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
margin-top: 2px;
|
||||
vertical-align: -0.25em;
|
||||
display: flex;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.toolbar button.toolbarItem:disabled svg {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.toolbar button.toolbarItem.active {
|
||||
background-color: var(--theme-toolbarActive);
|
||||
}
|
||||
|
||||
.toolbar button.toolbarItem.active svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toolbar .toolbarItem:hover:not([disabled]) {
|
||||
background-color: var(--theme-toolbarHover);
|
||||
}
|
||||
|
||||
.toolbar .divider {
|
||||
width: 1px;
|
||||
background-color: var(--theme-borderColor);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.toolbar .toolbarItem svg {
|
||||
fill: var(--theme-color) !important;
|
||||
}
|
||||
|
||||
.linkTooltip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
font-size: 0.7875rem;
|
||||
opacity: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.tooltipUrl {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import styles from './styles.module.css'
|
||||
|
||||
const theme = {
|
||||
paragraph: styles.paragraph,
|
||||
quote: styles.quote,
|
||||
heading: {
|
||||
h1: styles.heading1,
|
||||
h2: styles.heading2,
|
||||
h3: styles.heading2,
|
||||
h4: styles.heading2,
|
||||
h5: styles.heading2
|
||||
},
|
||||
image: styles.image,
|
||||
link: styles.link,
|
||||
code: styles.code,
|
||||
hr: styles.hr,
|
||||
list: {
|
||||
nested: {
|
||||
listitem: styles.nestedListItem
|
||||
},
|
||||
ol: styles.listOl,
|
||||
ul: styles.listUl,
|
||||
listitem: styles.listItem
|
||||
},
|
||||
text: {
|
||||
bold: styles.textBold,
|
||||
italic: styles.textItalic,
|
||||
// overflowed: 'editor-text-overflowed',
|
||||
// hashtag: 'editor-text-hashtag',
|
||||
underline: styles.textUnderline,
|
||||
strikethrough: styles.textStrikethrough,
|
||||
underlineStrikethrough: styles.underlineStrikethrough,
|
||||
code: styles.textCode
|
||||
}
|
||||
}
|
||||
|
||||
export default theme
|
|
@ -0,0 +1,55 @@
|
|||
import {
|
||||
$createImageNode,
|
||||
$isImageNode,
|
||||
ImageNode
|
||||
} from '../nodes/image'
|
||||
import {
|
||||
$createHorizontalRuleNode,
|
||||
$isHorizontalRuleNode,
|
||||
HorizontalRuleNode
|
||||
} from '@lexical/react/LexicalHorizontalRuleNode'
|
||||
import { TRANSFORMERS } from '@lexical/markdown'
|
||||
|
||||
export const IMAGE = {
|
||||
dependencies: [ImageNode],
|
||||
export: (node, exportChildren, exportFormat) => {
|
||||
if (!$isImageNode(node)) {
|
||||
return null
|
||||
}
|
||||
return `![${node.getAltText()}](${node.getSrc()})`
|
||||
},
|
||||
importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/,
|
||||
regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/,
|
||||
replace: (textNode, match) => {
|
||||
const [, altText, src] = match
|
||||
const imageNode = $createImageNode({ altText, src })
|
||||
textNode.replace(imageNode)
|
||||
},
|
||||
trigger: ')',
|
||||
type: 'text-match'
|
||||
}
|
||||
|
||||
export const HR = {
|
||||
dependencies: [HorizontalRuleNode],
|
||||
export: (node) => {
|
||||
return $isHorizontalRuleNode(node) ? '***' : null
|
||||
},
|
||||
regExp: /^(-{3,}|\*{3,}|_{3,})\s?$/,
|
||||
replace: (parentNode, _1, _2, isImport) => {
|
||||
const line = $createHorizontalRuleNode()
|
||||
|
||||
// TODO: Get rid of isImport flag
|
||||
if (isImport || parentNode.getNextSibling() != null) {
|
||||
parentNode.replace(line)
|
||||
} else {
|
||||
parentNode.insertBefore(line)
|
||||
}
|
||||
|
||||
line.selectNext()
|
||||
},
|
||||
type: 'element'
|
||||
}
|
||||
|
||||
export const SN_TRANSFORMERS = [
|
||||
HR, IMAGE, ...TRANSFORMERS
|
||||
]
|
|
@ -0,0 +1,24 @@
|
|||
import { $getSelection, $getTextContent, $isRangeSelection } from 'lexical'
|
||||
import { getSelectedNode } from './selected-node'
|
||||
import { $isLinkNode } from '@lexical/link'
|
||||
|
||||
export function getLinkFromSelection () {
|
||||
const selection = $getSelection()
|
||||
let url = ''
|
||||
let text = ''
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent)) {
|
||||
url = parent.getURL()
|
||||
text = parent.getTextContent()
|
||||
} else if ($isLinkNode(node)) {
|
||||
url = node.getURL()
|
||||
text = node.getTextContent()
|
||||
} else {
|
||||
url = ''
|
||||
text = $getTextContent(selection)
|
||||
}
|
||||
}
|
||||
return { url, text }
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { useCallback, useMemo, useState } from 'react'
|
||||
import * as React from 'react'
|
||||
import { Modal } from 'react-bootstrap'
|
||||
|
||||
export default function useModal () {
|
||||
const [modalContent, setModalContent] = useState(null)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setModalContent(null)
|
||||
}, [])
|
||||
|
||||
const modal = useMemo(() => {
|
||||
if (modalContent === null) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Modal onHide={onClose} show={!!modalContent}>
|
||||
<div className='modal-close' onClick={onClose}>X</div>
|
||||
<Modal.Body>
|
||||
{modalContent}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}, [modalContent, onClose])
|
||||
|
||||
const showModal = useCallback(
|
||||
(getContent) => {
|
||||
setModalContent(getContent(onClose))
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
return [modal, showModal]
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { $isAtNodeEnd } from '@lexical/selection'
|
||||
|
||||
export function getSelectedNode (selection) {
|
||||
const anchor = selection.anchor
|
||||
const focus = selection.focus
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const focusNode = selection.focus.getNode()
|
||||
if (anchorNode === focusNode) {
|
||||
return anchorNode
|
||||
}
|
||||
const isBackward = selection.isBackward()
|
||||
if (isBackward) {
|
||||
return $isAtNodeEnd(focus) ? anchorNode : focusNode
|
||||
} else {
|
||||
return $isAtNodeEnd(anchor) ? anchorNode : focusNode
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
const VERTICAL_GAP = 5
|
||||
const HORIZONTAL_OFFSET = 5
|
||||
|
||||
export function setTooltipPosition (
|
||||
targetRect,
|
||||
floatingElem,
|
||||
anchorElem,
|
||||
verticalGap = VERTICAL_GAP,
|
||||
horizontalOffset = HORIZONTAL_OFFSET
|
||||
) {
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
if (targetRect === null || !scrollerElem) {
|
||||
floatingElem.style.opacity = '0'
|
||||
floatingElem.style.transform = 'translate(-10000px, -10000px)'
|
||||
return
|
||||
}
|
||||
|
||||
const floatingElemRect = floatingElem.getBoundingClientRect()
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect()
|
||||
const editorScrollerRect = scrollerElem.getBoundingClientRect()
|
||||
|
||||
let top = targetRect.top - floatingElemRect.height - verticalGap
|
||||
let left = targetRect.left - horizontalOffset
|
||||
|
||||
top += floatingElemRect.height + targetRect.height + verticalGap * 2
|
||||
|
||||
if (left + floatingElemRect.width > editorScrollerRect.right) {
|
||||
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset
|
||||
}
|
||||
|
||||
top -= anchorElementRect.top
|
||||
left -= anchorElementRect.left
|
||||
|
||||
if (top > 0 && left > 0) {
|
||||
floatingElem.style.opacity = '1'
|
||||
} else {
|
||||
floatingElem.style.opacity = '0'
|
||||
}
|
||||
floatingElem.style.transform = `translate(${left}px, ${top}px)`
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
export function sanitizeUrl (url) {
|
||||
/** A pattern that matches safe URLs. */
|
||||
const SAFE_URL_PATTERN =
|
||||
/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi
|
||||
|
||||
/** A pattern that matches safe data URLs. */
|
||||
const DATA_URL_PATTERN =
|
||||
/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i
|
||||
|
||||
url = String(url).trim()
|
||||
|
||||
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url
|
||||
|
||||
return 'https://'
|
||||
}
|
||||
|
||||
// Source: https://stackoverflow.com/a/8234912/2013580
|
||||
const urlRegExp =
|
||||
/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/
|
||||
export function validateUrl (url) {
|
||||
// TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
|
||||
// Maybe show a dialog where they user can type the URL before inserting it.
|
||||
return url === 'https://' || urlRegExp.test(url)
|
||||
}
|
|
@ -16,3 +16,4 @@ export const ITEM_SPAM_INTERVAL = '10m'
|
|||
export const MAX_POLL_NUM_CHOICES = 10
|
||||
export const ITEM_FILTER_THRESHOLD = 1.2
|
||||
export const DONT_LIKE_THIS_COST = 1
|
||||
export const MAX_NOSTR_RELAY_NUM = 20
|
||||
|
|
18
lib/url.js
|
@ -4,3 +4,21 @@ export function ensureProtocol (value) {
|
|||
}
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
export function removeTracking (value) {
|
||||
const exprs = [
|
||||
// twitter URLs
|
||||
/^(?<url>https?:\/\/twitter\.com\/(?:#!\/)?(?<user>\w+)\/status(?:es)?\/(?<id>\d+))/,
|
||||
]
|
||||
for (const expr of exprs) {
|
||||
value = expr.exec(value)?.groups.url ?? value;
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const WS_REGEXP = /^(wss?:\/\/)([0-9]{1,3}(?:\.[0-9]{1,3}){3}|(?=[^\/]{1,254}(?![^\/]))(?:(?=[a-zA-Z0-9-]{1,63}\.)(?:xn--+)?[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,63})(:([0-9]{1,5}))?$/
|
||||
|
|
|
@ -42,6 +42,18 @@ module.exports = withPlausibleProxy()({
|
|||
value: 'public, max-age=31536000, immutable'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
source: '/.well-known/:slug*',
|
||||
headers: [
|
||||
...corsHeaders
|
||||
]
|
||||
},
|
||||
{
|
||||
source: '/api/lnauth',
|
||||
headers: [
|
||||
...corsHeaders
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -71,6 +83,10 @@ module.exports = withPlausibleProxy()({
|
|||
source: '/.well-known/lnurlp/:username',
|
||||
destination: '/api/lnurlp/:username'
|
||||
},
|
||||
{
|
||||
source: '/.well-known/nostr.json',
|
||||
destination: '/api/nostr/nip05'
|
||||
},
|
||||
{
|
||||
source: '/~:sub',
|
||||
destination: '/~/:sub'
|
||||
|
|
|
@ -1143,6 +1143,172 @@
|
|||
"@jridgewell/sourcemap-codec": "1.4.14"
|
||||
}
|
||||
},
|
||||
"@lexical/clipboard": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.7.5.tgz",
|
||||
"integrity": "sha512-H5KA7CfrCYJs3fcDG2LkG/s5dfT1KcuOxEZwinCE0Quzu4aPxHLWKRXImwNsN/zVU5zTnvjd29Zv2NYtfYfBiA==",
|
||||
"requires": {
|
||||
"@lexical/html": "0.7.5",
|
||||
"@lexical/list": "0.7.5",
|
||||
"@lexical/selection": "0.7.5",
|
||||
"@lexical/utils": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/code": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.7.5.tgz",
|
||||
"integrity": "sha512-WjLkKdP/fpCfokhXmUHIWm9SMJHnD2/u3X6DplQBAQ/BKHfSWHbIpkzOpxB+SPHezzhwX49/j2I3jiJSr4AMbw==",
|
||||
"requires": {
|
||||
"@lexical/utils": "0.7.5",
|
||||
"prismjs": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"@lexical/dragon": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.7.5.tgz",
|
||||
"integrity": "sha512-/EWJJNhlSW/4ixvDyhaS7sBkbKX5fW5wEvpBldmfPvVuALJxj0p/2vN2FVFmLxWlQGxCoFvDU5IA7RJ+Y/U+SQ=="
|
||||
},
|
||||
"@lexical/hashtag": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.7.5.tgz",
|
||||
"integrity": "sha512-5867EAZJvAThHta1JG85n/eLTRirkHaqb/7SI+WAIZSkGfYVJUhk0LjxF5B3GfzBbdGlqJ7NyKZ0DE9QqhL0oA==",
|
||||
"requires": {
|
||||
"@lexical/utils": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/history": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.7.5.tgz",
|
||||
"integrity": "sha512-vvUr7wQ9KiGXSm8dvozAMs/oOqi6ePcI7QyD1hwD2QF8z8x1f+0D6R9MMdN5y35XsnH6N2ujHI14LX5CuNXeqw==",
|
||||
"requires": {
|
||||
"@lexical/utils": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/html": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.7.5.tgz",
|
||||
"integrity": "sha512-aHG0pxvVmYvyXstCVmzXpVYR7n3rMUQ6aSCd7fnreJGSxOr/LafT5+8CZQkUm7iR2IiiK8umX8NHzz4cMNgR3Q==",
|
||||
"requires": {
|
||||
"@lexical/selection": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/link": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.7.5.tgz",
|
||||
"integrity": "sha512-95V183O7uotiF8JRS1CN2272ckgNe3EJI1ezT5dGjdw8JJYDN6mAMhUVQWKBR/kKpNXuznprMH8QjkLLESBakw==",
|
||||
"requires": {
|
||||
"@lexical/utils": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/list": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.7.5.tgz",
|
||||
"integrity": "sha512-YCDsP0hxIMwjtN3wDKLsFdjrDenX+OdOswm+4Zt/mtLMCOSwibcDDyzWnA7UbR2s4Fwy1Rvhl56xdR2egGCQhw==",
|
||||
"requires": {
|
||||
"@lexical/utils": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/mark": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.7.5.tgz",
|
||||
"integrity": "sha512-fKEyAUKTJt79YTN24Qi5Uc3HHYZCJs/HPq/yn3Y09Z74K258cW9uyj7LPpjRBeuiD47SVF1Mq7wbbqQ0ak4GPA==",
|
||||
"requires": {
|
||||
"@lexical/utils": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/markdown": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.7.5.tgz",
|
||||
"integrity": "sha512-kSg1qAgDGGrXo+LBEnOaAOkR0KPYorKjldBnlttH8x0qMIKrblDd6v56oOK7Yl/LgGT3uq7RpAJFK5gKDnVn6w==",
|
||||
"requires": {
|
||||
"@lexical/code": "0.7.5",
|
||||
"@lexical/link": "0.7.5",
|
||||
"@lexical/list": "0.7.5",
|
||||
"@lexical/rich-text": "0.7.5",
|
||||
"@lexical/text": "0.7.5",
|
||||
"@lexical/utils": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/offset": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.7.5.tgz",
|
||||
"integrity": "sha512-RvBCkXnpgESx0UUXg4G6xJMcT3PBe+kuSQElkFB2KqRqTF0S3eMskLoakFdpSA0yc89Ubrh9+zKMzxoIwGtrmg=="
|
||||
},
|
||||
"@lexical/overflow": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.7.5.tgz",
|
||||
"integrity": "sha512-JoY2jKfoDBVDy6XDCVMakWV7jJO8ks++wx+1uvPrK1s5Qx7UMvaUtID6f1+hph0437JuLKA0IpfDQFKQfp6RIg=="
|
||||
},
|
||||
"@lexical/plain-text": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.7.5.tgz",
|
||||
"integrity": "sha512-sZta4HgT8ShIHCDXF3sBrj7APtBKaLL6Lm9nwyVmv8NWLb1TItOKGy0+i3FrAAVSFNW+RHWJFltMiRUoPRBQFw=="
|
||||
},
|
||||
"@lexical/react": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.7.5.tgz",
|
||||
"integrity": "sha512-Lk51Y514kOfUMF3+92dKKVuPlFrL1r19hOTGhvdi71DF1ewUzTVbbS9oyMFzNAA+rsuKqLKn0zILT43CHRk22w==",
|
||||
"requires": {
|
||||
"@lexical/clipboard": "0.7.5",
|
||||
"@lexical/code": "0.7.5",
|
||||
"@lexical/dragon": "0.7.5",
|
||||
"@lexical/hashtag": "0.7.5",
|
||||
"@lexical/history": "0.7.5",
|
||||
"@lexical/link": "0.7.5",
|
||||
"@lexical/list": "0.7.5",
|
||||
"@lexical/mark": "0.7.5",
|
||||
"@lexical/markdown": "0.7.5",
|
||||
"@lexical/overflow": "0.7.5",
|
||||
"@lexical/plain-text": "0.7.5",
|
||||
"@lexical/rich-text": "0.7.5",
|
||||
"@lexical/selection": "0.7.5",
|
||||
"@lexical/table": "0.7.5",
|
||||
"@lexical/text": "0.7.5",
|
||||
"@lexical/utils": "0.7.5",
|
||||
"@lexical/yjs": "0.7.5",
|
||||
"react-error-boundary": "^3.1.4"
|
||||
}
|
||||
},
|
||||
"@lexical/rich-text": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.7.5.tgz",
|
||||
"integrity": "sha512-z4sscX2Xq7hRUXcLIqgQVcBsifTHreTcC4McXwyD5TC5U+2rk+guREeb6Rq/+Mhct1VttdWIdblw8Odw0oHSqA=="
|
||||
},
|
||||
"@lexical/selection": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.7.5.tgz",
|
||||
"integrity": "sha512-8AUwsDz/i1fshmRlXUQuDFKcilgzi5GIUc38Lpp7HN5ErvyH7EjbFWA+b8qKWidTnWQ9yKHQLUsIFlYMy91V/Q=="
|
||||
},
|
||||
"@lexical/table": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.7.5.tgz",
|
||||
"integrity": "sha512-6hPtEZq+0qOki8vU/sEBL5CHYJ7obt5kbCknEHW3xm38gW7NWMqZ15tvnw1owJnPJNhu4wfCSHPR1JeUHfcJLg==",
|
||||
"requires": {
|
||||
"@lexical/utils": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/text": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.7.5.tgz",
|
||||
"integrity": "sha512-JGx89XATNdqi3BD60kWdTv15kgV7HoFTgdgrzBc4ZJ2AYj6p16pMl1UaF+x5O++sNWONbQ0mGPyYjfRWhOxqvQ=="
|
||||
},
|
||||
"@lexical/utils": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.7.5.tgz",
|
||||
"integrity": "sha512-WGf96y1h1qDsJK9wztXPzcEupyToa4TmoSzSKhOvebFqS/yg3WlRVUBne6oBj3hptjGoeU9MhGdcGK61EQ/TOw==",
|
||||
"requires": {
|
||||
"@lexical/list": "0.7.5",
|
||||
"@lexical/table": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@lexical/yjs": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.7.5.tgz",
|
||||
"integrity": "sha512-pNyw175VfWhJQj4pnvXgddfjyZhF/lotn7/4MTKbtGxyAI61LrjV7Z3M7v76003v4SEIyQ3WqWOU4TCjNt+alQ==",
|
||||
"requires": {
|
||||
"@lexical/offset": "0.7.5"
|
||||
}
|
||||
},
|
||||
"@mdn/browser-compat-data": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz",
|
||||
|
@ -5280,6 +5446,11 @@
|
|||
"type-check": "~0.4.0"
|
||||
}
|
||||
},
|
||||
"lexical": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/lexical/-/lexical-0.7.5.tgz",
|
||||
"integrity": "sha512-NrSWqggN1/9EzKLwYKURc+AGTQcPh4kXrE8qemp5yYCJG0XGu25CVNXKFXQkCHJJbdAsfn6qmprH5aVV7sDp5Q=="
|
||||
},
|
||||
"lightning": {
|
||||
"version": "6.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lightning/-/lightning-6.2.7.tgz",
|
||||
|
@ -7545,6 +7716,14 @@
|
|||
"scheduler": "^0.20.2"
|
||||
}
|
||||
},
|
||||
"react-error-boundary": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
|
||||
"integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
}
|
||||
},
|
||||
"react-fast-compare": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.1",
|
||||
"@lexical/react": "^0.7.5",
|
||||
"@opensearch-project/opensearch": "^1.1.0",
|
||||
"@prisma/client": "^2.30.3",
|
||||
"apollo-server-micro": "^3.11.1",
|
||||
|
@ -29,6 +30,7 @@
|
|||
"graphql-tools": "^8.3.10",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"jquery": "^3.6.1",
|
||||
"lexical": "^0.7.5",
|
||||
"ln-service": "^54.2.6",
|
||||
"mdast-util-find-and-replace": "^1.1.1",
|
||||
"mdast-util-from-markdown": "^1.2.0",
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import '../styles/globals.scss'
|
||||
import { ApolloProvider, gql, useQuery } from '@apollo/client'
|
||||
import { Provider } from 'next-auth/client'
|
||||
import { FundErrorModal, FundErrorProvider } from '../components/fund-error'
|
||||
import { MeProvider } from '../components/me'
|
||||
import PlausibleProvider from 'next-plausible'
|
||||
import { LightningProvider } from '../components/lightning'
|
||||
import { ItemActModal, ItemActProvider } from '../components/item-act'
|
||||
import getApolloClient from '../lib/apollo'
|
||||
import NextNProgress from 'nextjs-progressbar'
|
||||
import { PriceProvider } from '../components/price'
|
||||
|
@ -14,6 +12,7 @@ import { useRouter } from 'next/dist/client/router'
|
|||
import { useEffect } from 'react'
|
||||
import Moon from '../svgs/moon-fill.svg'
|
||||
import Layout from '../components/layout'
|
||||
import { ShowModalProvider } from '../components/modal'
|
||||
|
||||
function CSRWrapper ({ Component, apollo, ...props }) {
|
||||
const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' })
|
||||
|
@ -89,15 +88,11 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||
<MeProvider me={me}>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<FundErrorProvider>
|
||||
<FundErrorModal />
|
||||
<ItemActProvider>
|
||||
<ItemActModal />
|
||||
{data || !apollo?.query
|
||||
? <Component {...props} />
|
||||
: <CSRWrapper Component={Component} {...props} />}
|
||||
</ItemActProvider>
|
||||
</FundErrorProvider>
|
||||
<ShowModalProvider>
|
||||
{data || !apollo?.query
|
||||
? <Component {...props} />
|
||||
: <CSRWrapper Component={Component} {...props} />}
|
||||
</ShowModalProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</MeProvider>
|
||||
|
|
|
@ -136,7 +136,8 @@ export default (req, res) => NextAuth(req, res, {
|
|||
signingKey: process.env.JWT_SIGNING_PRIVATE_KEY
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login'
|
||||
signIn: '/login',
|
||||
verifyRequest: '/email'
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import models from '../../../api/models'
|
||||
|
||||
export default async function Nip05 ({ query: { name } }, res) {
|
||||
const names = {}
|
||||
let relays = {}
|
||||
|
||||
const users = await models.user.findMany({
|
||||
where: {
|
||||
name,
|
||||
nostrPubkey: { not: null }
|
||||
},
|
||||
include: { nostrRelays: true }
|
||||
})
|
||||
|
||||
for (const user of users) {
|
||||
names[user.name] = user.nostrPubkey
|
||||
if (user.nostrRelays.length) {
|
||||
// append relays with key pubkey
|
||||
relays[user.nostrPubkey] = []
|
||||
for (const relay of user.nostrRelays) {
|
||||
relays[user.nostrPubkey].push(relay.nostrRelayAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
relays = Object.keys(relays).length ? relays : undefined
|
||||
return res.status(200).json({ names, relays })
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import LayoutError from '../components/layout-error'
|
||||
import { Image } from 'react-bootstrap'
|
||||
|
||||
export default function Email () {
|
||||
return (
|
||||
<LayoutError>
|
||||
<div className='p-4 text-center'>
|
||||
<h1>Check your email</h1>
|
||||
<h4 className='pb-4'>A sign in link has been sent to your email address</h4>
|
||||
<Image width='500' height='376' src='/hello.gif' fluid />
|
||||
</div>
|
||||
</LayoutError>
|
||||
)
|
||||
}
|
|
@ -6,6 +6,7 @@ import { gql } from '@apollo/client'
|
|||
import { INVITE_FIELDS } from '../../fragments/invites'
|
||||
import getSSRApolloClient from '../../api/ssrApollo'
|
||||
import Link from 'next/link'
|
||||
import LayoutCenter from '../../components/layout-center'
|
||||
|
||||
export async function getServerSideProps ({ req, res, query: { id, error = null } }) {
|
||||
const session = await getSession({ req })
|
||||
|
@ -63,7 +64,7 @@ function InviteHeader ({ invite }) {
|
|||
} else {
|
||||
Inner = () => (
|
||||
<div>
|
||||
get <span className='text-success'>{invite.gift} free sats</span> from{' '}
|
||||
Get <span className='text-success'>{invite.gift} free sats</span> from{' '}
|
||||
<Link href={`/${invite.user.name}`} passHref><a>@{invite.user.name}</a></Link>{' '}
|
||||
when you sign up today
|
||||
</div>
|
||||
|
@ -71,12 +72,16 @@ function InviteHeader ({ invite }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<h2 className='text-center pb-3'>
|
||||
<h3 className='text-center pb-3'>
|
||||
<Inner />
|
||||
</h2>
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Invite ({ invite, ...props }) {
|
||||
return <Login Header={() => <InviteHeader invite={invite} />} {...props} />
|
||||
return (
|
||||
<LayoutCenter>
|
||||
<Login Header={() => <InviteHeader invite={invite} />} text='Sign up' {...props} />
|
||||
</LayoutCenter>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,146 @@
|
|||
import LayoutCenter from '../components/layout-center'
|
||||
import styles from '../lexical/styles.module.css'
|
||||
|
||||
import Theme from '../lexical/theme'
|
||||
import ListMaxIndentLevelPlugin from '../lexical/plugins/list-max-indent'
|
||||
import AutoLinkPlugin from '../lexical/plugins/autolink'
|
||||
import ToolbarPlugin from '../lexical/plugins/toolbar'
|
||||
import LinkTooltipPlugin from '../lexical/plugins/link-tooltip'
|
||||
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
|
||||
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
|
||||
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
|
||||
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
|
||||
import { ListItemNode, ListNode } from '@lexical/list'
|
||||
import { CodeHighlightNode, CodeNode } from '@lexical/code'
|
||||
import { AutoLinkNode, LinkNode } from '@lexical/link'
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
|
||||
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
|
||||
import { useState } from 'react'
|
||||
import LinkInsertPlugin, { LinkInsertProvider } from '../lexical/plugins/link-insert'
|
||||
import { ImageNode } from '../lexical/nodes/image'
|
||||
import ImageInsertPlugin from '../lexical/plugins/image-insert'
|
||||
import { SN_TRANSFORMERS } from '../lexical/utils/image-markdown-transformer'
|
||||
import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import Text from '../components/text'
|
||||
import { Button } from 'react-bootstrap'
|
||||
|
||||
const editorConfig = {
|
||||
// The editor theme
|
||||
theme: Theme,
|
||||
// Handling of errors during update
|
||||
onError (error) {
|
||||
throw error
|
||||
},
|
||||
// Any custom nodes go here
|
||||
nodes: [
|
||||
HeadingNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
QuoteNode,
|
||||
CodeNode,
|
||||
CodeHighlightNode,
|
||||
TableNode,
|
||||
TableCellNode,
|
||||
TableRowNode,
|
||||
AutoLinkNode,
|
||||
LinkNode,
|
||||
HorizontalRuleNode,
|
||||
ImageNode
|
||||
]
|
||||
}
|
||||
|
||||
function Editor ({ markdown }) {
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
|
||||
|
||||
const onRef = (_floatingAnchorElem) => {
|
||||
if (_floatingAnchorElem !== null) {
|
||||
setFloatingAnchorElem(_floatingAnchorElem)
|
||||
}
|
||||
}
|
||||
|
||||
let initialConfig = editorConfig
|
||||
if (markdown) {
|
||||
initialConfig = { ...initialConfig, editorState: () => $convertFromMarkdownString(markdown, SN_TRANSFORMERS) }
|
||||
}
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div className={styles.editorContainer}>
|
||||
<div className={styles.editorInner}>
|
||||
<LinkInsertProvider>
|
||||
<ToolbarPlugin />
|
||||
<LinkTooltipPlugin anchorElem={floatingAnchorElem} />
|
||||
<LinkInsertPlugin />
|
||||
</LinkInsertProvider>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div className={styles.editor} ref={onRef}>
|
||||
<ContentEditable className={styles.editorInput} />
|
||||
</div>
|
||||
}
|
||||
placeholder={null}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ImageInsertPlugin />
|
||||
<AutoFocusPlugin />
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<AutoLinkPlugin />
|
||||
<HistoryPlugin />
|
||||
<ListMaxIndentLevelPlugin maxDepth={4} />
|
||||
<MarkdownShortcutPlugin transformers={SN_TRANSFORMERS} />
|
||||
</div>
|
||||
</div>
|
||||
{!markdown && <Markdown />}
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
function Markdown () {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [markdown, setMarkdown] = useState(null)
|
||||
const [preview, togglePreview] = useState(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='lexical text-left w-100'>
|
||||
<OnChangePlugin onChange={() => editor.update(() => {
|
||||
setMarkdown($convertToMarkdownString(SN_TRANSFORMERS))
|
||||
})}
|
||||
/>
|
||||
<Button size='sm' className='mb-2' onClick={() => togglePreview(!preview)}>{preview ? 'show markdown' : 'show preview'}</Button>
|
||||
<div style={{ border: '1px solid var(--theme-color)', padding: '.5rem', borderRadius: '.4rem' }}>
|
||||
|
||||
{preview
|
||||
? (
|
||||
<Text>
|
||||
{markdown}
|
||||
</Text>
|
||||
)
|
||||
: (
|
||||
<pre className='text-reset p-0 m-0'>
|
||||
{markdown}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Lexical () {
|
||||
return (
|
||||
<LayoutCenter footerLinks>
|
||||
<Editor />
|
||||
</LayoutCenter>
|
||||
)
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import { providers, getSession } from 'next-auth/client'
|
||||
import Link from 'next/link'
|
||||
import LayoutCenter from '../components/layout-center'
|
||||
import Login from '../components/login'
|
||||
|
||||
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
|
||||
|
@ -21,4 +23,19 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
|
|||
}
|
||||
}
|
||||
|
||||
export default Login
|
||||
function LoginFooter ({ callbackUrl }) {
|
||||
return (
|
||||
<small className='font-weight-bold text-muted pt-4'>Don't have an account? <Link href={{ pathname: '/signup', query: { callbackUrl } }}>sign up</Link></small>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage (props) {
|
||||
return (
|
||||
<LayoutCenter>
|
||||
<Login
|
||||
Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />}
|
||||
{...props}
|
||||
/>
|
||||
</LayoutCenter>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Checkbox, Form, Input, SubmitButton, Select } from '../components/form'
|
||||
import { Checkbox, Form, Input, SubmitButton, Select, VariableInput } from '../components/form'
|
||||
import * as Yup from 'yup'
|
||||
import { Alert, Button, InputGroup, Modal } from 'react-bootstrap'
|
||||
import LayoutCenter from '../components/layout-center'
|
||||
|
@ -15,6 +15,8 @@ import Info from '../components/info'
|
|||
import { CURRENCY_SYMBOLS } from '../components/price'
|
||||
import Link from 'next/link'
|
||||
import AccordianItem from '../components/accordian-item'
|
||||
import { MAX_NOSTR_RELAY_NUM } from '../lib/constants'
|
||||
import { WS_REGEXP } from '../lib/url'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(SETTINGS)
|
||||
|
||||
|
@ -23,7 +25,12 @@ const supportedCurrencies = Object.keys(CURRENCY_SYMBOLS)
|
|||
export const SettingsSchema = Yup.object({
|
||||
tipDefault: Yup.number().typeError('must be a number').required('required')
|
||||
.positive('must be positive').integer('must be whole'),
|
||||
fiatCurrency: Yup.string().required('required').oneOf(supportedCurrencies)
|
||||
fiatCurrency: Yup.string().required('required').oneOf(supportedCurrencies),
|
||||
nostrPubkey: Yup.string().matches(/^[0-9a-fA-F]{64}$/, 'must be 64 hex chars'),
|
||||
nostrRelays: Yup.array().of(
|
||||
Yup.string().matches(WS_REGEXP, 'invalid web socket address')
|
||||
).max(MAX_NOSTR_RELAY_NUM,
|
||||
({ max, value }) => `${Math.abs(max - value.length)} too many`)
|
||||
})
|
||||
|
||||
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
|
||||
|
@ -72,11 +79,26 @@ export default function Settings ({ data: { settings } }) {
|
|||
hideInvoiceDesc: settings?.hideInvoiceDesc,
|
||||
hideFromTopUsers: settings?.hideFromTopUsers,
|
||||
wildWestMode: settings?.wildWestMode,
|
||||
greeterMode: settings?.greeterMode
|
||||
greeterMode: settings?.greeterMode,
|
||||
nostrPubkey: settings?.nostrPubkey || '',
|
||||
nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : ['']
|
||||
}}
|
||||
schema={SettingsSchema}
|
||||
onSubmit={async ({ tipDefault, ...values }) => {
|
||||
await setSettings({ variables: { tipDefault: Number(tipDefault), ...values } })
|
||||
onSubmit={async ({ tipDefault, nostrPubkey, nostrRelays, ...values }) => {
|
||||
if (nostrPubkey.length === 0) {
|
||||
nostrPubkey = null
|
||||
}
|
||||
|
||||
const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0)
|
||||
|
||||
await setSettings({
|
||||
variables: {
|
||||
tipDefault: Number(tipDefault),
|
||||
nostrPubkey,
|
||||
nostrRelays: nostrRelaysFiltered,
|
||||
...values
|
||||
}
|
||||
})
|
||||
setSuccess('settings saved')
|
||||
}}
|
||||
>
|
||||
|
@ -217,6 +239,27 @@ export default function Settings ({ data: { settings } }) {
|
|||
}
|
||||
name='greeterMode'
|
||||
/>
|
||||
<AccordianItem
|
||||
headerColor='var(--theme-color)'
|
||||
show={settings?.nostrPubkey}
|
||||
header={<h4 className='mb-2 text-left'>nostr <small><a href='https://github.com/nostr-protocol/nips/blob/master/05.md' target='_blank' rel='noreferrer'>NIP-05</a></small></h4>}
|
||||
body={
|
||||
<>
|
||||
<Input
|
||||
label={<>pubkey <small className='text-muted ml-2'>optional</small></>}
|
||||
name='nostrPubkey'
|
||||
clear
|
||||
/>
|
||||
<VariableInput
|
||||
label={<>relays <small className='text-muted ml-2'>optional</small></>}
|
||||
name='nostrRelays'
|
||||
clear
|
||||
min={0}
|
||||
max={MAX_NOSTR_RELAY_NUM}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className='d-flex'>
|
||||
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { providers, getSession } from 'next-auth/client'
|
||||
import Link from 'next/link'
|
||||
import LayoutCenter from '../components/layout-center'
|
||||
import Login from '../components/login'
|
||||
|
||||
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
|
||||
const session = await getSession({ req })
|
||||
|
||||
if (session && res && callbackUrl) {
|
||||
res.writeHead(302, {
|
||||
Location: callbackUrl
|
||||
})
|
||||
res.end()
|
||||
return { props: {} }
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
providers: await providers({ req, res }),
|
||||
callbackUrl,
|
||||
error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function SignUpHeader () {
|
||||
return (
|
||||
<>
|
||||
<h3 className='w-100 pb-2'>
|
||||
Sign up
|
||||
</h3>
|
||||
<div className='font-weight-bold text-muted pb-4'>Join 9000+ bitcoiners and start stacking sats today</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SignUpFooter ({ callbackUrl }) {
|
||||
return (
|
||||
<small className='font-weight-bold text-muted pt-4'>Already have an account? <Link href={{ pathname: '/login', query: { callbackUrl } }}>login</Link></small>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SignUp ({ ...props }) {
|
||||
return (
|
||||
<LayoutCenter>
|
||||
<Login
|
||||
Header={() => <SignUpHeader />}
|
||||
Footer={() => <SignUpFooter callbackUrl={props.callbackUrl} />}
|
||||
text='Sign up'
|
||||
{...props}
|
||||
/>
|
||||
</LayoutCenter>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "nostrPubkey" TEXT;
|
|
@ -0,0 +1,24 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "NostrRelay" (
|
||||
"addr" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("addr")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserNostrRelay" (
|
||||
"userId" INTEGER NOT NULL,
|
||||
"nostrRelayAddr" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("userId","nostrRelayAddr")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserNostrRelay" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserNostrRelay" ADD FOREIGN KEY ("nostrRelayAddr") REFERENCES "NostrRelay"("addr") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -44,9 +44,14 @@ model User {
|
|||
photoId Int?
|
||||
photo Upload? @relation(fields: [photoId], references: [id])
|
||||
|
||||
// walkthrough
|
||||
upvotePopover Boolean @default(false)
|
||||
tipPopover Boolean @default(false)
|
||||
|
||||
// nostr
|
||||
nostrPubkey String?
|
||||
nostrRelays UserNostrRelay[]
|
||||
|
||||
// referrals
|
||||
referrer User? @relation("referrals", fields: [referrerId], references: [id])
|
||||
referrerId Int?
|
||||
|
@ -84,6 +89,24 @@ model User {
|
|||
@@map(name: "users")
|
||||
}
|
||||
|
||||
model NostrRelay {
|
||||
addr String @id
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
|
||||
users UserNostrRelay[]
|
||||
}
|
||||
|
||||
model UserNostrRelay {
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
NostrRelay NostrRelay @relation(fields: [nostrRelayAddr], references: [addr])
|
||||
nostrRelayAddr String
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
|
||||
|
||||
@@id([userId, nostrRelayAddr])
|
||||
}
|
||||
|
||||
model Donation {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// This will help to prevent a flash if dark mode is the default.
|
||||
const COLORS = {
|
||||
light: {
|
||||
body: '#f5f5f5',
|
||||
body: '#f5f5f7',
|
||||
color: '#212529',
|
||||
navbarVariant: 'light',
|
||||
navLink: 'rgba(0, 0, 0, 0.55)',
|
||||
|
@ -18,6 +18,11 @@ const COLORS = {
|
|||
brandColor: 'rgba(0, 0, 0, 0.9)',
|
||||
grey: '#707070',
|
||||
link: '#007cbe',
|
||||
toolbarActive: 'rgba(0, 0, 0, 0.10)',
|
||||
toolbarHover: 'rgba(0, 0, 0, 0.20)',
|
||||
toolbar: '#ffffff',
|
||||
quoteBar: 'rgb(206, 208, 212)',
|
||||
quoteColor: 'rgb(101, 103, 107)',
|
||||
linkHover: '#004a72',
|
||||
linkVisited: '#537587'
|
||||
},
|
||||
|
@ -37,6 +42,11 @@ const COLORS = {
|
|||
brandColor: 'var(--primary)',
|
||||
grey: '#969696',
|
||||
link: '#2e99d1',
|
||||
toolbarActive: 'rgba(255, 255, 255, 0.10)',
|
||||
toolbarHover: 'rgba(255, 255, 255, 0.20)',
|
||||
toolbar: '#3e3f3f',
|
||||
quoteBar: 'rgb(158, 159, 163)',
|
||||
quoteColor: 'rgb(141, 144, 150)',
|
||||
linkHover: '#007cbe',
|
||||
linkVisited: '#56798E'
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 418 KiB |
|
@ -7,9 +7,11 @@ $theme-colors: (
|
|||
"twitter" : #1da1f2,
|
||||
"boost" : #8c25f4,
|
||||
"grey" : #e9ecef,
|
||||
"grey-medium" : #d2d2d2,
|
||||
"grey-darkmode": #8c8c8c
|
||||
);
|
||||
|
||||
$body-bg: #f5f5f5;
|
||||
$body-bg: #f5f5f7;
|
||||
$border-radius: .4rem;
|
||||
$enable-transitions: false;
|
||||
$enable-gradients: false;
|
||||
|
@ -61,6 +63,14 @@ $tooltip-bg: #5c8001;
|
|||
}
|
||||
}
|
||||
|
||||
.line-height-1 {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.lexical blockquote>* {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
@ -155,17 +165,22 @@ a:hover {
|
|||
}
|
||||
|
||||
select.custom-select,
|
||||
div[contenteditable],
|
||||
.form-control {
|
||||
background-color: var(--theme-inputBg);
|
||||
color: var(--theme-color);
|
||||
border-color: var(--theme-borderColor);
|
||||
}
|
||||
|
||||
div[contenteditable]:focus,
|
||||
.form-control:focus {
|
||||
background-color: var(--theme-inputBg);
|
||||
color: var(--theme-color);
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.2rem rgb(250 218 94 / 25%);
|
||||
}
|
||||
|
||||
div[contenteditable]:disabled,
|
||||
.form-control:disabled,
|
||||
.form-control[readonly] {
|
||||
background-color: var(--theme-inputDisabledBg);
|
||||
|
@ -187,6 +202,10 @@ select.custom-select,
|
|||
fill: #212529;
|
||||
}
|
||||
|
||||
.btn-grey svg {
|
||||
fill: #212529;
|
||||
}
|
||||
|
||||
.fresh {
|
||||
background-color: var(--theme-clickToContextColor);
|
||||
border-radius: .4rem;
|
||||
|
@ -222,15 +241,15 @@ select.custom-select,
|
|||
}
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--theme-dropdownItemColor);
|
||||
color: var(--theme-dropdownItemColor) !important;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
color: var(--theme-dropdownItemColorHover);
|
||||
color: var(--theme-dropdownItemColorHover) !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
color: var(--theme-brandColor);
|
||||
color: var(--theme-brandColor) !important;
|
||||
text-shadow: 0 0 10px var(--primary);
|
||||
}
|
||||
|
||||
|
@ -286,12 +305,15 @@ footer {
|
|||
textarea,
|
||||
.form-control,
|
||||
.form-control:focus,
|
||||
div[contenteditable],
|
||||
div[contenteditable]:focus,
|
||||
.input-group-text {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
textarea.form-control,
|
||||
div[contenteditable] {
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
|
@ -364,6 +386,7 @@ textarea.form-control {
|
|||
text-shadow: 0 0 10px var(--primary);
|
||||
}
|
||||
|
||||
div[contenteditable]:focus,
|
||||
.form-control:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 13v7l-8-8 8-8v7h8v2z"/></svg>
|
After Width: | Height: | Size: 162 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M7.828 11H20v2H7.828l5.364 5.364-1.414 1.414L4 12l7.778-7.778 1.414 1.414z"/></svg>
|
After Width: | Height: | Size: 212 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M8 11h4.5a2.5 2.5 0 1 0 0-5H8v5zm10 4.5a4.5 4.5 0 0 1-4.5 4.5H6V4h6.5a4.5 4.5 0 0 1 3.256 7.606A4.498 4.498 0 0 1 18 15.5zM8 13v5h5.5a2.5 2.5 0 1 0 0-5H8z"/></svg>
|
After Width: | Height: | Size: 292 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z"/></svg>
|
After Width: | Height: | Size: 204 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 3h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v14h16V5H4zm16 7l-3.536 3.536-1.414-1.415L17.172 12 15.05 9.879l1.414-1.415L20 12zM6.828 12l2.122 2.121-1.414 1.415L4 12l3.536-3.536L8.95 9.88 6.828 12zm4.416 5H9.116l3.64-10h2.128l-3.64 10z"/></svg>
|
After Width: | Height: | Size: 403 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M23 12l-7.071 7.071-1.414-1.414L20.172 12l-5.657-5.657 1.414-1.414L23 12zM3.828 12l5.657 5.657-1.414 1.414L1 12l7.071-7.071 1.414 1.414L3.828 12z"/></svg>
|
After Width: | Height: | Size: 283 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M19.417 6.679C20.447 7.773 21 9 21 10.989c0 3.5-2.457 6.637-6.03 8.188l-.893-1.378c3.335-1.804 3.987-4.145 4.247-5.621-.537.278-1.24.375-1.929.311-1.804-.167-3.226-1.648-3.226-3.489a3.5 3.5 0 0 1 3.5-3.5c1.073 0 2.099.49 2.748 1.179zm-10 0C10.447 7.773 11 9 11 10.989c0 3.5-2.457 6.637-6.03 8.188l-.893-1.378c3.335-1.804 3.987-4.145 4.247-5.621-.537.278-1.24.375-1.929.311C4.591 12.322 3.17 10.841 3.17 9a3.5 3.5 0 0 1 3.5-3.5c1.073 0 2.099.49 2.748 1.179z"/></svg>
|
After Width: | Height: | Size: 594 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M10 6v15H8V6H2V4h14v2h-6zm8 8v7h-2v-7h-3v-2h8v2h-3z"/></svg>
|
After Width: | Height: | Size: 189 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M21 15v3h3v2h-3v3h-2v-3h-3v-2h3v-3h2zm.008-12c.548 0 .992.445.992.993V13h-2V5H4v13.999L14 9l3 3v2.829l-3-3L6.827 19H14v2H2.992A.993.993 0 0 1 2 20.007V3.993A1 1 0 0 1 2.992 3h18.016zM8 7a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></svg>
|
After Width: | Height: | Size: 353 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M4.828 21l-.02.02-.021-.02H2.992A.993.993 0 0 1 2 20.007V3.993A1 1 0 0 1 2.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 0 1-.992.993H4.828zM20 15V5H4v14L14 9l6 6zm0 2.828l-6-6L6.828 19H20v-1.172zM8 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>
|
After Width: | Height: | Size: 372 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 4h18v2H3V4zm0 15h18v2H3v-2zm8-5h10v2H11v-2zm0-5h10v2H11V9zm-8 3.5L7 9v7l-4-3.5z"/></svg>
|
After Width: | Height: | Size: 220 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 4h18v2H3V4zm0 15h18v2H3v-2zm8-5h10v2H11v-2zm0-5h10v2H11V9zm-4 3.5L3 16V9l4 3.5z"/></svg>
|
After Width: | Height: | Size: 220 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M15 20H7v-2h2.927l2.116-12H9V4h8v2h-2.927l-2.116 12H15z"/></svg>
|
After Width: | Height: | Size: 193 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M8 4h13v2H8V4zM5 3v3h1v1H3V6h1V4H3V3h2zM3 14v-2.5h2V11H3v-1h3v2.5H4v.5h2v1H3zm2 5.5H3v-1h2V18H3v-1h3v4H3v-1h2v-.5zM8 11h13v2H8v-2zm0 7h13v2H8v-2z"/></svg>
|
After Width: | Height: | Size: 283 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M17.154 14c.23.516.346 1.09.346 1.72 0 1.342-.524 2.392-1.571 3.147C14.88 19.622 13.433 20 11.586 20c-1.64 0-3.263-.381-4.87-1.144V16.6c1.52.877 3.075 1.316 4.666 1.316 2.551 0 3.83-.732 3.839-2.197a2.21 2.21 0 0 0-.648-1.603l-.12-.117H3v-2h18v2h-3.846zm-4.078-3H7.629a4.086 4.086 0 0 1-.481-.522C6.716 9.92 6.5 9.246 6.5 8.452c0-1.236.466-2.287 1.397-3.153C8.83 4.433 10.271 4 12.222 4c1.471 0 2.879.328 4.222.984v2.152c-1.2-.687-2.515-1.03-3.946-1.03-2.48 0-3.719.782-3.719 2.346 0 .42.218.786.654 1.099.436.313.974.562 1.613.75.62.18 1.297.414 2.03.699z"/></svg>
|
After Width: | Height: | Size: 694 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M8 3v9a4 4 0 1 0 8 0V3h2v9a6 6 0 1 1-12 0V3h2zM4 20h16v2H4v-2z"/></svg>
|
After Width: | Height: | Size: 200 B |