diff --git a/src/assets/App.css b/src/assets/App.css index 6ece399d..0851c977 100644 --- a/src/assets/App.css +++ b/src/assets/App.css @@ -201,6 +201,7 @@ a { box-sizing: border-box; scroll-snap-align: start; width: 100%; + position: relative; } .blockContent { @@ -467,7 +468,8 @@ a { padding: 0; pointer-events: all; text-align: center; - transition: opacity 0.3s ease, right 0.3s ease; + transition: opacity 0.3s ease, right 0.3s ease, transform 0.1s ease, background-color 0.15s ease, + color 0.15s ease; width: 28px; margin-bottom: 6px; margin-right: 6px; @@ -490,6 +492,36 @@ a { } } +.centerMessageWrapper.cardLoading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.centerMessageIcon { + display: block; + font-size: 36px; + margin-bottom: 12px; +} + +.centerMessage p { + margin: 0; + color: var(--primary-text-color); +} + +.centerMessage p:first-of-type { + font-size: 16px; + line-height: 24px; + margin-bottom: 6px; +} + +.centerMessageSubtext { + font-size: 14px; + opacity: 0.7; +} + /* Card (element) */ .blockRow { padding: 8px 16px; @@ -543,6 +575,10 @@ a { .rowTitle:hover { color: var(--primary-hover-text-color); } + +.blockRow.isRead { + opacity: 0.3; +} .titleWithCover { display: block; width: 100%; diff --git a/src/components/Elements/CardLink/CardLink.tsx b/src/components/Elements/CardLink/CardLink.tsx index c5e3380f..6b1fb531 100644 --- a/src/components/Elements/CardLink/CardLink.tsx +++ b/src/components/Elements/CardLink/CardLink.tsx @@ -14,7 +14,7 @@ export const CardLink = ({ link, children, className = '', - appendRef = false, + appendRef = true, analyticsAttributes, }: CardLinkProps) => { return ( diff --git a/src/components/Elements/CardWithActions/CardItemWithActions.tsx b/src/components/Elements/CardWithActions/CardItemWithActions.tsx index cdb1745b..0d1569dc 100644 --- a/src/components/Elements/CardWithActions/CardItemWithActions.tsx +++ b/src/components/Elements/CardWithActions/CardItemWithActions.tsx @@ -1,9 +1,12 @@ +import clsx from 'clsx' import React, { useCallback, useEffect, useState } from 'react' -import { BiBookmarkMinus, BiBookmarkPlus, BiShareAlt } from 'react-icons/bi' +import { BiBookmarkMinus, BiBookmarkPlus, BiCheckDouble, BiShareAlt } from 'react-icons/bi' import { ShareModal } from 'src/features/shareModal' import { ShareModalData } from 'src/features/shareModal/types' import { Attributes, trackLinkBookmark, trackLinkUnBookmark } from 'src/lib/analytics' import { useBookmarks } from 'src/stores/bookmarks' +import { useReadPosts } from 'src/stores/readPosts' +import { useShallow } from 'zustand/shallow' type CardItemWithActionsProps = { item: { @@ -32,10 +35,25 @@ export const CardItemWithActions = ({ const [shareModalData, setShareModalData] = useState() const { bookmarkPost, unbookmarkPost, userBookmarks } = useBookmarks() + const { isRead, markAsRead, markAsUnread } = useReadPosts( + useShallow((state) => ({ + markAsRead: state.markAsRead, + markAsUnread: state.markAsUnread, + isRead: state.readPostIds.includes(item.id), + })) + ) const [isBookmarked, setIsBookmarked] = useState( userBookmarks.some((bm) => bm.source === source && bm.url === item.url) ) + const onMarkAsReadClick = useCallback(() => { + if (isRead) { + markAsUnread(item.id) + } else { + markAsRead(item.id) + } + }, [isRead, item.id]) + const onBookmarkClick = useCallback(() => { const itemToBookmark = { title: item.title, @@ -70,7 +88,7 @@ export const CardItemWithActions = ({ setShareModalData({ title: item.title, link: item.url, source: source }) }, [item.title, item.url, source]) return ( -
+
setShareModalData(undefined)} @@ -100,6 +118,13 @@ export const CardItemWithActions = ({ {!isBookmarked ? : } )} + +
) diff --git a/src/components/Elements/UserTags/UserTags.tsx b/src/components/Elements/UserTags/UserTags.tsx index dc8c4b21..0fe7c3fa 100644 --- a/src/components/Elements/UserTags/UserTags.tsx +++ b/src/components/Elements/UserTags/UserTags.tsx @@ -1,33 +1,65 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' +import { FaGlobe } from 'react-icons/fa' 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 { cards, userSelectedTags, cardsSettings, setCardSettings } = useUserPreferences( - useShallow((state) => ({ - cards: state.cards, - userSelectedTags: state.userSelectedTags, - cardsSettings: state.cardsSettings, - setCardSettings: state.setCardSettings, - })) - ) + const { cards, userSelectedTags, cardsSettings, setCardSettings, clearCardSettingsLanguages } = + useUserPreferences( + useShallow((state) => ({ + cards: state.cards, + userSelectedTags: state.userSelectedTags, + cardsSettings: state.cardsSettings, + setCardSettings: state.setCardSettings, + clearCardSettingsLanguages: state.clearCardSettingsLanguages, + })) + ) - const onTagClicked = useCallback((tagValue: string) => { - cards.forEach((card) => { - setCardSettings(card.name, { - ...cardsSettings[card.id], - language: tagValue, + const onTagClicked = useCallback( + (tagValue: string) => { + if (tagValue === 'all') { + clearCardSettingsLanguages() + return + } + + cards.forEach((card) => { + setCardSettings(card.name, { + ...cardsSettings[card.id], + language: tagValue, + }) }) - }) - }, []) + }, + [cards, cardsSettings, setCardSettings] + ) + + const tagsList = useMemo(() => { + const tags = userSelectedTags.map((tag) => ({ + label: tag.label, + value: tag.value, + icon: undefined, + })) + + if (tags.length === 0) { + return tags + } + + return [ + { + label: 'All', + value: 'all', + icon: , + }, + ...tags, + ] + }, [userSelectedTags]) return (
- {userSelectedTags.map((tag, index) => ( + {tagsList.map((tag, index) => ( ))} diff --git a/src/components/List/ListComponent.tsx b/src/components/List/ListComponent.tsx index 7bab2714..abe84464 100644 --- a/src/components/List/ListComponent.tsx +++ b/src/components/List/ListComponent.tsx @@ -1,6 +1,8 @@ import React, { memo, ReactNode, useMemo } from 'react' import { Placeholder } from 'src/components/placeholders' import { MAX_ITEMS_PER_CARD } from 'src/config' +import { useUserPreferences } from 'src/stores/preferences' +import { useReadPosts } from 'src/stores/readPosts' type PlaceholdersProps = { placeholder: ReactNode @@ -42,13 +44,28 @@ export function ListComponent(props: ListComponentPropsType) { limit = MAX_ITEMS_PER_CARD, } = props + const { showReadPosts } = useUserPreferences() + const readPostIdSet = useReadPosts((state) => state.readPostIdSet) + + const filteredItems = useMemo(() => { + if (!items || items.length === 0) { + return [] + } + + if (showReadPosts) { + return items + } + + return items.filter((item: any) => !readPostIdSet.has(item.id)) + }, [items, readPostIdSet, showReadPosts]) + const sortedData = useMemo(() => { - if (!items || items.length == 0) return [] - if (!sortBy) return items + if (!filteredItems || filteredItems.length == 0) return [] + if (!sortBy) return filteredItems const result = sortFn - ? [...items].sort(sortFn) - : [...items].sort((a, b) => { + ? [...filteredItems].sort(sortFn) + : [...filteredItems].sort((a, b) => { const aVal = a[sortBy] const bVal = b[sortBy] if (typeof aVal === 'number' && typeof bVal === 'number') return bVal - aVal @@ -57,7 +74,7 @@ export function ListComponent(props: ListComponentPropsType) { }) return result - }, [sortBy, sortFn, items]) + }, [sortBy, sortFn, filteredItems]) const enrichedItems = useMemo(() => { if (!sortedData || sortedData.length === 0) { @@ -66,7 +83,8 @@ export function ListComponent(props: ListComponentPropsType) { try { return sortedData.slice(0, limit).map((item, index) => { - let content: ReactNode[] = [renderItem(item, index)] + const itemNode = renderItem(item, index) + let content: ReactNode[] = [itemNode] if (header && index === 0) { content.unshift(header) } @@ -93,5 +111,19 @@ export function ListComponent(props: ListComponentPropsType) { ) } + if (items && items.length > 0 && filteredItems.length === 0) { + return ( +
+
+ +

+ You're all caught up! +

+

Check back later for fresh content.

+
+
+ ) + } + return <>{enrichedItems} } diff --git a/src/features/cards/components/producthuntCard/ArticleItem.tsx b/src/features/cards/components/producthuntCard/ArticleItem.tsx index c062ba33..d0f14db5 100644 --- a/src/features/cards/components/producthuntCard/ArticleItem.tsx +++ b/src/features/cards/components/producthuntCard/ArticleItem.tsx @@ -18,7 +18,6 @@ const ArticleItem = ({ item, analyticsTag }: BaseItemPropsType) => {
{ listingMode, theme, maxVisibleCards, + showReadPosts, setTheme, setListingMode, setMaxVisibleCards, setOpenLinksNewTab, + setShowReadPosts, } = useUserPreferences() const onOpenLinksNewTabChange = (e: React.ChangeEvent) => { @@ -52,6 +54,10 @@ export const GeneralSettings = () => { identifyUserTheme(newTheme) } + const onShowReadPostsChange = (e: React.ChangeEvent) => { + setShowReadPosts(e.target.checked) + } + return ( {
+
+

Display read posts

+
+ +
+
+ diff --git a/src/stores/preferences.ts b/src/stores/preferences.ts index 4b988084..11e7d8b0 100644 --- a/src/stores/preferences.ts +++ b/src/stores/preferences.ts @@ -36,6 +36,7 @@ export type UserPreferencesState = { userCustomCards: SupportedCardType[] advStatus: boolean DNDDuration: DNDDuration + showReadPosts: boolean } type UserPreferencesStoreActions = { @@ -51,6 +52,7 @@ type UserPreferencesStoreActions = { unfollowTag: (tag: Tag) => void setMaxVisibleCards: (maxVisibleCards: number) => void setCardSettings: (card: string, settings: CardSettingsType) => void + clearCardSettingsLanguages: () => void setOccupation: (occupation: string | null) => void markOnboardingAsCompleted: () => void setUserCustomCards: (cards: SupportedCardType[]) => void @@ -60,6 +62,7 @@ type UserPreferencesStoreActions = { addSearchEngine: (searchEngine: SearchEngineType) => void removeSearchEngine: (searchEngineUrl: string) => void setAdvStatus: (status: boolean) => void + setShowReadPosts: (value: boolean) => void } export const useUserPreferences = create( @@ -91,6 +94,7 @@ export const useUserPreferences = create( userCustomCards: [], DNDDuration: 'never', advStatus: false, + showReadPosts: true, setLayout: (layout) => set({ layout }), setPromptEngine: (promptEngine: string) => set({ promptEngine }), setListingMode: (listingMode: ListingMode) => set({ listingMode }), @@ -106,6 +110,15 @@ export const useUserPreferences = create( [card]: { ...state.cardsSettings[card], ...settings }, }, })), + clearCardSettingsLanguages: () => + set((state) => ({ + cardsSettings: Object.fromEntries( + Object.entries(state.cardsSettings).map(([card, settings]) => [ + card, + { ...settings, language: undefined }, + ]) + ), + })), markOnboardingAsCompleted: () => set(() => ({ onboardingCompleted: true, @@ -156,6 +169,7 @@ export const useUserPreferences = create( } }), setAdvStatus: (status) => set({ advStatus: status }), + setShowReadPosts: (value) => set({ showReadPosts: value }), removeCard: (cardName: string) => set((state) => { return { diff --git a/src/stores/readPosts.ts b/src/stores/readPosts.ts new file mode 100644 index 00000000..869994fd --- /dev/null +++ b/src/stores/readPosts.ts @@ -0,0 +1,55 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +const MAX_READ_POST_IDS = 5000 + +type ReadPostsStore = { + readPostIds: string[] + readPostIdSet: Set + markAsRead: (postId: string) => void + markAsUnread: (postId: string) => void +} + +export const useReadPosts = create()( + persist( + (set) => ({ + readPostIds: [], + readPostIdSet: new Set(), + markAsRead: (postId) => + set((state) => { + if (state.readPostIds.includes(postId)) return state + + const next = + state.readPostIds.length >= MAX_READ_POST_IDS + ? [...state.readPostIds.slice(1), postId] + : [...state.readPostIds, postId] + + const nextSet = new Set(state.readPostIdSet) + nextSet.add(postId) + + return { readPostIds: next, readPostIdSet: nextSet } + }), + + markAsUnread: (postId) => + set((state) => { + if (!state.readPostIds.includes(postId)) return state + + const next = state.readPostIds.filter((id) => id !== postId) + const nextSet = new Set(state.readPostIdSet) + nextSet.delete(postId) + + return { readPostIds: next, readPostIdSet: nextSet } + }), + }), + { + name: 'read_post_ids_storage', + version: 0, + partialize: (state) => ({ readPostIds: state.readPostIds }), + onRehydrateStorage: () => (state) => { + if (state) { + state.readPostIdSet = new Set(state.readPostIds) + } + }, + } + ) +) diff --git a/src/types/index.ts b/src/types/index.ts index 6d956822..c34ba350 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -143,7 +143,7 @@ export type BaseItemPropsType< } export type CardSettingsType = { - language: string + language?: string sortBy: string dateRange?: string }