diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index de6f1dd6..4a212e3a 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -30,7 +30,7 @@ jobs: run: yarn - name: Build project - run: yarn build:web + run: yarn build:web --mode development - name: Copy build to remote host uses: appleboy/scp-action@v0.1.4 diff --git a/package.json b/package.json index 59d45ac6..c6cc4e3e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@sentry/react": "^9.38.0", + "@szhsin/react-menu": "^4.5.0", "@tanstack/query-async-storage-persister": "^5.8.3", "@tanstack/react-query": "^4.13.0", "@tanstack/react-query-persist-client": "^5.8.4", @@ -33,7 +34,6 @@ "react-share": "^4.4.1", "react-simple-toasts": "^6.1.0", "react-spinners": "^0.10.4", - "react-spring-bottom-sheet": "^3.4.1", "react-toggle": "^4.1.1", "react-tooltip": "^4.2.21", "timeago.js": "^4.0.2", diff --git a/script/build.sh b/script/build.sh index 8cc7aceb..806d0383 100755 --- a/script/build.sh +++ b/script/build.sh @@ -4,7 +4,7 @@ build() { echo 'Building Hackertab...' rm -rf dist tsc - vite build + vite build "$@" } -build \ No newline at end of file +build "$@" \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index e5e3ea0d..a17401b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useEffect, useLayoutEffect, useState } from 'react' +import { useEffect, useLayoutEffect } from 'react' import { DNDLayout } from 'src/components/Layout' import { identifyAdvBlocked, @@ -10,7 +10,6 @@ import { import { useUserPreferences } from 'src/stores/preferences' import { AppContentLayout } from './components/Layout' import { verifyAdvStatus } from './features/adv/utils/status' -import { isWebOrExtensionVersion } from './utils/Environment' import { lazyImport } from './utils/lazyImport' const { OnboardingModal } = lazyImport(() => import('src/features/onboarding'), 'OnboardingModal') @@ -25,10 +24,9 @@ const intersectionCallback = (entries: IntersectionObserverEntry[]) => { } export const App = () => { - const [showOnboarding, setShowOnboarding] = useState(true) const { - onboardingCompleted, maxVisibleCards, + onboardingCompleted, setAdvStatus, isDNDModeActive, layout, @@ -78,10 +76,7 @@ export const App = () => { return ( <> - {!onboardingCompleted && isWebOrExtensionVersion() === 'extension' && ( - - )} - + {!onboardingCompleted && }
( + ( + { + meta, + titleComponent, + settingsComponent, + className, + withAds = false, + children, + fullBlock = false, + knob, + }, + ref + ) => { + const { icon, label, badge } = meta + const [canAdsLoad, setCanAdsLoad] = useState(true) -export const Card = ({ - meta, - titleComponent, - className, - withAds = false, - children, - fullBlock = false, - knob, -}: RootCardProps) => { - const { openLinksNewTab } = useUserPreferences() - const { link, icon, label, badge } = meta - const [canAdsLoad, setCanAdsLoad] = useState(true) - const { adsConfig } = useRemoteConfigStore() - - useEffect(() => { - if (!adsConfig.enabled || !withAds) { - return - } + useEffect(() => { + if (!withAds) { + return + } - const handleClassChange = () => { - if (document.documentElement.classList.contains('dndState')) { - setCanAdsLoad(false) - } else { - setCanAdsLoad(true) + const handleClassChange = () => { + if (document.documentElement.classList.contains('dndState')) { + setCanAdsLoad(false) + } else { + setCanAdsLoad(true) + } } - } - const observer = new MutationObserver(handleClassChange) - observer.observe(document.documentElement, { attributes: true }) + const observer = new MutationObserver(handleClassChange) + observer.observe(document.documentElement, { attributes: true }) - return () => { - observer.disconnect() - } - }, [withAds, adsConfig.enabled]) + return () => { + observer.disconnect() + } + }, [withAds]) - const handleHeaderLinkClick = (e: React.MouseEvent) => { - e.preventDefault() - let url = `${link}?${ref}` - window.open(url, openLinksNewTab ? '_blank' : '_self') - } + return ( +
+ + {settingsComponent && } + +
+ {knob} + {icon} {titleComponent || label}{' '} + + {settingsComponent && ( + {settingsComponent} + )} + + {badge && {badge}} +
- return ( -
-
- {knob} - {icon} {titleComponent || label}{' '} - {link && ( - - - + {canAdsLoad && withAds && ( +
+ +
)} - {badge && {badge}} -
- {canAdsLoad && adsConfig.enabled && withAds && ( -
- -
- )} - -
{children}
-
- ) -} +
{children}
+
+ ) + } +) diff --git a/src/components/Elements/CardWithActions/CardItemWithActions.tsx b/src/components/Elements/CardWithActions/CardItemWithActions.tsx index ca674a2f..8c2f8ef3 100644 --- a/src/components/Elements/CardWithActions/CardItemWithActions.tsx +++ b/src/components/Elements/CardWithActions/CardItemWithActions.tsx @@ -7,11 +7,13 @@ import { ShareModalData } from 'src/features/shareModal/types' import { Attributes, trackLinkBookmark, trackLinkUnBookmark } from 'src/lib/analytics' import { useBookmarks } from 'src/stores/bookmarks' import { useUserPreferences } from 'src/stores/preferences' -import { BaseEntry } from 'src/types' type CardItemWithActionsProps = { - item: BaseEntry - index: number + item: { + title: string + url: string + id: string + } source: string cardItem: React.ReactNode sourceType?: 'rss' | 'supported' @@ -20,7 +22,6 @@ type CardItemWithActionsProps = { export const CardItemWithActions = ({ cardItem, item, - index, source, sourceType = 'supported', }: CardItemWithActionsProps) => { @@ -73,7 +74,7 @@ export const CardItemWithActions = ({ } return ( -
+
setShareModalData(undefined)} diff --git a/src/components/Elements/ChipsSet/ChipsSet.tsx b/src/components/Elements/ChipsSet/ChipsSet.tsx index b215c8e5..8065c220 100644 --- a/src/components/Elements/ChipsSet/ChipsSet.tsx +++ b/src/components/Elements/ChipsSet/ChipsSet.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { IoIosClose } from 'react-icons/io' import { Option } from 'src/types' import './chipset.css' @@ -48,6 +48,10 @@ export const ChipsSet = ({ }: ChipsSetProps) => { const [selectedChips, setSelectedChips] = useState(defaultValues || []) + useEffect(() => { + setSelectedChips(defaultValues || []) + }, [defaultValues]) + const onSelect = (option: Option) => { if (selectedChips?.some((chipValue) => chipValue === option.value)) { if (!canSelectMultiple) { diff --git a/src/components/Elements/FloatingFilter/FloatingFilter.tsx b/src/components/Elements/FloatingFilter/FloatingFilter.tsx deleted file mode 100644 index c0811e9b..00000000 --- a/src/components/Elements/FloatingFilter/FloatingFilter.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState } from 'react' -import { FiFilter } from 'react-icons/fi' -import { BottomSheet } from 'react-spring-bottom-sheet' -import 'react-spring-bottom-sheet/dist/style.css' -import { ChipsSet } from 'src/components/Elements' -import { dateRanges, GLOBAL_TAG, MY_LANGUAGES_TAG } from 'src/config' -import { trackCardDateRangeSelect, trackCardLanguageSelect } from 'src/lib/analytics' -import { useUserPreferences } from 'src/stores/preferences' -import { SupportedCardType } from 'src/types' - -type ListingFilterMobileProps = { - card: SupportedCardType - filters?: ('datesRange' | 'language')[] -} - -export const FloatingFilter = ({ card, filters = ['language'] }: ListingFilterMobileProps) => { - const [open, setOpen] = useState(false) - const { userSelectedTags, cardsSettings, setCardSettings } = useUserPreferences() - const [availableTagOptions] = useState( - [GLOBAL_TAG, ...userSelectedTags, MY_LANGUAGES_TAG].map((tag) => ({ - label: tag.label, - value: tag.value, - })) - ) - return ( - <> - - - maxHeight / 2} - open={open} - expandOnContentDrag={true} - onDismiss={() => setOpen(false)}> -
-
-

Customize {card.label}

- - {filters.includes('language') && ( -
-

Language

-
- tag.value === cardsSettings[card.value]?.language) - ?.map((tag) => tag.label) || [GLOBAL_TAG.value] - } - options={availableTagOptions} - onChange={(_, option) => { - setCardSettings(card.value, { - ...cardsSettings[card.value], - language: option[0].value, - }) - trackCardLanguageSelect(card.analyticsTag, option[0].value) - }} - /> -
-
- )} - - {filters.includes('datesRange') && ( -
-

Date Range

-
- date.value === cardsSettings[card.value]?.dateRange) - .map((date) => date.value) || dateRanges[0].value - } - options={dateRanges} - onChange={(_, option) => { - setCardSettings(card.value, { - ...cardsSettings[card.value], - dateRange: option[0].value, - }) - trackCardDateRangeSelect(card.analyticsTag, option[0].value) - }} - /> -
-
- )} -
-
-
- - ) -} diff --git a/src/components/Elements/FloatingFilter/index.ts b/src/components/Elements/FloatingFilter/index.ts deleted file mode 100644 index 9c885e20..00000000 --- a/src/components/Elements/FloatingFilter/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./FloatingFilter" \ No newline at end of file diff --git a/src/components/Elements/SearchBar/SearchBar.tsx b/src/components/Elements/SearchBar/SearchBar.tsx index f493dbea..340a927d 100644 --- a/src/components/Elements/SearchBar/SearchBar.tsx +++ b/src/components/Elements/SearchBar/SearchBar.tsx @@ -1,17 +1,13 @@ import React, { useEffect, useRef } from 'react' -import { HiSparkles } from 'react-icons/hi' -import { AI_PROMPT_ENGINES } from 'src/config/SearchEngines' -import { trackSearchEngineUse } from 'src/lib/analytics' -import { useUserPreferences } from 'src/stores/preferences' - -export const SearchBar = () => { - const { promptEngine, promptEngines } = useUserPreferences() - const mergedEngines = [...AI_PROMPT_ENGINES, ...promptEngines] +type SearchBarProps = { + iconStart?: React.ReactNode + placeholder: string + onSubmit?: (keyword: string) => void + onChange?: (keyword: string) => void +} +export const SearchBar = ({ iconStart, placeholder, onChange, onSubmit }: SearchBarProps) => { const keywordsInputRef = useRef(null) - const usedSearchEngine = - mergedEngines.find((engine) => engine.label.toLowerCase() === promptEngine.toLowerCase()) || - promptEngines[0] const handleSubmit = (e: React.FormEvent) => { e.preventDefault() @@ -19,8 +15,7 @@ export const SearchBar = () => { keyword: { value: string } } - trackSearchEngineUse(usedSearchEngine.label) - window.open(`${usedSearchEngine.url}${target.keyword.value}`, '_self') + onSubmit?.(target.keyword.value) } useEffect(() => { @@ -29,20 +24,14 @@ export const SearchBar = () => { return (
- {usedSearchEngine?.default === false ? ( - - ) : ( - - )} + {iconStart} onChange?.(e.target.value)} className="searchBarInput" - placeholder={`Ask ${usedSearchEngine.label}`} + placeholder={placeholder} /> ) diff --git a/src/components/Elements/SearchBar/SearchEngineBar.tsx b/src/components/Elements/SearchBar/SearchEngineBar.tsx new file mode 100644 index 00000000..ed652364 --- /dev/null +++ b/src/components/Elements/SearchBar/SearchEngineBar.tsx @@ -0,0 +1,34 @@ +import { HiSparkles } from 'react-icons/hi' +import { AI_PROMPT_ENGINES } from 'src/config/SearchEngines' +import { trackSearchEngineUse } from 'src/lib/analytics' +import { useUserPreferences } from 'src/stores/preferences' +import { SearchBar } from './SearchBar' + +export const SearchEngineBar = () => { + const { promptEngine, promptEngines } = useUserPreferences() + const mergedEngines = [...AI_PROMPT_ENGINES, ...promptEngines] + + const usedSearchEngine = + mergedEngines.find((engine) => engine.label.toLowerCase() === promptEngine.toLowerCase()) || + promptEngines[0] + + return ( + + ) : ( + + ) + } + onSubmit={(keyword) => { + trackSearchEngineUse(usedSearchEngine.label) + window.open(`${usedSearchEngine.url}${keyword}`, '_self') + }} + placeholder={`Ask ${usedSearchEngine.label}`} + /> + ) +} diff --git a/src/components/Elements/SearchBarWithLogo/SearchBarWithLogo.tsx b/src/components/Elements/SearchBarWithLogo/SearchBarWithLogo.tsx index 5830c483..f228a140 100644 --- a/src/components/Elements/SearchBarWithLogo/SearchBarWithLogo.tsx +++ b/src/components/Elements/SearchBarWithLogo/SearchBarWithLogo.tsx @@ -1,7 +1,7 @@ import { HiSparkles } from 'react-icons/hi' import { AI_PROMPT_ENGINES } from 'src/config/SearchEngines' import { useUserPreferences } from 'src/stores/preferences' -import { SearchBar } from '../SearchBar/SearchBar' +import { SearchEngineBar } from '../SearchBar/SearchEngineBar' import './SearchBarWithLogo.css' export const SearchBarWithLogo = () => { @@ -25,7 +25,7 @@ export const SearchBarWithLogo = () => { /> )} - +
) } diff --git a/src/components/Elements/UserTags/UserTags.tsx b/src/components/Elements/UserTags/UserTags.tsx index c07bccb7..68d8d4cb 100644 --- a/src/components/Elements/UserTags/UserTags.tsx +++ b/src/components/Elements/UserTags/UserTags.tsx @@ -1,16 +1,34 @@ +import { useCallback } from 'react' import { TiPlus } from 'react-icons/ti' import { Link } from 'react-router-dom' import { useUserPreferences } from 'src/stores/preferences' +import { useShallow } from 'zustand/shallow' export const UserTags = () => { - const { userSelectedTags } = useUserPreferences() + const { cards, userSelectedTags, cardsSettings, setCardSettings } = useUserPreferences( + useShallow((state) => ({ + cards: state.cards, + userSelectedTags: state.userSelectedTags, + cardsSettings: state.cardsSettings, + setCardSettings: state.setCardSettings, + })) + ) + + const onTagClicked = useCallback((tagValue: string) => { + cards.forEach((card) => { + setCardSettings(card.name, { + ...cardsSettings[card.id], + language: tagValue, + }) + }) + }, []) return (
{userSelectedTags.map((tag, index) => ( - + ))} diff --git a/src/components/Elements/index.ts b/src/components/Elements/index.ts index 14add55f..7457df73 100644 --- a/src/components/Elements/index.ts +++ b/src/components/Elements/index.ts @@ -6,7 +6,6 @@ export * from './CardWithActions' export * from './ChipsSet' export * from './ClickableItem' export * from './ColoredLanguagesBadges' -export * from './FloatingFilter' export * from './InlineTextFilter' export * from './Modal' export * from './Panel' diff --git a/src/components/Layout/AppLayout.tsx b/src/components/Layout/AppLayout.tsx index bcafb398..30dee94a 100644 --- a/src/components/Layout/AppLayout.tsx +++ b/src/components/Layout/AppLayout.tsx @@ -11,17 +11,17 @@ import { AuthProvider } from 'src/providers/AuthProvider' import { Header } from './Header' export const AppLayout = () => { - const { isAuthModalOpen, setStreak, isConnected } = useAuth() + const { isAuthModalOpen, setStreak, shouldIcrementStreak } = useAuth() const postStreakMutation = usePostStreak() useEffect(() => { - if (isConnected) { + if (shouldIcrementStreak()) { postStreakMutation.mutateAsync(undefined).then((data) => { setStreak(data.streak) identifyUserStreak(data.streak) }) } - }, [isConnected]) + }, []) return ( diff --git a/src/components/Layout/DesktopCards.tsx b/src/components/Layout/DesktopCards.tsx index cc69987e..85f8a99c 100644 --- a/src/components/Layout/DesktopCards.tsx +++ b/src/components/Layout/DesktopCards.tsx @@ -19,7 +19,6 @@ import { clsx } from 'clsx' import { MdOutlineDragIndicator } from 'react-icons/md' import { SUPPORTED_CARDS } from 'src/config/supportedCards' import { CustomRssCard } from 'src/features/cards' -import { useRemoteConfigStore } from 'src/features/remoteConfig' import { trackPageDrag } from 'src/lib/analytics' import { DesktopBreakpoint } from 'src/providers/DesktopBreakpoint' import { useUserPreferences } from 'src/stores/preferences' @@ -71,7 +70,6 @@ export const DesktopCards = ({ const AVAILABLE_CARDS = [...SUPPORTED_CARDS, ...userCustomCards] const { updateCardOrder } = useUserPreferences() const cardsWrapperRef = useRef(null) - const { adsConfig } = useRemoteConfigStore() const sensors = useSensors( useSensor(PointerSensor), @@ -131,14 +129,7 @@ export const DesktopCards = ({ items={memoCards.map(({ id }) => id)} strategy={horizontalListSortingStrategy}> {memoCards.map(({ id, card }, index) => { - return ( - - ) + return })} diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index 45225538..707527eb 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -1,25 +1,33 @@ import { clsx } from 'clsx' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { BsFillBookmarksFill, BsFillGearFill, BsMoonFill } from 'react-icons/bs' import { CgTab } from 'react-icons/cg' import { IoMdSunny } from 'react-icons/io' import { MdDoDisturbOff } from 'react-icons/md' +import { RiDashboardHorizontalFill } from 'react-icons/ri' +import { TfiLayoutColumn4Alt } from 'react-icons/tfi' import { Link, useLocation, useNavigate } from 'react-router-dom' import AvatarPlaceholder from 'src/assets/icons/avatar.svg?react' import StreakIcon from 'src/assets/icons/fire_icon.svg?react' import HackertabLogo from 'src/assets/logo.svg?react' -import { SearchBar } from 'src/components/Elements/SearchBar' import { UserTags } from 'src/components/Elements/UserTags' import { useAuth } from 'src/features/auth' import { Changelog } from 'src/features/changelog' -import { identifyUserTheme, trackDNDDisable, trackThemeSelect } from 'src/lib/analytics' +import { + identifyUserTheme, + trackDNDDisable, + trackDisplayTypeChange, + trackThemeSelect, +} from 'src/lib/analytics' import { useUserPreferences } from 'src/stores/preferences' import { Button, CircleButton } from '../Elements' +import { SearchEngineBar } from '../Elements/SearchBar/SearchEngineBar' export const Header = () => { const { openAuthModal, user, isConnected, isConnecting } = useAuth() const [themeIcon, setThemeIcon] = useState() - const { theme, setTheme, setDNDDuration, isDNDModeActive } = useUserPreferences() + const { theme, setTheme, setDNDDuration, isDNDModeActive, layout, setLayout } = + useUserPreferences() const navigate = useNavigate() const location = useLocation() @@ -38,21 +46,27 @@ export const Header = () => { } }, [theme]) - const onThemeChange = () => { + const onThemeChange = useCallback(() => { const newTheme = theme === 'dark' ? 'light' : 'dark' setTheme(newTheme) trackThemeSelect(newTheme) identifyUserTheme(newTheme) - } + }, [theme, setTheme]) - const onSettingsClick = () => { + const onLayoutChange = useCallback(() => { + const newLayout = layout === 'cards' ? 'grid' : 'cards' + trackDisplayTypeChange(newLayout) + setLayout(newLayout) + }, [layout, setLayout]) + + const onSettingsClick = useCallback(() => { navigate('/settings/general') - } + }, [navigate]) - const onUnpauseClicked = () => { + const onUnpauseClicked = useCallback(() => { trackDNDDisable() setDNDDuration('never') - } + }, [setDNDDuration]) return ( <> @@ -66,7 +80,7 @@ export const Header = () => { - +
@@ -83,6 +97,9 @@ export const Header = () => { + + {layout === 'cards' ? : } + {themeIcon} diff --git a/src/components/Layout/SettingsContentLayout/SettingsContentLayout.tsx b/src/components/Layout/SettingsContentLayout/SettingsContentLayout.tsx index cd2b596f..c3e15c7d 100644 --- a/src/components/Layout/SettingsContentLayout/SettingsContentLayout.tsx +++ b/src/components/Layout/SettingsContentLayout/SettingsContentLayout.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import './settingsContentLayout.css' type SettingsContentLayoutProps = { @@ -5,6 +6,7 @@ type SettingsContentLayoutProps = { description: string children: React.ReactNode actions?: React.ReactNode + bodyClassName?: string } export const SettingsContentLayout = ({ @@ -12,6 +14,7 @@ export const SettingsContentLayout = ({ description, actions, children, + bodyClassName, }: SettingsContentLayoutProps) => { return (
@@ -23,7 +26,7 @@ export const SettingsContentLayout = ({ {actions &&
{actions}
} -
{children}
+
{children}
) } diff --git a/src/components/Layout/SettingsContentLayout/settingsContentLayout.css b/src/components/Layout/SettingsContentLayout/settingsContentLayout.css index a79379a8..22ce4d59 100644 --- a/src/components/Layout/SettingsContentLayout/settingsContentLayout.css +++ b/src/components/Layout/SettingsContentLayout/settingsContentLayout.css @@ -13,6 +13,60 @@ display: flex; flex-direction: column; gap: 16px; + + .categoryTitle { + text-transform: capitalize; + font-size: 16px; + margin-bottom: 16px; + color: var(--primary-text-color); + + & .icon { + vertical-align: top; + } + } + + .subTitleButton { + cursor: pointer; + background: none; + border: none; + margin: 0; + padding: 0; + } + .subTitleIcon { + font-size: 12px; + } + .topicsFlex { + display: flex; + margin-top: 12px; + flex-direction: column; + flex-wrap: wrap; + gap: 24px; + } + .categoryContent { + margin-top: 12px; + } + .expandButton { + width: auto; + font-size: 12px; + height: 20px; + border: none; + background-color: var(--button-background-color); + border-radius: 20px; + justify-content: center; + align-items: center; + text-align: center; + display: inline-flex; + padding: 0 6px; + display: inline-flex; + column-gap: 4px; + text-transform: lowercase; + cursor: pointer; + } +} + +.topicsBottomSpacer { + margin-bottom: 80px; + padding-bottom: 24px; } .settingsContent header { display: flex; diff --git a/src/components/List/ListComponent.tsx b/src/components/List/ListComponent.tsx index 49c0feb9..7bab2714 100644 --- a/src/components/List/ListComponent.tsx +++ b/src/components/List/ListComponent.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react' +import React, { memo, ReactNode, useMemo } from 'react' import { Placeholder } from 'src/components/placeholders' import { MAX_ITEMS_PER_CARD } from 'src/config' @@ -6,10 +6,10 @@ type PlaceholdersProps = { placeholder: ReactNode } -const Placeholders = React.memo(({ placeholder }) => { +const Placeholders = memo(({ placeholder }) => { return ( <> - {[...Array(7)].map((x, i) => ( + {[...Array(7)].map((_, i) => ( {placeholder} ))} @@ -17,7 +17,9 @@ const Placeholders = React.memo(({ placeholder }) => { }) export type ListComponentPropsType = { - items: T[] + items?: T[] + sortBy?: keyof T + sortFn?: (a: T, b: T) => number isLoading: boolean renderItem: (item: T, index: number) => React.ReactNode placeholder?: React.ReactNode @@ -27,35 +29,69 @@ export type ListComponentPropsType = { limit?: number } -export function ListComponent(props: ListComponentPropsType) { +export function ListComponent(props: ListComponentPropsType) { const { items, + sortBy, isLoading, error, + sortFn, renderItem, header, placeholder = , limit = MAX_ITEMS_PER_CARD, } = props - if (error) { - return

{error?.message || error}

- } + const sortedData = useMemo(() => { + if (!items || items.length == 0) return [] + if (!sortBy) return items + + const result = sortFn + ? [...items].sort(sortFn) + : [...items].sort((a, b) => { + const aVal = a[sortBy] + const bVal = b[sortBy] + if (typeof aVal === 'number' && typeof bVal === 'number') return bVal - aVal + if (typeof aVal === 'string' && typeof bVal === 'string') return bVal.localeCompare(aVal) + return 0 + }) + + return result + }, [sortBy, sortFn, items]) + + const enrichedItems = useMemo(() => { + if (!sortedData || sortedData.length === 0) { + return [] + } + + try { + return sortedData.slice(0, limit).map((item, index) => { + let content: ReactNode[] = [renderItem(item, index)] + if (header && index === 0) { + content.unshift(header) + } - const renderItems = () => { - if (!items) { - return + return content + }) + } catch (e) { + return [] } + }, [sortedData, header, renderItem, limit]) - return items.slice(0, limit).map((item, index) => { - let content: ReactNode[] = [renderItem(item, index)] - if (header && index === 0) { - content.unshift(header) - } + if (isLoading) { + return + } + if (error) { + return

{error?.message || error}

+ } - return content - }) + if (items && items.length == 0) { + return ( +

+ No items found, try adjusting your filter or choosing a different tag. +

+ ) } - return <>{isLoading ? : renderItems()} + return <>{enrichedItems} } diff --git a/src/components/List/ListConferenceComponent.tsx b/src/components/List/ListConferenceComponent.tsx new file mode 100644 index 00000000..1bc60a49 --- /dev/null +++ b/src/components/List/ListConferenceComponent.tsx @@ -0,0 +1,6 @@ +import { Conference } from 'src/types' +import { ListComponent, ListComponentPropsType } from './ListComponent' + +export function ListConferenceComponent(props: ListComponentPropsType) { + return {...props} /> +} diff --git a/src/components/List/ListPostComponent.tsx b/src/components/List/ListPostComponent.tsx new file mode 100644 index 00000000..f9525a96 --- /dev/null +++ b/src/components/List/ListPostComponent.tsx @@ -0,0 +1,6 @@ +import { Article } from 'src/types' +import { ListComponent, ListComponentPropsType } from './ListComponent' + +export function ListPostComponent(props: ListComponentPropsType
) { + return {...props} /> +} diff --git a/src/components/List/ListRepoComponent.tsx b/src/components/List/ListRepoComponent.tsx new file mode 100644 index 00000000..645bc46a --- /dev/null +++ b/src/components/List/ListRepoComponent.tsx @@ -0,0 +1,6 @@ +import { Repository } from 'src/types' +import { ListComponent, ListComponentPropsType } from './ListComponent' + +export function ListRepoComponent(props: ListComponentPropsType) { + return {...props} /> +} diff --git a/src/config/index.tsx b/src/config/index.tsx index 98cc8766..1136a1cd 100644 --- a/src/config/index.tsx +++ b/src/config/index.tsx @@ -23,26 +23,6 @@ export const twitterHandle = '@hackertabdev' export const reportLink = 'https://www.hackertab.dev/report' export const LS_PREFERENCES_KEY = 'hackerTabPrefs' -export const GLOBAL_TAG = { - value: 'global', - label: 'Trending', - githubValues: ['global'], - devtoValues: ['programming'], - hashnodeValues: ['programming'], - mediumValues: ['programming'], - redditValues: ['programming'], - freecodecampValues: ['programming'], -} -export const MY_LANGUAGES_TAG = { - value: 'myLangs', - label: 'My Languages', - githubValues: ['myLangs'], - devtoValues: ['myLangs'], - hashnodeValues: ['myLangs'], - mediumValues: ['myLangs'], - redditValues: ['myLangs'], - freecodecampValues: ['myLangs'], -} export const MAX_ITEMS_PER_CARD = 50 export type DateRangeType = { @@ -50,7 +30,7 @@ export type DateRangeType = { label: string } export const dateRanges: DateRangeType[] = [ - { label: 'the day', value: 'daily' }, - { label: 'the week', value: 'weekly' }, - { label: 'the month', value: 'monthly' }, + { label: 'Today', value: 'daily' }, + { label: 'This week', value: 'weekly' }, + { label: 'This month', value: 'monthly' }, ] diff --git a/src/config/supportedCards.tsx b/src/config/supportedCards.tsx index cfbd1db8..cd0c1423 100644 --- a/src/config/supportedCards.tsx +++ b/src/config/supportedCards.tsx @@ -2,6 +2,7 @@ import { CgIndieHackers } from 'react-icons/cg' import { FaDev, FaFreeCodeCamp, FaMediumM, FaReddit } from 'react-icons/fa' import { HiSparkles, HiTicket } from 'react-icons/hi' import { SiGithub, SiProducthunt, SiYcombinator } from 'react-icons/si' +import HackernoonIcon from 'src/assets/icon_hackernoon.jpeg' import HashNodeIcon from 'src/assets/icon_hashnode.png' import LobstersIcon from 'src/assets/icon_lobsters.png' import { AICard } from 'src/features/cards/components/aiCard' @@ -18,6 +19,7 @@ const { IndiehackersCard } = lazyImport(() => import('src/features/cards'), 'Ind const { LobstersCard } = lazyImport(() => import('src/features/cards'), 'LobstersCard') const { ProductHuntCard } = lazyImport(() => import('src/features/cards'), 'ProductHuntCard') const { RedditCard } = lazyImport(() => import('src/features/cards'), 'RedditCard') +const { HackernoonCard } = lazyImport(() => import('src/features/cards'), 'HackernoonCard') export const SUPPORTED_CARDS: SupportedCardType[] = [ { @@ -126,6 +128,15 @@ export const SUPPORTED_CARDS: SupportedCardType[] = [ label: 'Powered by AI', component: AICard, type: 'supported', - badge: 'BETA', + link: 'https://hackertab.dev/', + }, + { + value: 'hackernoon', + analyticsTag: 'hackernoon', + label: 'Hackernoon', + component: HackernoonCard, + icon: hackernoon, + link: 'https://hackernoon.com/', + type: 'supported', }, ] diff --git a/src/features/auth/components/AuthModal.tsx b/src/features/auth/components/AuthModal.tsx index be527d4d..518e9442 100644 --- a/src/features/auth/components/AuthModal.tsx +++ b/src/features/auth/components/AuthModal.tsx @@ -20,7 +20,7 @@ const githubAuthProvider = new GithubAuthProvider() type AuthProvider = GoogleAuthProvider | GithubAuthProvider export const AuthModal = ({ showAuth }: AuthModalProps) => { - const { closeAuthModal, authError, setAuthError } = useAuth() + const { closeAuthModal, authError, setAuthError, providerId } = useAuth() const [selectedProvider, setSelectedProvider] = useState(googleAuthProvider) const getOauthLink = useGetOauthLink() @@ -96,14 +96,18 @@ export const AuthModal = ({ showAuth }: AuthModalProps) => { onClick={() => { signIn(githubAuthProvider) }} + className="relative" size="medium"> + {providerId === 'github.com' && Last used} Connect with Github
diff --git a/src/features/auth/components/authModal.css b/src/features/auth/components/authModal.css index 9a3ccfb8..df42f150 100644 --- a/src/features/auth/components/authModal.css +++ b/src/features/auth/components/authModal.css @@ -16,7 +16,19 @@ width: 100%; margin: 0 0 20px 0; } - +.authModal .relative { + position: relative; +} +.authModal .lastFlag { + position: absolute; + right: -6px; + top: -6px; + background: var(--tooltip-accent-color); + font-size: 0.7em; + color: white; + border-radius: 12px; + padding: 0 4px; +} .authModal .description { padding: 20px; text-align: center; diff --git a/src/features/auth/hooks/useAuth.ts b/src/features/auth/hooks/useAuth.ts index 93fcf6a5..c795ddac 100644 --- a/src/features/auth/hooks/useAuth.ts +++ b/src/features/auth/hooks/useAuth.ts @@ -1,4 +1,5 @@ import { signOut } from 'firebase/auth/web-extension' +import { useCallback } from 'react' import { AuthModalStore, AuthStore } from 'src/features/auth' import { trackUserDisconnect } from 'src/lib/analytics' import { firebaseAuth } from 'src/lib/firebase' @@ -9,17 +10,30 @@ export const useAuth = () => { const isConnected = authStore.user != null - const logout = async () => { + const shouldIcrementStreak = useCallback(() => { + if (!isConnected) return false + + const last = authStore.lastStreakUpdate + if (!last) return true + + const today = new Date().toISOString() + const lastDay = new Date(last).toISOString() + + return today !== lastDay + }, [isConnected, authStore.lastStreakUpdate]) + + const logout = useCallback(async () => { trackUserDisconnect() signOut(firebaseAuth) authStore.clear() return await firebaseAuth.signOut() - } + }, [authStore]) return { ...authModalStore, ...authStore, isConnected, + shouldIcrementStreak, logout, } } diff --git a/src/features/auth/stores/authStore.ts b/src/features/auth/stores/authStore.ts index 17c18ebc..ab369025 100644 --- a/src/features/auth/stores/authStore.ts +++ b/src/features/auth/stores/authStore.ts @@ -4,20 +4,25 @@ import { persist } from 'zustand/middleware' type AuthState = { user: User | null + lastStreakUpdate?: number providerId: string | null } type AuthActions = { initState: (state: AuthState) => void setStreak: (streak: number) => void + setLastStreakUpdate: (timestamp: number) => void clear: () => void } +type AuthStoreType = AuthState & AuthActions export const AuthStore = create( - persist( + persist( (set) => ({ user: null, providerId: null, + lastStreakUpdate: undefined, + setLastStreakUpdate: (timestamp: number) => set({ lastStreakUpdate: timestamp }), initState: (newState: AuthState) => set({ user: newState.user, @@ -25,15 +30,27 @@ export const AuthStore = create( }), setStreak: (streak: number) => set((state) => ({ + lastStreakUpdate: Date.now(), user: { ...state.user!, streak, }, })), - clear: () => set({ user: null }), + clear: () => set({ user: null, lastStreakUpdate: undefined }), }), { - name: 'auth-storage', // key in localStorage + version: 1, + name: 'auth-storage', + migrate: (persistedState, version) => { + const typedPersistedState = persistedState as unknown as AuthStoreType + if (version === 0) { + return { + ...typedPersistedState, + lastStreakUpdate: undefined, + } + } + return typedPersistedState + }, } ) ) diff --git a/src/features/auth/types/index.ts b/src/features/auth/types/index.ts index 51b82333..77342c6f 100644 --- a/src/features/auth/types/index.ts +++ b/src/features/auth/types/index.ts @@ -1,6 +1,7 @@ export type User = { id: string name: string + connectedAt?: Date imageURL?: string streak?: number } diff --git a/src/features/cards/api/getConferences.ts b/src/features/cards/api/getConferences.ts index acbe8a93..e56c2c3b 100644 --- a/src/features/cards/api/getConferences.ts +++ b/src/features/cards/api/getConferences.ts @@ -1,10 +1,14 @@ -import { useQueries, UseQueryOptions } from '@tanstack/react-query' -import { QueryConfig } from 'src/lib/react-query' -import { Conference } from 'src/types' +import { useQuery } from '@tanstack/react-query' import { axios } from 'src/lib/axios' +import { ExtractFnReturnType, QueryConfig } from 'src/lib/react-query' +import { Conference } from 'src/types' -const getConferences = async (tag: string): Promise => { - return axios.get(`/data/v2/conferences/${tag}.json`) +const getConferences = async (tags: string[]): Promise => { + return axios.get(`/engine/conferences`, { + params: { + tags: tags?.join(','), + }, + }) } type QueryFnType = typeof getConferences @@ -15,13 +19,9 @@ type UseGetConferencesOptions = { } export const useGetConferences = ({ config, tags }: UseGetConferencesOptions) => { - return useQueries({ - queries: tags.map>((tag) => { - return { - ...config, - queryKey: ['conferences', tag], - queryFn: () => getConferences(tag), - } - }) + return useQuery>({ + ...config, + queryKey: ['conferences_v2', ...tags], + queryFn: () => getConferences(tags), }) } diff --git a/src/features/cards/api/getDevtoArticles.ts b/src/features/cards/api/getDevtoArticles.ts deleted file mode 100644 index 4aeb90d5..00000000 --- a/src/features/cards/api/getDevtoArticles.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQueries, UseQueryOptions } from '@tanstack/react-query' -import { QueryConfig } from 'src/lib/react-query' -import { Article } from 'src/types' -import { axios } from 'src/lib/axios' - -const getArticles = async (tag: string): Promise => { - return axios.get(`/data/v2/devto/${tag}.json`) -} - -type QueryFnType = typeof getArticles - -type UseGetArticlesOptions = { - config?: QueryConfig - tags: string[] -} - -export const useGetDevtoArticles = ({ config, tags }: UseGetArticlesOptions) => { - return useQueries({ - queries: tags.map>((tag) => { - return { - ...config, - queryKey: ['devto', tag], - queryFn: () => getArticles(tag), - } - }) - }) -} diff --git a/src/features/cards/api/getFreeCodeCampArticles.ts b/src/features/cards/api/getFreeCodeCampArticles.ts deleted file mode 100644 index dcacd36f..00000000 --- a/src/features/cards/api/getFreeCodeCampArticles.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQueries, UseQueryOptions } from '@tanstack/react-query' -import { QueryConfig } from 'src/lib/react-query' -import { Article } from 'src/types' -import { axios } from 'src/lib/axios' - -const getArticles = async (tag: string): Promise => { - return axios.get(`/data/v2/freecodecamp/${tag}.json`) -} - -type QueryFnType = typeof getArticles - -type UseGetArticlesOptions = { - config?: QueryConfig - tags: string[] -} - -export const useGetFreeCodeCampArticles = ({ config, tags }: UseGetArticlesOptions) => { - return useQueries({ - queries: tags.map>((tag) => { - return { - ...config, - queryKey: ['freecodecamp', tag], - queryFn: () => getArticles(tag), - } - }) - }) -} diff --git a/src/features/cards/api/getGithubRepos.ts b/src/features/cards/api/getGithubRepos.ts index 07b7916a..ecbff38b 100644 --- a/src/features/cards/api/getGithubRepos.ts +++ b/src/features/cards/api/getGithubRepos.ts @@ -1,10 +1,21 @@ -import { useQueries, UseQueryOptions } from '@tanstack/react-query' -import { QueryConfig } from 'src/lib/react-query' -import { Repository } from 'src/types' +import { useQuery } from '@tanstack/react-query' import { axios } from 'src/lib/axios' +import { ExtractFnReturnType, QueryConfig } from 'src/lib/react-query' +import { Repository } from 'src/types' -const getRepos = async (tag: string, dateRange: string): Promise => { - return axios.get(`/data/v2/github/${tag}/${dateRange}.json`) +const getRepos = async ({ + tags, + dateRange, +}: { + tags: string[] + dateRange: string +}): Promise => { + return axios.get(`/engine/repos`, { + params: { + range: dateRange, + tags: tags.join(','), + }, + }) } type QueryFnType = typeof getRepos @@ -12,17 +23,13 @@ type QueryFnType = typeof getRepos type UseGetReposOptions = { config?: QueryConfig tags: string[] - dateRange: "daily" | "monthly" | "weekly" + dateRange: 'daily' | 'monthly' | 'weekly' } export const useGetGithubRepos = ({ config, tags, dateRange }: UseGetReposOptions) => { - return useQueries({ - queries: tags.map>((tag) => { - return { - ...config, - queryKey: ['github', tag, dateRange], - queryFn: () => getRepos(tag, dateRange), - } - }) + return useQuery>({ + ...config, + queryKey: ['github_v2', ...tags, dateRange], + queryFn: () => getRepos({ tags, dateRange }), }) -} \ No newline at end of file +} diff --git a/src/features/cards/api/getHackerNewsArticles.ts b/src/features/cards/api/getHackerNewsArticles.ts deleted file mode 100644 index f5b5ecd8..00000000 --- a/src/features/cards/api/getHackerNewsArticles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { ExtractFnReturnType, QueryConfig } from 'src/lib/react-query' -import { Article } from 'src/types' -import { axios } from 'src/lib/axios' - -const getArticles = async (): Promise => { - return axios.get('/data/v2/hackernews.json') -} - -type QueryFnType = typeof getArticles - -type UseGetArticlesOptions = { - config?: QueryConfig -} - -export const useGetHackertNewsArticles = ({ config }: UseGetArticlesOptions = {}) => { - return useQuery>({ - ...config, - queryKey: ['hackernews'], - queryFn: () => getArticles(), - }) -} diff --git a/src/features/cards/api/getHashnodeArticles.ts b/src/features/cards/api/getHashnodeArticles.ts deleted file mode 100644 index 69065436..00000000 --- a/src/features/cards/api/getHashnodeArticles.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQueries, UseQueryOptions } from '@tanstack/react-query' -import { QueryConfig } from 'src/lib/react-query' -import { Article } from 'src/types' -import { axios } from 'src/lib/axios' - -const getArticles = async (tag: string): Promise => { - return axios.get(`/data/v2/hashnode/${tag}.json`) -} - -type QueryFnType = typeof getArticles - -type UseGetArticlesOptions = { - config?: QueryConfig - tags: string[] -} - -export const useGetHashnodeArticles = ({ config, tags }: UseGetArticlesOptions) => { - return useQueries({ - queries: tags.map>((tag) => { - return { - ...config, - queryKey: ['hashnode', tag], - queryFn: () => getArticles(tag), - } - }) - }) -} diff --git a/src/features/cards/api/getIndieHackersArticles.ts b/src/features/cards/api/getIndieHackersArticles.ts deleted file mode 100644 index 938559f3..00000000 --- a/src/features/cards/api/getIndieHackersArticles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { ExtractFnReturnType, QueryConfig } from 'src/lib/react-query' -import { Article } from 'src/types' -import { axios } from 'src/lib/axios' - -const getArticles = async (): Promise => { - return axios.get('/data/v2/indiehackers.json') -} - -type QueryFnType = typeof getArticles - -type UseGetArticlesOptions = { - config?: QueryConfig -} - -export const useGetIndieHackersArticles = ({ config }: UseGetArticlesOptions = {}) => { - return useQuery>({ - ...config, - queryKey: ['indiehackers'], - queryFn: () => getArticles(), - }) -} diff --git a/src/features/cards/api/getLobstersArticles.ts b/src/features/cards/api/getLobstersArticles.ts deleted file mode 100644 index 560d97ad..00000000 --- a/src/features/cards/api/getLobstersArticles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { ExtractFnReturnType, QueryConfig } from 'src/lib/react-query' -import { Article } from 'src/types' -import { axios } from 'src/lib/axios' - -const getArticles = async (): Promise => { - return axios.get('/data/v2/lobsters.json') -} - -type QueryFnType = typeof getArticles - -type UseGetArticlesOptions = { - config?: QueryConfig -} - -export const useGetLobstersArticles = ({ config }: UseGetArticlesOptions = {}) => { - return useQuery>({ - ...config, - queryKey: ['lobsters'], - queryFn: () => getArticles(), - }) -} diff --git a/src/features/cards/api/getMediumArticles.ts b/src/features/cards/api/getMediumArticles.ts deleted file mode 100644 index ce07b590..00000000 --- a/src/features/cards/api/getMediumArticles.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQueries, UseQueryOptions } from '@tanstack/react-query' -import { QueryConfig } from 'src/lib/react-query' -import { Article } from 'src/types' -import { axios } from 'src/lib/axios' - -const getArticles = async (tag: string): Promise => { - return axios.get(`/data/v2/medium/${tag}.json`) -} - -type QueryFnType = typeof getArticles - -type UseGetArticlesOptions = { - config?: QueryConfig - tags: string[] -} - -export const useGetMediumArticles = ({ config, tags }: UseGetArticlesOptions) => { - return useQueries({ - queries: tags.map>((tag) => { - return { - ...config, - queryKey: ['medium', tag], - queryFn: () => getArticles(tag), - } - }) - }) -} diff --git a/src/features/cards/api/getProductHuntProducts.ts b/src/features/cards/api/getProductHuntProducts.ts index fcff8f87..cae9ac03 100644 --- a/src/features/cards/api/getProductHuntProducts.ts +++ b/src/features/cards/api/getProductHuntProducts.ts @@ -1,22 +1,27 @@ import { useQuery } from '@tanstack/react-query' -import { ExtractFnReturnType, QueryConfig } from 'src/lib/react-query' -import { Article } from 'src/types' import { axios } from 'src/lib/axios' +import { ExtractFnReturnType, QueryConfig } from 'src/lib/react-query' +import { Product } from 'src/types' -const getArticles = async (): Promise => { - return axios.get('/data/v2/producthunt.json') +const getArticles = async ({ date }: { date: string }): Promise => { + return axios.get(`/engine/products`, { + params: { + date, + }, + }) } type QueryFnType = typeof getArticles type UseGetArticlesOptions = { config?: QueryConfig + date: string } -export const useGeProductHuntProducts = ({ config }: UseGetArticlesOptions = {}) => { +export const useGeProductHuntProducts = ({ date, config }: UseGetArticlesOptions) => { return useQuery>({ ...config, - queryKey: ['producthunt'], - queryFn: () => getArticles(), + queryKey: ['producthunt_v2', date], + queryFn: () => getArticles({ date }), }) } diff --git a/src/features/cards/api/getRedditArticles.ts b/src/features/cards/api/getRedditArticles.ts deleted file mode 100644 index 523340c1..00000000 --- a/src/features/cards/api/getRedditArticles.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQueries, UseQueryOptions } from '@tanstack/react-query' -import { QueryConfig } from 'src/lib/react-query' -import { Article } from 'src/types' -import { axios } from 'src/lib/axios' - -const getArticles = async (tag: string): Promise => { - return axios.get(`/data/v2/reddit/${tag}.json`) -} - -type QueryFnType = typeof getArticles - -type UseGetArticlesOptions = { - config?: QueryConfig - tags: string[] -} - -export const useGetRedditArticles = ({ config, tags }: UseGetArticlesOptions) => { - return useQueries({ - queries: tags.map>((tag) => { - return { - ...config, - queryKey: ['reddit', tag], - queryFn: () => getArticles(tag), - } - }) - }) -} diff --git a/src/features/cards/api/getSourceArticles.ts b/src/features/cards/api/getSourceArticles.ts new file mode 100644 index 00000000..eef2bdd5 --- /dev/null +++ b/src/features/cards/api/getSourceArticles.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query' +import { axios } from 'src/lib/axios' +import { ExtractFnReturnType, QueryConfig } from 'src/lib/react-query' +import { Article } from 'src/types' + +const getArticles = async ({ + source, + tags, +}: { + source: string + tags?: string[] +}): Promise => { + return axios.get(`/engine/feeds`, { + params: { + source, + ...(tags?.length ? { tags: tags.join(',') } : {}), + }, + }) +} + +type QueryFnType = typeof getArticles + +type UseGetArticlesOptions = { + config?: QueryConfig + source: string + tags?: string[] +} + +export const useGetSourceArticles = ({ config, source, tags }: UseGetArticlesOptions) => { + return useQuery>({ + ...config, + queryKey: [source, ...(tags || [])], + queryFn: () => getArticles({ source, tags }), + }) +} diff --git a/src/features/cards/components/CardHeader.tsx b/src/features/cards/components/CardHeader.tsx new file mode 100644 index 00000000..a1afc221 --- /dev/null +++ b/src/features/cards/components/CardHeader.tsx @@ -0,0 +1,35 @@ +import { memo, useMemo } from 'react' +import { MY_LANGUAGES_OPTION } from '../config' + +type HeaderTitleProps = { + label: string + fallbackTag: { + label: string + value: string + } + selectedTag: { + label: string + value: string + } + children?: React.ReactNode +} +const CardHeader = ({ label, fallbackTag, selectedTag, children }: HeaderTitleProps) => { + if (children) { + return <>{children} + } + + const highlightLabel = useMemo(() => { + if (!selectedTag || selectedTag.value === fallbackTag.value) return null + if (selectedTag.value === MY_LANGUAGES_OPTION.value) return MY_LANGUAGES_OPTION.label + return selectedTag.label + }, [selectedTag, fallbackTag]) + + return ( + <> + {label} + {highlightLabel && {highlightLabel}} + + ) +} + +export const MemoizedCardHeader = memo(CardHeader) diff --git a/src/features/cards/components/CardSettings.tsx b/src/features/cards/components/CardSettings.tsx new file mode 100644 index 00000000..14a19eb6 --- /dev/null +++ b/src/features/cards/components/CardSettings.tsx @@ -0,0 +1,158 @@ +import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu' +import { memo, useCallback, useMemo } from 'react' +import { AiOutlineCode } from 'react-icons/ai' +import { BsBoxArrowInUpRight } from 'react-icons/bs' +import { FiFilter } from 'react-icons/fi' +import { GoGear } from 'react-icons/go' +import { IoTrashBinOutline } from 'react-icons/io5' +import { LiaSortSolid } from 'react-icons/lia' +import { MdDateRange } from 'react-icons/md' +import { useMediaQuery } from 'react-responsive' +import { ref } from 'src/config' +import { useUserPreferences } from 'src/stores/preferences' +import { useShallow } from 'zustand/shallow' +import { MY_LANGUAGES_OPTION } from '../config' + +type SortOption = { label: string; value: string; icon?: React.ReactNode } + +type CardSettingsProps = { + url?: string + id: string + sortBy?: string + globalTag?: { label: string; value: string } + language?: string + showLanguageFilter?: boolean + showDateRangeFilter?: boolean + customStartMenuItems?: React.ReactNode + sortOptions?: ((defaults: SortOption[]) => SortOption[]) | SortOption[] +} + +const DEFAULT_SORT_OPTIONS = [{ label: 'Newest', value: 'published_at', icon: }] +const SPECIAL_LABELS = ['global', MY_LANGUAGES_OPTION.label.toLowerCase()] + +const CardSettings = ({ + id, + url, + sortBy, + globalTag, + language, + showLanguageFilter = true, + showDateRangeFilter = true, + customStartMenuItems, + sortOptions, +}: CardSettingsProps) => { + const { userSelectedTags, openLinksNewTab, removeCard, setCardSettings, cardSettings } = + useUserPreferences( + useShallow((state) => ({ + userSelectedTags: state.userSelectedTags, + openLinksNewTab: state.openLinksNewTab, + removeCard: state.removeCard, + setCardSettings: state.setCardSettings, + cardSettings: state.cardsSettings?.[id], + })) + ) + + const userTagsMemo = useMemo(() => { + const tags = [...userSelectedTags] + .sort((a, b) => a.label.localeCompare(b.label)) + .concat(globalTag ? [globalTag] : []) + .concat(MY_LANGUAGES_OPTION) + return tags + }, [userSelectedTags, globalTag]) + + const resolvedSortOptions = useMemo(() => { + return typeof sortOptions === 'function' + ? sortOptions(DEFAULT_SORT_OPTIONS) + : sortOptions || DEFAULT_SORT_OPTIONS + }, [sortOptions]) + + const onOpenSourceUrlClicked = useCallback(() => { + if (!url) return + const link = `${url}?${ref}` + window.open(link, openLinksNewTab ? '_blank' : '_self') + }, [url, openLinksNewTab]) + + const firstSpecialIndex = useMemo( + () => userTagsMemo.findIndex((tag) => SPECIAL_LABELS.includes(tag.label.toLowerCase())), + [userTagsMemo] + ) + + const isMobile = useMediaQuery({ maxWidth: 767 }) + const menuIcon = isMobile ? : + + return ( + + {customStartMenuItems} + {showLanguageFilter && userTagsMemo.length > 0 && ( + + Language + + }> + {userTagsMemo.map((tag, i) => ( + <> + {i === firstSpecialIndex && } + { + setCardSettings(id, { ...cardSettings, language: tag.value }) + }}> + {tag.label} + + + ))} + + )} + + {showDateRangeFilter && ( + + + Sort by + + }> + {resolvedSortOptions.map((option) => ( + { + setCardSettings(id, { ...cardSettings, sortBy: option.value }) + }}> + {option.icon} {option.label} + + ))} + + )} + {(showDateRangeFilter || showLanguageFilter) && } + { + removeCard(id) + }}> + + Remove card + + + + Open in new tab + + + ) +} + +export const MemoizedCardSettings = memo(CardSettings) diff --git a/src/features/cards/components/aiCard/AICard.tsx b/src/features/cards/components/aiCard/AICard.tsx index 0261395a..6ab131f5 100644 --- a/src/features/cards/components/aiCard/AICard.tsx +++ b/src/features/cards/components/aiCard/AICard.tsx @@ -1,31 +1,46 @@ +import { useCallback, useMemo } from 'react' import { Card } from 'src/components/Elements' import { ListComponent } from 'src/components/List' import { FeedItem, useGetFeed } from 'src/features/feed' import { useUserPreferences } from 'src/stores/preferences' import { CardPropsType, FeedItemData } from 'src/types' +import { useShallow } from 'zustand/shallow' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { MemoizedCardSettings } from '../CardSettings' export function AICard(props: CardPropsType) { - const { meta, withAds, knob } = props - const { userSelectedTags } = useUserPreferences() + const { meta } = props + const userSelectedTags = useUserPreferences(useShallow((state) => state.userSelectedTags)) + const { ref, isVisible } = useLazyListLoad() + const queryTags = useMemo( + () => userSelectedTags.map((tag) => tag.label.toLocaleLowerCase()), + [userSelectedTags] + ) + const { data: articles, isLoading, error, } = useGetFeed({ - tags: userSelectedTags.map((tag) => tag.label.toLocaleLowerCase()), + tags: queryTags, config: { cacheTime: 0, staleTime: 0, useErrorBoundary: false, + enabled: isVisible, }, }) - const renderItem = (item: FeedItemData, index: number) => ( - + const renderItem = useCallback( + (item: FeedItemData) => , + [meta.analyticsTag] ) return ( - + } + {...props}> items={articles?.pages.flatMap((page) => page.data) || []} error={error} diff --git a/src/features/cards/components/conferencesCard/ConferenceItem.tsx b/src/features/cards/components/conferencesCard/ConferenceItem.tsx index 57981d8c..d6811e37 100644 --- a/src/features/cards/components/conferencesCard/ConferenceItem.tsx +++ b/src/features/cards/components/conferencesCard/ConferenceItem.tsx @@ -1,26 +1,32 @@ -import { CardLink, CardItemWithActions } from 'src/components/Elements' -import { Attributes } from 'src/lib/analytics' -import { BaseItemPropsType, Conference } from 'src/types' -import { MdAccessTime } from 'react-icons/md' -import { ColoredLanguagesBadge } from 'src/components/Elements' import { flag } from 'country-emoji' +import { useMemo } from 'react' import { IoIosPin } from 'react-icons/io' -import { RiCalendarEventFill } from 'react-icons/ri' +import { MdAccessTime } from 'react-icons/md' +import { CardItemWithActions, CardLink, ColoredLanguagesBadge } from 'src/components/Elements' +import { Attributes } from 'src/lib/analytics' import { useUserPreferences } from 'src/stores/preferences' +import { BaseItemPropsType, Conference } from 'src/types' -const ConferencesItem = ({ item, index, analyticsTag }: BaseItemPropsType) => { +const ConferencesItem = ({ item, analyticsTag }: BaseItemPropsType) => { const { listingMode } = useUserPreferences() - const ConferenceLocation = () => { + const conferenceLocation = useMemo(() => { if (item.online) { - return '🌐 Online' + return { + icon: '🌐', + label: 'Online', + } } if (item.country) { - return `${flag(item.country.replace(/[^a-zA-Z ]/g, ''))} ${item.city}` + return { + icon: flag(item.country.replace(/[^a-zA-Z ]/g, '')) || '🏳️', + label: item.city, + } } - } + return null + }, [item.online, item.country, item.city]) - const ConferenceDate = () => { + const conferenceDate = useMemo(() => { if (!item.start_date) { return '' } @@ -50,12 +56,20 @@ const ConferencesItem = ({ item, index, analyticsTag }: BaseItemPropsType { + if (!item.start_date) { + return 0 + } + const startDate = new Date(item.start_date) + const currentDate = new Date() + const diffTime = startDate.getTime() - currentDate.getTime() + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + }, [item.start_date]) return ( @@ -67,27 +81,35 @@ const ConferencesItem = ({ item, index, analyticsTag }: BaseItemPropsType - - {item.title} +
+ {differenceInDays < 0 && Ended}{' '} + {conferenceLocation?.icon} + {item.title} +
{listingMode === 'normal' ? ( <>
- {ConferenceLocation()} + {conferenceLocation?.label} - {ConferenceDate()} + {` `} + {differenceInDays > 0 + ? `In ${differenceInDays} days, ${conferenceDate}` + : differenceInDays === 0 + ? `Ongoing, ${conferenceDate}` + : `${conferenceDate}`} + + + -
-
-
) : (
- {ConferenceDate()} + {conferenceDate}
)} diff --git a/src/features/cards/components/conferencesCard/ConferencesCard.tsx b/src/features/cards/components/conferencesCard/ConferencesCard.tsx index cc07f635..66e21121 100644 --- a/src/features/cards/components/conferencesCard/ConferencesCard.tsx +++ b/src/features/cards/components/conferencesCard/ConferencesCard.tsx @@ -1,42 +1,72 @@ +import { useCallback } from 'react' import { Card } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' -import { useUserPreferences } from 'src/stores/preferences' +import { ListConferenceComponent } from 'src/components/List/ListConferenceComponent' import { CardPropsType, Conference } from 'src/types' -import { filterUniqueEntries, getCardTagsValue } from 'src/utils/DataEnhancement' import { useGetConferences } from '../../api/getConferences' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { useSelectedTags } from '../../hooks/useSelectedTags' +import { MemoizedCardHeader } from '../CardHeader' +import { MemoizedCardSettings } from '../CardSettings' import ConferenceItem from './ConferenceItem' +const GLOBAL_TAG = { label: 'Global', value: 'general' } export function ConferencesCard(props: CardPropsType) { const { meta } = props - const { userSelectedTags } = useUserPreferences() + const { ref, isVisible } = useLazyListLoad() + const { + queryTags, + selectedTag, + cardSettings: { sortBy, language } = {}, + } = useSelectedTags({ + source: meta.value, + fallbackTag: GLOBAL_TAG, + }) + const { + isLoading, + error, + data: results, + } = useGetConferences({ + tags: queryTags, + config: { + enabled: isVisible, + }, + }) - const results = useGetConferences({ tags: getCardTagsValue(userSelectedTags, 'confsValues') }) - - const isLoading = results.some((result) => result.isLoading) - - const getData = () => { - return filterUniqueEntries( - results - .reduce((acc: Conference[], curr) => { - if (!curr.data) return acc - return [...acc, ...curr.data] - }, []) - .sort((a, b) => a.start_date - b.start_date) - ) - } - - const renderItem = (item: Conference, index: number) => ( - + const renderItem = useCallback( + (item: Conference) => ( + + ), + [meta.analyticsTag] ) return ( - - + + } + settingsComponent={ + + }> + ) } diff --git a/src/features/cards/components/devtoCard/ArticleItem.tsx b/src/features/cards/components/devtoCard/ArticleItem.tsx index 25c642e9..9a9abed4 100644 --- a/src/features/cards/components/devtoCard/ArticleItem.tsx +++ b/src/features/cards/components/devtoCard/ArticleItem.tsx @@ -1,22 +1,19 @@ +import { AiOutlineLike } from 'react-icons/ai' import { BiCommentDetail } from 'react-icons/bi' -import { CardLink, CardItemWithActions } from 'src/components/Elements' +import { MdAccessTime } from 'react-icons/md' +import { CardItemWithActions, CardLink, ColoredLanguagesBadge } from 'src/components/Elements' import { Attributes } from 'src/lib/analytics' -import { BaseItemPropsType, Article } from 'src/types' import { useUserPreferences } from 'src/stores/preferences' +import { Article, BaseItemPropsType } from 'src/types' import { format } from 'timeago.js' -import { MdAccessTime } from 'react-icons/md' -import { AiOutlineLike } from 'react-icons/ai' -import { ColoredLanguagesBadge } from 'src/components/Elements' const ArticleItem = (props: BaseItemPropsType
) => { - const { item, index, selectedTag, analyticsTag } = props + const { item, analyticsTag } = props const { listingMode } = useUserPreferences() return ( @@ -24,16 +21,15 @@ const ArticleItem = (props: BaseItemPropsType
) => { link={item.url} analyticsAttributes={{ [Attributes.TRIGERED_FROM]: 'card', - [Attributes.POINTS]: item.reactions, + [Attributes.POINTS]: item.points_count, [Attributes.TITLE]: item.title, [Attributes.LINK]: item.url, [Attributes.SOURCE]: analyticsTag, - [Attributes.LANGUAGE]: selectedTag?.value, }}> {listingMode === 'compact' && (
- {item.reactions} + {item.points_count}
)}
{item.title}
@@ -48,11 +44,11 @@ const ArticleItem = (props: BaseItemPropsType
) => { - {item.comments} comments + {item.comments_count} comments - {item.reactions} reactions + {item.points_count} reactions

diff --git a/src/features/cards/components/devtoCard/DevtoCard.tsx b/src/features/cards/components/devtoCard/DevtoCard.tsx index 5cdcd8fe..2a079794 100644 --- a/src/features/cards/components/devtoCard/DevtoCard.tsx +++ b/src/features/cards/components/devtoCard/DevtoCard.tsx @@ -1,79 +1,84 @@ -import { Card, FloatingFilter, InlineTextFilter } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' -import { GLOBAL_TAG, MY_LANGUAGES_TAG } from 'src/config' -import { trackCardLanguageSelect } from 'src/lib/analytics' -import { useUserPreferences } from 'src/stores/preferences' +import { useCallback } from 'react' +import { AiOutlineLike } from 'react-icons/ai' +import { BiCommentDetail } from 'react-icons/bi' +import { Card } from 'src/components/Elements' +import { ListPostComponent } from 'src/components/List/ListPostComponent' import { Article, CardPropsType } from 'src/types' -import { filterUniqueEntries, getCardTagsValue } from 'src/utils/DataEnhancement' -import { useGetDevtoArticles } from '../../api/getDevtoArticles' +import { useGetSourceArticles } from '../../api/getSourceArticles' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { useSelectedTags } from '../../hooks/useSelectedTags' +import { MemoizedCardHeader } from '../CardHeader' +import { MemoizedCardSettings } from '../CardSettings' import ArticleItem from './ArticleItem' +const GLOBAL_TAG = { label: 'Global', value: 'programming' } + export function DevtoCard(props: CardPropsType) { const { meta } = props - const { userSelectedTags, cardsSettings, setCardSettings } = useUserPreferences() - - const selectedTag = - [GLOBAL_TAG, MY_LANGUAGES_TAG, ...userSelectedTags].find( - (lang) => lang.value === cardsSettings?.[meta.value]?.language - ) || GLOBAL_TAG - - const getQueryTags = () => { - if (!selectedTag) { - return [] - } - - if (selectedTag.value === MY_LANGUAGES_TAG.devtoValues[0]) { - return getCardTagsValue(userSelectedTags, 'devtoValues') - } - return selectedTag.devtoValues - } + const { ref, isVisible } = useLazyListLoad() - const results = useGetDevtoArticles({ tags: getQueryTags() }) + const { + queryTags, + selectedTag, + cardSettings: { sortBy, language } = {}, + } = useSelectedTags({ + source: meta.value, + fallbackTag: GLOBAL_TAG, + }) - const getIsLoading = () => results.some((result) => result.isLoading) + const { + data: results, + error, + isLoading, + } = useGetSourceArticles({ + source: 'devto', + tags: queryTags, + config: { + enabled: isVisible, + }, + }) - const getData = () => { - return filterUniqueEntries( - results.reduce((acc: Article[], curr) => { - if (!curr.data) return acc - return [...acc, ...curr.data] - }, []) - ) - } - - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Article) => , + [meta.analyticsTag] ) - const HeaderTitle = () => { - return ( - <> - {meta.label} - ({ - label: tag.label, - value: tag.value, - }))} - onChange={(item) => { - setCardSettings(meta.value, { ...cardsSettings[meta.value], language: item.value }) - trackCardLanguageSelect(meta.analyticsTag, item.value) - }} - value={cardsSettings?.[meta.value]?.language} - /> - - ) - } - return ( - } {...props}> - - + + } + settingsComponent={ + [ + ...defaults, + { + label: 'Reactions', + value: 'points_count', + icon: , + }, + { + label: 'Comments', + value: 'comments_count', + icon: , + }, + ]} + /> + } + {...props}> + ) } diff --git a/src/features/cards/components/freecodecampCard/ArticleItem.tsx b/src/features/cards/components/freecodecampCard/ArticleItem.tsx index 22cc3638..01262328 100644 --- a/src/features/cards/components/freecodecampCard/ArticleItem.tsx +++ b/src/features/cards/components/freecodecampCard/ArticleItem.tsx @@ -1,17 +1,16 @@ -import { CardLink, CardItemWithActions } from 'src/components/Elements' +import { MdAccessTime } from 'react-icons/md' +import { CardItemWithActions, CardLink, ColoredLanguagesBadge } from 'src/components/Elements' import { Attributes } from 'src/lib/analytics' -import { BaseItemPropsType, Article } from 'src/types' +import { useUserPreferences } from 'src/stores/preferences' +import { Article, BaseItemPropsType } from 'src/types' import { format } from 'timeago.js' -import { MdAccessTime } from 'react-icons/md' -import { ColoredLanguagesBadge } from 'src/components/Elements' const ArticleItem = (props: BaseItemPropsType

) => { - const { item, index, selectedTag, analyticsTag } = props + const { item, selectedTag, analyticsTag } = props + const { listingMode } = useUserPreferences() return ( @@ -26,17 +25,19 @@ const ArticleItem = (props: BaseItemPropsType
) => { }}>
{item.title}
- <> -

- - - {format(new Date(item.published_at))} - -

-

- -

- + {listingMode === 'normal' && ( + <> +

+ + + {format(new Date(item.published_at))} + +

+

+ +

+ + )} } /> diff --git a/src/features/cards/components/freecodecampCard/FreecodecampCard.tsx b/src/features/cards/components/freecodecampCard/FreecodecampCard.tsx index 62a4ff40..ff11f745 100644 --- a/src/features/cards/components/freecodecampCard/FreecodecampCard.tsx +++ b/src/features/cards/components/freecodecampCard/FreecodecampCard.tsx @@ -1,80 +1,65 @@ -import { Card, FloatingFilter, InlineTextFilter } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' -import { GLOBAL_TAG, MY_LANGUAGES_TAG } from 'src/config' -import { trackCardLanguageSelect } from 'src/lib/analytics' -import { useUserPreferences } from 'src/stores/preferences' +import { useCallback } from 'react' +import { Card } from 'src/components/Elements' +import { ListPostComponent } from 'src/components/List/ListPostComponent' import { Article, CardPropsType } from 'src/types' -import { filterUniqueEntries, getCardTagsValue } from 'src/utils/DataEnhancement' -import { useGetFreeCodeCampArticles } from '../../api/getFreeCodeCampArticles' +import { useGetSourceArticles } from '../../api/getSourceArticles' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { useSelectedTags } from '../../hooks/useSelectedTags' +import { MemoizedCardHeader } from '../CardHeader' +import { MemoizedCardSettings } from '../CardSettings' import ArticleItem from './ArticleItem' +const GLOBAL_TAG = { label: 'Global', value: '' } + export function FreecodecampCard(props: CardPropsType) { const { meta } = props - const { userSelectedTags, cardsSettings, setCardSettings } = useUserPreferences() - const selectedTag = - [GLOBAL_TAG, MY_LANGUAGES_TAG, ...userSelectedTags].find( - (lang) => lang.value === cardsSettings?.[meta.value]?.language - ) || GLOBAL_TAG - - const getQueryTags = () => { - if (!selectedTag) { - return [] - } - - if (selectedTag.value === MY_LANGUAGES_TAG.freecodecampValues[0]) { - return getCardTagsValue(userSelectedTags, 'freecodecampValues') - } - return selectedTag.freecodecampValues - } - - const results = useGetFreeCodeCampArticles({ tags: getQueryTags() }) + const { ref, isVisible } = useLazyListLoad() + const { + queryTags, + selectedTag, + cardSettings: { sortBy, language } = {}, + } = useSelectedTags({ + source: meta.value, + fallbackTag: GLOBAL_TAG, + }) - const getIsLoading = () => results.some((result) => result.isLoading) + const { data, error, isLoading } = useGetSourceArticles({ + source: 'freecodecamp', + tags: queryTags, + config: { + enabled: isVisible, + }, + }) - const getData = () => { - return filterUniqueEntries( - results - .reduce((acc: Article[], curr) => { - if (!curr.data) return acc - return [...acc, ...curr.data] - }, []) - .sort((a, b) => b.published_at - a.published_at) - ) - } - - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Article) => , + [meta.analyticsTag] ) - const HeaderTitle = () => { - return ( - <> - {meta.label} - ({ - label: tag.label, - value: tag.value, - }))} - onChange={(item) => { - setCardSettings(meta.value, { ...cardsSettings[meta.value], language: item.value }) - trackCardLanguageSelect(meta.analyticsTag, item.value) - }} - value={cardsSettings?.[meta.value]?.language} - /> - - ) - } - return ( - } {...props}> - - + + } + settingsComponent={ + + } + {...props}> + ) } diff --git a/src/features/cards/components/githubCard/GithubCard.tsx b/src/features/cards/components/githubCard/GithubCard.tsx index 570fac55..bca8aeab 100644 --- a/src/features/cards/components/githubCard/GithubCard.tsx +++ b/src/features/cards/components/githubCard/GithubCard.tsx @@ -1,109 +1,117 @@ -import { Card, FloatingFilter, InlineTextFilter } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' -import { GLOBAL_TAG, MY_LANGUAGES_TAG, dateRanges } from 'src/config' -import { trackCardDateRangeSelect, trackCardLanguageSelect } from 'src/lib/analytics' +import { MenuDivider, MenuItem } from '@szhsin/react-menu' +import { useCallback, useMemo } from 'react' +import { VscRepoForked, VscStarFull } from 'react-icons/vsc' +import { Card } from 'src/components/Elements' +import { ListRepoComponent } from 'src/components/List/ListRepoComponent' +import { dateRanges } from 'src/config' import { useUserPreferences } from 'src/stores/preferences' import { CardPropsType, Repository } from 'src/types' -import { filterUniqueEntries, getCardTagsValue } from 'src/utils/DataEnhancement' import { useGetGithubRepos } from '../../api/getGithubRepos' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { useSelectedTags } from '../../hooks/useSelectedTags' +import { MemoizedCardSettings } from '../CardSettings' import RepoItem from './RepoItem' -export function GithubCard(props: CardPropsType) { - const { meta, withAds, knob } = props - const { userSelectedTags, cardsSettings, setCardSettings } = useUserPreferences() - - const selectedTag = - [GLOBAL_TAG, MY_LANGUAGES_TAG, ...userSelectedTags].find( - (lang) => lang.value === cardsSettings?.[meta.value]?.language - ) || GLOBAL_TAG +const GLOBAL_TAG = { label: 'Global', value: 'global' } - const selectedDateRange = - dateRanges.find((date) => date.value === cardsSettings?.[meta.value]?.dateRange) || - dateRanges[0] +export function GithubCard(props: CardPropsType) { + const { meta } = props - const getQueryTags = () => { - if (!selectedTag?.githubValues) { - return [] - } + const { ref, isVisible } = useLazyListLoad() + const setCardSettings = useUserPreferences((state) => state.setCardSettings) + const { queryTags, selectedTag, cardSettings } = useSelectedTags({ + source: meta.value, + fallbackTag: GLOBAL_TAG, + }) + const { dateRange, language, sortBy } = cardSettings ?? {} - if (selectedTag.value === MY_LANGUAGES_TAG.githubValues[0]) { - return getCardTagsValue(userSelectedTags, 'githubValues') - } - return selectedTag.githubValues - } + const selectedDateRange = useMemo( + () => dateRanges.find((date) => date.value === dateRange) || dateRanges[0], + [dateRange] + ) - const results = useGetGithubRepos({ - tags: getQueryTags(), + const { data, error, isLoading } = useGetGithubRepos({ + tags: queryTags, dateRange: selectedDateRange.value, config: { - enabled: !!selectedTag?.githubValues, + enabled: isVisible, }, }) - const getIsLoading = () => results.some((result) => result.isLoading) - - const getData = () => { - return filterUniqueEntries( - results.reduce((acc: Repository[], curr) => { - if (!curr.data) return acc - return [...acc, ...curr.data] - }, []) - ) - } - - const renderItem = (item: Repository, index: number) => ( - + const renderItem = useCallback( + (item: Repository) => ( + + ), + [meta.analyticsTag, selectedTag] ) - const HeaderTitle = () => { + const headerTitle = useMemo(() => { return ( <> - ({ - label: tag.label, - value: tag.value, - }))} - onChange={(item) => { - setCardSettings(meta.value, { ...cardsSettings[meta.value], language: item.value }) - trackCardLanguageSelect(meta.analyticsTag, item.value) - }} - value={cardsSettings?.[meta.value]?.language} - /> - Repos of - { - setCardSettings(meta.value, { ...cardsSettings[meta.value], dateRange: item.value }) - trackCardDateRangeSelect(meta.analyticsTag, item.value) - }} - value={cardsSettings?.[meta.value]?.dateRange} - /> + Github {selectedTag.label}{' '} + {selectedDateRange.label.toLowerCase()} ) - } + }, [selectedTag, selectedDateRange]) - const getError = () => { - if (!selectedTag?.githubValues) { - return `Github Trending does not support ${selectedTag?.label || 'the selected tag'}.` - } else if (results.every((result) => result.isError)) { - return 'Failed to load Github trending repositories' - } else { - return undefined - } - } return ( - } {...props}> - - + {dateRanges.map((date) => ( + { + setCardSettings(meta.value, { ...cardSettings, dateRange: date.value }) + }}> + {date.label} + + ))} + + + } + sortOptions={[ + { + label: 'Stars', + value: 'stars_count', + icon: , + }, + { + label: 'Stars today', + value: 'stars_in_range', + icon: , + }, + { + label: 'Forks', + value: 'forks_count', + icon: , + }, + ]} + /> + } + {...props}> + diff --git a/src/features/cards/components/githubCard/RepoItem.tsx b/src/features/cards/components/githubCard/RepoItem.tsx index bec834a9..d915a4e3 100644 --- a/src/features/cards/components/githubCard/RepoItem.tsx +++ b/src/features/cards/components/githubCard/RepoItem.tsx @@ -1,23 +1,21 @@ -import { CardLink, CardItemWithActions } from 'src/components/Elements' +import { CardItemWithActions, CardLink } from 'src/components/Elements' -import { Attributes } from 'src/lib/analytics' -import { ColoredLanguagesBadge } from 'src/components/Elements' import { VscRepo, VscRepoForked, VscStarFull } from 'react-icons/vsc' -import { BaseItemPropsType, Repository } from 'src/types' +import { ColoredLanguagesBadge } from 'src/components/Elements' +import { Attributes } from 'src/lib/analytics' import { useUserPreferences } from 'src/stores/preferences' +import { BaseItemPropsType, Repository } from 'src/types' function numberWithCommas(x: number | string) { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') } -const RepoItem = ({ item, index, selectedTag, analyticsTag }: BaseItemPropsType) => { +const RepoItem = ({ item, selectedTag, analyticsTag }: BaseItemPropsType) => { const { listingMode } = useUserPreferences() return ( @@ -25,28 +23,35 @@ const RepoItem = ({ item, index, selectedTag, analyticsTag }: BaseItemPropsType< className="githubTitle" link={item.url} analyticsAttributes={{ - [Attributes.POINTS]: item.stars, + [Attributes.POINTS]: item.stars_count, [Attributes.TRIGERED_FROM]: 'card', - [Attributes.TITLE]: item.title, + [Attributes.TITLE]: item.name, [Attributes.LINK]: item.url, [Attributes.SOURCE]: analyticsTag, [Attributes.LANGUAGE]: selectedTag?.value, }}> - {item.title} + {item.owner}/{item.name}

{item.description}

{listingMode === 'normal' && (
- - {numberWithCommas(item.stars) && ( + + {numberWithCommas(item.stars_count) && ( + + {numberWithCommas(item.stars_count)} stars + + )} + {item.stars_in_range && ( - {numberWithCommas(item.stars)} stars + {' '} + {numberWithCommas(item.stars_in_range || 0)} stars today )} - {item.forks && ( + {item.forks_count && ( - {numberWithCommas(item.forks)} forks + {numberWithCommas(item.forks_count)}{' '} + forks )}
diff --git a/src/features/cards/components/hackernewsCard/ArticleItem.tsx b/src/features/cards/components/hackernewsCard/ArticleItem.tsx index aefc8dfd..aa6de431 100644 --- a/src/features/cards/components/hackernewsCard/ArticleItem.tsx +++ b/src/features/cards/components/hackernewsCard/ArticleItem.tsx @@ -9,22 +9,20 @@ import { Article, BaseItemPropsType } from 'src/types' import { format } from 'timeago.js' const ArticleItem = (props: BaseItemPropsType
) => { - const { item, index, analyticsTag } = props + const { item, analyticsTag } = props const { listingMode } = useUserPreferences() return (

) => { {listingMode === 'compact' && ( - {item.reactions} + {item.points_count} )} @@ -43,7 +41,7 @@ const ArticleItem = (props: BaseItemPropsType

) => { {listingMode === 'normal' && (
- {item.reactions} points + {item.points_count} points {format(new Date(item.published_at))} @@ -52,13 +50,13 @@ const ArticleItem = (props: BaseItemPropsType
) => { link={`https://news.ycombinator.com/item?id=${item.id}`} className="rowItem rowItemClickable" analyticsAttributes={{ - [Attributes.POINTS]: item.reactions, + [Attributes.POINTS]: item.comments_count, [Attributes.TRIGERED_FROM]: 'card', [Attributes.TITLE]: `${item.title} comments`, [Attributes.LINK]: `https://news.ycombinator.com/item?id=${item.id}`, [Attributes.SOURCE]: analyticsTag, }}> - {item.comments} comments + {item.comments_count} comments
)} diff --git a/src/features/cards/components/hackernewsCard/HackernewsCard.tsx b/src/features/cards/components/hackernewsCard/HackernewsCard.tsx index d08a2160..03aaeafb 100644 --- a/src/features/cards/components/hackernewsCard/HackernewsCard.tsx +++ b/src/features/cards/components/hackernewsCard/HackernewsCard.tsx @@ -1,20 +1,65 @@ +import { useCallback } from 'react' +import { BiCommentDetail, BiSolidCircle } from 'react-icons/bi' import { Card } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' +import { ListPostComponent } from 'src/components/List/ListPostComponent' +import { useUserPreferences } from 'src/stores/preferences' import { Article, CardPropsType } from 'src/types' -import { useGetHackertNewsArticles } from '../../api/getHackerNewsArticles' +import { useShallow } from 'zustand/shallow' +import { useGetSourceArticles } from '../../api/getSourceArticles' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { MemoizedCardSettings } from '../CardSettings' import ArticleItem from './ArticleItem' export function HackernewsCard(props: CardPropsType) { const { meta } = props - const { data: articles = [], isLoading, error } = useGetHackertNewsArticles() + const { ref, isVisible } = useLazyListLoad() + const sortBy = useUserPreferences( + useShallow((state) => state.cardsSettings?.[meta.value]?.sortBy) + ) + const { data, isLoading, error } = useGetSourceArticles({ + source: 'hackernews', + config: { + enabled: isVisible, + }, + }) - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Article) => , + [meta.analyticsTag] ) return ( - - + [ + ...defaults, + { + label: 'Points', + value: 'points_count', + icon: , + }, + { + label: 'Comments', + value: 'comments_count', + icon: , + }, + ]} + /> + }> + ) } diff --git a/src/features/cards/components/hackernoonCard/ArticleItem.tsx b/src/features/cards/components/hackernoonCard/ArticleItem.tsx new file mode 100644 index 00000000..01262328 --- /dev/null +++ b/src/features/cards/components/hackernoonCard/ArticleItem.tsx @@ -0,0 +1,47 @@ +import { MdAccessTime } from 'react-icons/md' +import { CardItemWithActions, CardLink, ColoredLanguagesBadge } from 'src/components/Elements' +import { Attributes } from 'src/lib/analytics' +import { useUserPreferences } from 'src/stores/preferences' +import { Article, BaseItemPropsType } from 'src/types' +import { format } from 'timeago.js' + +const ArticleItem = (props: BaseItemPropsType
) => { + const { item, selectedTag, analyticsTag } = props + const { listingMode } = useUserPreferences() + return ( + + +
{item.title}
+
+ {listingMode === 'normal' && ( + <> +

+ + + {format(new Date(item.published_at))} + +

+

+ +

+ + )} + + } + /> + ) +} + +export default ArticleItem diff --git a/src/features/cards/components/hackernoonCard/HackernoonCard.tsx b/src/features/cards/components/hackernoonCard/HackernoonCard.tsx new file mode 100644 index 00000000..0f5d7cb8 --- /dev/null +++ b/src/features/cards/components/hackernoonCard/HackernoonCard.tsx @@ -0,0 +1,65 @@ +import { useCallback } from 'react' +import { Card } from 'src/components/Elements' +import { ListPostComponent } from 'src/components/List/ListPostComponent' +import { Article, CardPropsType } from 'src/types' +import { useGetSourceArticles } from '../../api/getSourceArticles' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { useSelectedTags } from '../../hooks/useSelectedTags' +import { MemoizedCardHeader } from '../CardHeader' +import { MemoizedCardSettings } from '../CardSettings' +import ArticleItem from './ArticleItem' + +const GLOBAL_TAG = { label: 'Global', value: '' } + +export function HackernoonCard(props: CardPropsType) { + const { meta } = props + const { ref, isVisible } = useLazyListLoad() + const { + queryTags, + selectedTag, + cardSettings: { sortBy, language } = {}, + } = useSelectedTags({ + source: meta.value, + fallbackTag: GLOBAL_TAG, + }) + + const { data, error, isLoading } = useGetSourceArticles({ + source: 'hackernoon', + tags: queryTags, + config: { + enabled: isVisible, + }, + }) + + const renderItem = useCallback( + (item: Article) => , + [meta.analyticsTag] + ) + + return ( + + } + settingsComponent={ + + } + {...props}> + + + ) +} diff --git a/src/features/cards/components/hackernoonCard/index.ts b/src/features/cards/components/hackernoonCard/index.ts new file mode 100644 index 00000000..d54a47dc --- /dev/null +++ b/src/features/cards/components/hackernoonCard/index.ts @@ -0,0 +1 @@ +export * from './HackernoonCard' diff --git a/src/features/cards/components/hashnodeCard/ArticleItem.tsx b/src/features/cards/components/hashnodeCard/ArticleItem.tsx index a3bc88b4..a751fefa 100644 --- a/src/features/cards/components/hashnodeCard/ArticleItem.tsx +++ b/src/features/cards/components/hashnodeCard/ArticleItem.tsx @@ -1,31 +1,29 @@ import { BiCommentDetail } from 'react-icons/bi' -import { CardLink, CardItemWithActions } from 'src/components/Elements' +import { CardItemWithActions, CardLink } from 'src/components/Elements' -import { BaseItemPropsType, Article } from 'src/types' -import { format } from 'timeago.js' import { MdAccessTime } from 'react-icons/md' import { ColoredLanguagesBadge } from 'src/components/Elements' import { useUserPreferences } from 'src/stores/preferences' +import { Article, BaseItemPropsType } from 'src/types' +import { format } from 'timeago.js' import { AiTwotoneHeart } from 'react-icons/ai' import { Attributes } from 'src/lib/analytics' const ArticleItem = (props: BaseItemPropsType
) => { - const { item, index, selectedTag, analyticsTag } = props + const { item, selectedTag, analyticsTag } = props const { listingMode } = useUserPreferences() return ( ) => { {listingMode === 'compact' && (
- {item.reactions || 0} + {item.points_count || 0}
)}
{item.title}
@@ -50,11 +48,11 @@ const ArticleItem = (props: BaseItemPropsType
) => { - {item.comments || 0} comments + {item.comments_count || 0} comments - {item.reactions || 0} reactions + {item.points_count || 0} reactions

diff --git a/src/features/cards/components/hashnodeCard/HashnodeCard.tsx b/src/features/cards/components/hashnodeCard/HashnodeCard.tsx index 58ca3c03..02066181 100644 --- a/src/features/cards/components/hashnodeCard/HashnodeCard.tsx +++ b/src/features/cards/components/hashnodeCard/HashnodeCard.tsx @@ -1,80 +1,78 @@ -import { Card, FloatingFilter, InlineTextFilter } from 'src/components/Elements' +import { useCallback } from 'react' +import { AiTwotoneHeart } from 'react-icons/ai' +import { BiCommentDetail } from 'react-icons/bi' +import { Card } from 'src/components/Elements' import { ListComponent } from 'src/components/List' -import { GLOBAL_TAG, MY_LANGUAGES_TAG } from 'src/config' -import { trackCardLanguageSelect } from 'src/lib/analytics' -import { useUserPreferences } from 'src/stores/preferences' import { Article, CardPropsType } from 'src/types' -import { filterUniqueEntries, getCardTagsValue } from 'src/utils/DataEnhancement' -import { useGetHashnodeArticles } from '../../api/getHashnodeArticles' +import { useGetSourceArticles } from '../../api/getSourceArticles' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { useSelectedTags } from '../../hooks/useSelectedTags' +import { MemoizedCardHeader } from '../CardHeader' +import { MemoizedCardSettings } from '../CardSettings' import ArticleItem from './ArticleItem' +const GLOBAL_TAG = { label: 'Global', value: '' } + export function HashnodeCard(props: CardPropsType) { const { meta } = props - const { userSelectedTags, cardsSettings, setCardSettings } = useUserPreferences() - const selectedTag = - [GLOBAL_TAG, MY_LANGUAGES_TAG, ...userSelectedTags].find( - (lang) => lang.value === cardsSettings?.[meta.value]?.language - ) || GLOBAL_TAG - - const getQueryTags = () => { - if (!selectedTag) { - return [] - } - - if (selectedTag.value === MY_LANGUAGES_TAG.hashnodeValues[0]) { - return getCardTagsValue(userSelectedTags, 'hashnodeValues') - } - return selectedTag.hashnodeValues - } - - const results = useGetHashnodeArticles({ tags: getQueryTags() }) - - const getIsLoading = () => results.some((result) => result.isLoading) + const { ref, isVisible } = useLazyListLoad() + const { + queryTags, + selectedTag, + cardSettings: { sortBy, language } = {}, + } = useSelectedTags({ + source: meta.value, + fallbackTag: GLOBAL_TAG, + }) + const { data, error, isLoading } = useGetSourceArticles({ + source: 'hashnode', + tags: queryTags, + config: { + enabled: isVisible, + }, + }) - const getData = () => { - return filterUniqueEntries( - results - .reduce((acc: Article[], curr) => { - if (!curr.data) return acc - return [...acc, ...curr.data] - }, []) - .sort((a, b) => b.published_at - a.published_at) - ) - } - - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Article) => , + [meta.analyticsTag] ) - const HeaderTitle = () => { - return ( - <> - {meta.label} - ({ - label: tag.label, - value: tag.value, - }))} - onChange={(item) => { - setCardSettings(meta.value, { ...cardsSettings[meta.value], language: item.value }) - trackCardLanguageSelect(meta.analyticsTag, item.value) - }} - value={cardsSettings?.[meta.value]?.language} - /> - - ) - } - return ( - } {...props}> - - + + } + settingsComponent={ + [ + ...defaults, + { + label: 'Reactions', + value: 'points_count', + icon: , + }, + { + label: 'Comments', + value: 'comments_count', + icon: , + }, + ]} + /> + } + {...props}> + + sortBy={sortBy as keyof Article} + error={error} + items={data} + isLoading={isLoading} + renderItem={renderItem} + /> ) } diff --git a/src/features/cards/components/indiehackersCard/ArticleItem.tsx b/src/features/cards/components/indiehackersCard/ArticleItem.tsx index fa0248fd..a25b3d7c 100644 --- a/src/features/cards/components/indiehackersCard/ArticleItem.tsx +++ b/src/features/cards/components/indiehackersCard/ArticleItem.tsx @@ -1,21 +1,20 @@ -import { format } from 'timeago.js' -import { VscTriangleUp } from 'react-icons/vsc' import { BiCommentDetail } from 'react-icons/bi' +import { FaChevronUp } from 'react-icons/fa' import { MdAccessTime } from 'react-icons/md' -import { CardLink, CardItemWithActions } from 'src/components/Elements' +import { VscTriangleUp } from 'react-icons/vsc' +import { CardItemWithActions, CardLink } from 'src/components/Elements' import { Attributes } from 'src/lib/analytics' -import { BaseItemPropsType, Article } from 'src/types' -import { FaChevronUp } from 'react-icons/fa' import { useUserPreferences } from 'src/stores/preferences' +import { Article, BaseItemPropsType } from 'src/types' +import { format } from 'timeago.js' export const ArticleItem = (props: BaseItemPropsType

) => { - const { item, index, analyticsTag } = props + const { item, analyticsTag } = props const { listingMode } = useUserPreferences() return ( ) => { ) => { {listingMode === 'compact' && ( - {item.reactions} + {item.points_count} )} @@ -43,13 +42,13 @@ export const ArticleItem = (props: BaseItemPropsType
) => { {listingMode === 'normal' && (
- {item.reactions} points + {item.points_count} points {format(new Date(item.published_at))} - {item.comments} comments + {item.comments_count} comments
)} diff --git a/src/features/cards/components/indiehackersCard/IndiehackersCard.tsx b/src/features/cards/components/indiehackersCard/IndiehackersCard.tsx index 3309496f..7d9ee416 100644 --- a/src/features/cards/components/indiehackersCard/IndiehackersCard.tsx +++ b/src/features/cards/components/indiehackersCard/IndiehackersCard.tsx @@ -1,20 +1,61 @@ +import { useCallback } from 'react' +import { FaChevronUp } from 'react-icons/fa' import { Card } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' +import { ListPostComponent } from 'src/components/List/ListPostComponent' +import { useUserPreferences } from 'src/stores/preferences' import { Article, CardPropsType } from 'src/types' -import { useGetIndieHackersArticles } from '../../api/getIndieHackersArticles' +import { useShallow } from 'zustand/shallow' +import { useGetSourceArticles } from '../../api/getSourceArticles' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { MemoizedCardSettings } from '../CardSettings' import { ArticleItem } from './ArticleItem' export function IndiehackersCard(props: CardPropsType) { const { meta } = props - const { data: articles = [], isLoading, error } = useGetIndieHackersArticles() + const { ref, isVisible } = useLazyListLoad() + const sortBy = useUserPreferences( + useShallow((state) => state.cardsSettings?.[meta.value]?.sortBy) + ) + + const { data, isLoading, error } = useGetSourceArticles({ + source: 'indiehackers', + config: { + enabled: isVisible, + }, + }) - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Article) => , + [meta.analyticsTag] ) return ( - - + [ + ...defaults, + { + label: 'Points', + value: 'points_count', + icon: , + }, + ]} + /> + } + {...props}> + ) } diff --git a/src/features/cards/components/lobstersCard/ArticleItem.tsx b/src/features/cards/components/lobstersCard/ArticleItem.tsx index 919d4423..c2123c6a 100644 --- a/src/features/cards/components/lobstersCard/ArticleItem.tsx +++ b/src/features/cards/components/lobstersCard/ArticleItem.tsx @@ -8,22 +8,20 @@ import { useUserPreferences } from 'src/stores/preferences' import { Article, BaseItemPropsType } from 'src/types' import { format } from 'timeago.js' -const ArticleItem = ({ item, index, analyticsTag }: BaseItemPropsType
) => { +const ArticleItem = ({ item, analyticsTag }: BaseItemPropsType
) => { const { listingMode } = useUserPreferences() return (

) {listingMode === 'compact' && (

- {item.reactions} + {item.points_count}
)} -
{item.title}
+ {item.title}

{listingMode === 'normal' && (
- {item.reactions} points + {item.points_count} points {format(new Date(item.published_at))} @@ -51,13 +49,13 @@ const ArticleItem = ({ item, index, analyticsTag }: BaseItemPropsType
) link={item.comments_url as string} className="rowItem rowItemClickable" analyticsAttributes={{ - [Attributes.POINTS]: item.reactions, + [Attributes.POINTS]: item.points_count, [Attributes.TRIGERED_FROM]: 'card', [Attributes.TITLE]: `${item.title} comments`, [Attributes.LINK]: item.comments_url, [Attributes.SOURCE]: analyticsTag, }}> - {item.comments} comments + {item.comments_count} comments
)} diff --git a/src/features/cards/components/lobstersCard/LobstersCard.tsx b/src/features/cards/components/lobstersCard/LobstersCard.tsx index b9e9c679..597aef8a 100644 --- a/src/features/cards/components/lobstersCard/LobstersCard.tsx +++ b/src/features/cards/components/lobstersCard/LobstersCard.tsx @@ -1,20 +1,60 @@ +import { useCallback } from 'react' +import { BiSolidCircle } from 'react-icons/bi' import { Card } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' +import { ListPostComponent } from 'src/components/List/ListPostComponent' +import { useUserPreferences } from 'src/stores/preferences' import { Article, CardPropsType } from 'src/types' -import { useGetLobstersArticles } from '../../api/getLobstersArticles' +import { useShallow } from 'zustand/shallow' +import { useGetSourceArticles } from '../../api/getSourceArticles' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { MemoizedCardSettings } from '../CardSettings' import ArticleItem from './ArticleItem' export function LobstersCard(props: CardPropsType) { const { meta } = props - const { data: articles = [], isLoading, error } = useGetLobstersArticles() + const { ref, isVisible } = useLazyListLoad() + const sortBy = useUserPreferences( + useShallow((state) => state.cardsSettings?.[meta.value]?.sortBy) + ) + const { data, isLoading, error } = useGetSourceArticles({ + source: 'lobsters', + config: { + enabled: isVisible, + }, + }) - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Article) => , + [meta.analyticsTag] ) return ( - - + [ + ...defaults, + { + label: 'Points', + value: 'points_count', + icon: , + }, + ]} + /> + }> + ) } diff --git a/src/features/cards/components/mediumCard/ArticleItem.tsx b/src/features/cards/components/mediumCard/ArticleItem.tsx index 492ba06c..62490ca3 100644 --- a/src/features/cards/components/mediumCard/ArticleItem.tsx +++ b/src/features/cards/components/mediumCard/ArticleItem.tsx @@ -1,27 +1,24 @@ import { BiCommentDetail } from 'react-icons/bi' -import { CardLink, CardItemWithActions } from 'src/components/Elements' +import { MdAccessTime, MdWavingHand } from 'react-icons/md' +import { CardItemWithActions, CardLink } from 'src/components/Elements' import { Attributes } from 'src/lib/analytics' -import { BaseItemPropsType, Article } from 'src/types' import { useUserPreferences } from 'src/stores/preferences' +import { Article, BaseItemPropsType } from 'src/types' import { format } from 'timeago.js' -import { MdAccessTime } from 'react-icons/md' -import { MdWavingHand } from 'react-icons/md' -const ArticleItem = ({ item, index, selectedTag, analyticsTag }: BaseItemPropsType
) => { +const ArticleItem = ({ item, selectedTag, analyticsTag }: BaseItemPropsType
) => { const { listingMode } = useUserPreferences() return ( - {item.reactions || 0} + {item.points_count || 0}
)}
{item.title}
@@ -40,14 +37,13 @@ const ArticleItem = ({ item, index, selectedTag, analyticsTag }: BaseItemPropsTy {listingMode === 'normal' && (

- {item.reactions || 0} claps + {item.points_count || 0} claps - {item.comments || 0} comments + {item.comments_count || 0} comments - - {format(new Date(item.published_at))} + {format(new Date(item.published_at))}

)} diff --git a/src/features/cards/components/mediumCard/MediumCard.tsx b/src/features/cards/components/mediumCard/MediumCard.tsx index d8eeb9f2..1e342860 100644 --- a/src/features/cards/components/mediumCard/MediumCard.tsx +++ b/src/features/cards/components/mediumCard/MediumCard.tsx @@ -1,78 +1,85 @@ -import { Card, FloatingFilter, InlineTextFilter } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' -import { GLOBAL_TAG, MY_LANGUAGES_TAG } from 'src/config' -import { trackCardLanguageSelect } from 'src/lib/analytics' -import { useUserPreferences } from 'src/stores/preferences' +import { useCallback } from 'react' +import { BiCommentDetail } from 'react-icons/bi' +import { MdWavingHand } from 'react-icons/md' +import { Card } from 'src/components/Elements' +import { ListPostComponent } from 'src/components/List/ListPostComponent' import { Article, CardPropsType } from 'src/types' -import { filterUniqueEntries, getCardTagsValue } from 'src/utils/DataEnhancement' -import { useGetMediumArticles } from '../../api/getMediumArticles' +import { useGetSourceArticles } from '../../api/getSourceArticles' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { useSelectedTags } from '../../hooks/useSelectedTags' +import { MemoizedCardHeader } from '../CardHeader' +import { MemoizedCardSettings } from '../CardSettings' import ArticleItem from './ArticleItem' -export function MediumCard(props: CardPropsType) { - const { meta, withAds } = props - const { userSelectedTags, cardsSettings, setCardSettings } = useUserPreferences() - const selectedTag = - [GLOBAL_TAG, MY_LANGUAGES_TAG, ...userSelectedTags].find( - (lang) => lang.value === cardsSettings?.[meta.value]?.language - ) || GLOBAL_TAG - - const getQueryTags = () => { - if (!selectedTag) { - return [] - } - - if (selectedTag.value === MY_LANGUAGES_TAG.mediumValues[0]) { - return getCardTagsValue(userSelectedTags, 'mediumValues') - } - return selectedTag.mediumValues - } - - const results = useGetMediumArticles({ tags: getQueryTags() }) - - const getIsLoading = () => results.some((result) => result.isLoading) +const GLOBAL_TAG = { label: 'Global', value: 'programming' } - const getData = () => { - return filterUniqueEntries( - results.reduce((acc: Article[], curr) => { - if (!curr.data) return acc - return [...acc, ...curr.data] - }, []) - ) - } +export function MediumCard(props: CardPropsType) { + const { meta } = props + const { ref, isVisible } = useLazyListLoad() + const { + queryTags, + selectedTag, + cardSettings: { sortBy, language } = {}, + } = useSelectedTags({ + source: meta.value, + fallbackTag: GLOBAL_TAG, + }) + const { data, isLoading, error } = useGetSourceArticles({ + source: 'medium', + tags: queryTags, + config: { + enabled: isVisible, + }, + }) - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Article) => ( + + ), + [selectedTag, meta.analyticsTag] ) - const HeaderTitle = () => { - return ( - <> - {meta.label} - ({ - label: tag.label, - value: tag.value, - }))} - onChange={(item) => { - setCardSettings(meta.value, { ...cardsSettings[meta.value], language: item.value }) - trackCardLanguageSelect(meta.analyticsTag, item.value) - }} - value={cardsSettings?.[meta.value]?.language} - /> - - ) - } - return ( - } {...props}> - - + + } + settingsComponent={ + [ + ...defaults, + { + label: 'Claps', + value: 'points_count', + icon: , + }, + { + label: 'Comments', + value: 'comments_count', + icon: , + }, + ]} + /> + } + {...props}> + ) } diff --git a/src/features/cards/components/producthuntCard/ArticleItem.tsx b/src/features/cards/components/producthuntCard/ArticleItem.tsx index e1198730..c062ba33 100644 --- a/src/features/cards/components/producthuntCard/ArticleItem.tsx +++ b/src/features/cards/components/producthuntCard/ArticleItem.tsx @@ -1,28 +1,26 @@ import { BiCommentDetail } from 'react-icons/bi' import { VscTriangleUp } from 'react-icons/vsc' -import { CardLink, CardItemWithActions } from 'src/components/Elements' +import { CardItemWithActions, CardLink } from 'src/components/Elements' import { Attributes } from 'src/lib/analytics' -import { BaseItemPropsType, Article } from 'src/types' import { useUserPreferences } from 'src/stores/preferences' +import { BaseItemPropsType, Product } from 'src/types' -const ArticleItem = ({ item, index, analyticsTag }: BaseItemPropsType
) => { +const ArticleItem = ({ item, analyticsTag }: BaseItemPropsType) => { const { listingMode } = useUserPreferences() return ( - {item.title} + {item.title}
) }}> {item.title} -

{item.description}

+

{item.tagline}

{listingMode === 'normal' && (

- {item.comments || 0} comments + {item.comments_count || 0} comments {item.tags && item.tags.length > 0 ? ( {item.tags[0]} @@ -45,7 +43,7 @@ const ArticleItem = ({ item, index, analyticsTag }: BaseItemPropsType

)
- {item.reactions} + {item.votes_count}
} diff --git a/src/features/cards/components/producthuntCard/ProducthuntCard.tsx b/src/features/cards/components/producthuntCard/ProducthuntCard.tsx index 1043006d..f685965a 100644 --- a/src/features/cards/components/producthuntCard/ProducthuntCard.tsx +++ b/src/features/cards/components/producthuntCard/ProducthuntCard.tsx @@ -1,30 +1,65 @@ +import { useCallback } from 'react' +import { BiCommentDetail } from 'react-icons/bi' +import { VscTriangleUp } from 'react-icons/vsc' import { Card } from 'src/components/Elements' import { ListComponent } from 'src/components/List' import { ProductHuntPlaceholder } from 'src/components/placeholders' -import { Article, CardPropsType } from 'src/types' +import { useUserPreferences } from 'src/stores/preferences' +import { CardPropsType, Product } from 'src/types' +import { useShallow } from 'zustand/shallow' import { useGeProductHuntProducts } from '../../api/getProductHuntProducts' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { MemoizedCardSettings } from '../CardSettings' import ArticleItem from './ArticleItem' export function ProductHuntCard(props: CardPropsType) { const { meta } = props + const { ref, isVisible } = useLazyListLoad() + const sortBy = useUserPreferences( + useShallow((state) => state.cardsSettings?.[meta.value]?.sortBy) + ) + const { data: products = [], isLoading, error, } = useGeProductHuntProducts({ + date: new Date().toISOString().split('T')[0], config: { - staleTime: 900000, //15 minutes - cacheTime: 3600000, // 1 Day + enabled: isVisible, }, }) - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Product) => , + [meta.analyticsTag] ) return ( - - , + }, + { + label: 'Comments', + value: 'comments_count', + icon: , + }, + ]} + /> + }> + items={products} error={error} isLoading={isLoading} diff --git a/src/features/cards/components/redditCard/ArticleItem.tsx b/src/features/cards/components/redditCard/ArticleItem.tsx index 3a3f0984..1369f3b4 100644 --- a/src/features/cards/components/redditCard/ArticleItem.tsx +++ b/src/features/cards/components/redditCard/ArticleItem.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { BiCommentDetail } from 'react-icons/bi' import { BsArrowReturnRight } from 'react-icons/bs' import { GoDotFill } from 'react-icons/go' @@ -9,36 +10,28 @@ import { useUserPreferences } from 'src/stores/preferences' import { Article, BaseItemPropsType } from 'src/types' import { format } from 'timeago.js' -type PostFlairPropsType = { - text: string - textColor?: string - bgColor?: string -} +const ArticleItem = ({ item, analyticsTag }: BaseItemPropsType
) => { + const { listingMode } = useUserPreferences() -const PostFlair = ({ text, bgColor, textColor }: PostFlairPropsType) => { - const color = textColor === 'light' ? '#fff' : '#000' - const backgroundColor = bgColor ? bgColor : '#dadada' - return ( -
- {text} -
- ) -} + const subReddit = useMemo(() => { + const parts = item.url.split('/') + const rIndex = parts.findIndex((part) => part.toLowerCase() === 'r') + if (rIndex !== -1 && parts.length > rIndex + 1) { + return parts[rIndex + 1] + } + return null + }, [item.url]) -const ArticleItem = ({ item, index, analyticsTag }: BaseItemPropsType
) => { - const { listingMode } = useUserPreferences() return ( ) {listingMode === 'compact' && (
- {item.reactions} + {item.points_count}
)} -
- {item.flair_text && ( - - )} - {item.title} -
+
{item.title}
{listingMode === 'normal' && ( <> - {item.reactions} points + {item.points_count} upvotes {format(new Date(item.published_at))} - {item.comments} comments - - - {`r/${item.subreddit}`} + {item.comments_count} comments + {subReddit && ( + + {`r/${subReddit}`} + + )} )}
diff --git a/src/features/cards/components/redditCard/RedditCard.tsx b/src/features/cards/components/redditCard/RedditCard.tsx index a892d368..96d3b31e 100644 --- a/src/features/cards/components/redditCard/RedditCard.tsx +++ b/src/features/cards/components/redditCard/RedditCard.tsx @@ -1,80 +1,79 @@ -import { Card, FloatingFilter, InlineTextFilter } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' -import { GLOBAL_TAG, MY_LANGUAGES_TAG } from 'src/config' -import { trackCardLanguageSelect } from 'src/lib/analytics' -import { useUserPreferences } from 'src/stores/preferences' +import { VscTriangleUp } from 'react-icons/vsc' +import { Card } from 'src/components/Elements' +import { ListPostComponent } from 'src/components/List/ListPostComponent' + +import { useCallback } from 'react' import { Article, CardPropsType } from 'src/types' -import { filterUniqueEntries, getCardTagsValue } from 'src/utils/DataEnhancement' -import { useGetRedditArticles } from '../../api/getRedditArticles' +import { useGetSourceArticles } from '../../api/getSourceArticles' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { useSelectedTags } from '../../hooks/useSelectedTags' +import { MemoizedCardHeader } from '../CardHeader' +import { MemoizedCardSettings } from '../CardSettings' import ArticleItem from './ArticleItem' +const GLOBAL_TAG = { label: 'Global', value: '' } + export function RedditCard(props: CardPropsType) { const { meta } = props - const { userSelectedTags, cardsSettings, setCardSettings } = useUserPreferences() - const selectedTag = - [GLOBAL_TAG, MY_LANGUAGES_TAG, ...userSelectedTags].find( - (lang) => lang.value === cardsSettings?.[meta.value]?.language - ) || GLOBAL_TAG - - const getQueryTags = () => { - if (!selectedTag) { - return [] - } - - if (selectedTag.value === MY_LANGUAGES_TAG.redditValues[0]) { - return getCardTagsValue(userSelectedTags, 'redditValues') || [] - } - return selectedTag.redditValues || [] - } - - const results = useGetRedditArticles({ tags: getQueryTags() }) + const { ref, isVisible } = useLazyListLoad() + const { + queryTags, + selectedTag, + cardSettings: { sortBy, language } = {}, + } = useSelectedTags({ + source: meta.value, + fallbackTag: GLOBAL_TAG, + }) + const { isLoading, data: results } = useGetSourceArticles({ + source: 'reddit', + tags: queryTags, + config: { + enabled: isVisible, + }, + }) - const getIsLoading = () => results.some((result) => result.isLoading) - - const getData = () => { - return filterUniqueEntries( - results - .reduce((acc: Article[], curr) => { - if (!curr.data) return acc - return [...acc, ...curr.data] - }, []) - .sort((a, b) => b.reactions - a.reactions) - ) - } - - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Article) => ( + + ), + [selectedTag, meta.analyticsTag] ) - const HeaderTitle = () => { - return ( - <> - {meta.label} - ({ - label: tag.label, - value: tag.value, - }))} - onChange={(item) => { - setCardSettings(meta.value, { ...cardsSettings[meta.value], language: item.value }) - trackCardLanguageSelect(meta.analyticsTag, item.value) - }} - value={cardsSettings?.[meta.value]?.language} - /> - - ) - } - return ( - } {...props}> - - + + } + {...props} + settingsComponent={ + [ + ...defaults, + { + label: 'Upvotes', + value: 'points_count', + icon: , + }, + ]} + /> + }> + ) } diff --git a/src/features/cards/components/rssCard/ArticleItem.tsx b/src/features/cards/components/rssCard/ArticleItem.tsx index 9eb6361f..195e6a01 100644 --- a/src/features/cards/components/rssCard/ArticleItem.tsx +++ b/src/features/cards/components/rssCard/ArticleItem.tsx @@ -5,7 +5,7 @@ import { Article, BaseItemPropsType } from 'src/types' import { format } from 'timeago.js' const ArticleItem = (props: BaseItemPropsType
) => { - const { item, index, selectedTag, analyticsTag } = props + const { item, selectedTag, analyticsTag } = props if (!item) { return null } @@ -13,8 +13,6 @@ const ArticleItem = (props: BaseItemPropsType
) => { diff --git a/src/features/cards/components/rssCard/CustomRssCard.tsx b/src/features/cards/components/rssCard/CustomRssCard.tsx index 4785ebc5..388365d2 100644 --- a/src/features/cards/components/rssCard/CustomRssCard.tsx +++ b/src/features/cards/components/rssCard/CustomRssCard.tsx @@ -1,32 +1,51 @@ +import { useCallback } from 'react' import { Card } from 'src/components/Elements' -import { ListComponent } from 'src/components/List' +import { ListPostComponent } from 'src/components/List/ListPostComponent' import { Article, CardPropsType } from 'src/types' import { useRssFeed } from '../../api/getRssFeed' +import { useLazyListLoad } from '../../hooks/useLazyListLoad' +import { MemoizedCardSettings } from '../CardSettings' import ArticleItem from './ArticleItem' import CardIcon from './CardIcon' +const HeaderTitle = ({ title }: { title: string }) => { + return ( + <> +

{title}

+ + ) +} + export function CustomRssCard(props: CardPropsType) { const { meta } = props - const { data = [], isLoading } = useRssFeed({ feedUrl: meta.feedUrl || '' }) + const { ref, isVisible } = useLazyListLoad() + const { data = [], isLoading } = useRssFeed({ + feedUrl: meta.feedUrl || '', + config: { + enabled: isVisible, + }, + }) - const renderItem = (item: Article, index: number) => ( - + const renderItem = useCallback( + (item: Article) => , + [meta.analyticsTag] ) - const HeaderTitle = () => { - return ( - <> -

{meta.label}

- - ) - } - return ( } + ref={ref} + titleComponent={} {...props} - meta={{ ...meta, icon: }}> - + meta={{ ...meta, icon: }} + settingsComponent={ + + }> + ) } diff --git a/src/features/cards/config/index.ts b/src/features/cards/config/index.ts new file mode 100644 index 00000000..a088d661 --- /dev/null +++ b/src/features/cards/config/index.ts @@ -0,0 +1 @@ +export const MY_LANGUAGES_OPTION = { label: 'My Stack', value: 'myLangs' } diff --git a/src/features/cards/hooks/useLazyListLoad.tsx b/src/features/cards/hooks/useLazyListLoad.tsx new file mode 100644 index 00000000..99c1abe1 --- /dev/null +++ b/src/features/cards/hooks/useLazyListLoad.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react' + +type useLazyListLoadProps = + | { + rootMargin?: string + } + | undefined + +export const useLazyListLoad = ( + { rootMargin }: useLazyListLoadProps = { + rootMargin: '0px', + } +) => { + const ref = useRef(null) + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + if (!ref.current) return + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if (entry.isIntersecting) { + setIsVisible(true) + observer.unobserve(entry.target) + } + }, + { threshold: 0.1, rootMargin } + ) + + observer.observe(ref.current) + + return () => observer.disconnect() + }, [rootMargin]) + + return { ref, isVisible } +} diff --git a/src/features/cards/hooks/useSelectedTags.tsx b/src/features/cards/hooks/useSelectedTags.tsx new file mode 100644 index 00000000..7b2c7a4c --- /dev/null +++ b/src/features/cards/hooks/useSelectedTags.tsx @@ -0,0 +1,51 @@ +import { useMemo } from 'react' +import { useUserPreferences } from 'src/stores/preferences' +import { useShallow } from 'zustand/shallow' +import { MY_LANGUAGES_OPTION } from '../config' + +type useSelectedTagsProps = { + source: string + fallbackTag: { + label: string + value: string + } +} +export const useSelectedTags = ({ source, fallbackTag }: useSelectedTagsProps) => { + const { cardSettings, userSelectedTags } = useUserPreferences( + useShallow((state) => { + return { + cardSettings: state.cardsSettings?.[source], + userSelectedTags: state.userSelectedTags, + } + }) + ) + const { language } = cardSettings || {} + const selectedTags = useMemo(() => { + if (!language || (language === MY_LANGUAGES_OPTION.value && userSelectedTags.length === 0)) { + return [fallbackTag] + } + + if (language === MY_LANGUAGES_OPTION.value) { + return userSelectedTags + } + return [userSelectedTags.find((lang) => lang.value === language) || fallbackTag] + }, [userSelectedTags, language]) + + const selectedTag = useMemo(() => { + return language + ? [MY_LANGUAGES_OPTION, ...userSelectedTags].find((lang) => lang.value === language) || + fallbackTag + : fallbackTag + }, [language, userSelectedTags]) + + const queryTags = useMemo(() => { + return selectedTags.map((tag) => tag.value) + }, [selectedTags]) + + return { + selectedTags, + queryTags, + selectedTag, + cardSettings, + } +} diff --git a/src/features/cards/index.ts b/src/features/cards/index.ts index 47e18318..6c9e784e 100644 --- a/src/features/cards/index.ts +++ b/src/features/cards/index.ts @@ -6,6 +6,7 @@ export * from './components/devtoCard' export * from './components/freecodecampCard' export * from './components/githubCard' export * from './components/hackernewsCard' +export * from './components/hackernoonCard' export * from './components/hashnodeCard' export * from './components/indiehackersCard' export * from './components/lobstersCard' diff --git a/src/features/feed/components/Feed.tsx b/src/features/feed/components/Feed.tsx index 2bdbc531..c9cd831a 100644 --- a/src/features/feed/components/Feed.tsx +++ b/src/features/feed/components/Feed.tsx @@ -57,7 +57,7 @@ export const Feed = () => { if (isInitialLoading) { return ( -
+
{Array.from({ length: 10, }).map((_, index) => ( @@ -75,13 +75,7 @@ export const Feed = () => {
{(feed?.pages.flatMap((page) => page.data) || []).map((article, index) => { return ( - + ) })} {hasNextPage && ( diff --git a/src/features/feed/components/FeedItemImage.tsx b/src/features/feed/components/FeedItemImage.tsx index 220a451f..b7af9d55 100644 --- a/src/features/feed/components/FeedItemImage.tsx +++ b/src/features/feed/components/FeedItemImage.tsx @@ -11,7 +11,7 @@ export const FeedItemImage = ({ imageUrl, fallbackImage }: FeedItemImageProps) = if (hasError || !imageUrl) { if (typeof fallbackImage === 'string') { - return + return } else { return ( fallbackImage || ( diff --git a/src/features/feed/components/FeedItemSource.tsx b/src/features/feed/components/FeedItemSource.tsx index ac477d51..ac7f2e30 100644 --- a/src/features/feed/components/FeedItemSource.tsx +++ b/src/features/feed/components/FeedItemSource.tsx @@ -1,10 +1,11 @@ +import { memo, useState } from 'react' import { FaDev, FaMediumM, FaReddit } from 'react-icons/fa' import { GoDotFill } from 'react-icons/go' -import { SiGithub, SiProducthunt } from 'react-icons/si' +import { SiGithub, SiProducthunt, SiYcombinator } from 'react-icons/si' import HashNodeIcon from 'src/assets/icon_hashnode.png' -const FeedItemKV: { - [key: string]: React.ReactNode -} = { +import LobstersIcon from 'src/assets/icon_lobsters.png' + +const SOURCE_MAP: Record = { producthunt: ( <> Product hunt @@ -35,13 +36,43 @@ const FeedItemKV: { hn Hashnode ), + hackernews: ( + <> + Hackernews + + ), + lobsters: ( + <> + lobsters Lobsters + + ), } -export const FeedItemSource = ({ source }: { source: string }) => { - return ( - FeedItemKV[source] || ( +export const FeedItemSource = memo(({ source }: { source: string }) => { + const [fallback, setFallback] = useState(false) + + if (SOURCE_MAP[source]) { + return SOURCE_MAP[source] + } + + if (!fallback && source.includes('.')) { + return ( <> - {source} + {source} { + setFallback(true) + }} + />{' '} + {source} ) + } + + return ( + <> + {source} + ) -} +}) diff --git a/src/features/feed/components/feed.css b/src/features/feed/components/feed.css index f791348b..1c536659 100644 --- a/src/features/feed/components/feed.css +++ b/src/features/feed/components/feed.css @@ -49,6 +49,14 @@ } } +.feed > * { + box-sizing: border-box; + max-width: 100%; + overflow: hidden; +} +.feedLoading .placeholder { + min-height: 360px; +} .feed .placeholder { animation-duration: 1.5s; animation-name: cardPlaceholderPulse; @@ -56,8 +64,8 @@ padding: 12px; display: flex; flex-direction: column; - min-height: 360px; gap: 16px; + box-sizing: border-box; .image { background-color: var(--placeholder-background-color); border: 1px solid var(--placeholder-border-color); @@ -122,3 +130,16 @@ margin: 0; padding: 0; } + +.sourceName { + text-transform: none; + + &::first-letter { + text-transform: capitalize; + } +} + +.feedItemSourceFallback { + width: 12px; + height: 12px; +} diff --git a/src/features/feed/components/feedItems/ArticleFeedItem.tsx b/src/features/feed/components/feedItems/ArticleFeedItem.tsx index e990dd9a..bb51fc47 100644 --- a/src/features/feed/components/feedItems/ArticleFeedItem.tsx +++ b/src/features/feed/components/feedItems/ArticleFeedItem.tsx @@ -7,29 +7,27 @@ import { FeedItemHeader } from '../FeedItemHeader' import { FeedItemSource } from '../FeedItemSource' export const ArticleFeedItem = (props: BaseItemPropsType) => { - const { item, index, analyticsTag, className } = props + const { item, analyticsTag, className } = props const { listingMode } = useUserPreferences() return (
{listingMode === 'compact' && (
- +
)} {listingMode === 'normal' && (
- + diff --git a/src/features/feed/components/feedItems/ProductFeedItem.tsx b/src/features/feed/components/feedItems/ProductFeedItem.tsx index 91a641d7..cd5db5fa 100644 --- a/src/features/feed/components/feedItems/ProductFeedItem.tsx +++ b/src/features/feed/components/feedItems/ProductFeedItem.tsx @@ -7,16 +7,14 @@ import { FeedItemHeader } from '../FeedItemHeader' import { FeedItemSource } from '../FeedItemSource' export const ProductFeedItem = (props: BaseItemPropsType) => { - const { item, index, analyticsTag, className } = props + const { item, analyticsTag, className } = props const { listingMode } = useUserPreferences() return (
diff --git a/src/features/feed/components/feedItems/RepoFeedItem.tsx b/src/features/feed/components/feedItems/RepoFeedItem.tsx index d2545555..14236c18 100644 --- a/src/features/feed/components/feedItems/RepoFeedItem.tsx +++ b/src/features/feed/components/feedItems/RepoFeedItem.tsx @@ -11,16 +11,14 @@ function numberWithCommas(x: number | string) { } export const RepoFeedItem = (props: BaseItemPropsType) => { - const { item, index, analyticsTag, className } = props + const { item, analyticsTag, className } = props const { listingMode } = useUserPreferences() return (
) => { stars )} + {item.stars_in_range && ( + + {' '} + {numberWithCommas(item.stars_in_range || 0)} stars today + + )} {numberWithCommas(item?.forks || 0)}{' '} forks diff --git a/src/features/onboarding/components/OnboardingModal.tsx b/src/features/onboarding/components/OnboardingModal.tsx index 97cd7407..95f88dc4 100644 --- a/src/features/onboarding/components/OnboardingModal.tsx +++ b/src/features/onboarding/components/OnboardingModal.tsx @@ -1,43 +1,21 @@ import { useEffect } from 'react' import ReactModal from 'react-modal' -import { Steps } from 'src/components/Elements' -import { SUPPORTED_CARDS } from 'src/config/supportedCards' -import { Tag, useRemoteConfigStore } from 'src/features/remoteConfig' -import { - identifyUserCards, - identifyUserLanguages, - identifyUserOccupation, - trackOnboardingFinish, - trackOnboardingSkip, - trackOnboardingStart, -} from 'src/lib/analytics' -import { useUserPreferences } from 'src/stores/preferences' -import { SelectedCard } from 'src/types' +import { trackOnboardingStart } from 'src/lib/analytics' import { HelloTab } from './steps/HelloTab' -import { LanguagesTab } from './steps/LanguagesTab' -import { SourcesTab } from './steps/SourcesTab' import './steps/tabs.css' -type OnboardingModalProps = { - showOnboarding: boolean - setShowOnboarding: (show: boolean) => void -} - -export const OnboardingModal = ({ showOnboarding, setShowOnboarding }: OnboardingModalProps) => { - const { markOnboardingAsCompleted, setTags, setCards } = useUserPreferences() - const { supportedTags } = useRemoteConfigStore() - +export const OnboardingModal = () => { useEffect(() => { trackOnboardingStart() }, []) + return ( setShowOnboarding(false)} contentLabel="Onboarding" className="Modal scrollable" style={{ @@ -47,50 +25,7 @@ export const OnboardingModal = ({ showOnboarding, setShowOnboarding }: Onboardin }} overlayClassName="Overlay">
- { - trackOnboardingSkip() - markOnboardingAsCompleted(null) - setShowOnboarding(false) - }} - onFinish={(tabsData) => { - trackOnboardingFinish() - if (tabsData) { - const { icon, ...occupation } = tabsData - markOnboardingAsCompleted(occupation) - identifyUserOccupation(occupation.title) - - const tags = - (occupation.tags - .map((tag) => supportedTags.find((st) => st.value === tag)) - .filter(Boolean) as Tag[]) || [] - - setTags(tags) - identifyUserLanguages(tags.map((tag) => tag.value)) - - const cards = (occupation.sources - .map((source) => SUPPORTED_CARDS.find((sc) => sc.value === source)) - .filter(Boolean) - .map((source, index) => { - return { - id: index, - name: source?.value || '', - type: 'supported', - } - }) || []) as SelectedCard[] - - setCards(cards) - identifyUserCards(cards.map((card) => card.name)) - } - - setShowOnboarding(false) - }} - /> +
) diff --git a/src/features/onboarding/components/steps/HelloTab.tsx b/src/features/onboarding/components/steps/HelloTab.tsx index b97b8f9e..656e9f74 100644 --- a/src/features/onboarding/components/steps/HelloTab.tsx +++ b/src/features/onboarding/components/steps/HelloTab.tsx @@ -1,104 +1,143 @@ import clsx from 'clsx' -import { useState } from 'react' import { AiFillMobile, AiFillSecurityScan } from 'react-icons/ai' import { BsArrowRight, BsFillGearFill } from 'react-icons/bs' import { FaDatabase, FaPaintBrush, FaRobot, FaServer } from 'react-icons/fa' import { RiDeviceFill } from 'react-icons/ri' import { TbDots } from 'react-icons/tb' -import { StepProps } from 'src/components/Elements' +import { Tag, useRemoteConfigStore } from 'src/features/remoteConfig' +import { useUserPreferences } from 'src/stores/preferences' import { Occupation } from '../../types' const OCCUPATIONS: Occupation[] = [ { title: 'Front-End Engineer', + value: 'frontend', icon: FaPaintBrush, sources: ['devto', 'github', 'medium', 'hashnode'], - tags: ['javascript', 'typescript'], + tags: ['frontend', 'javascript', 'typescript', 'css', 'react', 'vue', 'angular'], }, { title: 'Back-End Engineer', + value: 'backend', icon: BsFillGearFill, sources: ['devto', 'github', 'medium', 'hashnode'], - tags: ['go', 'php', 'ruby', 'rust', 'r'], + tags: ['backend', 'go', 'php', 'ruby', 'rust', 'r'], }, { title: 'Full Stack Engineer', icon: RiDeviceFill, + value: 'fullstack', sources: ['devto', 'github', 'medium', 'hashnode'], - tags: ['javascript', 'typescript', 'php', 'ruby', 'rust'], + tags: ['webdev', 'javascript', 'typescript', 'php', 'devops'], }, { title: 'Mobile', + value: 'mobile', icon: AiFillMobile, sources: ['reddit', 'github', 'medium', 'hashnode'], - tags: ['android', 'kotlin', 'java', 'swift', 'objective-c'], + tags: [ + 'mobile', + 'android', + 'kotlin', + 'java', + 'ios', + 'swift', + 'objectivec', + 'react native', + 'flutter', + ], }, { title: 'Devops Engineer', + value: 'devops', icon: FaServer, - sources: ['freecodecamp', 'github', 'reddit', 'devto'], - tags: ['devops', 'bash'], + sources: ['hackernoon', 'github', 'reddit', 'hackernews'], + tags: ['devops', 'kubernetes', 'docker', 'bash'], }, { title: 'Data Engineer', + value: 'data', icon: FaDatabase, - sources: ['freecodecamp', 'github', 'reddit', 'devto'], - tags: ['data-science', 'python', 'artificial-intelligence', 'machine-learning'], + sources: ['hackernoon', 'github', 'reddit', 'devto'], + tags: ['data science', 'python', 'artificial intelligence', 'machine learning'], }, { title: 'Security Engineer', + value: 'security', icon: AiFillSecurityScan, - sources: ['freecodecamp', 'github', 'reddit', 'devto'], - tags: ['c++', 'bash', 'python'], + sources: ['hackernoon', 'github', 'reddit', 'devto'], + tags: ['security', 'cpp', 'bash', 'python'], }, { title: 'ML Engineer', + value: 'ai', icon: FaRobot, - sources: ['github', 'freecodecamp', 'hackernews', 'devto'], - tags: ['machine-learning', 'artificial-intelligence', 'python'], + sources: ['github', 'hackernoon', 'hackernews', 'devto'], + tags: ['artificial intelligence', 'machine learning', 'python'], }, { title: 'Other', + value: 'other', icon: TbDots, sources: ['hackernews', 'github', 'producthunt', 'devto'], - tags: [], + tags: ['webdev', 'mobile'], }, ] -export const HelloTab = ({ - moveToNext, - moveToPrevious, - setTabsData, - tabsData, -}: StepProps) => { - const [selectedOccupation, setSelectedOccupation] = useState( - tabsData || OCCUPATIONS[0] - ) - const onOccupationClicked = (occupation: Occupation) => { - setSelectedOccupation(occupation) - } +export const HelloTab = () => { + const { + markOnboardingAsCompleted, + setCardSettings, + setCards, + setTags, + setOccupation, + occupation, + } = useUserPreferences() + + const { tags } = useRemoteConfigStore() - const onClickNext = () => { - if (selectedOccupation === undefined) { - return + const onStartClicked = () => { + const selectedOccupation = OCCUPATIONS.find((occ) => occ.title === occupation) + if (selectedOccupation) { + setOccupation(selectedOccupation.value) + setCards( + selectedOccupation.sources.map((source, index) => ({ + id: index, + name: source, + type: 'supported', + })) + ) + const userTags = selectedOccupation.tags + .map((tag) => { + return tags.find((t) => t.value === tag) + }) + .filter(Boolean) as Array + + setTags(userTags) + for (const source of selectedOccupation.sources) { + setCardSettings(source, { + language: selectedOccupation.tags[0], + sortBy: 'published_at', + }) + } } - setTabsData(selectedOccupation) - moveToNext && moveToNext() + markOnboardingAsCompleted() } + return (
-

Hi, πŸ‘‹ Welcome to Hackertab

-

Let's customize your Hackertab experience!

+

πŸ‘‹ Let’s set up your Hackertab

+

Select your developer role πŸ‘¨πŸ»β€πŸ’» to personalize your Hackertab.

{OCCUPATIONS.map((occ) => { return (
- - + {occupation && ( + + )}
) diff --git a/src/features/onboarding/components/steps/LanguagesTab.tsx b/src/features/onboarding/components/steps/LanguagesTab.tsx deleted file mode 100644 index c9e947c4..00000000 --- a/src/features/onboarding/components/steps/LanguagesTab.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { ChipsSet, StepProps } from 'src/components/Elements' -import { useRemoteConfigStore } from 'src/features/remoteConfig' -import { Occupation } from '../../types' - -export const LanguagesTab = ({ - moveToPrevious, - moveToNext, - setTabsData, - tabsData, -}: StepProps) => { - const { supportedTags } = useRemoteConfigStore() - - const sources = supportedTags - .map((tag) => { - return { - label: tag.label, - value: tag.value, - } - }) - .sort((a, b) => (a.label > b.label ? 1 : -1)) - - return ( -
-
-

🦾 Select your languages & topics

-

Select the languages you're interested in following.

-
-
- { - setTabsData({ ...tabsData, tags: selectedChips.map((chip) => chip.value) }) - }} - /> -
-
- - -
-
- ) -} diff --git a/src/features/onboarding/components/steps/SourcesTab.tsx b/src/features/onboarding/components/steps/SourcesTab.tsx deleted file mode 100644 index ddd860ba..00000000 --- a/src/features/onboarding/components/steps/SourcesTab.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { BsArrowRight } from 'react-icons/bs' -import { ChipsSet, StepProps } from 'src/components/Elements' -import { SUPPORTED_CARDS } from '../../../../config/supportedCards' -import { Occupation } from '../../types' - -export const SourcesTab = ({ - moveToPrevious, - moveToNext, - setTabsData, - tabsData, -}: StepProps) => { - const sources = SUPPORTED_CARDS.map((source) => { - return { - label: source.label, - value: source.value, - icon: source.icon, - } - }).sort((a, b) => (a.label > b.label ? 1 : -1)) - - return ( -
-
-

πŸ“™ Pick your sources

-

Your feed will be tailored by your followed sources

-
-
- { - setTabsData({ ...tabsData, sources: selectedChips.map((chip) => chip.value) }) - }} - /> -
-
- - -
-
- ) -} diff --git a/src/features/onboarding/components/steps/tabs.css b/src/features/onboarding/components/steps/tabs.css index 278f16bf..e4426f61 100644 --- a/src/features/onboarding/components/steps/tabs.css +++ b/src/features/onboarding/components/steps/tabs.css @@ -77,7 +77,7 @@ column-gap: 12px; align-items: center; margin-top: 32px; - justify-content: flex-end; + justify-content: center; } .tabFooter button { border: none; diff --git a/src/features/onboarding/types/index.ts b/src/features/onboarding/types/index.ts index 8d3b6bc1..40fdb665 100644 --- a/src/features/onboarding/types/index.ts +++ b/src/features/onboarding/types/index.ts @@ -2,6 +2,7 @@ import { IconType } from 'react-icons/lib' export type Occupation = { title: string + value: string icon: IconType sources: string[] tags: string[] diff --git a/src/features/remoteConfig/api/getRemoteConfig.ts b/src/features/remoteConfig/api/getRemoteConfig.ts index 166ed770..8d41b728 100644 --- a/src/features/remoteConfig/api/getRemoteConfig.ts +++ b/src/features/remoteConfig/api/getRemoteConfig.ts @@ -5,7 +5,7 @@ import { useRemoteConfigStore } from '../stores/remoteConfig' import { RemoteConfig } from '../types' const getRemoteConfig = async (): Promise => { - return axios.get('/data/remoteConfiguration.json') + return axios.get('/data/config.json') } type QueryFnType = typeof getRemoteConfig diff --git a/src/features/remoteConfig/stores/remoteConfig.ts b/src/features/remoteConfig/stores/remoteConfig.ts index d8e8335a..4de8ab03 100644 --- a/src/features/remoteConfig/stores/remoteConfig.ts +++ b/src/features/remoteConfig/stores/remoteConfig.ts @@ -4,54 +4,24 @@ import { persist } from 'zustand/middleware' import { RemoteConfig, Tag } from '../types' type RemoteConfigStore = { - supportedTags: Tag[] - marketingBannerConfig?: any - adsConfig: { - rowPosition: number - columnPosition: number - enabled: boolean - } + tags: Tag[] setRemoteConfig: (remoteConfig: RemoteConfig) => void } export const useRemoteConfigStore = create( persist( (set) => ({ - marketingBannerConfig: undefined, - adsConfig: { - rowPosition: 0, - columnPosition: 0, - enabled: false, - }, - supportedTags: [ + tags: [ { value: 'javascript', label: 'Javascript', - githubValues: ['javascript'], - confsValues: ['javascript'], - devtoValues: ['javascript'], - hashnodeValues: ['javascript'], - mediumValues: ['javascript'], - redditValues: ['javascript'], - freecodecampValues: ['javascript'], }, ], - setRemoteConfig: (remoteConfig: RemoteConfig) => - set(() => { - const { marketingBannerConfig, ...otherConfigs } = remoteConfig - return { ...otherConfigs } - }), + setRemoteConfig: (remoteConfig: RemoteConfig) => set({ tags: remoteConfig.tags }), }), { name: 'remote_config_storage', - version: 1, - migrate(persistedState, version) { - const newState = persistedState as RemoteConfigStore - if (version === 0) { - delete newState.marketingBannerConfig - } - return newState - }, + version: 2, } ) ) diff --git a/src/features/remoteConfig/types/index.ts b/src/features/remoteConfig/types/index.ts index 1357aa78..ef20d0e5 100644 --- a/src/features/remoteConfig/types/index.ts +++ b/src/features/remoteConfig/types/index.ts @@ -1,30 +1,9 @@ export type Tag = { - confsValues: string[] - devtoValues: string[] - hashnodeValues: string[] - redditValues: string[] - githubValues: string[] - freecodecampValues: string[] - mediumValues: string[] label: string value: string + category?: string } -export type TagValuesFieldType = - | 'confsValues' - | 'devtoValues' - | 'hashnodeValues' - | 'redditValues' - | 'githubValues' - | 'freecodecampValues' - | 'mediumValues' - export type RemoteConfig = { - supportedTags: Tag[] - marketingBannerConfig?: any - adsConfig: { - rowPosition: number - columnPosition: number - enabled: boolean - } + tags: Tag[] } diff --git a/src/features/settings/components/TopicSettings.tsx b/src/features/settings/components/TopicSettings.tsx index 86d50a7d..c6759997 100644 --- a/src/features/settings/components/TopicSettings.tsx +++ b/src/features/settings/components/TopicSettings.tsx @@ -1,45 +1,170 @@ -import { ChipsSet } from 'src/components/Elements' +import { useMemo, useState } from 'react' +import { AiFillMobile } from 'react-icons/ai' +import { BsChevronDown, BsChevronUp, BsFillGearFill, BsFillShieldLockFill } from 'react-icons/bs' +import { FaDatabase, FaPaintBrush, FaRobot, FaServer } from 'react-icons/fa' +import { IoMdSearch } from 'react-icons/io' +import { RiDeviceFill } from 'react-icons/ri' +import { SiBnbchain } from 'react-icons/si' +import { ChipsSet, SearchBar } from 'src/components/Elements' import { SettingsContentLayout } from 'src/components/Layout/SettingsContentLayout/SettingsContentLayout' +import { repository } from 'src/config' import { Tag, useRemoteConfigStore } from 'src/features/remoteConfig' import { trackLanguageAdd, trackLanguageRemove } from 'src/lib/analytics' import { useUserPreferences } from 'src/stores/preferences' +const CATEGORY_TO_ICON: Record = { + frontend: , + backend: , + fullstack: , + mobile: , + devops: , + ai: , + data: , + security: , + blockchain: , +} export const TopicSettings = () => { - const { userSelectedTags, setTags } = useUserPreferences() + const { userSelectedTags, occupation, followTag, unfollowTag } = useUserPreferences() + + const { tags } = useRemoteConfigStore() + const [searchKeyword, setSearchKeyword] = useState('') + const filteredTags = useMemo(() => { + if (searchKeyword.trim() === '') { + return tags + } + return tags.filter( + (tag) => + tag.label.toLowerCase().includes(searchKeyword.trim().toLowerCase()) || + tag.category?.toLowerCase().includes(searchKeyword.trim().toLowerCase()) + ) + }, [tags, searchKeyword]) + + const groupedTags = useMemo(() => { + const groups = filteredTags.reduce>((acc, tag) => { + ;(acc[tag.category || 'Other'] ??= []).push(tag) + return acc + }, {}) + + if ('Other' in groups) { + const { Other, ...rest } = groups + return { ...rest, Other } + } - const { supportedTags } = useRemoteConfigStore() + return groups + }, [filteredTags]) - const tags = supportedTags - .map((tag) => { - return { - label: tag.label, - value: tag.value, - } - }) - .sort((a, b) => (a.label > b.label ? 1 : -1)) + const [expandedCategories, setExpandedCategories] = useState( + occupation ? [occupation] : ['backend', 'frontend'] + ) return ( - tag.value)} - onChange={(changes, selectedChips) => { - const selectedTags = - (selectedChips - .map((tag) => supportedTags.find((st) => st.value === tag.value)) - .filter(Boolean) as Tag[]) || [] - setTags(selectedTags) - - if (changes.action == 'ADD') { - trackLanguageAdd(changes.option.value) - } else { - trackLanguageRemove(changes.option.value) - } - }} - /> + {userSelectedTags.length > 0 ? ( + tag.value)} + onChange={(changes) => { + if (changes.action == 'ADD') { + followTag(changes.option as Tag) + trackLanguageAdd(changes.option.value) + } else { + unfollowTag(changes.option as Tag) + trackLanguageRemove(changes.option.value) + } + }} + /> + ) : ( +
+

You are not following any topics yet. Start exploring below!

+
+ )} + +
+ +
+
+

Explore new topics

+

+ Explore and follow new topics to customize your feed further. +

+
+
+
+ } + placeholder="Search by programming language, framework, or topic" + onChange={(keyword) => { + setSearchKeyword(keyword) + }} + /> +
+
+ {Object.keys(groupedTags).length == 0 ? ( +
+

+ No results found, try adjusting your search keyword. +
+ If you think this technology is missing, feel free to{' '} + + open an issue + {' '} + to suggest it. +

+
+ ) : ( + Object.keys(groupedTags).map((category) => ( +
+ + + {expandedCategories.includes(category) && ( + (a.label > b.label ? 1 : -1)) + .map((tag) => ({ + label: tag.label, + value: tag.value, + }))} + defaultValues={userSelectedTags.map((tag) => tag.value)} + onChange={(changes) => { + if (changes.action == 'ADD') { + followTag(changes.option as Tag) + trackLanguageAdd(changes.option.value) + } else { + unfollowTag(changes.option as Tag) + trackLanguageRemove(changes.option.value) + } + }} + /> + )} +
+ )) + )} +
) } diff --git a/src/index.tsx b/src/index.tsx index fc6fc484..1a0138d3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,8 +8,11 @@ import { persister, queryClient } from 'src/lib/react-query' import { AppErrorBoundary } from 'src/providers/AppErrorBoundary' import { AppRoutes } from './routes/AppRoutes' +import '@szhsin/react-menu/dist/index.css' +import '@szhsin/react-menu/dist/transitions/zoom.css' import { createRoot } from 'react-dom/client' import { initSentry } from './lib/sentry' + const container = document.getElementById('root') if (!container) { throw new Error('Failed to find the root element') diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index f883fa88..6b969f21 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -99,11 +99,11 @@ export const setupAnalytics = () => { export const setupIdentification = () => { const { userSelectedTags, - onboardingResult, theme, cards, listingMode, openLinksNewTab, + occupation, promptEngine, maxVisibleCards, layout, @@ -118,8 +118,8 @@ export const setupIdentification = () => { identifyUserLinksInNewTab(openLinksNewTab) identifyUserMaxVisibleCards(maxVisibleCards) identifyDisplayLayout(layout) - if (onboardingResult?.title) { - identifyUserOccupation(onboardingResult.title) + if (occupation) { + identifyUserOccupation(occupation) } identifySentryUser({ [Attributes.LANGUAGES]: userSelectedTags.map((tag: any) => tag.value), diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx index 92a4fc92..1ec0b28b 100644 --- a/src/providers/AuthProvider.tsx +++ b/src/providers/AuthProvider.tsx @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/react' import { GithubAuthProvider, GoogleAuthProvider, @@ -17,6 +18,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { closeAuthModal, initState, setAuthError, + user, openAuthModal, setConnecting, logout, @@ -42,6 +44,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { initState({ user: { id: user.uid, + connectedAt: new Date(), name: user.displayName || 'Anonymous', imageURL: user.photoURL || '', }, @@ -148,10 +151,18 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { * and logout the user if the session has expired */ useEffect(() => { - const unsubscribe = firebaseAuth.onAuthStateChanged(async (user) => { - if (!user) { + const unsubscribe = firebaseAuth.onAuthStateChanged(async (fbUser) => { + if (!fbUser) { if (isConnected) { toast('Session expired, please reconnect', { theme: 'dangerToast' }) + + Sentry.captureMessage(`Session expired`, { + level: 'info', + extra: { + fbID: user?.id || 'anonymous', + connectedAt: user?.connectedAt?.toISOString(), + }, + }) } await logout() diff --git a/src/stores/preferences.ts b/src/stores/preferences.ts index 6ea7f4d7..44b4160b 100644 --- a/src/stores/preferences.ts +++ b/src/stores/preferences.ts @@ -1,4 +1,3 @@ -import { Occupation } from 'src/features/onboarding/types' import { Tag, useRemoteConfigStore } from 'src/features/remoteConfig' import { enhanceTags } from 'src/utils/DataEnhancement' import { create } from 'zustand' @@ -20,7 +19,12 @@ export type UserPreferencesState = { theme: Theme openLinksNewTab: boolean onboardingCompleted: boolean - onboardingResult: Omit | null + onboardingResult?: { + title: string + sources: string[] + tags: string[] + } | null + occupation: string | null listingMode: ListingMode promptEngine: string promptEngines: SearchEngineType[] @@ -40,10 +44,14 @@ type UserPreferencesStoreActions = { setOpenLinksNewTab: (openLinksNewTab: boolean) => void setListingMode: (listingMode: ListingMode) => void setCards: (selectedCards: SelectedCard[]) => void + removeCard: (cardName: string) => void setTags: (selectedTags: Tag[]) => void + followTag: (tag: Tag) => void + unfollowTag: (tag: Tag) => void setMaxVisibleCards: (maxVisibleCards: number) => void setCardSettings: (card: string, settings: CardSettingsType) => void - markOnboardingAsCompleted: (occupation: Omit | null) => void + setOccupation: (occupation: string | null) => void + markOnboardingAsCompleted: () => void setUserCustomCards: (cards: SupportedCardType[]) => void updateCardOrder: (prevIndex: number, newIndex: number) => void setDNDDuration: (value: DNDDuration) => void @@ -69,14 +77,29 @@ const defaultStorage: StateStorage = { state: UserPreferencesState } = JSON.parse(item) - const remoteConfigStore = useRemoteConfigStore.getState() - const newState = { ...state, - userSelectedTags: enhanceTags( - remoteConfigStore, - state.userSelectedTags as unknown as string[] - ), + } + if (version == 0) { + const MAP_OLD_TAGS: Record = { + 'artificial-intelligence': 'artificial intelligence', + 'machine-learning': 'machine learning', + c: 'clang', + cpp: 'c++', + csharp: 'c#', + 'data-science': 'data science', + go: 'golang', + 'objective-c': 'objectivec', + } + + const stateTags = state.userSelectedTags as unknown as string[] + const newTags = stateTags.map((tag) => { + if (MAP_OLD_TAGS[tag]) { + return MAP_OLD_TAGS[tag] + } + return tag + }) + newState.userSelectedTags = enhanceTags(useRemoteConfigStore.getState(), newTags) } return JSON.stringify({ state: newState, version }) @@ -86,21 +109,7 @@ const defaultStorage: StateStorage = { }, setItem: (name: string, value: string) => { try { - let { - state, - version, - }: { - version: number - state: UserPreferencesState - } = JSON.parse(value) - - const newState = { - ...state, - userSelectedTags: state.userSelectedTags.map((tag) => tag.value), - } - - const newValue = JSON.stringify({ state: newState, version }) - window.localStorage.setItem(name, newValue) + window.localStorage.setItem(name, value) } catch (e) { window.localStorage.setItem(name, '') } @@ -117,21 +126,14 @@ export const useUserPreferences = create( { value: 'javascript', label: 'Javascript', - githubValues: ['javascript'], - confsValues: ['javascript'], - devtoValues: ['javascript'], - hashnodeValues: ['javascript'], - mediumValues: ['javascript'], - redditValues: ['javascript'], - freecodecampValues: ['javascript'], }, ], + occupation: null, layout: 'cards', cardsSettings: {}, maxVisibleCards: 4, theme: 'dark', onboardingCompleted: false, - onboardingResult: null, promptEngine: 'chatgpt', promptEngines: [], listingMode: 'normal', @@ -146,18 +148,14 @@ export const useUserPreferences = create( userCustomCards: [], DNDDuration: 'never', advStatus: false, - setLayout: (layout) => set({ layout: layout }), - setPromptEngine: (promptEngine: string) => set({ promptEngine: promptEngine }), - setListingMode: (listingMode: ListingMode) => set({ listingMode: listingMode }), - setTheme: (theme: Theme) => set({ theme: theme }), - setOpenLinksNewTab: (openLinksNewTab: boolean) => set({ openLinksNewTab: openLinksNewTab }), + setLayout: (layout) => set({ layout }), + setPromptEngine: (promptEngine: string) => set({ promptEngine }), + setListingMode: (listingMode: ListingMode) => set({ listingMode }), + setTheme: (theme: Theme) => set({ theme }), + setOpenLinksNewTab: (openLinksNewTab: boolean) => set({ openLinksNewTab }), setCards: (selectedCards: SelectedCard[]) => set({ cards: selectedCards }), setTags: (selectedTags: Tag[]) => set({ userSelectedTags: selectedTags }), - setMaxVisibleCards: (maxVisibleCards: number) => set({ maxVisibleCards: maxVisibleCards }), - initState: (newState: UserPreferencesState) => - set(() => { - return { ...newState } - }), + setMaxVisibleCards: (maxVisibleCards: number) => set({ maxVisibleCards }), setCardSettings: (card: string, settings: CardSettingsType) => set((state) => ({ cardsSettings: { @@ -165,10 +163,13 @@ export const useUserPreferences = create( [card]: { ...state.cardsSettings[card], ...settings }, }, })), - markOnboardingAsCompleted: (occupation: Omit | null) => + markOnboardingAsCompleted: () => set(() => ({ onboardingCompleted: true, - onboardingResult: occupation, + })), + setOccupation: (occupation: string | null) => + set(() => ({ + occupation: occupation, })), setUserCustomCards: (cards: SupportedCardType[]) => set({ userCustomCards: cards }), updateCardOrder: (prevIndex: number, newIndex: number) => @@ -212,10 +213,47 @@ export const useUserPreferences = create( } }), setAdvStatus: (status) => set({ advStatus: status }), + removeCard: (cardName: string) => + set((state) => { + return { + cards: state.cards.filter((card) => card.name !== cardName), + } + }), + followTag: (tag: Tag) => + set((state) => { + const exists = state.userSelectedTags.find((t) => t.value === tag.value) + if (exists) { + return state + } + return { + userSelectedTags: [...state.userSelectedTags, tag], + } + }), + unfollowTag: (tag: Tag) => + set((state) => { + return { + userSelectedTags: state.userSelectedTags.filter((t) => t.value !== tag.value), + } + }), }), { name: 'preferences_storage', + version: 1, storage: createJSONStorage(() => defaultStorage), + migrate: (persistedState, version) => { + const state = persistedState as unknown as UserPreferencesState & + UserPreferencesStoreActions + if (version === 0) { + console.log('Migrating preferences_storage to version 1', state) + + return { + ...state, + onboardingCompleted: true, + occupation: state.onboardingResult?.title || '', + } + } + return state + }, } ) ) diff --git a/src/types/index.ts b/src/types/index.ts index 22fb1201..51f2e4a8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -32,24 +32,25 @@ export type BaseEntry = { id: string url: string title: string + tags: Array + comments_count: number + points_count: number + image_url: string + published_at: number + description?: string } export type Article = BaseEntry & { - published_at: number - tags: Array - reactions: number - comments: number - image_url: string source: string original_url?: string comments_url?: string - description?: string - subreddit?: string - flair_text?: string - flair_background_color?: string - flair_text_color?: string } +export type Product = BaseEntry & { + tagline: string + votes_count: number + topics: Array +} export type FeedItem = { title: string id: string @@ -74,6 +75,7 @@ export type ProductHuntFeedItemData = FeedItem & { export type GithubFeedItemData = FeedItem & { type: 'github' stars: number + stars_in_range: number forks: number programmingLanguage: string description?: string @@ -91,20 +93,20 @@ export type FeedItemData = | AdFeedItemData export type Repository = BaseEntry & { - programmingLanguage: string - stars: number + technology: string + stars_count: number source: string description: string owner: string - forks: number - starsInDateRange?: number + forks_count: number + stars_in_range: number name: string } export type Conference = BaseEntry & { start_date: number end_date: number - tag: string + tags: string[] online: Boolean city?: string country?: string @@ -134,7 +136,6 @@ export type BaseItemPropsType< id: string } > = { - index: number item: T className?: string analyticsTag: string @@ -143,6 +144,7 @@ export type BaseItemPropsType< export type CardSettingsType = { language: string + sortBy: string dateRange?: string } diff --git a/src/utils/DataEnhancement.ts b/src/utils/DataEnhancement.ts index 1de39bad..68fb97c1 100644 --- a/src/utils/DataEnhancement.ts +++ b/src/utils/DataEnhancement.ts @@ -1,24 +1,9 @@ -import { RemoteConfig, Tag, TagValuesFieldType } from 'src/features/remoteConfig' -import { BaseEntry } from 'src/types' +import { RemoteConfig, Tag } from 'src/features/remoteConfig' export const enhanceTags = (remoteConfigStore: RemoteConfig, tags: string[]): Tag[] => { return tags .map((tag) => - remoteConfigStore.supportedTags.find((st) => st.value.toLowerCase() === tag.toLocaleString()) + remoteConfigStore.tags.find((st) => st.value.toLowerCase() === tag.toLocaleString()) ) .filter(Boolean) as Tag[] } - -export const getCardTagsValue = (tags: Tag[], valuesField: TagValuesFieldType): string[] => { - return tags.reduce((acc: string[], curr) => { - if (!curr[valuesField] || curr[valuesField].length === 0) return acc - acc = [...acc, ...curr[valuesField]] - return acc - }, []) -} - -export const filterUniqueEntries = (entries: BaseEntry[]) => { - const uniqueResults = new Map() - entries.forEach((item) => uniqueResults.set(item.id, item)) - return Array.from(uniqueResults.values()) -} diff --git a/vite.config.mjs b/vite.config.mjs index cb8180db..8202a168 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -9,6 +9,7 @@ import viteTsconfigPaths from 'vite-tsconfig-paths' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') + const isDev = mode === 'development' const buildTarget = env.VITE_BUILD_TARGET || 'web' const buildPlatform = env.VITE_BUILD_PLATFORM const manifestPath = path.resolve(__dirname, 'public', 'base.manifest.json') @@ -38,13 +39,15 @@ export default defineConfig(({ mode }) => { org: 'hackertabdev', project: 'hackertab', authToken: env.VITE_SENTRY_TOKEN, - disable: mode === 'development', + disable: isDev, release: { name: `${appVersion}-${buildTarget == 'extension' ? buildPlatform : buildTarget}`, }, - sourcemaps: { - filesToDeleteAfterUpload: ['./dist/assets/*.map', './assets/*.map'], - }, + sourcemaps: !isDev + ? { + filesToDeleteAfterUpload: ['./dist/assets/*.map', './assets/*.map'], + } + : false, }), ], define: { @@ -75,13 +78,16 @@ export default defineConfig(({ mode }) => { 'react-select', 'react-share', 'react-simple-toasts', + 'react-responsive', 'react-toggle', 'react-tooltip', 'react-icons', 'react-markdown', - 'react-spring-bottom-sheet', + 'react-modal', + 'react-infinite-scroll-hook', '@dnd-kit/core', '@dnd-kit/sortable', + '@szhsin/react-menu', ], utils: [ '@amplitude/analytics-browser', @@ -97,6 +103,7 @@ export default defineConfig(({ mode }) => { }, server: { open: true, + sourcemap: false, proxy: { '/api': { target: env.VITE_API_URL, diff --git a/yarn.lock b/yarn.lock index 1b50dad1..18b71f91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -277,14 +277,7 @@ dependencies: "@babel/types" "^7.23.0" -"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0" - integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w== - dependencies: - "@babel/types" "^7.22.15" - -"@babel/helper-module-imports@^7.27.1": +"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== @@ -292,6 +285,13 @@ "@babel/traverse" "^7.27.1" "@babel/types" "^7.27.1" +"@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0" + integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w== + dependencies: + "@babel/types" "^7.22.15" + "@babel/helper-module-transforms@^7.23.3": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz#d7d12c3c5d30af5b3c0fcab2a6d5217773e2d0f1" @@ -1266,13 +1266,18 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== -"@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.23.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d" integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ== dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.7.2": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" + integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== + "@babel/template@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38" @@ -2185,11 +2190,6 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@juggle/resize-observer@^3.2.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" - integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== - "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" @@ -2271,23 +2271,6 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== -"@reach/portal@^0.13.0": - version "0.13.2" - resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.13.2.tgz#6f2a6f4afc14894bde9c6435667bb9b660887ed9" - integrity sha512-g74BnCdtuTGthzzHn2cWW+bcyIYb0iIE/yRsm89i8oNzNgpopbkh9UY8TPbhNlys52h7U60s4kpRTmcq+JqsTA== - dependencies: - "@reach/utils" "0.13.2" - tslib "^2.1.0" - -"@reach/utils@0.13.2": - version "0.13.2" - resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.13.2.tgz#87e8fef8ebfe583fa48250238a1a3ed03189fcc8" - integrity sha512-3ir6cN60zvUrwjOJu7C6jec/samqAeyAB12ZADK+qjnmQPdzSYldrFWwDVV5H0WkhbYXR3uh+eImu13hCetNPQ== - dependencies: - "@types/warning" "^3.0.0" - tslib "^2.1.0" - warning "^4.0.3" - "@remix-run/router@1.14.0": version "1.14.0" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.14.0.tgz#9bc39a5a3a71b81bdb310eba6def5bc3966695b7" @@ -2631,6 +2614,13 @@ "@svgr/hast-util-to-babel-ast" "8.0.0" svg-parser "^2.0.4" +"@szhsin/react-menu@^4.5.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@szhsin/react-menu/-/react-menu-4.5.0.tgz#c302b652b8de896ec80669b1fcc492210101e290" + integrity sha512-fblZBPxFGjg+QxSbdDsWk3H8brupuQG+ayYXElwg+FdCxwLQLvrHG9K6O9+4pE8qLyDy5REn/2HmffPXcBZviA== + dependencies: + react-transition-state "^2.3.1" + "@tanstack/query-async-storage-persister@^5.8.3": version "5.14.1" resolved "https://registry.yarnpkg.com/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.14.1.tgz#71666d5be8eba5c09eaebed56918e6df2117a69e" @@ -2914,11 +2904,6 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" integrity sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA== -"@types/warning@^3.0.0": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.3.tgz#d1884c8cc4a426d1ac117ca2611bf333834c6798" - integrity sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q== - "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -3033,14 +3018,6 @@ "@types/babel__core" "^7.20.5" react-refresh "^0.14.0" -"@xstate/react@^1.2.0": - version "1.6.3" - resolved "https://registry.yarnpkg.com/@xstate/react/-/react-1.6.3.tgz#706f3beb7bc5879a78088985c8fd43b9dab7f725" - integrity sha512-NCUReRHPGvvCvj2yLZUTfR0qVp6+apc8G83oXSjN4rl89ZjyujiKrTff55bze/HrsvCsP/sUJASf2n0nzMF1KQ== - dependencies: - use-isomorphic-layout-effect "^1.0.0" - use-subscription "^1.3.0" - acorn@^8.8.1: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" @@ -3342,11 +3319,6 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -body-scroll-lock@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-3.1.5.tgz#c1392d9217ed2c3e237fee1e910f6cdd80b7aaec" - integrity sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg== - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -4377,13 +4349,6 @@ firebase@^11.2.0: "@firebase/util" "1.10.3" "@firebase/vertexai" "1.0.3" -focus-trap@^6.2.2: - version "6.9.4" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.9.4.tgz#436da1a1d935c48b97da63cd8f361c6f3aa16444" - integrity sha512-v2NTsZe2FF59Y+sDykKY+XjqZ0cPfhq/hikWVL88BqLivnNiEffAsac6rP6H45ff9wG9LL5ToiDqrLEP9GX9mw== - dependencies: - tabbable "^5.3.3" - follow-redirects@^1.15.6: version "1.15.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" @@ -4672,7 +4637,15 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== -import-fresh@^3.1.0, import-fresh@^3.2.1, import-fresh@^3.3.0: +import-fresh@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== @@ -4759,6 +4732,13 @@ is-core-module@^2.13.0, is-core-module@^2.13.1: dependencies: hasown "^2.0.0" +is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + is-date-object@^1.0.1, is-date-object@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" @@ -5731,7 +5711,7 @@ progress@^2.0.3: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -prop-types@^15.0.0, prop-types@^15.0.0-0, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.0.0, prop-types@^15.0.0-0, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -5931,28 +5911,6 @@ react-spinners@^0.10.4: dependencies: "@emotion/core" "^10.0.35" -react-spring-bottom-sheet@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/react-spring-bottom-sheet/-/react-spring-bottom-sheet-3.4.1.tgz#9a4f90b1c0af17eb4a22a606a5efc5d6e62c7b0c" - integrity sha512-yDFqiPMm/fjefjnOe6Q9zxccbCl6HMUKsK5bWgfGHJIj4zmXVKio5d4icQvmOLuwpuCA2pwv4J6nGWS6fUZidQ== - dependencies: - "@juggle/resize-observer" "^3.2.0" - "@reach/portal" "^0.13.0" - "@xstate/react" "^1.2.0" - body-scroll-lock "^3.1.5" - focus-trap "^6.2.2" - react-spring "^8.0.27" - react-use-gesture "^8.0.1" - xstate "^4.15.1" - -react-spring@^8.0.27: - version "8.0.27" - resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-8.0.27.tgz#97d4dee677f41e0b2adcb696f3839680a3aa356a" - integrity sha512-nDpWBe3ZVezukNRandTeLSPcwwTMjNVu1IDq9qA/AMiUqHuRN4BeSWvKr3eIxxg1vtiYiOLy4FqdfCP5IoP77g== - dependencies: - "@babel/runtime" "^7.3.1" - prop-types "^15.5.8" - react-toggle@^4.1.1: version "4.1.3" resolved "https://registry.yarnpkg.com/react-toggle/-/react-toggle-4.1.3.tgz#99193392cca8e495710860c49f55e74c4e6cf452" @@ -5978,10 +5936,10 @@ react-transition-group@^4.3.0: loose-envify "^1.4.0" prop-types "^15.6.2" -react-use-gesture@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-8.0.1.tgz#4360c0f7c9e26baf9fbe58f63fc9de7ef699c17f" - integrity sha512-CXzUNkulUdgouaAlvAsC5ZVo0fi9KGSBSk81WrE4kOIcJccpANe9zZkAYr5YZZhqpicIFxitsrGVS4wmoMun9A== +react-transition-state@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/react-transition-state/-/react-transition-state-2.3.1.tgz#53f5d33a95d1859d1ad2b1673c4466da1518e4ed" + integrity sha512-Z48el73x+7HUEM131dof9YpcQ5IlM4xB+pKWH/lX3FhxGfQaNTZa16zb7pWkC/y5btTZzXfCtglIJEGc57giOw== react@^19.1.0: version "19.1.0" @@ -6088,7 +6046,16 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve@^1.12.0, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.4: +resolve@^1.12.0: + version "1.22.11" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== + dependencies: + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.4: version "1.22.8" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== @@ -6387,11 +6354,6 @@ svg-parser@^2.0.4: resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== -tabbable@^5.3.3: - version "5.3.3" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" - integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== - terser@^5.19.2: version "5.26.0" resolved "https://registry.yarnpkg.com/terser/-/terser-5.26.0.tgz#ee9f05d929f4189a9c28a0feb889d96d50126fe1" @@ -6667,18 +6629,11 @@ update-browserslist-db@^1.1.3: escalade "^3.2.0" picocolors "^1.1.1" -use-isomorphic-layout-effect@^1.0.0, use-isomorphic-layout-effect@^1.1.2: +use-isomorphic-layout-effect@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== -use-subscription@^1.3.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.8.0.tgz#f118938c29d263c2bce12fc5585d3fe694d4dbce" - integrity sha512-LISuG0/TmmoDoCRmV5XAqYkd3UCBNM0ML3gGBndze65WITcsExCD3DTvXXTLyNcOC0heFQZzluW88bN/oC1DQQ== - dependencies: - use-sync-external-store "^1.2.0" - use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" @@ -6871,11 +6826,6 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" -xstate@^4.15.1: - version "4.38.3" - resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.3.tgz#4e15e7ad3aa0ca1eea2010548a5379966d8f1075" - integrity sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw== - y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"