Merge pull request #3078 from omnivore-app/feat/ios-header-pins
Header pins on the web
This commit is contained in:
@ -174,14 +174,36 @@ export const Button = styled('button', {
|
||||
},
|
||||
},
|
||||
ctaPill: {
|
||||
borderRadius: '$3',
|
||||
px: '$3',
|
||||
py: '$2',
|
||||
border: '1px solid $grayBorder',
|
||||
bg: '$grayBgActive',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '15px',
|
||||
px: '12px',
|
||||
py: '5px',
|
||||
font: '$inter',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '$thLibraryMenuPrimary',
|
||||
border: '1px solid $thLeftMenuBackground',
|
||||
backgroundColor: '$thLeftMenuBackground',
|
||||
'&:hover': {
|
||||
bg: '$grayBgHover',
|
||||
border: '1px solid $grayBorderHover',
|
||||
bg: '$thBackgroundActive',
|
||||
border: '1px solid $thBackgroundActive',
|
||||
},
|
||||
},
|
||||
ctaPillUnselected: {
|
||||
cursor: 'pointer',
|
||||
borderRadius: '15px',
|
||||
px: '12px',
|
||||
py: '5px',
|
||||
font: '$inter',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'medium',
|
||||
whiteSpace: 'nowrap',
|
||||
border: '1px solid $thBackground4',
|
||||
backgroundColor: '$thBackground4',
|
||||
'&:hover': {
|
||||
bg: '$thBackgroundActive',
|
||||
border: '1px solid $thBackgroundActive',
|
||||
},
|
||||
},
|
||||
link: {
|
||||
@ -193,24 +215,6 @@ export const Button = styled('button', {
|
||||
color: '$thLibraryMenuUnselected',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
circularIcon: {
|
||||
mx: '$1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontWeight: 500,
|
||||
height: 44,
|
||||
width: 44,
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
background: '$grayBase',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
opacity: 0.9,
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
squareIcon: {
|
||||
mx: '$1',
|
||||
display: 'flex',
|
||||
@ -246,7 +250,6 @@ export const Button = styled('button', {
|
||||
'&:hover': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
},
|
||||
articleActionIcon: {
|
||||
bg: 'transparent',
|
||||
|
||||
@ -2,7 +2,7 @@ import { Box, HStack } from '../elements/LayoutPrimitives'
|
||||
import { OmnivoreNameLogo } from '../elements/images/OmnivoreNameLogo'
|
||||
import { UserBasicData } from '../../lib/networking/queries/useGetViewerQuery'
|
||||
import { PrimaryDropdown } from '../templates/PrimaryDropdown'
|
||||
import { HEADER_HEIGHT } from '../templates/homeFeed/HeaderSpacer'
|
||||
import { DEFAULT_HEADER_HEIGHT } from '../templates/homeFeed/HeaderSpacer'
|
||||
import { LogoBox } from '../elements/LogoBox'
|
||||
|
||||
type HeaderProps = {
|
||||
@ -23,7 +23,7 @@ export function SettingsHeader(props: HeaderProps): JSX.Element {
|
||||
position: 'fixed',
|
||||
width: '100%',
|
||||
pr: '25px',
|
||||
height: HEADER_HEIGHT,
|
||||
height: DEFAULT_HEADER_HEIGHT,
|
||||
'@mdDown': {
|
||||
pr: '15px',
|
||||
},
|
||||
|
||||
@ -206,7 +206,7 @@ export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
const StyledToggleButton = styled('button', {
|
||||
export const StyledToggleButton = styled('button', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@ -5,13 +5,11 @@ import { navigationCommands } from '../../lib/keyboardShortcuts/navigationShortc
|
||||
import { useKeyboardShortcuts } from '../../lib/keyboardShortcuts/useKeyboardShortcuts'
|
||||
import { useRouter } from 'next/router'
|
||||
import { applyStoredTheme } from '../../lib/themeUpdater'
|
||||
import { logoutMutation } from '../../lib/networking/mutations/logoutMutation'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ConfirmationModal } from '../patterns/ConfirmationModal'
|
||||
import { KeyboardShortcutListModal } from './KeyboardShortcutListModal'
|
||||
import { PageMetaData } from '../patterns/PageMetaData'
|
||||
import { HEADER_HEIGHT } from './homeFeed/HeaderSpacer'
|
||||
import { deinitAnalytics } from '../../lib/analytics'
|
||||
import { DEFAULT_HEADER_HEIGHT } from './homeFeed/HeaderSpacer'
|
||||
import { logout } from '../../lib/logout'
|
||||
import { SettingsMenu } from './SettingsMenu'
|
||||
|
||||
@ -53,7 +51,7 @@ export function SettingsLayout(props: SettingsLayoutProps): JSX.Element {
|
||||
<VStack css={{ width: '100%', height: '100%' }}>
|
||||
<Box
|
||||
css={{
|
||||
height: HEADER_HEIGHT,
|
||||
height: DEFAULT_HEADER_HEIGHT,
|
||||
}}
|
||||
></Box>
|
||||
<HStack css={{ width: '100%', height: '100%' }} distribution="start">
|
||||
|
||||
@ -86,6 +86,8 @@ export function SettingsMenu(): JSX.Element {
|
||||
{ name: 'Feeds', destination: '/settings/feeds' },
|
||||
{ name: 'Subscriptions', destination: '/settings/subscriptions' },
|
||||
{ name: 'Labels', destination: '/settings/labels' },
|
||||
{ name: 'Saved Searches', destination: '/settings/saved-searches' },
|
||||
{ name: 'Pinned Searches', destination: '/settings/pinned-searches' },
|
||||
]
|
||||
|
||||
const section2 = [
|
||||
@ -249,5 +251,5 @@ function SettingsButton(props: SettingsButtonProps): JSX.Element {
|
||||
{props.name}
|
||||
</SpanBox>
|
||||
</Link>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ import { pspdfKitKey } from '../../../lib/appConfig'
|
||||
import { NotebookModal } from './NotebookModal'
|
||||
import { HighlightNoteModal } from './HighlightNoteModal'
|
||||
import { showErrorToast } from '../../../lib/toastHelpers'
|
||||
import { HEADER_HEIGHT } from '../homeFeed/HeaderSpacer'
|
||||
import { DEFAULT_HEADER_HEIGHT } from '../homeFeed/HeaderSpacer'
|
||||
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
|
||||
import Epub, { EpubCFI } from 'epubjs'
|
||||
import { Rendition, Contents } from 'epubjs/types'
|
||||
@ -294,7 +294,7 @@ export default function EpubContainer(props: EpubContainerProps): JSX.Element {
|
||||
paddingBottom: '0px',
|
||||
},
|
||||
width: '100%',
|
||||
height: `calc(100vh - ${HEADER_HEIGHT})`,
|
||||
height: `calc(100vh - ${DEFAULT_HEADER_HEIGHT})`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
||||
@ -14,7 +14,7 @@ import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeH
|
||||
import { pspdfKitKey } from '../../../lib/appConfig'
|
||||
import { HighlightNoteModal } from './HighlightNoteModal'
|
||||
import { showErrorToast } from '../../../lib/toastHelpers'
|
||||
import { HEADER_HEIGHT } from '../homeFeed/HeaderSpacer'
|
||||
import { DEFAULT_HEADER_HEIGHT } from '../homeFeed/HeaderSpacer'
|
||||
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
|
||||
import SlidingPane from 'react-sliding-pane'
|
||||
import 'react-sliding-pane/dist/react-sliding-pane.css'
|
||||
@ -569,7 +569,7 @@ export default function PdfArticleContainer(
|
||||
id="article-wrapper"
|
||||
css={{
|
||||
width: '100%',
|
||||
height: `calc(100vh - ${HEADER_HEIGHT})`,
|
||||
height: `calc(100vh - ${DEFAULT_HEADER_HEIGHT})`,
|
||||
}}
|
||||
>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
|
||||
@ -2,8 +2,10 @@ import { Book } from 'phosphor-react'
|
||||
import { VStack } from '../../elements/LayoutPrimitives'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { useGetHeaderHeight } from './HeaderSpacer'
|
||||
|
||||
export function EmptyHighlights(): JSX.Element {
|
||||
const headerHeight = useGetHeaderHeight()
|
||||
return (
|
||||
<VStack
|
||||
alignment="center"
|
||||
@ -11,7 +13,7 @@ export function EmptyHighlights(): JSX.Element {
|
||||
css={{
|
||||
color: '$grayTextContrast',
|
||||
textAlign: 'center',
|
||||
marginTop: '70px',
|
||||
marginTop: headerHeight,
|
||||
}}
|
||||
>
|
||||
<Book size={44} color={theme.colors.grayTextContrast.toString()} />
|
||||
|
||||
@ -1,12 +1,33 @@
|
||||
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
|
||||
import { PinnedSearch } from '../../../pages/settings/pinned-searches'
|
||||
import { Box } from '../../elements/LayoutPrimitives'
|
||||
|
||||
export const HEADER_HEIGHT = '70px'
|
||||
export const DEFAULT_HEADER_HEIGHT = '70px'
|
||||
|
||||
export const useGetHeaderHeight = () => {
|
||||
const [hidePinnedSearches] = usePersistedState({
|
||||
key: '--library-hide-pinned-searches',
|
||||
initialValue: false,
|
||||
isSessionStorage: false,
|
||||
})
|
||||
const [pinnedSearches] = usePersistedState<PinnedSearch[] | null>({
|
||||
key: `--library-pinned-searches`,
|
||||
initialValue: [],
|
||||
isSessionStorage: false,
|
||||
})
|
||||
|
||||
if (hidePinnedSearches || !pinnedSearches?.length) {
|
||||
return '70px'
|
||||
}
|
||||
return '100px'
|
||||
}
|
||||
|
||||
export function HeaderSpacer(): JSX.Element {
|
||||
const headerHeight = useGetHeaderHeight()
|
||||
return (
|
||||
<Box
|
||||
css={{
|
||||
height: HEADER_HEIGHT,
|
||||
height: headerHeight,
|
||||
bg: '$grayBase',
|
||||
}}
|
||||
></Box>
|
||||
|
||||
@ -13,7 +13,6 @@ import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
|
||||
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { MenuTrigger } from '../../elements/MenuTrigger'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import {
|
||||
MetaStyle,
|
||||
timeAgo,
|
||||
@ -21,7 +20,7 @@ import {
|
||||
import { LibraryHighlightGridCard } from '../../patterns/LibraryCards/LibraryHighlightGridCard'
|
||||
import { NotebookContent } from '../article/Notebook'
|
||||
import { EmptyHighlights } from './EmptyHighlights'
|
||||
import { HEADER_HEIGHT } from './HeaderSpacer'
|
||||
import { useGetHeaderHeight } from './HeaderSpacer'
|
||||
import { highlightsAsMarkdown } from './HighlightItem'
|
||||
|
||||
type HighlightItemsLayoutProps = {
|
||||
@ -34,8 +33,10 @@ type HighlightItemsLayoutProps = {
|
||||
export function HighlightItemsLayout(
|
||||
props: HighlightItemsLayoutProps
|
||||
): JSX.Element {
|
||||
const [currentItem, setCurrentItem] =
|
||||
useState<LibraryItem | undefined>(undefined)
|
||||
const headerHeight = useGetHeaderHeight()
|
||||
const [currentItem, setCurrentItem] = useState<LibraryItem | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const listReducer = (
|
||||
state: LibraryItem[],
|
||||
@ -105,7 +106,7 @@ export function HighlightItemsLayout(
|
||||
<Box
|
||||
css={{
|
||||
width: '100%',
|
||||
height: `calc(100vh - ${HEADER_HEIGHT})`,
|
||||
height: `calc(100vh - ${headerHeight})`,
|
||||
}}
|
||||
>
|
||||
<EmptyHighlights />
|
||||
@ -118,7 +119,7 @@ export function HighlightItemsLayout(
|
||||
<HStack
|
||||
css={{
|
||||
width: '100%',
|
||||
height: `calc(100vh - ${HEADER_HEIGHT})`,
|
||||
height: `calc(100vh - ${headerHeight})`,
|
||||
'@lgDown': {
|
||||
overflowY: 'scroll',
|
||||
},
|
||||
@ -146,7 +147,7 @@ export function HighlightItemsLayout(
|
||||
>
|
||||
<VStack
|
||||
css={{
|
||||
minHeight: `calc(100vh - ${HEADER_HEIGHT})`,
|
||||
minHeight: `calc(100vh - ${headerHeight})`,
|
||||
bg: '$thBackground',
|
||||
}}
|
||||
distribution="start"
|
||||
|
||||
@ -51,6 +51,8 @@ import { NotebookPresenter } from '../article/NotebookPresenter'
|
||||
import { saveUrlMutation } from '../../../lib/networking/mutations/saveUrlMutation'
|
||||
import { articleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { searchQuery } from '../../../lib/networking/queries/search'
|
||||
import { MoreOptionsIcon } from '../../elements/images/MoreOptionsIcon'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
|
||||
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
|
||||
export type LibraryMode = 'reads' | 'highlights'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { FormInput } from '../../elements/FormElements'
|
||||
@ -16,7 +16,7 @@ import {
|
||||
import { LayoutType } from './HomeFeedContainer'
|
||||
import { PrimaryDropdown } from '../PrimaryDropdown'
|
||||
import { OmnivoreSmallLogo } from '../../elements/images/OmnivoreNameLogo'
|
||||
import { HeaderSpacer, HEADER_HEIGHT } from './HeaderSpacer'
|
||||
import { HeaderSpacer, useGetHeaderHeight } from './HeaderSpacer'
|
||||
import { LIBRARY_LEFT_MENU_WIDTH } from '../../templates/homeFeed/LibraryFilterMenu'
|
||||
import { CardCheckbox } from '../../patterns/LibraryCards/LibraryCardStyles'
|
||||
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
@ -30,6 +30,9 @@ import { LabelIcon } from '../../elements/icons/LabelIcon'
|
||||
import { ListViewIcon } from '../../elements/icons/ListViewIcon'
|
||||
import { GridViewIcon } from '../../elements/icons/GridViewIcon'
|
||||
import { CaretDownIcon } from '../../elements/icons/CaretDownIcon'
|
||||
import { PinnedButtons } from './PinnedButtons'
|
||||
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
|
||||
import { PinnedSearch } from '../../../pages/settings/pinned-searches'
|
||||
|
||||
export type MultiSelectMode = 'off' | 'none' | 'some' | 'visible' | 'search'
|
||||
|
||||
@ -61,7 +64,30 @@ type LibraryHeaderProps = {
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
const controlWidths = (
|
||||
layout: LayoutType,
|
||||
multiSelectMode: MultiSelectMode
|
||||
) => {
|
||||
return {
|
||||
width: '95%',
|
||||
'@mdDown': {
|
||||
width: multiSelectMode !== 'off' ? '100%' : '95%',
|
||||
display: multiSelectMode !== 'off' ? 'flex' : 'none',
|
||||
},
|
||||
'@media (min-width: 930px)': {
|
||||
width: layout == 'GRID_LAYOUT' ? '660px' : '640px',
|
||||
},
|
||||
'@media (min-width: 1280px)': {
|
||||
width: '1000px',
|
||||
},
|
||||
'@media (min-width: 1600px)': {
|
||||
width: '1340px',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function LibraryHeader(props: LibraryHeaderProps): JSX.Element {
|
||||
const headerHeight = useGetHeaderHeight()
|
||||
return (
|
||||
<>
|
||||
<VStack
|
||||
@ -73,7 +99,7 @@ export function LibraryHeader(props: LibraryHeaderProps): JSX.Element {
|
||||
left: LIBRARY_LEFT_MENU_WIDTH,
|
||||
zIndex: 5,
|
||||
position: 'fixed',
|
||||
height: HEADER_HEIGHT,
|
||||
height: headerHeight,
|
||||
bg: '$thLibraryBackground',
|
||||
'@mdDown': {
|
||||
left: '0px',
|
||||
@ -94,6 +120,14 @@ export function LibraryHeader(props: LibraryHeaderProps): JSX.Element {
|
||||
}
|
||||
|
||||
function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element {
|
||||
const [pinnedSearches, setPinnedSearches] = usePersistedState<
|
||||
PinnedSearch[] | null
|
||||
>({
|
||||
key: `--library-pinned-searches`,
|
||||
initialValue: [],
|
||||
isSessionStorage: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<HStack
|
||||
alignment="center"
|
||||
@ -106,19 +140,40 @@ function LargeHeaderLayout(props: LibraryHeaderProps): JSX.Element {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ControlButtonBox
|
||||
layout={props.layout}
|
||||
updateLayout={props.updateLayout}
|
||||
numItemsSelected={props.numItemsSelected}
|
||||
multiSelectMode={props.multiSelectMode}
|
||||
setMultiSelectMode={props.setMultiSelectMode}
|
||||
showAddLinkModal={props.showAddLinkModal}
|
||||
performMultiSelectAction={props.performMultiSelectAction}
|
||||
searchTerm={props.searchTerm}
|
||||
applySearchQuery={props.applySearchQuery}
|
||||
allowSelectMultiple={props.allowSelectMultiple}
|
||||
handleLinkSubmission={props.handleLinkSubmission}
|
||||
/>
|
||||
<VStack alignment="center" distribution="start">
|
||||
<ControlButtonBox
|
||||
layout={props.layout}
|
||||
updateLayout={props.updateLayout}
|
||||
numItemsSelected={props.numItemsSelected}
|
||||
multiSelectMode={props.multiSelectMode}
|
||||
setMultiSelectMode={props.setMultiSelectMode}
|
||||
showAddLinkModal={props.showAddLinkModal}
|
||||
performMultiSelectAction={props.performMultiSelectAction}
|
||||
searchTerm={props.searchTerm}
|
||||
applySearchQuery={props.applySearchQuery}
|
||||
allowSelectMultiple={props.allowSelectMultiple}
|
||||
handleLinkSubmission={props.handleLinkSubmission}
|
||||
/>
|
||||
<SpanBox
|
||||
css={{
|
||||
...controlWidths(props.layout, props.multiSelectMode),
|
||||
width: '100%',
|
||||
maxWidth: '587px',
|
||||
alignSelf: 'flex-start',
|
||||
'-ms-overflow-style': 'none',
|
||||
scrollbarWidth: 'none',
|
||||
'::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PinnedButtons
|
||||
items={pinnedSearches ?? []}
|
||||
searchTerm={props.searchTerm}
|
||||
applySearchQuery={props.applySearchQuery}
|
||||
/>
|
||||
</SpanBox>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@ -616,20 +671,7 @@ function ControlButtonBox(props: ControlButtonBoxProps): JSX.Element {
|
||||
distribution={props.multiSelectMode !== 'off' ? 'center' : 'start'}
|
||||
css={{
|
||||
gap: '10px',
|
||||
width: '95%',
|
||||
'@mdDown': {
|
||||
width: props.multiSelectMode !== 'off' ? '100%' : '95%',
|
||||
display: props.multiSelectMode !== 'off' ? 'flex' : 'none',
|
||||
},
|
||||
'@media (min-width: 930px)': {
|
||||
width: props.layout == 'GRID_LAYOUT' ? '660px' : '640px',
|
||||
},
|
||||
'@media (min-width: 1280px)': {
|
||||
width: '1000px',
|
||||
},
|
||||
'@media (min-width: 1600px)': {
|
||||
width: '1340px',
|
||||
},
|
||||
...controlWidths(props.layout, props.multiSelectMode),
|
||||
}}
|
||||
>
|
||||
<MuliSelectControl {...props} />
|
||||
|
||||
102
packages/web/components/templates/homeFeed/PinnedButtons.tsx
Normal file
102
packages/web/components/templates/homeFeed/PinnedButtons.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { HStack, SpanBox } from '../../elements/LayoutPrimitives'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { Button } from '../../elements/Button'
|
||||
|
||||
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
import { MoreOptionsIcon } from '../../elements/images/MoreOptionsIcon'
|
||||
import { PinnedSearch } from '../../../pages/settings/pinned-searches'
|
||||
import { useRouter } from 'next/router'
|
||||
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
|
||||
|
||||
type PinnedButtonsProps = {
|
||||
items: PinnedSearch[]
|
||||
searchTerm: string | undefined
|
||||
applySearchQuery: (searchQuery: string) => void
|
||||
}
|
||||
|
||||
export const PinnedButtons = (props: PinnedButtonsProps): JSX.Element => {
|
||||
const router = useRouter()
|
||||
const [hidePinnedSearches, setHidePinnedSearches] = usePersistedState({
|
||||
key: '--library-hide-pinned-searches',
|
||||
initialValue: false,
|
||||
isSessionStorage: false,
|
||||
})
|
||||
|
||||
if (hidePinnedSearches || !props.items.length) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
pt: '10px',
|
||||
pb: '0px',
|
||||
gap: '10px',
|
||||
bg: 'transparent',
|
||||
overflowX: 'scroll',
|
||||
}}
|
||||
>
|
||||
{props.items.map((item) => {
|
||||
const style =
|
||||
item.search == props.searchTerm ? 'ctaPill' : 'ctaPillUnselected'
|
||||
return (
|
||||
<Button
|
||||
key={item.search}
|
||||
style={style}
|
||||
onClick={(event) => {
|
||||
props.applySearchQuery(item.search)
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<Dropdown
|
||||
triggerElement={
|
||||
<SpanBox
|
||||
css={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
border: '1px solid $thBackground4',
|
||||
backgroundColor: '$thBackground4',
|
||||
'&:hover': {
|
||||
bg: '$grayBgHover',
|
||||
border: '1px solid $grayBgHover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MoreOptionsIcon
|
||||
size={16}
|
||||
strokeColor={theme.colors.grayText.toString()}
|
||||
orientation={'horizontal'}
|
||||
/>
|
||||
</SpanBox>
|
||||
}
|
||||
css={{}}
|
||||
>
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
router.push('/settings/pinned-searches')
|
||||
}}
|
||||
title="Edit"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
setHidePinnedSearches(true)
|
||||
}}
|
||||
title="Hide"
|
||||
/>
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,9 @@
|
||||
import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { PrimaryDropdown } from '../PrimaryDropdown'
|
||||
import { TooltipWrapped } from '../../elements/Tooltip'
|
||||
import { LogoBox } from '../../elements/LogoBox'
|
||||
import { ReactNode } from 'react'
|
||||
import { HEADER_HEIGHT } from '../homeFeed/HeaderSpacer'
|
||||
import { useGetHeaderHeight } from '../homeFeed/HeaderSpacer'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { ReaderSettingsIcon } from '../../elements/icons/ReaderSettingsIcon'
|
||||
import { CircleUtilityMenuIcon } from '../../elements/icons/CircleUtilityMenuIcon'
|
||||
@ -17,6 +16,7 @@ type ReaderHeaderProps = {
|
||||
}
|
||||
|
||||
export function ReaderHeader(props: ReaderHeaderProps): JSX.Element {
|
||||
const headerHeight = useGetHeaderHeight()
|
||||
return (
|
||||
<>
|
||||
<VStack
|
||||
@ -29,7 +29,7 @@ export function ReaderHeader(props: ReaderHeaderProps): JSX.Element {
|
||||
pt: '0px',
|
||||
position: 'fixed',
|
||||
width: '100%',
|
||||
height: HEADER_HEIGHT,
|
||||
height: headerHeight,
|
||||
display: props.alwaysDisplayToolbar ? 'flex' : 'transparent',
|
||||
pointerEvents: props.alwaysDisplayToolbar ? 'unset' : 'none',
|
||||
borderBottom: '1px solid transparent',
|
||||
|
||||
375
packages/web/pages/settings/pinned-searches.tsx
Normal file
375
packages/web/pages/settings/pinned-searches.tsx
Normal file
@ -0,0 +1,375 @@
|
||||
import React, {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
} from 'react'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
SpanBox,
|
||||
VStack,
|
||||
} from '../../components/elements/LayoutPrimitives'
|
||||
import { StyledText } from '../../components/elements/StyledText'
|
||||
import { SettingsLayout } from '../../components/templates/SettingsLayout'
|
||||
import { applyStoredTheme } from '../../lib/themeUpdater'
|
||||
import { useGetLabelsQuery } from '../../lib/networking/queries/useGetLabelsQuery'
|
||||
import { useGetSavedSearchQuery } from '../../lib/networking/queries/useGetSavedSearchQuery'
|
||||
import { Label } from '../../lib/networking/fragments/labelFragment'
|
||||
import { Circle } from 'phosphor-react'
|
||||
import { SavedSearch } from '../../lib/networking/fragments/savedSearchFragment'
|
||||
import { usePersistedState } from '../../lib/hooks/usePersistedState'
|
||||
|
||||
export type PinnedSearch = {
|
||||
type: 'saved-search' | 'label'
|
||||
itemId: string
|
||||
name: string
|
||||
search: string
|
||||
}
|
||||
|
||||
const PINNED_SEARCHES_KEY = `--library-pinned-searches`
|
||||
type ListAction = 'RESET' | 'ADD_ITEM' | 'REMOVE_ITEM'
|
||||
|
||||
export default function PinnedSearches(): JSX.Element {
|
||||
const { labels } = useGetLabelsQuery()
|
||||
const { savedSearches } = useGetSavedSearchQuery()
|
||||
const [hidePinnedSearches, setHidePinnedSearches] = usePersistedState({
|
||||
key: '--library-hide-pinned-searches',
|
||||
initialValue: false,
|
||||
isSessionStorage: false,
|
||||
})
|
||||
|
||||
const listReducer = (
|
||||
state: { state: string; items: PinnedSearch[] },
|
||||
action: {
|
||||
type: ListAction
|
||||
item?: PinnedSearch
|
||||
}
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case 'RESET': {
|
||||
const itemStr = window['localStorage'].getItem(PINNED_SEARCHES_KEY)
|
||||
if (itemStr) {
|
||||
try {
|
||||
const parsed = JSON.parse(itemStr)
|
||||
if (Array.isArray(parsed)) {
|
||||
return { state: 'CURRENT', items: parsed as PinnedSearch[] }
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('error: ', err)
|
||||
}
|
||||
}
|
||||
return { state: 'CURRENT', items: [] }
|
||||
}
|
||||
case 'ADD_ITEM': {
|
||||
const item = action.item
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
const existing = state.items.find(
|
||||
(existing) =>
|
||||
existing.type == item.type && existing.itemId == item.itemId
|
||||
)
|
||||
if (existing) {
|
||||
return state
|
||||
}
|
||||
state.items.push(item)
|
||||
return { state: 'CURRENT', items: [...state.items] }
|
||||
}
|
||||
case 'REMOVE_ITEM': {
|
||||
const item = action.item
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
const updated = state.items.filter(
|
||||
(existing) => existing.itemId != item.itemId
|
||||
)
|
||||
return { state: 'CURRENT', items: [...updated] }
|
||||
}
|
||||
default:
|
||||
throw new Error('unknown action')
|
||||
}
|
||||
}
|
||||
|
||||
const [pinnedSearches, dispatchList] = useReducer(listReducer, {
|
||||
state: 'INITIAL',
|
||||
items: [],
|
||||
})
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (pinnedSearches.state == 'INITIAL') {
|
||||
return { labelItems: [], savedSearchItems: [] }
|
||||
}
|
||||
const labelItems = labels.map((label) => {
|
||||
return {
|
||||
label,
|
||||
isSelected: !!pinnedSearches.items.find(
|
||||
(ps) => ps.type == 'label' && ps.itemId == label.id
|
||||
),
|
||||
}
|
||||
})
|
||||
const savedSearchItems = (savedSearches ?? []).map((savedSearch) => {
|
||||
return {
|
||||
savedSearch,
|
||||
isSelected: !!pinnedSearches.items.find(
|
||||
(ps) => ps.type == 'saved-search' && ps.itemId == savedSearch.id
|
||||
),
|
||||
}
|
||||
})
|
||||
return { labelItems, savedSearchItems }
|
||||
}, [pinnedSearches, labels, savedSearches])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Only write updated state to local storage
|
||||
if (pinnedSearches.state == 'CURRENT') {
|
||||
window['localStorage'].setItem(
|
||||
PINNED_SEARCHES_KEY,
|
||||
JSON.stringify(pinnedSearches.items)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('error": ', error)
|
||||
}
|
||||
}, [pinnedSearches])
|
||||
|
||||
useEffect(() => {
|
||||
dispatchList({ type: 'RESET' })
|
||||
}, [])
|
||||
|
||||
applyStoredTheme(false)
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Toaster
|
||||
containerStyle={{
|
||||
top: '5rem',
|
||||
}}
|
||||
/>
|
||||
<VStack
|
||||
css={{ width: '100%', height: '100%' }}
|
||||
distribution="start"
|
||||
alignment="center"
|
||||
>
|
||||
<VStack
|
||||
css={{
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
gap: '25px',
|
||||
minWidth: '300px',
|
||||
maxWidth: '865px',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<StyledText style="fixedHeadline" css={{ my: '6px' }}>
|
||||
Pinned Searches
|
||||
</StyledText>
|
||||
<StyledText style="caption" css={{}}>
|
||||
Pin up to five searches from your labels or saved searches.
|
||||
</StyledText>
|
||||
</Box>
|
||||
|
||||
<VStack
|
||||
css={{
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
bg: '$grayBg',
|
||||
gap: '5px',
|
||||
borderRadius: '5px',
|
||||
}}
|
||||
distribution="start"
|
||||
alignment="start"
|
||||
>
|
||||
<HStack alignment="center" css={{ gap: '5px', mb: '10px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="switch"
|
||||
checked={!hidePinnedSearches}
|
||||
onChange={(event) => {
|
||||
setHidePinnedSearches(!event.currentTarget.checked)
|
||||
}}
|
||||
style={{ padding: '0px', margin: '0px' }}
|
||||
/>
|
||||
Enable Pinned Searches
|
||||
</HStack>
|
||||
|
||||
{!hidePinnedSearches && (
|
||||
<>
|
||||
<StyledText style="modalTitle" css={{}}>
|
||||
Saved Searches
|
||||
</StyledText>
|
||||
{items.savedSearchItems.map((item) => {
|
||||
return (
|
||||
<SearchButton
|
||||
key={`search-${item.savedSearch.id}`}
|
||||
search={item.savedSearch}
|
||||
isSelected={item.isSelected}
|
||||
listAction={dispatchList}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<StyledText style="modalTitle" css={{ mt: '20px' }}>
|
||||
Labels
|
||||
</StyledText>
|
||||
{items.labelItems.map((item) => {
|
||||
return (
|
||||
<LabelButton
|
||||
label={item.label}
|
||||
key={`label-${item.label.id}`}
|
||||
listAction={dispatchList}
|
||||
isSelected={item.isSelected}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
type LabelButtonProps = {
|
||||
label: Label
|
||||
isSelected: boolean
|
||||
listAction: (arg: {
|
||||
type: ListAction
|
||||
item?: PinnedSearch | undefined
|
||||
}) => void
|
||||
}
|
||||
|
||||
function LabelButton(props: LabelButtonProps): JSX.Element {
|
||||
const labelId = `checkbox-label-${props.label.id}`
|
||||
return (
|
||||
<CheckboxButton
|
||||
key={labelId}
|
||||
title={props.label.name}
|
||||
isSelected={props.isSelected}
|
||||
item={{
|
||||
type: 'label',
|
||||
itemId: props.label.id,
|
||||
name: props.label.name,
|
||||
search: `label:\"${props.label.name}\"`,
|
||||
}}
|
||||
listAction={props.listAction}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.isSelected}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onChange={(event) => {}}
|
||||
/>
|
||||
<SpanBox css={{}}>{props.label.name}</SpanBox>
|
||||
<Circle size={9} color={props.label.color} weight="fill" />
|
||||
</CheckboxButton>
|
||||
)
|
||||
}
|
||||
|
||||
type SearchButtonProps = {
|
||||
search: SavedSearch
|
||||
isSelected: boolean
|
||||
listAction: (arg: {
|
||||
type: ListAction
|
||||
item?: PinnedSearch | undefined
|
||||
}) => void
|
||||
}
|
||||
|
||||
function SearchButton(props: SearchButtonProps): JSX.Element {
|
||||
const searchId = `checkbox-search-${props.search.id}`
|
||||
return (
|
||||
<CheckboxButton
|
||||
key={searchId}
|
||||
title={props.search.filter}
|
||||
isSelected={props.isSelected}
|
||||
item={{
|
||||
type: 'saved-search',
|
||||
itemId: props.search.id,
|
||||
name: props.search.name,
|
||||
search: props.search.filter,
|
||||
}}
|
||||
listAction={props.listAction}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.isSelected}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onChange={(e) => {}}
|
||||
/>
|
||||
<SpanBox css={{}}>{props.search.name}</SpanBox>
|
||||
</CheckboxButton>
|
||||
)
|
||||
}
|
||||
|
||||
type CheckboxButtonProps = {
|
||||
key: string
|
||||
title: string
|
||||
isSelected: boolean
|
||||
item: PinnedSearch
|
||||
|
||||
listAction: (arg: {
|
||||
type: ListAction
|
||||
item?: PinnedSearch | undefined
|
||||
}) => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function CheckboxButton(props: CheckboxButtonProps): JSX.Element {
|
||||
const handleChange = useCallback(
|
||||
(selected: boolean) => {
|
||||
if (!selected) {
|
||||
props.listAction({
|
||||
type: 'REMOVE_ITEM',
|
||||
item: props.item,
|
||||
})
|
||||
} else {
|
||||
props.listAction({
|
||||
type: 'ADD_ITEM',
|
||||
item: props.item,
|
||||
})
|
||||
}
|
||||
},
|
||||
[props]
|
||||
)
|
||||
return (
|
||||
<HStack
|
||||
key={props.key}
|
||||
title={props.title}
|
||||
css={{
|
||||
px: '10px',
|
||||
pt: '2px',
|
||||
height: '30px',
|
||||
gap: '5px',
|
||||
|
||||
fontSize: '14px',
|
||||
fontWeight: 'regular',
|
||||
fontFamily: '$display',
|
||||
color: props.isSelected
|
||||
? '$thLibraryMenuSecondary'
|
||||
: '$thLibraryMenuUnselected',
|
||||
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
|
||||
m: '0px',
|
||||
'&:hover': {
|
||||
backgroundColor: '$thBackground4',
|
||||
},
|
||||
}}
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
onClick={(event) => {
|
||||
handleChange(!props.isSelected)
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user