diff --git a/README.md b/README.md index 5657f11cf..cbfbc5ffd 100644 --- a/README.md +++ b/README.md @@ -518,7 +518,7 @@ For every edit on dnsmasq, it will give you the option to either edit the `/etc/ ## Stack -The site is written in javascript (not typescript 😱) using [Next.js](https://nextjs.org/), a [React](https://react.dev/) framework. The backend API is provided via [GraphQL](https://graphql.org/). The database is [PostgreSQL](https://www.postgresql.org/) modeled with [Prisma](https://www.prisma.io/). The [job queue](https://github.com/timgit/pg-boss) is also maintained in PostgreSQL. We use [lnd](https://github.com/lightningnetwork/lnd) for our lightning node. A customized [Bootstrap](https://react-bootstrap.netlify.app/) theme is used for styling. +The site is written in javascript (not typescript 😱) using [Next.js](https://nextjs.org/), a [React](https://react.dev/) framework. The backend API is provided via [GraphQL](https://graphql.org/). The database is [PostgreSQL](https://www.postgresql.org/) modeled with [Prisma](https://www.prisma.io/). The [job queue](https://github.com/timgit/pg-boss) is also maintained in PostgreSQL. We use [lnd](https://github.com/lightningnetwork/lnd) for our lightning node. Styling uses [Tailwind CSS](https://tailwindcss.com/) utilities with [Base UI](https://base-ui.com/) components; the conventions are documented in [docs/dev/styling.md](./docs/dev/styling.md).
diff --git a/capture/index.js b/capture/index.js index 5b0d83446..71da44f98 100644 --- a/capture/index.js +++ b/capture/index.js @@ -203,11 +203,7 @@ async function addCaptureCleanupScript (page) { const style = document.createElement('style') style.textContent = ` #nprogress, - .navbar, - nav.d-block.d-md-none:has(.navbar), - [class*="sticky"]:has(.navbar), - .d-none.d-md-block:has(.navbar), - .d-block.d-md-none:has(.navbar) { + [data-sn-navigation] { display: none !important; } ` diff --git a/components/accordian-item.js b/components/accordian-item.js deleted file mode 100644 index 8b1e8f739..000000000 --- a/components/accordian-item.js +++ /dev/null @@ -1,66 +0,0 @@ -import Accordion from 'react-bootstrap/Accordion' -import AccordionContext from 'react-bootstrap/AccordionContext' -import { useAccordionButton } from 'react-bootstrap/AccordionButton' -import ArrowRight from '@/svgs/arrow-right-s-fill.svg' -import ArrowDown from '@/svgs/arrow-down-s-fill.svg' -import { useContext, useEffect, useState } from 'react' -import classNames from 'classnames' - -const KEY_ID = '0' - -function ContextAwareToggle ({ children, headerColor = 'var(--theme-grey)', eventKey, show }) { - const { activeEventKey } = useContext(AccordionContext) - const decoratedOnClick = useAccordionButton(eventKey) - - useEffect(() => { - // if we want to show the accordian and it's not open, open it - if (show && activeEventKey !== eventKey) { - decoratedOnClick() - } - }, [show]) - - const isCurrentEventKey = activeEventKey === eventKey - - return ( -
- {isCurrentEventKey - ? - : } - {children} -
- ) -} - -export default function AccordianItem ({ header, body, className, headerColor = 'var(--theme-grey)', show }) { - const [activeKey, setActiveKey] = useState() - - useEffect(() => { - setActiveKey(show ? KEY_ID : null) - }, [show]) - - const handleOnSelect = () => { - setActiveKey(activeKey === KEY_ID ? null : KEY_ID) - } - - return ( - -
{header}
- -
{body}
-
-
- ) -} - -export function AccordianCard ({ header, children, show, className }) { - return ( - - - {header} - - {children} - - - - ) -} diff --git a/components/accordion-item.js b/components/accordion-item.js new file mode 100644 index 000000000..0250e5ed1 --- /dev/null +++ b/components/accordion-item.js @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' +import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from './ui/collapsible' +import styles from './ui/collapsible.module.css' +import ArrowRight from '@/svgs/arrow-right-s-fill.svg' +import ArrowDown from '@/svgs/arrow-down-s-fill.svg' +import { cn } from '@/lib/cn' + +export default function AccordionItem ({ header, body, className, headerColor = 'var(--sn-grey)', show }) { + const [open, setOpen] = useState(!!show) + + useEffect(() => { + // `show` transitions force open/close; manual toggles stay free either way + setOpen(!!show) + }, [show]) + + return ( + + + {open + ? + : } +
{header}
+
+ {body} +
+ ) +} + +export function AccordionCard ({ header, children, show, className }) { + return ( + + + {header} + + + +
{children}
+
+
+ ) +} diff --git a/components/account.js b/components/account.js index f74129cd6..6bc113a5d 100644 --- a/components/account.js +++ b/components/account.js @@ -25,7 +25,7 @@ export default function SwitchAccountList () { return ( <>
-
+

Accounts

existing account @@ -81,10 +81,10 @@ const AccountListRow = ({ account, selected, ...props }) => { } return ( -
+
- e.preventDefault()}> - - - - {children} - - + + {/* Base UI gives the rendered span button semantics. */} + } /> + {children} + ) } diff --git a/components/action-tooltip.js b/components/action-tooltip.js index 7528f0687..38c99b91a 100644 --- a/components/action-tooltip.js +++ b/components/action-tooltip.js @@ -1,8 +1,7 @@ import { useFormikContext } from 'formik' -import OverlayTrigger from 'react-bootstrap/OverlayTrigger' -import Tooltip from 'react-bootstrap/Tooltip' +import Tooltip from '@/components/ui/tooltip' -export default function ActionTooltip ({ children, notForm, disable, overlayText, placement, noWrapper, showDelay, hideDelay, transition }) { +export default function ActionTooltip ({ children, notForm, disable, overlayText, placement, noWrapper, showDelay }) { // if we're in a form, we want to hide tooltip on submit let formik if (!notForm) { @@ -12,26 +11,13 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText return children } return ( - - {overlayText} - - } - trigger={['hover', 'focus']} - show={formik?.isSubmitting ? false : undefined} - delay={{ show: showDelay || 0, hide: hideDelay || 0 }} - transition={transition || false} - popperConfig={{ - modifiers: { - preventOverflow: { - enabled: false - } - } - }} + {noWrapper ? children : {children}} - + ) } diff --git a/components/adv-post-form.js b/components/adv-post-form.js index 2697f6d35..e85d28dfc 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react' -import AccordianItem from './accordian-item' -import { Input, InputUserSuggest, VariableInput, Checkbox } from './form' -import InputGroup from 'react-bootstrap/InputGroup' +import AccordionItem from './accordion-item' +import { Input, InputAddon, InputUserSuggest, VariableInput, Checkbox } from './form' import { MAX_FORWARDS } from '@/lib/constants' import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr' import Info from './info' @@ -34,7 +33,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) { const isDirty = formik?.values.forward?.[0].nym !== '' || formik?.values.forward?.[0].pct !== '' || (router.query?.type === 'link' && formik?.values.text !== '') - // if the adv post form is dirty on first render, show the accordian + // if the adv post form is dirty on first render, show the accordion if (isDirty) { setShow(FormStatus.DIRTY) } @@ -54,7 +53,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) { }, [formik?.values, storageKeyPrefix]) useEffect(() => { - // force show the accordian if there is an error and the form is submitting + // force show the accordion if there is an error and the form is submitting const hasError = formik?.errors?.forward?.length > 0 // if it's open we don't want to collapse on submit setShow(show => hasError && formik?.isSubmitting ? FormStatus.ERROR : show) @@ -101,7 +100,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) { } return ( - options
} show={show} body={ @@ -117,12 +116,12 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) { > {({ index, AppendColumn }) => { return ( -
+
@} + prepend={@} showValid - groupClassName={`${styles.name} me-3 mb-0`} + groupClassName={`${styles.name} me-4 mb-0`} /> %} + append={%} groupClassName={`${styles.percent} mb-0`} AppendColumn={AppendColumn} /> @@ -141,7 +140,7 @@ export default function AdvPostForm ({ children, item, storageKeyPrefix }) { {me && itemType && crosspost to nostr +
crosspost to nostr
    {renderCrosspostDetails(itemType)} diff --git a/components/adv-post-form.module.css b/components/adv-post-form.module.css index e4bfe363f..e3201fcea 100644 --- a/components/adv-post-form.module.css +++ b/components/adv-post-form.module.css @@ -1,14 +1,20 @@ +@layer components { + +/* Both flex items must shrink below their inputs' intrinsic width on narrow + screens. */ .name { display: flex; flex: 1 1 60%; height: fit-content; flex-flow: column; + min-width: 0; } .percent { display: flex; flex: 0 1 fit-content; height: fit-content; + min-width: 0; } .boostMax small { @@ -16,4 +22,5 @@ margin-left: 0.25rem; margin-right: 0.25rem; opacity: 0.5; -} \ No newline at end of file +} +} diff --git a/components/avatar.js b/components/avatar.js index 88fb40665..e59cf64c2 100644 --- a/components/avatar.js +++ b/components/avatar.js @@ -1,14 +1,14 @@ import { useRef, useState } from 'react' import AvatarEditor from 'react-avatar-editor' -import Button from 'react-bootstrap/Button' -import BootstrapForm from 'react-bootstrap/Form' +import Button from '@/components/ui/button' +import { Slider } from '@/components/form' import EditImage from '@/svgs/image-edit-fill.svg' import Moon from '@/svgs/moon-fill.svg' import { useShowModal } from './modal' import { FileUpload } from './file-upload' import { gql } from '@apollo/client' import { useMutation } from '@apollo/client/react' -import { useToast } from './toast' +import { useToast } from '@/components/ui/toast' export default function Avatar ({ onSuccess }) { const [cropPhoto] = useMutation(gql` @@ -25,7 +25,7 @@ export default function Avatar ({ onSuccess }) { const ref = useRef() return ( -
    +
    - - setScale(parseFloat(e.target.value))} - min={1} max={2} step='0.05' - // defaultValue={scale} +
    + {/* Keep the thumb and crop scale aligned on the first render. */} + - +
    + ) } diff --git a/components/carousel.js b/components/carousel.js index 45ef163f2..498c06ef9 100644 --- a/components/carousel.js +++ b/components/carousel.js @@ -4,7 +4,7 @@ import ArrowLeft from '@/svgs/arrow-left-line.svg' import ArrowRight from '@/svgs/arrow-right-line.svg' import styles from './carousel.module.css' import { useShowModal } from './modal' -import { Dropdown } from 'react-bootstrap' +import { MenuItem } from '@/components/ui/menu' function useSwiping ({ moveLeft, moveRight }) { const [touchStartX, setTouchStartX] = useState(null) @@ -38,7 +38,12 @@ function useSwiping ({ moveLeft, moveRight }) { }, [onTouchStart, onTouchEnd]) } -function useArrowKeys ({ moveLeft, moveRight }) { +// arrow keys bind to the carousel's own container, not document: inside a Base UI +// Dialog.Popup the popup stopPropagation()s composite keys (Arrow/Home/End) at the +// popup level as its roving-focus contract, so a document listener never sees them. +// the container owns focus (below) so the keydown fires on it directly, with no +// reaching past the popup, and the modal shell needs no key-passthrough escape hatch. +function useArrowKeys (ref, { moveLeft, moveRight }) { const onKeyDown = useCallback((e) => { if (e.key === 'ArrowLeft') { moveLeft() @@ -48,9 +53,10 @@ function useArrowKeys ({ moveLeft, moveRight }) { }, [moveLeft, moveRight]) useEffect(() => { - document.addEventListener('keydown', onKeyDown) - return () => document.removeEventListener('keydown', onKeyDown) - }, [onKeyDown]) + const el = ref.current + el?.addEventListener('keydown', onKeyDown) + return () => el?.removeEventListener('keydown', onKeyDown) + }, [ref, onKeyDown]) } function Carousel ({ close, mediaArr, src, setOptions }) { @@ -75,11 +81,16 @@ function Carousel ({ close, mediaArr, src, setOptions }) { setIndex(i => Math.min(mediaArr.length - 1, i + 1)) }, [setIndex, mediaArr.length]) + // the carousel owns its own focus so arrow keys land on the container (see useArrowKeys); + // tabIndex=-1 keeps it out of the tab order, focus() paints no ring on programmatic focus + const containerRef = useRef(null) + useEffect(() => { containerRef.current?.focus() }, []) + useSwiping({ moveLeft, moveRight }) - useArrowKeys({ moveLeft, moveRight }) + useArrowKeys(containerRef, { moveLeft, moveRight }) return ( -
    +
    view original + return view original } export function CarouselProvider ({ children }) { diff --git a/components/carousel.module.css b/components/carousel.module.css index 505c999a2..a68146f87 100644 --- a/components/carousel.module.css +++ b/components/carousel.module.css @@ -1,3 +1,5 @@ +@layer components { + div.fullScreenNavContainer { height: 100%; width: 100%; @@ -11,8 +13,9 @@ div.fullScreenNavContainer { align-items: center; } -img.fullScreen { - cursor: zoom-out !important; +.fullScreenContainer img.fullScreen { + /* the container qualifier outranks text.module's .mediaContainer img zoom-in */ + cursor: zoom-out; max-height: 100%; max-width: 100vw; min-width: 0; @@ -23,11 +26,10 @@ img.fullScreen { } .fullScreenContainer { - --bs-columns: 1; - --bs-rows: 1; display: grid; width: 100%; height: 100%; + outline: none; /* focused programmatically to own arrow keys, never paint a ring */ } div.fullScreenNav:hover > svg { @@ -60,4 +62,5 @@ div.fullScreenNav > svg { max-width: 34px; padding: 0.35rem; margin: .75rem; -} \ No newline at end of file +} +} diff --git a/components/charts-skeletons.js b/components/charts-skeletons.js index 51f135588..7442554e3 100644 --- a/components/charts-skeletons.js +++ b/components/charts-skeletons.js @@ -15,5 +15,5 @@ export function WhenLineChartSkeleton ({ height = '300px', minWidth = '300px' }) } function ChartSkeleton (props) { - return
    + return
    } diff --git a/components/charts.js b/components/charts.js index 8329957b3..0b25a424f 100644 --- a/components/charts.js +++ b/components/charts.js @@ -66,13 +66,13 @@ const transformData = data => { } const COLORS = [ - 'var(--bs-secondary)', - 'var(--bs-info)', - 'var(--bs-success)', - 'var(--bs-boost)', - 'var(--theme-grey)', - 'var(--bs-danger)', - 'var(--bs-code-color)' + 'var(--sn-secondary)', + 'var(--sn-info)', + 'var(--sn-success)', + 'var(--sn-boost)', + 'var(--sn-grey)', + 'var(--sn-danger)', + 'var(--sn-code-color)' ] function getColor (i) { @@ -104,10 +104,10 @@ export function WhenAreaChart ({ data }) { > - - + + {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) => )} @@ -141,10 +141,10 @@ export function WhenLineChart ({ data }) { > - - + + {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) => )} @@ -183,11 +183,11 @@ export function WhenComposedChart ({ > - - - + + + {barNames?.map((v, i) => )} @@ -215,7 +215,7 @@ export function GrowthPieChart ({ data }) { minAngle={5} paddingAngle={0} outerRadius={80} - fill='var(--bs-secondary)' + fill='var(--sn-secondary)' label > { diff --git a/components/comment.js b/components/comment.js index cc921aded..751f9e16e 100644 --- a/components/comment.js +++ b/components/comment.js @@ -16,7 +16,7 @@ import ActionTooltip from './action-tooltip' import { numWithUnits } from '@/lib/format' import Share from './share' import ItemInfo from './item-info' -import Badge from 'react-bootstrap/Badge' +import Badge from '@/components/ui/badge' import { RootProvider, useRoot } from './root' import { useMe } from './me' import { useQuoteReply } from './use-quote-reply' @@ -51,7 +51,7 @@ function Parent ({ item, rootText }) { {root.subNames?.map(subName => ( - {' '}{subName} + {' '}{subName} ))} @@ -77,7 +77,7 @@ export function CommentFlat ({ item, rank, siblingComments, search, ...props }) <> {rank ? ( -
    +
    {rank}
    ) :
    } @@ -233,7 +233,7 @@ export default function Comment ({ ? : }
    -
    +
    {item.user?.meMute && !includeParent && collapse === 'yep' ? ( {op}} + embellishUser={op && <> {op}} onQuoteReply={quoteReply} nested={!includeParent} {...props} @@ -258,7 +258,7 @@ export default function Comment ({ {includeParent && } {bountyPaid && - + } } @@ -281,7 +281,7 @@ export default function Comment ({ }} />)} {topLevel && ( - + )} @@ -309,7 +309,7 @@ export default function Comment ({
    {collapse !== 'yep' && ( bottomedOut - ?
    + ?
    : (
    {!noReply && @@ -325,7 +325,7 @@ export default function Comment ({ ))} {item.comments.comments.length < item.nDirectComments && ( -
    +
    )} @@ -357,7 +357,7 @@ export function ViewMoreReplies ({ item, threadContext = false }) { {text} @@ -383,7 +383,7 @@ export function CommentSkeleton ({ skeletonChildren }) {
    -
    +
    {skeletonChildren ? : null} diff --git a/components/comment.module.css b/components/comment.module.css index 4a185ffd8..9fed4b7c8 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -1,11 +1,13 @@ +@layer components { + .item { align-items: flex-start; - margin-bottom: 0 !important; - padding-top: 0 !important; + margin-bottom: 0; + padding-top: 0; } .searchComment { - border: 1px solid var(--theme-note-reply); + border: 1px solid var(--sn-note-reply); margin-bottom: 0.5rem; } @@ -38,11 +40,6 @@ cursor: pointer; } -.op { - margin-top: -1px; - vertical-align: text-top; -} - .collapsed .hunk { margin-bottom: .5rem; } @@ -59,7 +56,7 @@ .collapser { cursor: pointer; - fill: var(--theme-grey); + fill: var(--sn-grey); width: 45px; margin-left: auto; user-select: none; @@ -116,7 +113,7 @@ border-radius: .4rem; padding-top: .5rem; padding-left: .7rem; - background-color: var(--theme-commentBg); + background-color: var(--sn-commentBg); } .bountyIcon { @@ -169,5 +166,7 @@ width: 12px; height: 12px; border-radius: 50%; - background-color: #007cbe; + background-color: var(--sn-info); +} + } diff --git a/components/comments.js b/components/comments.js index 7e46c0abc..a5f0ee6d5 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,8 +1,7 @@ import { Fragment, useMemo } from 'react' import Comment, { CommentSkeleton } from './comment' import styles from './header.module.css' -import Nav from 'react-bootstrap/Nav' -import Navbar from 'react-bootstrap/Navbar' +import { Nav, NavLink, NavItem } from '@/components/ui/nav' import { numWithUnits } from '@/lib/format' import { defaultCommentSort } from '@/lib/item' import { useRouter } from 'next/router' @@ -22,45 +21,45 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm } return ( - + ) } diff --git a/components/copy-chip.js b/components/copy-chip.js index 73a0f4625..b0eac1414 100644 --- a/components/copy-chip.js +++ b/components/copy-chip.js @@ -1,8 +1,8 @@ import copy from 'clipboard-copy' -import { useToast } from '@/components/toast' +import { useToast } from '@/components/ui/toast' import styles from './copy-chip.module.css' -function chipClassName ({ full, tone, truncate, className }) { +export function chipClassName ({ full, tone, truncate, className }) { return [ styles.chip, full ? styles.chipFull : null, diff --git a/components/copy-chip.module.css b/components/copy-chip.module.css index e0d905abf..8f673627b 100644 --- a/components/copy-chip.module.css +++ b/components/copy-chip.module.css @@ -1,3 +1,5 @@ +@layer components { + .chip { box-sizing: border-box; min-height: 32px; @@ -6,11 +8,11 @@ max-width: 100%; padding: 0.3rem 0.75rem; overflow: hidden; - border: 1px solid color-mix(in srgb, var(--theme-borderColor) 70%, transparent); + border: 1px solid color-mix(in srgb, var(--sn-borderColor) 70%, transparent); border-radius: 999px; background: transparent; - color: var(--theme-grey); - font-family: var(--bs-font-monospace); + color: var(--sn-grey); + font-family: var(--sn-font-monospace); font-size: 0.9rem; font-weight: 700; line-height: 1; @@ -62,16 +64,18 @@ button.chip:hover, button.chip:focus-visible { - border-color: var(--bs-body-color); - color: var(--bs-body-color); + border-color: var(--sn-body-color); + color: var(--sn-body-color); } button.chip:focus-visible { - outline: 2px solid var(--theme-borderColor); + outline: 2px solid var(--sn-borderColor); outline-offset: 2px; } .danger { - border-color: color-mix(in srgb, var(--bs-danger) 80%, transparent); - color: var(--bs-danger); + border-color: color-mix(in srgb, var(--sn-danger) 80%, transparent); + color: var(--sn-danger); +} + } diff --git a/components/dark-mode.js b/components/dark-mode.js index 2653913df..6662b390a 100644 --- a/components/dark-mode.js +++ b/components/dark-mode.js @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react' const handleThemeChange = (dark) => { const root = window.document.documentElement - root.setAttribute('data-bs-theme', dark ? 'dark' : 'light') + const theme = dark ? 'dark' : 'light' + root.setAttribute('data-theme', theme) } const STORAGE_KEY = 'darkMode' @@ -54,10 +55,10 @@ const listenForThemeChange = (onChange) => { const root = window.document.documentElement const observer = new window.MutationObserver(() => { - const theme = root.getAttribute('data-bs-theme') + const theme = root.getAttribute('data-theme') onChange(dark => ({ ...dark, dark: theme === 'dark' })) }) - observer.observe(root, { attributes: true, attributeFilter: ['data-bs-theme'] }) + observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] }) return () => { observer.disconnect() diff --git a/components/delete.js b/components/delete.js index 84897f71a..653ee1c36 100644 --- a/components/delete.js +++ b/components/delete.js @@ -1,24 +1,31 @@ import { useMutation } from '@apollo/client/react' import { gql } from 'graphql-tag' import { useState } from 'react' -import Alert from 'react-bootstrap/Alert' -import Button from 'react-bootstrap/Button' -import Dropdown from 'react-bootstrap/Dropdown' +import { Alert } from '@/components/ui/alert' +import Button from '@/components/ui/button' +import { MenuItem } from '@/components/ui/menu' import { useShowModal } from './modal' -import { useToast } from './toast' +import { useToast } from '@/components/ui/toast' -export default function Delete ({ itemId, children, onDelete, type = 'post' }) { +/* the confirm-open lives in a hook so the handler can sit on the activating + element itself: Delete's span hears bubbled clicks from in-tree children like + post.js's Button, but a portaled MenuItem is no DOM descendant, so mouse + worked only through React's synthetic portal bubbling and Enter never arrived */ +export function useDeleteConfirm ({ itemId, onDelete, type = 'post' }) { const showModal = useShowModal() const [deleteItem] = useMutation( gql` mutation deleteItem($id: ID!) { deleteItem(id: $id) { + id text title url pollCost deletedAt + lexicalState + html } }`, { update (cache, { data: { deleteItem } }) { @@ -29,36 +36,43 @@ export default function Delete ({ itemId, children, onDelete, type = 'post' }) { title: () => deleteItem.title, url: () => deleteItem.url, pollCost: () => deleteItem.pollCost, - deletedAt: () => deleteItem.deletedAt + deletedAt: () => deleteItem.deletedAt, + // the body renders from the resolver-derived lexicalState and html + // (item-full's Lexical read path), not text, so they must repaint too + lexicalState: () => deleteItem.lexicalState, + html: () => deleteItem.html }, optimistic: true }) } } ) + + return () => { + showModal(onClose => { + return ( + { + const { error } = await deleteItem({ variables: { id: itemId } }) + if (error) { + throw error + } + if (onDelete) { + onDelete() + } + onClose() + }} + /> + ) + }) + } +} + +export default function Delete ({ itemId, children, onDelete, type = 'post' }) { + const showDeleteConfirm = useDeleteConfirm({ itemId, onDelete, type }) return ( - { - showModal(onClose => { - return ( - { - const { error } = await deleteItem({ variables: { id: itemId } }) - if (error) { - throw error - } - if (onDelete) { - onDelete() - } - onClose() - }} - /> - ) - }) - }} - >{children} - + {children} ) } @@ -69,8 +83,8 @@ export function DeleteConfirm ({ onConfirm, type }) { return ( <> {error && setError(undefined)} dismissible>{error}} -

    Are you sure? This is a gone forever kind of delete.

    -
    +

    Are you sure? This is a gone forever kind of delete.

    +
    @@ -187,8 +189,8 @@ function EditorContent ({ )} {isMarkdown && } - {hint && {hint}} - {warn && {warn}} + {hint && {hint}} + {warn && {warn}}
    ) } diff --git a/components/editor/nodes/math/index.js b/components/editor/nodes/math/index.js index fc44650dd..af074dccb 100644 --- a/components/editor/nodes/math/index.js +++ b/components/editor/nodes/math/index.js @@ -14,7 +14,7 @@ import { } from 'lexical' import ErrorBoundary from '@/components/error-boundary' import KatexRenderer from '@/components/katex-renderer' -import { useToast } from '@/components/toast' +import { useToast } from '@/components/ui/toast' import useDecoratorNodeSelection from '@/components/editor/hooks/use-decorator-selection' import styles from './math.module.css' diff --git a/components/editor/nodes/math/math.module.css b/components/editor/nodes/math/math.module.css index 298fefe3e..2bf7799f3 100644 --- a/components/editor/nodes/math/math.module.css +++ b/components/editor/nodes/math/math.module.css @@ -1,11 +1,13 @@ +@layer components { + .container { display: flex; flex-direction: column; gap: 0.35rem; padding: 0.5rem; - border: 1px solid var(--theme-borderColor); + border: 1px solid var(--sn-borderColor); border-radius: 0.4rem; - background-color: var(--theme-inputBg); + background-color: var(--sn-inputBg); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); } @@ -14,9 +16,9 @@ flex-direction: column; gap: 0.25rem; padding: 0.35rem 0.5rem; - border: 1px solid var(--theme-borderColor); + border: 1px solid var(--sn-borderColor); border-radius: 0.4rem; - background-color: var(--theme-inputBg); + background-color: var(--sn-inputBg); vertical-align: middle; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); } @@ -27,26 +29,26 @@ outline: 0; padding: 0.25rem 0.35rem; border-radius: 0.25rem; - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-family: var(--sn-font-monospace); font-size: 13px; line-height: 1.5; - color: var(--theme-color); background-color: transparent; resize: none; overflow: hidden; } .input::placeholder { - color: var(--theme-grey); + color: var(--sn-grey); opacity: 0.6; } .preview { padding: 0.2rem 0.35rem; - border-top: 1px solid color-mix(in srgb, var(--theme-borderColor) 50%, transparent); + border-top: 1px solid color-mix(in srgb, var(--sn-borderColor) 50%, transparent); min-height: 1.4em; } .preview:empty { display: none; -} \ No newline at end of file +} +} diff --git a/components/editor/nodes/media.js b/components/editor/nodes/media.js index fc19204d7..ce7cda2af 100644 --- a/components/editor/nodes/media.js +++ b/components/editor/nodes/media.js @@ -40,7 +40,7 @@ function MediaError ({ className, width, height, src, rel }) {

    content not available
    - + {src}

    diff --git a/components/editor/nodes/toc.js b/components/editor/nodes/toc.js index 550ef17fe..b80c0106b 100644 --- a/components/editor/nodes/toc.js +++ b/components/editor/nodes/toc.js @@ -46,7 +46,7 @@ export function TableOfContents ({ headings }) { ))}
) - :
no headings
} + :
no headings
} ) } diff --git a/components/editor/plugins/core/formik.js b/components/editor/plugins/core/formik.js index 845e50e83..c7e5f8f0a 100644 --- a/components/editor/plugins/core/formik.js +++ b/components/editor/plugins/core/formik.js @@ -6,7 +6,7 @@ import { COMMAND_PRIORITY_HIGH, createCommand, BLUR_COMMAND } from 'lexical' import { useFeeButton } from '@/components/fee-button' import { isMarkdownMode } from '@/lib/lexical/commands/utils' import useDebounceCallback from '@/components/use-debounce-callback' -import { useToast } from '@/components/toast' +import { useToast } from '@/components/ui/toast' /** instantly syncs Formik with the latest markdown resulting from the editor */ export const SYNC_FORMIK_COMMAND = createCommand('SYNC_FORMIK_COMMAND') diff --git a/components/editor/plugins/core/max-length.js b/components/editor/plugins/core/max-length.js index ad5645e80..6bb5a0c8f 100644 --- a/components/editor/plugins/core/max-length.js +++ b/components/editor/plugins/core/max-length.js @@ -4,6 +4,7 @@ import { $getSelection, $isRangeSelection, RootNode, $getRoot } from 'lexical' import { $trimTextContentFromAnchor } from '@lexical/selection' import { $restoreEditorState } from '@lexical/utils' import { MAX_POST_TEXT_LENGTH } from '@/lib/constants' +import { hintClasses } from '@/components/form' function getRemaining (editor, maxLength) { return editor.getEditorState().read(() => { @@ -84,7 +85,7 @@ export function MaxLengthPlugin ({ lengthOptions = {} }) { if (show || remaining < 10) { return ( -
{remaining} characters remaining
+
{remaining} characters remaining
) } diff --git a/components/editor/plugins/link/editor.js b/components/editor/plugins/link/editor.js index ee198feee..dc2afebeb 100644 --- a/components/editor/plugins/link/editor.js +++ b/components/editor/plugins/link/editor.js @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' +import { useCallback, useEffect, useState } from 'react' import { $findMatchingParent, mergeRegister } from '@lexical/utils' import { $createLinkNode, $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' @@ -8,9 +7,11 @@ import { KEY_ESCAPE_COMMAND, $getSelection, $isNodeSelection, $isRangeSelection, isCurrentlyReadOnlyMode } from 'lexical' +import { Popover as BasePopover } from '@base-ui/react/popover' +import { popoverClasses } from '@/components/ui/popover' +import popoverStyles from '@/components/ui/popover.module.css' import Check from '@/svgs/check-line.svg' import Pencil from '@/svgs/edit-line.svg' -import { setFloatingElemPosition } from '@/lib/lexical/utils/position' import { getSelectedNode } from '@/lib/lexical/commands/utils' import { ensureProtocol } from '@/lib/url' import styles from './linkeditor.module.css' @@ -19,39 +20,41 @@ import CloseIcon from '@/svgs/close-line.svg' import UnlinkIcon from '@/svgs/editor/toolbar/inline/link-unlink.svg' import { DEFAULT_URL } from '@/lib/lexical/commands/links' -/** how distant the link editor should appear from the link element */ -const LINK_ELEMENT_VERTICAL_OFFSET = 26 - -export default function LinkEditor ({ nodeKey, anchorElem, onDismiss }) { +export default function LinkEditor ({ nodeKey, onDismiss }) { const [isLinkEditMode, setIsLinkEditMode] = useState(false) const [editor] = useLexicalComposerContext() - const floatingRef = useRef(null) - const inputRef = useRef(null) const [linkUrl, setLinkUrl] = useState('') const [editedLinkUrl, setEditedLinkUrl] = useState('') - const hideFloatingElem = useCallback((dismiss = true) => { - if (!floatingRef.current) return - setFloatingElemPosition({ targetRect: null, floatingElem: floatingRef.current, anchorElem, fade: false }) - if (dismiss) onDismiss() - }, [anchorElem, onDismiss]) + // the deliberate edit-mode focus steal, as a callback ref: the popup renders + // through a portal whose container appears a render after isLinkEditMode + // flips, so an effect keyed on the flag can run while the input doesn't + // exist yet and never re-fire; the ref fires on the mount itself. The input + // only renders in edit mode, so mounting means stealing + const inputRef = useCallback((el) => { + if (el) { + // preventScroll: the ref fires at DOM insertion, before floating-ui + // positions the popup, and a plain focus() scroll-into-views the popup + // while it still sits at the document origin (page jumps to top) + el.focus({ preventScroll: true }) + el.select() + } + }, []) + // must stay idempotent: the popover's document-level Escape and Lexical's + // KEY_ESCAPE_COMMAND both funnel here on one keypress (a second + // TOGGLE_LINK_COMMAND null on a linkless selection is a Lexical no-op, + // onDismiss twice is a state no-op) const handleCancel = useCallback(() => { - hideFloatingElem() // don't toggle link if the editor is currently read-only // e.g. lexical reconciliation during a markdown-to-rich mode switch - if (isCurrentlyReadOnlyMode()) return - if (linkUrl === '' || linkUrl === DEFAULT_URL) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) + if (!isCurrentlyReadOnlyMode()) { + if (linkUrl === '' || linkUrl === DEFAULT_URL) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) + } } - }, [hideFloatingElem, editor, linkUrl]) - - useEffect(() => { - if (isLinkEditMode) { - inputRef.current?.focus() - inputRef.current?.select() - } - }, [isLinkEditMode]) + onDismiss() + }, [editor, linkUrl, onDismiss]) const $updateLink = useCallback(() => { const selection = $getSelection() @@ -68,7 +71,6 @@ export default function LinkEditor ({ nodeKey, anchorElem, onDismiss }) { setLinkUrl('') setEditedLinkUrl('') if (isLinkEditMode) setIsLinkEditMode(false) - hideFloatingElem(false) return } @@ -79,25 +81,7 @@ export default function LinkEditor ({ nodeKey, anchorElem, onDismiss }) { setEditedLinkUrl('') setIsLinkEditMode(true) } - - const floatingElem = floatingRef.current - if (!floatingElem || !anchorElem) return - - const el = editor.getElementByKey(nodeKey) - if (!el) { - hideFloatingElem() - return - } - const { top, left, width, height } = el.getBoundingClientRect() - setFloatingElemPosition({ - targetRect: { top: top + LINK_ELEMENT_VERTICAL_OFFSET, left, width, height }, - floatingElem, - anchorElem, - verticalGap: 8, - horizontalOffset: 0, - fade: false - }) - }, [anchorElem, editor, isLinkEditMode, nodeKey, hideFloatingElem]) + }, [isLinkEditMode, nodeKey]) const handleLinkConfirm = useCallback(() => { const value = editedLinkUrl.trim() @@ -115,30 +99,29 @@ export default function LinkEditor ({ nodeKey, anchorElem, onDismiss }) { } }) } else { - hideFloatingElem() editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) + onDismiss() } setEditedLinkUrl('') setIsLinkEditMode(false) - }, [editedLinkUrl, editor, hideFloatingElem]) - - const handleBlur = useCallback((event) => { - const floatingElem = floatingRef.current - if (!floatingElem) return - - if (!event || !floatingElem.contains(event.relatedTarget)) { - handleCancel() - } - }, [handleCancel]) + }, [editedLinkUrl, editor, onDismiss]) // editor updates, selection changes, escape key useEffect(() => { + // initial read: the editor state that mounted us already holds the link, + // and registerUpdateListener only fires on later updates + editor.getEditorState().read(() => { $updateLink() }) + return mergeRegister( editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { $updateLink() }) }), + // kept alongside the popover's own Escape handling on purpose: Base UI's + // dismissal listens document-level, so with focus in the editor both this + // command and the popover's escape-key close fire on one keypress; that + // double-fire is safe only while handleCancel stays idempotent (above) editor.registerCommand( KEY_ESCAPE_COMMAND, () => { @@ -148,102 +131,89 @@ export default function LinkEditor ({ nodeKey, anchorElem, onDismiss }) { ) }, [editor, $updateLink, handleCancel]) - // throttled update of position - useEffect(() => { - const scrollerElem = anchorElem?.parentElement - let rafId = null - - const update = () => { - if (rafId !== null) window.cancelAnimationFrame(rafId) - rafId = window.requestAnimationFrame(() => { - rafId = null - editor.getEditorState().read(() => { - $updateLink() - }) - }) - } - - // synchronous initial read so the position is correct on the same frame - editor.getEditorState().read(() => { $updateLink() }) - - window.addEventListener('resize', update) - scrollerElem?.addEventListener('scroll', update) - - return () => { - if (rafId !== null) window.cancelAnimationFrame(rafId) - window.removeEventListener('resize', update) - scrollerElem?.removeEventListener('scroll', update) - } - }, [editor, anchorElem, $updateLink]) - - useEffect(() => { - const editorElem = floatingRef.current - if (!editorElem || !anchorElem) return - - editorElem.addEventListener('focusout', handleBlur) - anchorElem.addEventListener('focusout', handleBlur) - return () => { - editorElem.removeEventListener('focusout', handleBlur) - anchorElem.removeEventListener('focusout', handleBlur) - } - }, [anchorElem, handleBlur]) - - if (!anchorElem) return null - - return createPortal( -
-
- {isLinkEditMode - ? ( - <> - { setEditedLinkUrl(e.target.value) }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - handleLinkConfirm() - } else if (e.key === 'Escape') { - e.preventDefault() - handleCancel() - } - }} - /> -
- e.preventDefault()} onClick={handleCancel}> - - - e.preventDefault()} onClick={handleLinkConfirm}> - - -
- - ) - : ( - <> - - {linkUrl} - -
- e.preventDefault()} onClick={() => { setEditedLinkUrl(linkUrl); setIsLinkEditMode(true) }}> - - - e.preventDefault()} onClick={() => editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)}> - - -
- - )} -
-
, - anchorElem + // open is always true while mounted: the plugin's isLinkEditable && !dismissed + // control above is the open state; position tracks the live link element by + // nodeKey (the function anchor re-resolves per update, floating-ui autoUpdate + // covers ancestor scroll and resize natively) + return ( + { + if (open) return + if (details.reason === 'outside-press') { + // Presses inside the editor only move the caret. A link press can + // open this popup on pointerdown, so its release must not immediately + // dismiss the same popup. + const target = details.event?.target + if (target instanceof window.Node && editor.getRootElement()?.contains(target)) return + handleCancel() + } else if (details.reason === 'escape-key' || details.reason === 'focus-out') { + handleCancel() + } + }} + > + + editor.getElementByKey(nodeKey)} + side='bottom' align='start' sideOffset={8} + className={popoverStyles.positioner} + > + +
+ {isLinkEditMode + ? ( + <> + { setEditedLinkUrl(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleLinkConfirm() + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancel() + } + }} + /> +
+ + +
+ + ) + : ( + <> + + {linkUrl} + +
+ + +
+ + )} +
+
+
+
+
) } diff --git a/components/editor/plugins/link/linkeditor.module.css b/components/editor/plugins/link/linkeditor.module.css index 68989187a..65eef4866 100644 --- a/components/editor/plugins/link/linkeditor.module.css +++ b/components/editor/plugins/link/linkeditor.module.css @@ -1,28 +1,12 @@ -.linkEditorContainer { - position: absolute; - top: 0; - left: 0; - z-index: 600; - max-width: 100%; - opacity: 0; - box-shadow: 0 0 8px 0 rgba(0, 0, 0, var(--theme-floating-toolbar-shadow-opacity)); - will-change: transform; - border-radius: 0.4rem; - background-color: var(--theme-forceCommentBg); -} - -.linkEditorContainer:hover { - opacity: 1; -} +@layer components { +/* positioning, z and chrome live on the Base UI Popover popup; this file is + layout, input and icon skins only */ .linkEditor { display: flex; align-items: center; gap: 0.5rem; max-width: min(400px, 100%); - border-radius: 0.4rem; - background-color: var(--theme-commentBg); - border: 1px solid var(--theme-borderColor); } .linkInput, @@ -37,7 +21,7 @@ font-family: inherit; font-size: 12px; line-height: 1.2; - color: var(--bs-body-color); + color: var(--sn-body-color); outline: 0; white-space: nowrap; overflow: hidden; @@ -48,12 +32,12 @@ max-width: 300px; } -[data-bs-theme="light"] .linkInput { - border-right: 1px solid var(--theme-borderColor); +[data-theme="light"] .linkInput { + border-right: 1px solid var(--sn-borderColor); } .linkView { - color: var(--theme-link); + color: var(--sn-link); min-height: calc(1em * 1.2 + 1rem); } @@ -74,6 +58,10 @@ justify-content: center; width: 24px; height: 24px; + padding: 0; + border: 0; + color: inherit; + background: transparent; border-radius: 4px; opacity: 0.5; cursor: pointer; @@ -85,7 +73,7 @@ .linkEditIcon:hover, .linkRemoveIcon:hover { opacity: 0.85; - background-color: var(--theme-toolbarHoverBg, rgba(128, 128, 128, 0.1)); + background-color: var(--sn-toolbarHover); } .linkConfirmIcon:hover { @@ -117,3 +105,5 @@ height: 20px; } } + +} diff --git a/components/editor/plugins/mentions.js b/components/editor/plugins/mentions.js index b4cb260e7..168762e8d 100644 --- a/components/editor/plugins/mentions.js +++ b/components/editor/plugins/mentions.js @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import Dropdown from 'react-bootstrap/Dropdown' +import { menuClasses, itemClasses } from '@/components/ui/menu' +import { cn } from '@/lib/cn' import { useApolloClient } from '@apollo/client/react' import { LexicalTypeaheadMenuPlugin, MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' @@ -157,21 +158,22 @@ export default function MentionsPlugin () { ) => anchorElementRef.current && suggestions?.length ? createPortal( - - e.preventDefault()}> - {options.map((o, i) => - { - setHighlightedIndex(i) - selectOptionAndCleanUp(o) - }} - > - {o.name} - )} - - , anchorElementRef.current) + // a plain listbox on the menu chrome, Lexical owns the keyboard; + // .suggestionsMenu only carries the z-index +
e.preventDefault()}> + {options.map((o, i) => ( +
{ + setHighlightedIndex(i) + selectOptionAndCleanUp(o) + }} + > + {o.name} +
+ ))} +
, anchorElementRef.current) : null} /> ) diff --git a/components/editor/plugins/toolbar/index.js b/components/editor/plugins/toolbar/index.js index 4e9e0d642..73991e706 100644 --- a/components/editor/plugins/toolbar/index.js +++ b/components/editor/plugins/toolbar/index.js @@ -2,20 +2,23 @@ import ActionTooltip from '@/components/action-tooltip' import classNames from 'classnames' import styles from '@/lib/lexical/theme/editor.module.css' import dropdownStyles from '@/components/dropdown.module.css' +import menuStyles from '@/components/ui/menu.module.css' +import { cn } from '@/lib/cn' +import { Toolbar } from '@base-ui/react/toolbar' +import { Menu as BaseMenu } from '@base-ui/react/menu' +import { Menu, MenuTrigger } from '@/components/ui/menu' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { SN_UPLOAD_FILES_COMMAND } from '@/components/editor/plugins/upload' import ModeSwitchPlugin from '@/components/editor/plugins/toolbar/switch' import UploadIcon from '@/svgs/editor/toolbar/inserts/upload-paperclip.svg' import { useToolbarState, INITIAL_FORMAT_STATE } from '@/components/editor/contexts/toolbar' -import { useEffect, useRef, useState, forwardRef, useCallback } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import BoldIcon from '@/svgs/editor/toolbar/inline/bold.svg' import ItalicIcon from '@/svgs/editor/toolbar/inline/italic.svg' import LinkIcon from '@/svgs/editor/toolbar/inline/link.svg' import QuoteIcon from '@/svgs/editor/toolbar/block/quote-text.svg' import CodeIcon from '@/svgs/editor/toolbar/inline/code.svg' -import Dropdown from 'react-bootstrap/Dropdown' import ArrowDownIcon from '@/svgs/editor/toolbar/arrow-down.svg' -import { createPortal } from 'react-dom' import SuperscriptIcon from '@/svgs/editor/toolbar/inline/superscript.svg' import SubscriptIcon from '@/svgs/editor/toolbar/inline/subscript.svg' import StrikethroughIcon from '@/svgs/editor/toolbar/inline/strikethrough.svg' @@ -30,7 +33,6 @@ import CheckListIcon from '@/svgs/editor/toolbar/block/check-list.svg' import CodeBlockIcon from '@/svgs/editor/toolbar/block/code-block.svg' import MoreIcon from '@/svgs/editor/toolbar/more-line.svg' import FontStyleIcon from '@/svgs/editor/toolbar/font-style.svg' -import { useIsClient } from '@/components/use-client' import { SN_FORMAT_BLOCK_COMMAND } from '@/lib/lexical/commands/formatting/blocks' import { SN_FORMAT_COMMAND } from '@/lib/lexical/commands/formatting/format' import { SN_TOGGLE_LINK_COMMAND } from '@/lib/lexical/commands/links' @@ -65,46 +67,39 @@ const FORMAT_OPTIONS = [ { id: 'underline', active: 'isUnderline', name: 'underline', icon: , type: 'underline' } ] -const MenuAlternateDimension = forwardRef(function MenuAlternateDimension ({ children, style, className }, ref) { - // document doesn't exist on SSR - const isClient = useIsClient() - if (!isClient) return null - - return createPortal( -
- {children} -
, - document.body - ) -}) - function ToolbarDropdown ({ icon, tooltip, options, onAction, arrow = true, showDelay = 500, children }) { const { toolbarState } = useToolbarState() const [dropdownOpen, setDropdownOpen] = useState(false) return ( - - - e.preventDefault()} - className={classNames(styles.toolbarItem, dropdownOpen && styles.active)} + + {/* The tooltip wrapper provides the anchor shared with Menu. */} + + e.preventDefault()} /* keeps the Lexical selection; also suppresses Base UI's mousedown-open… */ + onClick={() => setDropdownOpen(o => !o)} /* …so the click toggle drives open */ + render={} > {icon} {arrow && } - - - {options.map(option => ( - - ))} - {children} - - + + + + + {options.map(option => ( + + ))} + {children} + + + + ) } @@ -114,11 +109,14 @@ function DropdownMenuItem ({ option, onAction, isActive }) { const shortcutDisplay = useFormattedShortcut(shortcut?.key) const tooltipText = shortcutDisplay ? `${option.name} (${shortcutDisplay})` : option.name + // raw BaseMenu.Item on the composed skins: menuStyles.item carries the item + // color and hover paint, the dropdownExtra* module skins carry the + // toolbar-specific metrics; items close on click natively return ( - onAction(option)} - className={classNames(dropdownStyles.dropdownExtraItem, isActive && dropdownStyles.active)} + className={classNames(menuStyles.item, dropdownStyles.dropdownExtraItem, isActive && dropdownStyles.active)} onPointerDown={e => e.preventDefault()} > @@ -128,24 +126,28 @@ function DropdownMenuItem ({ option, onAction, isActive }) { {shortcutDisplay} - + ) } -function ToolbarButton ({ id, isActive, onClick, tooltip, children, showDelay = 500 }) { +// composite=false for the buttons OUTSIDE Toolbar.Root (the innerToolbar extras): +// they stay individually tabbable instead of joining the roving composite +function ToolbarButton ({ id, isActive, onClick, tooltip, children, showDelay = 500, composite = true }) { const shortcut = SHORTCUTS[id] const shortcutDisplay = useFormattedShortcut(shortcut?.key) const tooltipText = shortcutDisplay ? `${tooltip} (${shortcutDisplay})` : tooltip + const ButtonTag = composite ? Toolbar.Button : 'button' return ( - - + e.preventDefault()} onClick={onClick} > {children} - + ) } @@ -256,7 +258,7 @@ export function ToolbarPlugin ({ name, topLevel }) {
-
+ } tooltip='blocks' @@ -269,7 +271,7 @@ export function ToolbarPlugin ({ name, topLevel }) { handleFormat('italic')} tooltip='italic'> - + handleFormatBlock('quote')} tooltip='quote'> @@ -279,7 +281,7 @@ export function ToolbarPlugin ({ name, topLevel }) { handleToggleLink()} tooltip='link'> - + } tooltip='additional formats' @@ -288,18 +290,19 @@ export function ToolbarPlugin ({ name, topLevel }) { arrow={false} >
- inserts + inserts
, type: 'math' }} onAction={() => handleInsertMath()} /> , type: 'inlineMath' }} onAction={() => handleInsertMath(true)} />
-
- - e.preventDefault()} className={classNames(styles.toolbarItem, toolbarState.showToolbar && styles.active)} onClick={() => updateToolbarState('showToolbar', !toolbarState.showToolbar)}> + + + {/* outside the composite on purpose: individually tabbable, no roving into a visibility:hidden row's group */} + - editor.dispatchCommand(SN_UPLOAD_FILES_COMMAND)} tooltip='upload files'> + editor.dispatchCommand(SN_UPLOAD_FILES_COMMAND)} tooltip='upload files'>
diff --git a/components/editor/plugins/toolbar/switch.js b/components/editor/plugins/toolbar/switch.js index 14d87646d..7ab64eb51 100644 --- a/components/editor/plugins/toolbar/switch.js +++ b/components/editor/plugins/toolbar/switch.js @@ -1,6 +1,6 @@ import { useCallback, useEffect } from 'react' import styles from '@/lib/lexical/theme/editor.module.css' -import Nav from 'react-bootstrap/Nav' +import { Tabs } from '@base-ui/react/tabs' import { useEditorMode, MARKDOWN_MODE, RICH_MODE } from '@/components/editor/contexts/mode' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { createCommand, COMMAND_PRIORITY_HIGH } from 'lexical' @@ -8,7 +8,7 @@ import { isMarkdownMode } from '@/lib/lexical/commands/utils' import { SYNC_FORMIK_COMMAND } from '@/components/editor/plugins/core/formik' import { useFeeButton } from '@/components/fee-button' import { UPLOAD_SUBMIT_DISABLED_REASON } from '@/components/editor/plugins/upload' -import { useToast } from '@/components/toast' +import { useToast } from '@/components/ui/toast' /** command to toggle between markdown and rich mode * @param {string} [newMode] - the new mode to switch to, if not provided, the current mode will be toggled @@ -51,32 +51,21 @@ export default function ModeSwitchPlugin ({ name }) { ) }, [editor, changeMode, toggleMode, disabledReasons, toaster]) - const handleTabSelect = useCallback((eventKey) => { - editor.dispatchCommand(TOGGLE_MODE_COMMAND, eventKey) + const handleTabSelect = useCallback((value) => { + editor.dispatchCommand(TOGGLE_MODE_COMMAND, value) }, [editor]) + // panel-less Tabs: value is controlled by useEditorMode and only moves when the + // TOGGLE_MODE_COMMAND handler above accepts the switch (upload guard, same-mode + // no-op), so a refused command leaves the tabs where they are. Clicking the + // active tab fires nothing in Tabs source, so no disabled={active} hack is needed. + // mousedown preventDefault keeps the Lexical selection on tab click return ( - + + e.preventDefault()}> + write + compose + + ) } diff --git a/components/editor/plugins/upload.js b/components/editor/plugins/upload.js index a42fdd794..839f4e427 100644 --- a/components/editor/plugins/upload.js +++ b/components/editor/plugins/upload.js @@ -242,7 +242,7 @@ export default function FileUploadPlugin ({ editorRef }) { }, [placeholderKey, setSubmitDisabled]) return ( -
+
setShow(true)}>
show full note
- or other stuff + or other stuff }
) diff --git a/components/error-boundary.js b/components/error-boundary.js index f7c410bc8..6dcef69dd 100644 --- a/components/error-boundary.js +++ b/components/error-boundary.js @@ -2,8 +2,8 @@ import { Component } from 'react' import { StaticLayout } from './layout' import styles from '@/styles/error.module.css' import copy from 'clipboard-copy' -import Button from 'react-bootstrap/Button' -import { useToast } from './toast' +import Button from '@/components/ui/button' +import { useToast } from '@/components/ui/toast' import { decodeMinifiedStackTrace } from '@/lib/stacktrace' import LoopVideo from './loop-video' class ErrorBoundary extends Component { @@ -72,5 +72,5 @@ const CopyErrorButton = ({ errorDetails }) => { toaster?.danger?.('failed to copy') } } - return + return } diff --git a/components/fee-button.js b/components/fee-button.js index 6e6471d74..1661b76a6 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -1,6 +1,4 @@ import { useEffect, useContext, createContext, useState, useCallback, useId, useMemo } from 'react' -import Table from 'react-bootstrap/Table' -import BootstrapForm from 'react-bootstrap/Form' import ActionTooltip from './action-tooltip' import Info from './info' import styles from './fee-button.module.css' @@ -12,7 +10,7 @@ import { useMe } from './me' import AnonIcon from '@/svgs/spy-fill.svg' import { useShowModal } from './modal' import Link from 'next/link' -import { SubmitButton } from './form' +import { Checkbox, SubmitButton } from './form' import { useFormikContext } from 'formik' const FeeButtonContext = createContext() @@ -202,13 +200,12 @@ export function FreebieCheckbox () { if (!freebieAvailable) return null return ( - setFieldValue('useFreebie', e.target.checked, false)} /> ) } @@ -216,7 +213,7 @@ export function FreebieCheckbox () { function FreebieDialog ({ freeCommentsLeft }) { return ( <> -
if you don't have enough sats, this one is on us
+
if you don't have enough sats, this one is on us
  • Free items have limited visibility and can only earn cowboy credits.
  • {freeCommentsLeft !== null && ( @@ -261,22 +258,22 @@ export default function FeeButton ({ ChildButton = SubmitButton, variant, text, function Receipt ({ lines, total }) { return ( - +
    {Object.entries(lines).sort(([, a], [, b]) => sortHelper(a, b)).map(([key, { term, label, omit }]) => ( !omit && - + ))} - - + + -
    {term}{label}{label}
    {numWithUnits(total, { abbreviate: false, format: true })}total fee{numWithUnits(total, { abbreviate: false, format: true })}total fee
    + ) } @@ -285,18 +282,18 @@ function AnonInfo () { return ( showModal(onClose => -
    You are posting without an account
    -
      +
      You are posting without an account
      +
      1. You'll pay by invoice
      2. Your content will be content-joined (get it?!) under the @anon account
      3. Any sats your content earns will go toward rewards
      4. We won't be able to notify you when you receive replies
      - btw if you don't need to be anonymous, posting is cheaper with an account + btw if you don't need to be anonymous, posting is cheaper with an account
      ) } /> diff --git a/components/fee-button.module.css b/components/fee-button.module.css index aa4f21c8e..9c0950691 100644 --- a/components/fee-button.module.css +++ b/components/fee-button.module.css @@ -1,9 +1,12 @@ +@layer components { + .receipt { - background-color: var(--theme-inputBg); + background-color: var(--sn-inputBg); max-width: 300px; margin: auto; table-layout: auto; width: 100%; + border-collapse: collapse; } .feeButton { @@ -21,19 +24,23 @@ } .freebieCheckbox label { - color: var(--theme-grey); + color: var(--sn-grey); } .freebieCheckbox:focus-within label { - color: var(--theme-color); + color: var(--sn-body-color); } .receipt td { padding: .25rem .1rem; - background-color: var(--theme-inputBg); - color: var(--bs-body-color); + background-color: var(--sn-inputBg); + color: var(--sn-body-color); + line-height: 1.2rem; + vertical-align: top; } .receipt tfoot { - border-top: 2px solid var(--theme-borderColor) !important; + /* pins the seam against the unlayered .sn-text table chrome */ + border-top: 2px solid var(--sn-borderColor) !important; +} } diff --git a/components/file-upload.js b/components/file-upload.js index e9b763e92..1d9611194 100644 --- a/components/file-upload.js +++ b/components/file-upload.js @@ -1,6 +1,6 @@ import { Fragment, useCallback, forwardRef, useRef } from 'react' import { UPLOAD_TYPES_ALLOW, MEDIA_URL } from '@/lib/constants' -import { useToast } from './toast' +import { useToast } from '@/components/ui/toast' import gql from 'graphql-tag' import { useMutation } from '@apollo/client/react' import piexif from 'piexifjs' @@ -95,7 +95,7 @@ export const FileUpload = forwardRef(({ children, className, onSelect, onUpload, ref={ref} type='file' multiple={multiple} - className='d-none' + className='hidden' accept={accept.join(', ')} onChange={async (e) => { const fileList = e.target.files diff --git a/components/footer-rewards.js b/components/footer-rewards.js index 9bdfbcf86..8a2f4fce7 100644 --- a/components/footer-rewards.js +++ b/components/footer-rewards.js @@ -1,6 +1,7 @@ import { gql } from '@apollo/client' import { useQuery } from '@apollo/client/react' import Link from 'next/link' +import { navLinkClasses } from '@/components/ui/nav' import { RewardLine } from '@/pages/rewards' import { LONG_POLL_INTERVAL_MS, SSR } from '@/lib/constants' @@ -17,7 +18,7 @@ export default function Rewards () { const total = data?.rewards?.[0]?.total const time = data?.rewards?.[0]?.time return ( - + {total ? : 'rewards'} ) diff --git a/components/footer.js b/components/footer.js index 23ba32628..a234934a6 100644 --- a/components/footer.js +++ b/components/footer.js @@ -1,6 +1,6 @@ -import Container from 'react-bootstrap/Container' -import OverlayTrigger from 'react-bootstrap/OverlayTrigger' -import Popover from 'react-bootstrap/Popover' +import Container from '@/components/ui/container' +import { navLinkClasses } from '@/components/ui/nav' +import { Popover, PopoverTrigger, PopoverContent, PopoverBody } from '@/components/ui/popover' import { CopyInput } from './form' import styles from './footer.module.css' import Texas from '@/svgs/texas.svg' @@ -20,123 +20,135 @@ import ActionTooltip from './action-tooltip' import { useAnimationEnabled } from '@/components/animation' import { useLiveCommentsToggle } from './use-live-comments' -const RssPopover = ( - - - } /> + + {children} + + + ) +} + +function RssPopover () { + return ( + + -
      - + - - -) + + ) +} -const SocialsPopover = ( - - -
      +function SocialsPopover () { + return ( + + -
      + - - -) + + ) +} -const ChatPopover = ( - - +function ChatPopover () { + return ( + telegram \ signal - - -) + + ) +} -const LegalPopover = ( - - -
      - +function LegalPopover () { + return ( + +
      + terms of service \ - + privacy policy
      -
      - +
      + copyright policy
      - - -) + + ) +} export default function Footer ({ links = true }) { const [darkMode, darkModeToggle] = useDarkMode() @@ -153,74 +165,60 @@ export default function Footer ({ links = true }) { return (