diff --git a/packages/web/components/elements/Button.tsx b/packages/web/components/elements/Button.tsx index 69eb8ac72..e0a999ce3 100644 --- a/packages/web/components/elements/Button.tsx +++ b/packages/web/components/elements/Button.tsx @@ -428,6 +428,16 @@ export const Button = styled('button', { opacity: 0.8, }, }, + homeAction: { + display: 'flex', + color: 'transparent', + border: 'none', + bg: 'transparent', + cursor: 'pointer', + p: '5px', + borderRadius: '5px', + '&:hover': { bg: '$readerHoverBg', opacity: '1' }, + }, themeSwitch: { p: '0px', m: '0px', diff --git a/packages/web/components/elements/Pagination.tsx b/packages/web/components/elements/Pagination.tsx index 595f130ec..2d36cdb59 100644 --- a/packages/web/components/elements/Pagination.tsx +++ b/packages/web/components/elements/Pagination.tsx @@ -1,45 +1,60 @@ -import React, { useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { Button } from './Button' import { HStack, VStack } from './LayoutPrimitives' type PaginationProps = { items: T[] itemsPerPage: number + loadMoreButtonText?: string render: (item: T) => React.ReactNode } const Pagination = ({ items, itemsPerPage, + loadMoreButtonText, render, }: PaginationProps) => { const [currentPage, setCurrentPage] = useState(1) const maxPage = Math.ceil(items.length / itemsPerPage) - function createChangePageHandler(page: number) { - return function handlePageChange() { - setCurrentPage(page) - } - } + const incrementCurrentPage = useCallback(() => { + setCurrentPage(currentPage + 1) + }, [currentPage, setCurrentPage]) - const itemsToShow = items.slice( - (currentPage - 1) * itemsPerPage, - currentPage * itemsPerPage - ) + const itemsToShow = useMemo(() => { + const count = currentPage * itemsPerPage + return items.slice(0, count) + }, [items, currentPage, itemsPerPage]) return ( - + {itemsToShow.map(render)} - - {Array.from({ length: maxPage }, (_, i) => i + 1).map((pageNum) => ( + + {currentPage < maxPage && ( - ))} + )} ) diff --git a/packages/web/components/elements/icons/home/AddToLibraryActionIcon.tsx b/packages/web/components/elements/icons/home/AddToLibraryActionIcon.tsx index e42a1bb8a..b2db19fe2 100644 --- a/packages/web/components/elements/icons/home/AddToLibraryActionIcon.tsx +++ b/packages/web/components/elements/icons/home/AddToLibraryActionIcon.tsx @@ -11,16 +11,15 @@ export class AddToLibraryActionIcon extends React.Component { return ( - { render() { const strokeColor = (this.props.color || '#D9D9D9').toString() - const backgroundColor = (this.props.color || '#3D3D3D').toString() return ( - { return ( - { render() { const strokeColor = (this.props.color || '#D9D9D9').toString() - const backgroundColor = (this.props.color || '#3D3D3D').toString() return ( - { render() { const strokeColor = (this.props.color || '#D9D9D9').toString() - const backgroundColor = (this.props.color || '#3D3D3D').toString() return ( - void @@ -192,7 +194,8 @@ export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element { - {props.showThemeSection && } + {props.showThemeSection && } + {props.showFullThemeSection && } headerDropdownActionHandler('navigate-to-install')} title="Install" @@ -261,7 +264,21 @@ export const StyledToggleButton = styled('button', { }, }) -function ThemeSection(props: PrimaryDropdownProps): JSX.Element { +const ThemeSection = (props: PrimaryDropdownProps): JSX.Element => { + const { currentTheme, setCurrentTheme, currentThemeIsDark } = + useCurrentTheme() + + return ( + <> + + + + + + ) +} + +function LegacyMenuThemeSection(props: PrimaryDropdownProps): JSX.Element { const { currentTheme, setCurrentTheme, currentThemeIsDark } = useCurrentTheme() diff --git a/packages/web/components/templates/article/ReaderSettingsControl.tsx b/packages/web/components/templates/article/ReaderSettingsControl.tsx index fd220d1d7..279605c87 100644 --- a/packages/web/components/templates/article/ReaderSettingsControl.tsx +++ b/packages/web/components/templates/article/ReaderSettingsControl.tsx @@ -278,7 +278,9 @@ function BasicSettings(props: SettingsProps): JSX.Element { - + + + @@ -597,14 +599,14 @@ function LayoutControls(props: LayoutControlsProps): JSX.Element { ) } -function ThemeSelector(): JSX.Element { +export function ThemeSelector(): JSX.Element { useDarkModeListener() const { currentTheme, setCurrentTheme, resetSystemTheme } = useCurrentTheme() return ( void } @@ -28,7 +29,10 @@ export const NavMenuFooter = (props: NavMenuFooterProps): JSX.Element => { }, }} > - + void + + searchTerm: string | undefined + applySearchQuery: (searchTerm: string) => void + + showFilterMenu: boolean + setShowFilterMenu: (show: boolean) => void +} + +export function NavigationMenu(props: LibraryFilterMenuProps): JSX.Element { + const [labels, setLabels] = usePersistedState({ + key: 'menu-labels', + isSessionStorage: false, + initialValue: [], + }) + const [savedSearches, setSavedSearches] = usePersistedState({ + key: 'menu-searches', + isSessionStorage: false, + initialValue: [], + }) + const [subscriptions, setSubscriptions] = usePersistedState({ + key: 'menu-subscriptions', + isSessionStorage: false, + initialValue: [], + }) + const labelsResponse = useGetLabelsQuery() + const searchesResponse = useGetSavedSearchQuery() + const subscriptionsResponse = useGetSubscriptionsQuery() + + useEffect(() => { + if ( + !labelsResponse.error && + !labelsResponse.isLoading && + labelsResponse.labels + ) { + setLabels(labelsResponse.labels) + } + }, [setLabels, labelsResponse]) + + useEffect(() => { + if ( + !subscriptionsResponse.error && + !subscriptionsResponse.isLoading && + subscriptionsResponse.subscriptions + ) { + setSubscriptions(subscriptionsResponse.subscriptions) + } + }, [setSubscriptions, subscriptionsResponse]) + + useEffect(() => { + if ( + !searchesResponse.error && + !searchesResponse.isLoading && + searchesResponse.savedSearches + ) { + setSavedSearches(searchesResponse.savedSearches) + } + }, [setSavedSearches, searchesResponse]) + + return ( + <> + + + + + + + + + + + + + + {/* This spacer pushes library content to the right of + the fixed left side menu. */} + + + ) +} + +const LibraryNav = (props: LibraryFilterMenuProps): JSX.Element => { + return ( + + } + /> + } + /> + } + /> + } + /> + } + /> + + ) +} + +const Shortcuts = (props: LibraryFilterMenuProps): JSX.Element => { + const router = useRouter() + const [shortcuts] = usePersistedState({ + key: 'library-shortcuts', + isSessionStorage: false, + initialValue: [], + }) + + // const shortcuts: Shortcut[] = [ + // { + // id: '12asdfasdf', + // name: 'Omnivore Blog', + // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', + // filter: 'subscription:"Money Talk"', + // type: 'feed', + // }, + // { + // id: 'sdfsdfgdsfg', + // name: 'Follow the Money | Arne & Harr', + // filter: 'subscription:"Money Talk"', + // type: 'feed', + // }, + // { + // id: 'sdfasdfasdfsdfsdfsgasdfg', + // name: 'Andrew Kenneson from Center for the Study of Partisanship and Ideology', + // // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', + // filter: 'in:all label:"Hockey"', + // type: 'newsletter', + // }, + // { + // id: 'sdfasdfasdfsdfsdfsgasdfg', + // name: 'Robert的博客', + // // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', + // filter: 'in:all label:"Hockey"', + // type: 'feed', + // }, + // { + // id: 'sdfasdfasdfasdfasf', + // name: 'Oldest First', + // // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', + // filter: 'in:all label:"Hockey"', + // type: 'search', + // }, + // { + // id: 'sdfasdfasdfgasdfg', + // name: 'Hockey', + // // icon: 'https://substackcdn.com/image/fetch/w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F052c15c4-ecfd-4d32-87db-13bcac9afad5_512x512.png', + // filter: 'in:all label:"Hockey"', + // type: 'label', + // label: { + // id: 'sdfsdfsdf', + // name: 'Hockey', + // color: '#E98B8B', + // createdAt: new Date(), + // }, + // }, + // ] + // + + return ( + + + + SHORTCUTS + + + } + css={{ ml: 'auto' }} + > + { + router.push(`/settings/shortcuts`) + }} + title="Edit shortcuts" + /> + + + + {shortcuts.map((shortcut) => { + const selected = props.searchTerm === shortcut.filter + return ( + { + props.applySearchQuery(shortcut.filter) + props.setShowFilterMenu(false) + e.preventDefault() + }} + > + {(shortcut.type == 'feed' || shortcut.type == 'newsletter') && ( + + )} + {shortcut.type == 'search' && ( + + )} + {shortcut.type == 'label' && } + + ) + })} + + ) +} + +type ShortcutItemProps = { + shortcut: Shortcut +} + +const FeedOrNewsletterShortcut = (props: ShortcutItemProps): JSX.Element => { + return ( + + + {props.shortcut.icon ? ( + + ) : props.shortcut.type == 'newsletter' ? ( + + ) : ( + + )} + + {props.shortcut.name} + + ) +} + +const SearchShortcut = (props: ShortcutItemProps): JSX.Element => { + return ( + + + + + {props.shortcut.name} + + ) +} + +const LabelShortcut = (props: ShortcutItemProps): JSX.Element => { + return ( + + + + ) +} + +function SavedSearches( + props: LibraryFilterMenuProps & { savedSearches: SavedSearch[] | undefined } +): JSX.Element { + const sortedSearches = useMemo(() => { + return props.savedSearches + ?.filter((it) => it.visible) + ?.sort( + (left: SavedSearch, right: SavedSearch) => + left.position - right.position + ) + }, [props.savedSearches]) + + useRegisterActions( + (sortedSearches ?? []).map((item, idx) => { + const key = String(idx + 1) + return { + id: `saved_search_${key}`, + name: item.name, + shortcut: [key], + section: 'Saved Searches', + keywords: '?' + item.name, + perform: () => { + props.applySearchQuery(item.filter) + }, + } + }), + [props.savedSearches] + ) + + const [collapsed, setCollapsed] = usePersistedState({ + key: `--saved-searches-collapsed`, + initialValue: false, + }) + + return ( + + {!collapsed && + sortedSearches && + sortedSearches?.map((item) => ( + + ))} + {!collapsed && sortedSearches !== undefined && ( + + )} + + + + ) +} + +function Subscriptions( + props: LibraryFilterMenuProps & { subscriptions: Subscription[] | undefined } +): JSX.Element { + const [collapsed, setCollapsed] = usePersistedState({ + key: `--subscriptions-collapsed`, + initialValue: false, + }) + + const sortedSubscriptions = useMemo(() => { + if (!props.subscriptions) { + return [] + } + return props.subscriptions + .filter((s) => s.status == 'ACTIVE') + .sort((a, b) => a.name.localeCompare(b.name)) + }, [props.subscriptions]) + + useRegisterActions( + (sortedSubscriptions ?? []).map((subscription, idx) => { + const key = String(idx + 1) + const name = subscription.name + return { + id: `subscription_${key}`, + section: 'Subscriptions', + name: name, + keywords: '*' + name, + perform: () => { + props.applySearchQuery(`subscription:\"${escapeQuotes(name)}\"`) + }, + } + }), + [sortedSubscriptions] + ) + + return ( + + {!collapsed ? ( + <> + + + + {(sortedSubscriptions ?? []).map((item) => { + switch (item.type) { + case SubscriptionType.NEWSLETTER: + return ( + + ) + case SubscriptionType.RSS: + return ( + + ) + } + })} + + + ) : ( + + )} + + ) +} + +function Labels( + props: LibraryFilterMenuProps & { labels: Label[] } +): JSX.Element { + const [collapsed, setCollapsed] = usePersistedState({ + key: `--labels-collapsed`, + initialValue: false, + }) + + const sortedLabels = useMemo(() => { + return props.labels.sort((left: Label, right: Label) => + left.name.localeCompare(right.name) + ) + }, [props.labels]) + + return ( + + {!collapsed && ( + <> + {sortedLabels.map((item) => { + return + })} + + + )} + + ) +} + +type MenuPanelProps = { + title: string + children: ReactNode + editFunc?: () => void + editTitle?: string + hideBottomBorder?: boolean + collapsed: boolean + setCollapsed: (collapsed: boolean) => void +} + +function MenuPanel(props: MenuPanelProps): JSX.Element { + return ( + + + + {props.title} + + + + + + {props.children} + + ) +} + +type NavButtonProps = { + text: string + icon: ReactNode + + filterTerm: string + searchTerm: string | undefined + + applySearchQuery: (searchTerm: string) => void + setShowFilterMenu: (show: boolean) => void +} + +type NavButtonRedirectProps = { + text: string + icon: ReactNode + + redirectLocation: string +} + +function NavRedirectButton(props: NavButtonRedirectProps): JSX.Element { + const [selected, setSelected] = useState(false) + const router = useRouter() + + useEffect(() => { + setSelected(window.location.pathname.includes(props.redirectLocation)) + }, []) + + return ( + { + router.push(props.redirectLocation) + e.preventDefault() + }} + > + {props.icon} + {props.text} + + ) +} + +function NavButton(props: NavButtonProps): JSX.Element { + const isInboxFilter = (filter: string) => { + return filter === '' || filter === 'in:inbox' + } + const selected = useMemo(() => { + if (isInboxFilter(props.filterTerm) && !props.searchTerm) { + return true + } + return props.searchTerm === props.filterTerm + }, [props.searchTerm, props.filterTerm]) + + return ( + { + props.applySearchQuery(props.filterTerm) + props.setShowFilterMenu(false) + e.preventDefault() + }} + > + {props.icon} + {props.text} + + ) +} + +type FilterButtonProps = { + text: string + + filterTerm: string + searchTerm: string | undefined + + applySearchQuery: (searchTerm: string) => void + + setShowFilterMenu: (show: boolean) => void +} + +function FilterButton(props: FilterButtonProps): JSX.Element { + const isInboxFilter = (filter: string) => { + return filter === '' || filter === 'in:inbox' + } + const selected = useMemo(() => { + if (isInboxFilter(props.filterTerm) && !props.searchTerm) { + return true + } + return props.searchTerm === props.filterTerm + }, [props.searchTerm, props.filterTerm]) + + return ( + { + props.applySearchQuery(props.filterTerm) + props.setShowFilterMenu(false) + e.preventDefault() + }} + > + {props.text} + + ) +} + +type LabelButtonProps = { + label: Label + searchTerm: string | undefined + applySearchQuery: (searchTerm: string) => void +} + +function LabelButton(props: LabelButtonProps): JSX.Element { + const labelId = `checkbox-label-${props.label.id}` + const checkboxRef = useRef(null) + const state = useMemo(() => { + const term = props.searchTerm ?? '' + if (term.indexOf(`label:\"${escapeQuotes(props.label.name)}\"`) >= 0) { + return 'on' + } + return 'off' + }, [props.searchTerm, props.label]) + + return ( + + + + { + const escapedLabelName = escapeQuotes(props.label.name) + if (e.target.checked) { + props.applySearchQuery( + `${props.searchTerm ?? ''} label:\"${escapedLabelName}\"` + ) + } else { + const query = + props.searchTerm?.replace( + `label:\"${escapedLabelName}\"`, + '' + ) ?? '' + props.applySearchQuery(query) + } + }} + /> + + + ) +} + +type EditButtonProps = { + title: string + destination: string +} + +function EditButton(props: EditButtonProps): JSX.Element { + return ( + + + {props.title} + + + ) +} diff --git a/packages/web/components/templates/reader/ReaderHeader.tsx b/packages/web/components/templates/reader/ReaderHeader.tsx index e1399ae03..75c776241 100644 --- a/packages/web/components/templates/reader/ReaderHeader.tsx +++ b/packages/web/components/templates/reader/ReaderHeader.tsx @@ -144,7 +144,7 @@ function ControlButtonBox(props: ReaderHeaderProps): JSX.Element { color={theme.colors.thHighContrast.toString()} /> - + + +
{ + setShowLeftMenu(!showLeftMenu) + }} + /> + {showLeftMenu && ( + {}} + searchTerm={''} + // eslint-disable-next-line @typescript-eslint/no-empty-function + applySearchQuery={(searchQuery: string) => {}} + showFilterMenu={showLeftMenu} + setShowFilterMenu={(show) => { + setShowLeftMenu(show) + }} + /> + )} {homeData.sections?.map((homeSection, idx) => { + if (homeSection.items.length < 1) { + return <> + } switch (homeSection.layout) { case 'just_added': return ( @@ -150,6 +178,7 @@ const TopPicksHomeSection = (props: HomeSectionProps): JSX.Element => { ( )} @@ -165,14 +194,23 @@ const QuickLinksHomeSection = (props: HomeSectionProps): JSX.Element => { css={{ width: '100%', gap: '20px', + bg: '#3D3D3D', + py: '30px', + px: '20px', + borderRadius: '5px', }} > {props.homeSection.title} @@ -279,9 +317,9 @@ const TimeAgo = (props: HomeItemViewProps): JSX.Element => { alignment="center" css={{ fontSize: '12px', - fontWeight: 'normal', + fontWeight: 'medium', fontFamily: '$inter', - color: '$readerContrast', + color: '$readerTextSubtle', }} > {timeAgo(props.homeItem.date)} @@ -300,6 +338,12 @@ const Title = (props: HomeItemViewProps): JSX.Element => { fontWeight: '600', fontFamily: '$inter', color: '$readerText', + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', + display: '-webkit-box', + '-webkit-line-clamp': '3', + '-webkit-box-orient': 'vertical', }} > {props.homeItem.title} @@ -307,6 +351,31 @@ const Title = (props: HomeItemViewProps): JSX.Element => { ) } +type PreviewContentProps = { + previewContent?: string + maxLines?: string +} + +const PreviewContent = (props: PreviewContentProps): JSX.Element => { + return ( + + {props.previewContent ?? ''} + + ) +} + const JustAddedItemView = (props: HomeItemViewProps): JSX.Element => { const router = useRouter() @@ -349,10 +418,12 @@ const TopicPickHomeItemView = (props: HomeItemViewProps): JSX.Element => { { router.push(props.homeItem.url) } }} + alignment="start" > - + { )} - - {props.homeItem.previewContent} + + - - - - - - + ) } @@ -423,11 +497,13 @@ const QuickLinkHomeItemView = (props: HomeItemViewProps): JSX.Element => { { @@ -440,6 +516,10 @@ const QuickLinkHomeItemView = (props: HomeItemViewProps): JSX.Element => { > + <PreviewContent + previewContent={props.homeItem.previewContent} + maxLines="2" + /> </VStack> ) } @@ -573,3 +653,144 @@ const SubscriptionSourceHoverContent = ( </VStack> ) } + +// const SiteSourceHoverContent = ( +// props: SourceHoverContentProps +// ): JSX.Element => { +// const sendHomeFeedback = useCallback( +// async (feedbackType: SendHomeFeedbackType) => { +// const feedback: SendHomeFeedbackInput = { +// feedbackType, +// } +// feedback.site = props.source.name +// const result = await sendHomeFeedbackMutation(feedback) +// if (result) { +// showSuccessToast('Feedback sent') +// } else { +// showErrorToast('Error sending feedback') +// } +// }, +// [props] +// ) + +// return ( +// <VStack +// alignment="start" +// distribution="start" +// css={{ +// width: '240px', +// height: '100px', +// bg: '$thBackground2', +// borderRadius: '10px', +// padding: '15px', +// gap: '10px', +// boxShadow: theme.shadows.cardBoxShadow.toString(), +// }} +// > +// <HStack +// distribution="start" +// alignment="center" +// css={{ width: '100%', gap: '10px' }} +// > +// {props.source.icon && ( +// <SiteIcon +// src={props.source.icon} +// alt={props.source.name} +// size="large" +// /> +// )} +// <SpanBox +// css={{ +// fontFamily: '$inter', +// fontWeight: '500', +// fontSize: '14px', +// }} +// > +// {props.source.name} +// </SpanBox> +// </HStack> +// {/* <SpanBox +// css={{ +// fontFamily: '$inter', +// fontSize: '13px', +// color: '$thTextSubtle4', +// }} +// > +// {subscription ? <>{subscription.description}</> : <></>} +// </SpanBox> */} +// <FeedbackView sendFeedback={sendHomeFeedback} /> +// </VStack> +// ) +// } + +// type FeedbackViewProps = { +// sendFeedback: (type: SendHomeFeedbackType) => void +// } + +// const FeedbackView = (props: FeedbackViewProps): JSX.Element => { +// return ( +// <HStack css={{ ml: 'auto', mt: 'auto', gap: '5px' }}> +// <Button +// style="plainIcon" +// onClick={(event) => { +// props.sendFeedback('MORE') +// event.preventDefault() +// event.stopPropagation() +// }} +// > +// <ThumbsUp weight="fill" /> +// </Button> +// <Button +// style="plainIcon" +// onClick={(event) => { +// props.sendFeedback('LESS') +// event.preventDefault() +// event.stopPropagation() +// }} +// > +// <ThumbsDown weight="fill" /> +// </Button> +// </HStack> +// ) +// } + +type HeaderProps = { + toggleMenu: () => void +} + +const Header = (props: HeaderProps): JSX.Element => { + const small = false + + return ( + <VStack + alignment="start" + distribution="start" + css={{ + zIndex: 5, + position: 'fixed', + left: '15px', + top: '15px', + height: small ? '60px' : DEFAULT_HEADER_HEIGHT, + transition: 'height 0.5s', + '@lgDown': { px: '20px' }, + '@mdDown': { + px: '10px', + left: '0px', + right: '0', + }, + }} + > + <VStack alignment="center" distribution="center"> + <Button + style="plainIcon" + onClick={(event) => { + props.toggleMenu() + event.preventDefault() + }} + > + <List size="25" color={theme.colors.readerTextSubtle.toString()} /> + </Button> + </VStack> + </VStack> + ) +}