diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index e87af9e1af4..f7c00cd0297 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -77,6 +77,7 @@ import { ClientQuestEventType } from '../graphql/quests'; import { ProfileEmptyScreen } from './profile/ProfileEmptyScreen'; import { Origin } from '../lib/log'; import { ExploreTabs, tabToUrl, urlToTab } from './header'; +import { FeedExploreTabs } from './header/FeedExploreTabs'; import { QueryStateKeys, useQueryState } from '../hooks/utils/useQueryState'; import { useSearchResultsLayout } from '../hooks/search/useSearchResultsLayout'; import useCustomDefaultFeed from '../hooks/feed/useCustomDefaultFeed'; @@ -85,8 +86,6 @@ import { isDevelopment, isProductionAPI, webappUrl } from '../lib/constants'; import { checkIsExtension } from '../lib/func'; import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; -import { ExploreSectionTabs } from './header/ExploreSectionTabs'; -import { ExploreSortDropdown } from './header/ExploreSortDropdown'; const FeedExploreHeader = dynamic( () => @@ -250,7 +249,6 @@ export default function MainFeedLayout({ isPopular, isAnyExplore, isExploreLatest, - isDiscussed, isSortableFeed, isCustomFeed, isSearch: isSearchPage, @@ -707,14 +705,11 @@ export default function MainFeedLayout({ ); }, [isLaptop, onTabChange, tab]); - // v2 hoists the explore section tabs into the floating card's - // page-header strip (matching the SquadDirectoryLayout pattern). The - // inline FeedExploreComponent is suppressed below to avoid showing - // the same tabs twice. - // The Discussions feed (/discussed) is part of the Explore hub — show the - // same section tabs there so the hub persists. The Sort dropdown is only - // for the actual Explore sorts, so it stays gated on isAnyExplore. - const showExploreV2PageHeader = (isAnyExplore || isDiscussed) && isV2; + // v2 reaches the Explore hub sections (Explore, Tags, Sources, Leaderboard, + // Discussions) from the sidebar's Explore panel, so the page header no longer + // carries a section-tab strip. The header now only hosts the Explore sort + // dropdown, so it's gated on isAnyExplore. + const showExploreV2PageHeader = isAnyExplore && isV2; // v2 also hoists the regular page-header strip up here, OUTSIDE // `FeedPageLayoutComponent`, so it can span the full floating-card @@ -753,8 +748,9 @@ export default function MainFeedLayout({ <> {showExploreV2PageHeader && (
- - {isAnyExplore && } + {/* Sort options as pill tabs — same navbar as the Tags / Squad + directory pages, not the underlined TabContainer. */} +
)} {showFeedV2PageHeader && ( diff --git a/packages/shared/src/components/header/ExploreHubHeader.tsx b/packages/shared/src/components/header/ExploreHubHeader.tsx index d8c81cfbd12..d7e989a6d7d 100644 --- a/packages/shared/src/components/header/ExploreHubHeader.tsx +++ b/packages/shared/src/components/header/ExploreHubHeader.tsx @@ -1,19 +1,32 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; +import { useRouter } from 'next/router'; import { PageHeader } from '../layout/PageHeader'; -import { ExploreSectionTabs } from './ExploreSectionTabs'; -// Shared v2 header for the Explore hub's directory pages (Tags, Sources, -// Leaderboard, Best of). Keeps the section-tab strip and its height -// (`!py-0`) consistent in one place. Optional children render as header -// actions (e.g. the "Suggest source" button). +// The Explore hub sections live in the sidebar's Explore panel, so the page +// header is just the standard title strip (same as Analytics / Settings) — +// no breadcrumb, no icon. The title is derived from the route. +const hubTitles: { match: (path: string) => boolean; label: string }[] = [ + { match: (path) => path.startsWith('/sources'), label: 'Sources' }, + { match: (path) => path.startsWith('/users'), label: 'Leaderboard' }, +]; + +// Shared v2 header for the Explore hub's directory pages (Sources, Leaderboard). +// Optional children render as header actions (e.g. "Suggest source"). export function ExploreHubHeader({ children, }: { children?: ReactNode; }): ReactElement { + const router = useRouter(); + // asPath-first (the resolved URL) — consistent with FeedExploreTabs and + // correct for dynamic routes where pathname is the template. + const path = (router.asPath || router.pathname || '').split('?')[0]; + const title = + hubTitles.find((entry) => entry.match(path))?.label ?? 'Explore'; + return ( - } className="!py-0"> + {children} ); diff --git a/packages/shared/src/components/header/ExploreSectionTabs.tsx b/packages/shared/src/components/header/ExploreSectionTabs.tsx deleted file mode 100644 index 31fa09ba867..00000000000 --- a/packages/shared/src/components/header/ExploreSectionTabs.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { useRouter } from 'next/router'; -import { - SquadDirectoryNavbar, - SquadDirectoryNavbarItem, -} from '../squads/layout/SquadDirectoryNavbar'; -import { ButtonSize } from '../buttons/Button'; -import { checkIsExtension } from '../../lib/func'; -import { webappUrl } from '../../lib/constants'; - -type ExploreSection = { - label: string; - // Bare app path — used both to active-match the current route (e.g. /tags or - // /tags/react keeps the Tags tab active) and as the webapp href. The href is - // resolved per-context below: the extension needs the webapp origin for the - // directory pages, the webapp navigates client-side from the bare path. - path: string; - // The Explore feed renders in-place in both the webapp and the extension, so - // it always links to the bare path. The other sections are webapp-only - // directory pages, so from the extension they must point at webappUrl. - inPlace?: boolean; -}; - -const sections: ExploreSection[] = [ - { label: 'Explore', path: '/posts', inPlace: true }, - { label: 'Tags', path: '/tags' }, - { label: 'Sources', path: '/sources' }, - { label: 'Leaderboard', path: '/users' }, - { label: 'Discussions', path: '/discussed' }, -]; - -// Primary navbar for the unified Explore hub (v2). Sits above the Explore -// feed's sort tabs and on the Tags/Sources/Leaderboard/Discussions pages so -// the sections stay one click apart after Discover was folded into Home. -export function ExploreSectionTabs(): ReactElement { - const router = useRouter(); - const currentPath = (router.asPath || router.pathname).split('?')[0]; - // The extension runs on the extension origin, so directory links must point - // at the webapp explicitly; the in-place Explore feed stays a bare path. - const isExtension = checkIsExtension(); - - return ( - - {sections.map((section) => { - const href = - isExtension && !section.inPlace - ? `${webappUrl}${section.path.slice(1)}` - : section.path; - - return ( - - ); - })} - - ); -} diff --git a/packages/shared/src/components/header/FeedExploreHeader.tsx b/packages/shared/src/components/header/FeedExploreHeader.tsx index f8a70eff8cf..b52c13b45ca 100644 --- a/packages/shared/src/components/header/FeedExploreHeader.tsx +++ b/packages/shared/src/components/header/FeedExploreHeader.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useRouter } from 'next/router'; import classNames from 'classnames'; import { BreadCrumbs } from './BreadCrumbs'; -import { CalendarIcon, HotIcon } from '../icons'; +import { CalendarIcon, CompassIcon } from '../icons'; import { IconSize } from '../Icon'; import TabList from '../tabs/TabList'; import { Tab, TabContainer } from '../tabs/TabContainer'; @@ -91,7 +91,7 @@ export function FeedExploreHeader({ isListMode && 'tablet:pt-4 laptop:pt-5', )} > - Explore + Explore )}
+
+ + {Object.entries(urlToTab).map(([url, label]) => ( + + ))} + {sortsWithPeriod.includes(activeTab) && ( } buttonSize={ButtonSize.Small} buttonVariant={ButtonVariant.Float} - // Render the date filter as a true icon-only square button (the - // shared Dropdown otherwise lays its trigger out as a full-width - // value field). Matches the design system's icon-only Small spec - // (`IconOnlySizeToClassName`) and the v2 layout's compact icon - // buttons: 32px square, rounded-10, no padding. className={{ button: '!size-8 !rounded-10 !p-0' }} selectedIndex={period} options={periodTexts} @@ -50,19 +62,6 @@ export function ExploreSortDropdown(): ReactElement { buttonAriaLabel="Filter by date range" /> )} - { - const url = tabToUrl[value as ExploreTabs]; - if (url) { - router.push(url).catch(() => undefined); - } - }} - /> - +
); } diff --git a/packages/shared/src/components/icons/Compass/filled.svg b/packages/shared/src/components/icons/Compass/filled.svg new file mode 100644 index 00000000000..a35933d348e --- /dev/null +++ b/packages/shared/src/components/icons/Compass/filled.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/shared/src/components/icons/Compass/index.tsx b/packages/shared/src/components/icons/Compass/index.tsx new file mode 100644 index 00000000000..be896e9567d --- /dev/null +++ b/packages/shared/src/components/icons/Compass/index.tsx @@ -0,0 +1,12 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const CompassIcon = (props: IconProps): ReactElement => ( + +); + +export default CompassIcon; diff --git a/packages/shared/src/components/icons/Compass/outlined.svg b/packages/shared/src/components/icons/Compass/outlined.svg new file mode 100644 index 00000000000..901232ee7fe --- /dev/null +++ b/packages/shared/src/components/icons/Compass/outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index a8506ac6745..feaea9e0065 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -32,6 +32,7 @@ export * from './Codeberg'; export * from './CodePen'; export * from './Coin'; export * from './CommunityPicksIcon'; +export * from './Compass'; export * from './Cookie'; export * from './Copy'; export * from './Core'; diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 0b17ebff20a..dc821bb48c0 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -64,9 +64,15 @@ function NotificationsBell({ @@ -84,7 +90,7 @@ function NotificationsBell({ )} {!railHideLabel && ( - Alerts + Activity )} diff --git a/packages/shared/src/components/notifications/Toast.tsx b/packages/shared/src/components/notifications/Toast.tsx index d69971845be..91209163915 100644 --- a/packages/shared/src/components/notifications/Toast.tsx +++ b/packages/shared/src/components/notifications/Toast.tsx @@ -102,9 +102,11 @@ const Toast = ({ return; } - // Auto-dismiss (and the countdown ring) only when the setting is on and the - // toast isn't explicitly persistent; otherwise it stays until dismissed. - const shouldAutoDismiss = autoDismissNotifications && !toast.persistent; + // Auto-dismiss (and the countdown ring) when the setting is on — or a toast + // forces it — and the toast isn't explicitly persistent; otherwise it stays + // until dismissed. + const shouldAutoDismiss = + (autoDismissNotifications || toast.forceAutoDismiss) && !toast.persistent; if (!toastRef.current) { toastRef.current = toast; @@ -133,9 +135,13 @@ const Toast = ({ return; } - // No running countdown when auto-dismiss is off or the toast is persistent, - // so clear directly; otherwise let the timed animation play out. - if (!autoDismissNotifications || acted.persistent) { + // No running countdown when auto-dismiss is off (and not forced) or the + // toast is persistent, so clear directly; otherwise let the timed animation + // play out. + if ( + (!autoDismissNotifications && !acted.forceAutoDismiss) || + acted.persistent + ) { toastRef.current = null; client.setQueryData(TOAST_NOTIF_KEY, null); return; @@ -192,7 +198,8 @@ const Toast = ({ // The dismiss ring is the auto-dismiss countdown made visible, so it shows // exactly when the toast will auto-dismiss (setting on + not persistent). // dashoffset drains 0→100 as the remaining time elapses. - const shouldAutoDismiss = autoDismissNotifications && !isPersistentToast; + const shouldAutoDismiss = + (autoDismissNotifications || toast.forceAutoDismiss) && !isPersistentToast; const showRing = shouldAutoDismiss && toast.timer > 0; const remaining = toast.timer > 0 ? (timer / toast.timer) * 100 : 0; const dashoffset = Math.min(100, Math.max(0, 100 - remaining)); diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index e2bae5205de..80704bfac26 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -225,11 +225,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { icon: EyeIcon, href: `${settingsUrl}/customization/gamification`, }, - streaks: { - title: 'Streaks', - icon: HotIcon, - href: `${settingsUrl}/customization/streaks`, - }, ...(!optOutAchievements && { achievements: { title: 'Achievements', diff --git a/packages/shared/src/components/quest/CompactQuestList.tsx b/packages/shared/src/components/quest/CompactQuestList.tsx new file mode 100644 index 00000000000..f68a0818485 --- /dev/null +++ b/packages/shared/src/components/quest/CompactQuestList.tsx @@ -0,0 +1,220 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import ProgressCircle from '../ProgressCircle'; +import { ArrowIcon, CoreIcon, ReputationIcon, VIcon } from '../icons'; +import { IconSize } from '../Icon'; +import Link from '../utilities/Link'; +import { webappUrl } from '../../lib/constants'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import type { QuestReward, UserQuest } from '../../graphql/quests'; +import { QuestRewardType, QuestStatus } from '../../graphql/quests'; +import { useQuestDashboard } from '../../hooks/useQuestDashboard'; +import { useClaimQuestReward } from '../../hooks/useClaimQuestReward'; + +const rewardColorByType: Record = { + // XP reads as plain white, normal weight (de-emphasized); Cores/Reputation + // stay bold + colour-coded so they pop. + [QuestRewardType.Xp]: 'text-text-primary', + [QuestRewardType.Cores]: 'text-accent-cheese-default', + [QuestRewardType.Reputation]: 'text-accent-onion-default', +}; + +// The reward "value" of a quest — the thing that answers "what do I get?". +const QuestRewardValue = ({ + reward, +}: { + reward: QuestReward; +}): ReactElement => ( + + {reward.type === QuestRewardType.Cores && } + {reward.type === QuestRewardType.Reputation && ( + + )} + + {reward.amount} + {reward.type === QuestRewardType.Xp ? ' XP' : ''} + + +); + +interface CompactQuestRowProps { + quest: UserQuest; + isClaiming: boolean; + onClaim: (quest: UserQuest) => void; +} + +const CompactQuestRow = ({ + quest, + isClaiming, + onClaim, +}: CompactQuestRowProps): ReactElement => { + const target = Math.max(quest.quest.targetCount, 1); + const value = Math.min(Math.max(quest.progress, 0), target); + const percentage = Math.min(100, Math.round((value / target) * 100)); + const isClaimed = + quest.status === QuestStatus.Claimed || Boolean(quest.claimedAt); + const canClaim = quest.claimable && !!quest.userQuestId && !isClaimed; + + return ( + // Rounded hover pill + `hover:bg-surface-hover`, matching every other v2 + // panel list row (the px-3 container gives the same ~12px inset as `mx-3`). +
  • + {/* Title row: title takes the full width with the open-details chevron in + the top-right corner. */} +
    + + {/* Stretched link — its `before` covers the whole row (the row is + `relative`), so clicking anywhere on the quest opens the Game + Center. The Claim button opts out via `relative z-1`; the + chevron/progress sit under the link so they navigate too. */} + + + {quest.quest.name} + + + + +
    + {quest.quest.description && ( + + {quest.quest.description} + + )} + + {/* Bottom strip: XP/Cores rewards on the left; status aligned to the right + on the same row — claim/claimed, or the step count + small radial. */} +
    + + {quest.rewards.map((reward) => ( + + ))} + +
    + {canClaim && ( + + )} + {!canClaim && isClaimed && ( + + + Claimed + + )} + {!canClaim && !isClaimed && ( + <> + {/* Step count on the LEFT of the radial. */} + + {value}/{target} + + + + )} +
    +
    +
  • + ); +}; + +// Compact quest list for the streak panel: rounded hover-pill rows (matching +// the other v2 panels) — title + subtitle full width with the rewards beneath, +// and a right column holding the open-details chevron + step count/radial (or +// claim/claimed). Daily and weekly quests are merged into one list under the +// panel's "Daily quests" header. Replaces the full QuestButton dashboard. +export const CompactQuestList = (): ReactElement | null => { + const { data } = useQuestDashboard(); + const { + mutate: claim, + isPending: isClaiming, + variables, + } = useClaimQuestReward(); + const claimingId = isClaiming ? variables?.userQuestId : undefined; + + const quests = useMemo(() => { + if (!data) { + return []; + } + return [ + ...data.daily.regular, + ...data.daily.plus, + ...data.weekly.regular, + ...data.weekly.plus, + ]; + }, [data]); + + if (!data) { + return null; + } + + if (quests.length === 0) { + return ( + + You're all caught up — new quests tomorrow. + + ); + } + + const onClaim = (quest: UserQuest) => + claim({ + userQuestId: quest.userQuestId as string, + questId: quest.quest.id, + questType: quest.quest.type, + }); + + return ( +
      + {quests.map((quest) => ( + + ))} +
    + ); +}; diff --git a/packages/shared/src/components/sidebar/RailMoreMenu.tsx b/packages/shared/src/components/sidebar/RailMoreMenu.tsx new file mode 100644 index 00000000000..bd805375cf2 --- /dev/null +++ b/packages/shared/src/components/sidebar/RailMoreMenu.tsx @@ -0,0 +1,71 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useRef } from 'react'; +import classNames from 'classnames'; +import { MenuIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { railTabClass, railTabLabelClass } from './common'; +import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; +import { useOutsideClick } from '../../hooks/utils/useOutsideClick'; +import { RootPortal } from '../tooltips/Portal'; +import { useAnchoredRailPopup } from './useAnchoredRailPopup'; + +// A rail "More" item: a three-dots + label button that opens a click dropdown +// with the exact same chrome, placement and behavior as the Support/Settings +// popups and the shortcuts customize tray. Used when the rail is too short to +// show the tabs inline — the dropdown then holds the tabs (and a Shortcuts +// category). Shares the rail popup group so it's mutually exclusive with the +// other rail popups. +export const RailMoreMenu = ({ + label = 'More', + compact = false, + children, +}: { + label?: string; + compact?: boolean; + children: ReactNode; +}): ReactElement => { + const { isOpen, onUpdate, wrapHandler } = useInteractivePopup('sidebar-rail'); + const btnRef = useRef(null); + const popupRef = useRef(null); + useOutsideClick(popupRef, () => onUpdate(false), isOpen); + const pos = useAnchoredRailPopup(btnRef, isOpen); + + return ( + <> + + {isOpen && pos && ( + +
    + {children} +
    +
    + )} + + ); +}; diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx index f5fe7fcff4e..499297cab1d 100644 --- a/packages/shared/src/components/sidebar/Section.tsx +++ b/packages/shared/src/components/sidebar/Section.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import type { ReactElement } from 'react'; -import React, { useRef } from 'react'; +import React, { useState } from 'react'; import type { ItemInnerProps, SidebarMenuItem } from './common'; import { NavHeader, NavSection } from './common'; import { SidebarItem } from './SidebarItem'; @@ -11,6 +11,7 @@ import { useSettingsContext } from '../../contexts/SettingsContext'; import { isNullOrUndefined } from '../../lib/func'; import useSidebarRendered from '../../hooks/useSidebarRendered'; import Link from '../utilities/Link'; +import { HorizontalSeparator } from '../utilities'; export interface SectionCommonProps extends Pick { @@ -57,16 +58,19 @@ export function Section({ // always expanded. const initialIsVisible = !title || isNullOrUndefined(currentFlagValue) ? true : currentFlagValue; - const isVisible = useRef(initialIsVisible); + // State (not a ref) so toggling re-renders even for sections without a + // persisted `flag` (e.g. the settings panel groups) — otherwise the + // collapse never visibly happens. + const [isVisible, setIsVisible] = useState(initialIsVisible); const toggleFlag = () => { - const nextIsVisible = !isVisible.current; + const nextIsVisible = !isVisible; if (flag) { updateFlag(flag, nextIsVisible); } - isVisible.current = nextIsVisible; + setIsVisible(nextIsVisible); }; return ( @@ -97,7 +101,7 @@ export function Section({ type="button" onClick={toggleFlag} aria-label={`Toggle ${title}`} - aria-expanded={!!isVisible.current} + aria-expanded={!!isVisible} aria-controls={flag ? `section-${flag}` : undefined} className="flex items-center gap-1 rounded-6 px-1 py-0.5 transition-colors hover:bg-surface-hover hover:text-text-primary" > @@ -122,7 +126,7 @@ export function Section({ compact ? 'opacity-0 transition-[transform,opacity] group-focus-within/section:opacity-100 group-hover/section:opacity-100' : 'h-2.5 w-2.5 transition-transform', - isVisible.current ? 'rotate-180' : 'rotate-90', + isVisible ? 'rotate-180' : 'rotate-90', )} /> @@ -157,7 +161,7 @@ export function Section({ // only toggle). A flagged-but-title-less section — e.g. the Squads // and Saved panels — would otherwise get stuck hidden when its flag // is false, with no arrow to re-expand it. - !title || isVisible.current || shouldAlwaysBeVisible + !title || isVisible || shouldAlwaysBeVisible ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', )} @@ -168,15 +172,22 @@ export function Section({ compact && 'gap-px', )} > - {items.map((item) => ( - - ))} + {items.map((item) => + item.isSeparator ? ( + + ) : ( + + ), + )}
    diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 066b1959b63..750fd313b7e 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -10,6 +10,23 @@ import React, { import { useRouter } from 'next/router'; import * as HoverCardPrimitive from '@radix-ui/react-hover-card'; import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { DragEndEvent } from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + createSidebarSeparatorItem, + isSidebarItemActive, ListIcon, Nav, railTabClass, @@ -24,28 +41,21 @@ import type { SidebarCategoryId } from './sidebarCategory'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { useLogContext } from '../../contexts/LogContext'; import { useBanner } from '../../hooks/useBanner'; -import { MainSection } from './sections/MainSection'; -import { PinnedSection } from './sections/PinnedSection'; -import { RecentSection } from './sections/RecentSection'; -import { CustomFeedSection } from './sections/CustomFeedSection'; +import { ExploreSection } from './sections/ExploreSection'; +import { ProfilePanelSection } from './sections/ProfilePanelSection'; import { SettingsPanelSection } from './sections/SettingsPanelSection'; import type { ComposerKind } from '../post/composer/types'; -import { QuestRailIcon } from '../quest/QuestRailIcon'; import { useClaimableQuestCount } from '../../hooks/useQuestDashboard'; import { Bubble } from '../tooltips/utils'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { BookmarkSection } from './sections/BookmarkSection'; import { NetworkSection } from './sections/NetworkSection'; -import { GameCenterSection } from './sections/GameCenterSection'; +import { StreakQuestsSection } from './sections/StreakQuestsSection'; import { HelpWidget } from '../help/HelpWidget'; import { - AnalyticsIcon, - AppIcon, BellIcon, - BookmarkIcon, BrowserGroupIcon, + CompassIcon, CreditCardIcon, - DevCardIcon, DocsIcon, EditIcon, ExitIcon, @@ -54,16 +64,14 @@ import { GiftIcon, HelpIcon, HomeIcon, - InviteIcon, - JobIcon, + HotIcon, + JoystickIcon, LinkIcon, MegaphoneIcon, - MenuIcon, MicrophoneIcon, MoveToIcon, NewPostIcon, PhoneIcon, - PlusIcon, PollIcon, PrivacyIcon, SearchIcon, @@ -74,19 +82,26 @@ import { TrendingIcon, } from '../icons'; import { useSettingsBooleanFlag } from '../../hooks/useSettingsBooleanFlag'; -import { Origin, TargetId } from '../../lib/log'; import { IconSize } from '../Icon'; import { Tooltip } from '../tooltip/Tooltip'; import { RailHoverPanel } from './RailHoverPanel'; -import { StreakPopover } from './StreakPopover'; -import { StreakRing } from './StreakRing'; +import { StreakBadge } from './StreakBadge'; +import { + SidebarShortcutsDock, + useSidebarShortcutItems, +} from './SidebarShortcutsDock'; +import { RailMoreMenu } from './RailMoreMenu'; +import { + SidebarDragStateProvider, + useSidebarDragState, +} from './useSidebarDragState'; import { useSpotlight } from '../spotlight/SpotlightContext'; import { useAuthContext } from '../../contexts/AuthContext'; import NotificationsBell from '../notifications/NotificationsBell'; import { NotificationsRailPanel } from '../notifications/NotificationsRailPanel'; import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; -import { SidebarProfileStats } from './SidebarProfileStats'; import Link from '../utilities/Link'; +import { SharedFeedPage, HorizontalSeparator } from '../utilities'; import { appsUrl, businessWebsiteUrl, @@ -98,27 +113,28 @@ import { termsOfService, webappUrl, } from '../../lib/constants'; -import { isAppleDevice } from '../../lib/func'; +import { isAppleDevice, isExtension } from '../../lib/func'; import LogoIcon from '../../svg/LogoIcon'; import InteractivePopup, { InteractivePopupPosition, } from '../tooltips/InteractivePopup'; import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; +import usePersistentContext from '../../hooks/usePersistentContext'; import { ProfileSection as ProfileMenuSection } from '../ProfileMenu/ProfileSection'; import type { ProfileSectionItemProps } from '../ProfileMenu/ProfileSectionItem'; -import { ProfileMenuHeader } from '../ProfileMenu/ProfileMenuHeader'; import { ThemeSection } from '../ProfileMenu/sections/ThemeSection'; -import { UpgradeToPlus } from '../UpgradeToPlus'; import { LogoutReason } from '../../lib/user'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; import { useCanPurchaseCores } from '../../hooks/useCoresFeature'; -import { useSquadNavigation } from '../../hooks'; -import { useAddBookmarkFolder } from '../../hooks/bookmark/useAddBookmarkFolder'; +import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; import { useStreakRingState } from '../../hooks/streaks/useStreakRingState'; import { FeedbackWidget } from '../feedback'; -import { HorizontalSeparator } from '../utilities'; -import { Typography, TypographyType } from '../typography/Typography'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; type SidebarCategoryConfig = { id: SidebarCategoryId; @@ -130,8 +146,19 @@ type SidebarCategoryConfig = { const sidebarCategories: SidebarCategoryConfig[] = [ { id: SidebarCategory.Main, - label: 'Home', - defaultPath: webappUrl, + label: 'Explore', + defaultPath: `${webappUrl}posts`, + icon: (active) => ( + + ), + }, + { + // Rendered via the avatar (not the tablist loop); listed here so panel + // title / label lookups resolve. The icon is unused — the avatar renders + // the user's profile picture. + id: SidebarCategory.Profile, + // Surfaced as the panel title and the avatar tooltip/label. + label: 'You', icon: (active) => ( ), @@ -145,32 +172,23 @@ const sidebarCategories: SidebarCategoryConfig[] = [ ), }, { + // The reading-streak tab. Its panel leads with the streak details, then a + // Game Center link and the daily quests. Clicking the rail icon lands on + // Game Center; the streak/quests detail is one hover away in the panel. id: SidebarCategory.GameCenter, - label: 'Quests', - // First sub-page in the Game Center category is the Daily quests - // page (the panel that used to live in the sidebar). Clicking the - // rail icon lands you there; Game Center proper is one click away - // via the hover panel. - defaultPath: `${webappUrl}daily-quests`, - icon: (active) => , - }, - { - id: SidebarCategory.Saved, - label: 'Saved', - defaultPath: `${webappUrl}bookmarks`, + label: 'Streak', + defaultPath: `${webappUrl}game-center`, icon: (active) => ( - + ), }, ]; const railButtonClass = - 'flex size-10 items-center justify-center rounded-12 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus-outline'; + 'flex size-10 items-center justify-center rounded-12 text-text-tertiary transition-[background-color,color,transform] duration-150 ease-out hover:bg-surface-hover hover:text-text-primary active:scale-90 motion-reduce:transition-none focus-outline'; // Shared group so the rail's click popups (support, profile menu, streak) are // mutually exclusive — opening one closes the others. const RAIL_POPUP_GROUP = 'sidebar-rail'; -// How long the urgency tooltip auto-surfaces when the streak turns critical. -const STREAK_CRITICAL_TOOLTIP_MS = 5000; const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K']; const settingsDefaultPath = `${settingsUrl}/profile`; @@ -186,6 +204,36 @@ const RAIL_TOOLTIP_COLLISION_PADDING = 4; // slightly past the panel's top/bottom edge while arcing in without losing it. const SAFE_ZONE_BUFFER = 26; +// Wraps a rail category tab so it can be reordered by cursor drag. Drag +// listeners sit on this outer element; the tab's own button stays the focus +// target. PointerSensor's distance constraint (set on the DndContext) means a +// plain click still selects the category instead of starting a drag. +const SortableRailTab = ({ + id, + children, +}: { + id: string; + children: ReactNode; +}): ReactElement => { + const { setNodeRef, listeners, transform, transition, isDragging } = + useSortable({ id }); + return ( +
    + {children} +
    + ); +}; + interface RailHoverCardProps { label: string; children: ReactNode; @@ -208,13 +256,19 @@ const RailHoverCard = ({ // up after navigation. const [open, setOpen] = useState(false); const suppressOpenRef = useRef(false); + // Never let the panel pop open while something is being dragged — its portal + // (z-tooltip) would render over the drag ghost. + const { isDragging } = useSidebarDragState(); - const handleOpenChange = useCallback((next: boolean) => { - if (next && suppressOpenRef.current) { - return; - } - setOpen(next); - }, []); + const handleOpenChange = useCallback( + (next: boolean) => { + if (next && (suppressOpenRef.current || isDragging)) { + return; + } + setOpen(next); + }, + [isDragging], + ); const handleTriggerClick = useCallback(() => { suppressOpenRef.current = true; @@ -232,7 +286,7 @@ const RailHoverCard = ({ {panel} @@ -332,7 +386,7 @@ const SidebarSupportButton = (): ReactElement => { closeOutsideClick onClose={() => onUpdate(false)} position={InteractivePopupPosition.SidebarSupportMenu} - className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" + className="animate-rail-popup-in flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" > @@ -376,94 +430,19 @@ const createMenuOptions: { }, ]; -// Profile menu anchored to the bottom rail avatar. A curated, lean subset of -// the production ProfileMenu (built from the shared ProfileSection item rows) -// plus the rail-specific reputation/cores stats card. -const SidebarProfileButton = ({ - onPreviewHref, -}: { - onPreviewHref: (href: string) => void; -}): ReactElement | null => { - const { user, logout } = useAuthContext(); +// Account/app controls that used to live in the avatar dropdown now sit behind +// a bottom-rail gear (sibling to Invite/Support). Profile-related items moved +// to the avatar panel; this keeps the leftover account/app/billing actions. +const SidebarSettingsButton = (): ReactElement => { + const { logout } = useAuthContext(); const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(RAIL_POPUP_GROUP); const { openModal } = useLazyModal(); const canPurchaseCores = useCanPurchaseCores(); - // The reading streak is one connected colored shape behind the avatar: the - // border around the avatar + a peeking tab share the fill (state colour). The - // avatar opens the profile menu; the tab opens the streak calendar. - const { - isEnabled: isStreakEnabled, - isLoading: isStreakLoading, - streak, - state: streakState, - count: streakCount, - hasReadToday, - copy: streakCopy, - } = useStreakRingState(); - const { isOpen: isStreakOpen, onUpdate: setStreakOpen } = - useInteractivePopup(RAIL_POPUP_GROUP); - const streakChipRef = useRef(null); - // Only on critical: auto-open the streak tooltip to nudge the user for ~5s, - // then hide it (or sooner, the moment they hover the streak). Re-arms each - // time the streak re-enters the critical state. - const [autoOpenStreakTooltip, setAutoOpenStreakTooltip] = useState(false); - const prevStreakCriticalRef = useRef(false); - useEffect(() => { - const isCritical = streakState === 'critical'; - const wasCritical = prevStreakCriticalRef.current; - prevStreakCriticalRef.current = isCritical; - if (isCritical && !wasCritical) { - setAutoOpenStreakTooltip(true); - const timeout = setTimeout( - () => setAutoOpenStreakTooltip(false), - STREAK_CRITICAL_TOOLTIP_MS, - ); - return () => clearTimeout(timeout); - } - if (!isCritical) { - setAutoOpenStreakTooltip(false); - } - return undefined; - }, [streakState]); - - if (!user) { - return null; - } - - // Optimistically switch the context panel to the link's category on click — - // same instant feedback as a rail-tab click — so the panel doesn't visibly - // lag a slow route transition (especially the heavy Settings pages). - const withPreview = ( - items: ProfileSectionItemProps[], - ): ProfileSectionItemProps[] => - items.map((item) => { - if (!item.href || item.external) { - return item; - } - const { href, onClick } = item; - return { - ...item, - onClick: () => { - onPreviewHref(href); - onClick?.(); - }, - }; - }); - const mainItems: ProfileSectionItemProps[] = [ - { title: 'Analytics', href: `${webappUrl}analytics`, icon: AnalyticsIcon }, - { title: 'Jobs', href: `${webappUrl}jobs`, icon: JobIcon }, - { - title: 'DevCard', - href: `${settingsUrl}/customization/devcard`, - icon: DevCardIcon, - }, - { - title: 'Invite friends', - href: `${settingsUrl}/invite`, - icon: InviteIcon, - }, + const settingsItems: ProfileSectionItemProps[] = [ + { title: 'Settings', href: settingsDefaultPath, icon: SettingsIcon }, + { title: 'Appearance', href: `${settingsUrl}/appearance`, icon: EyeIcon }, ]; const billingItems: ProfileSectionItemProps[] = [ @@ -489,16 +468,6 @@ const SidebarProfileButton = ({ }, ]; - const settingsItems: ProfileSectionItemProps[] = [ - { title: 'Settings', href: settingsDefaultPath, icon: SettingsIcon }, - { title: 'Appearance', href: `${settingsUrl}/appearance`, icon: EyeIcon }, - { - title: 'Feed settings', - href: `${settingsUrl}/feed/general`, - icon: AppIcon, - }, - ]; - const logoutItems: ProfileSectionItemProps[] = [ { title: 'Log out', @@ -509,134 +478,108 @@ const SidebarProfileButton = ({ return ( <> -
    - {isStreakEnabled ? ( - // Shared StreakRing renders the "border legend" visual (avatar in a - // bordered box; flame + count break through the bottom border). The - // avatar (profile menu) and the chip (streak popover) are two distinct - // buttons — we pass the avatar button + the chip's interactivity here; - // all state visuals live in StreakRing / useStreakRingState. - { - event.stopPropagation(); - setStreakOpen(!isStreakOpen); - }} - chipTooltip={streakCopy} - chipTooltipOpen={autoOpenStreakTooltip} - onMouseEnter={() => setAutoOpenStreakTooltip(false)} - avatar={ - - - - } - /> - ) : ( - - )} -
    - {isStreakOpen && streak && ( - setStreakOpen(false)} - /> - )} + + + {isOpen && ( onUpdate(false)} - position={InteractivePopupPosition.SidebarProfileMenu} - className="flex max-h-[calc(100dvh-4rem)] w-72 flex-col gap-3 overflow-y-auto !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" + position={InteractivePopupPosition.SidebarSupportMenu} + className="animate-rail-popup-in flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" > - - - - - - - - - - + + + + + + + )} ); }; +// The avatar is a rail tab: it opens the Profile context panel (your feeds, +// activity, pins, custom feeds) like every other category — no dropdown menu. +// Styled identically to the category tabs (icon + label below, same hover and +// selected states) — the profile picture stands in for the glyph icon. +const SidebarProfileButton = ({ + isSelected, + isPreviewing, + isCompact, + isExpanded, + panel, + onSelect, + onPreview, + onPreviewLeave, +}: { + isSelected: boolean; + isPreviewing: boolean; + isCompact: boolean; + isExpanded: boolean; + panel: ReactElement; + onSelect: () => void; + onPreview: () => void; + onPreviewLeave: (event: React.MouseEvent) => void; +}): ReactElement | null => { + const { user } = useAuthContext(); + + if (!user) { + return null; + } + + return ( + + + + ); +}; + type SidebarDesktopV2Props = { activePage?: string; featureTheme?: { @@ -665,14 +608,35 @@ export const SidebarDesktopV2 = ({ toggleSidebarExpanded, loadedSettings, optOutQuestSystem, + optOutReadingStreak, + isGamificationEnabled, } = useSettingsContext(); + // The reading-streak rail tab: hidden entirely when all gamification is off; + // when only streaks are off (but quests/levels/etc. remain) it stays but reads + // as the broader "Quests" / Game Center tab instead of a streak. + const showGameCenterTab = isGamificationEnabled; + const isStreakTabAStreak = !optOutReadingStreak; + // Short label under the rail tab icon (and the More-menu row): "Streak" / + // "Quests" — kept compact for the narrow rail (the tab usually shows the day + // count anyway). + const gameCenterLabel = isStreakTabAStreak ? 'Streak' : 'Quests'; + // Fuller title for the panel + hover card: "Current Streak" when streaks are + // on (the panel leads with the current-streak hero), else "Daily Quests". + const gameCenterPanelTitle = isStreakTabAStreak + ? 'Current Streak' + : 'Daily Quests'; const { logEvent } = useLogContext(); const { isAvailable: isBannerAvailable } = useBanner(); const { open: openSpotlight } = useSpotlight(); - const { openModal } = useLazyModal(); - const { isLoggedIn } = useAuthContext(); - const { openNewSquad } = useSquadNavigation(); - const addBookmarkFolder = useAddBookmarkFolder(); + const { openModal, modal } = useLazyModal(); + const { isLoggedIn, user } = useAuthContext(); + const { isCustomDefaultFeed } = useCustomDefaultFeed(); + // The flat Home button targets the "For You" feed. On extension there's no + // router, so it always uses the explicit /my-feed path. + let myFeedPath = isCustomDefaultFeed ? '/my-feed' : '/'; + if (isExtension) { + myFeedPath = '/my-feed'; + } const { value: isCompact } = useSettingsBooleanFlag('sidebarCompact'); // Compact mode reverts to the original icon-only widths (pre-label rail). // Both width sets are known-good; MainLayout mirrors the collapsed/expanded @@ -686,65 +650,191 @@ export const SidebarDesktopV2 = ({ const claimableQuestCount = useClaimableQuestCount(); const showQuestBadge = !optOutQuestSystem && claimableQuestCount > 0; - // --- Vertical "More" overflow ------------------------------------------- - // When the rail is too short to fit every nav item, the lowest-priority - // items fold into the Settings button, which becomes a 3-dots "More" - // dropdown. Fold order: Saved, then Quests, then Alerts. Home, Squads and - // New post always stay. Measured against the nav list's height so it tracks - // the viewport like Slack's sidebar. - const navListRef = useRef(null); - const [maxNavSlots, setMaxNavSlots] = useState(Number.POSITIVE_INFINITY); + // Drives the Streak tab's status: flame fills once you've read today and is + // tinted by state (safe / at-risk / critical / freeze); the label shows the + // day count. Reuses the same state machine the avatar streak ring used. + const { + isEnabled: isStreakEnabled, + state: streakState, + count: streakCount, + hasReadToday: streakReadToday, + copy: streakCopy, + } = useStreakRingState(); + + // The reorderable rail tabs (each opens a panel), including the avatar/"You" + // tab so it can be moved too. Order is user-customizable via drag-and-drop and + // persisted; logo / New post / settings are fixed and live outside this list. + const reorderableCategories = useMemo( + () => + [ + isLoggedIn ? SidebarCategory.Profile : null, + SidebarCategory.Main, + SidebarCategory.Squads, + isLoggedIn ? SidebarCategory.Notifications : null, + // Drops out of the rail entirely when all gamification is opted out. + showGameCenterTab ? SidebarCategory.GameCenter : null, + ].filter(Boolean) as SidebarCategoryId[], + [isLoggedIn, showGameCenterTab], + ); + const [storedRailOrder, setStoredRailOrder] = usePersistentContext< + SidebarCategoryId[] + >('sidebar_rail_order', reorderableCategories); + // Reconcile the saved order against the valid set: drop unknown/stale ids and + // surface any newly-added category that isn't in the stored order yet at the + // front (e.g. the avatar tab for users who saved an order before it existed). + const railOrder = useMemo(() => { + const known = (storedRailOrder ?? []).filter((id) => + reorderableCategories.includes(id), + ); + const missing = reorderableCategories.filter((id) => !known.includes(id)); + return [...missing, ...known]; + }, [reorderableCategories, storedRailOrder]); + + // Overflow, measured against the content-independent (flex-1) height of the + // lower region that holds the tabs + dock — so folding never changes the + // measurement and it can't oscillate. Above the threshold tabs are inline and + // the dock scrolls; below it (short viewport) the whole rail folds into one + // click "More" menu (tabs list + Shortcuts category). + const SHORTCUTS_MIN_INLINE = 3; + const lowerRegionRef = useRef(null); + const [regionHeight, setRegionHeight] = useState(Number.POSITIVE_INFINITY); useEffect(() => { - const list = navListRef.current; - if (!list || typeof ResizeObserver === 'undefined') { + const region = lowerRegionRef.current; + if (!region || typeof ResizeObserver === 'undefined') { return undefined; } - const GAP = 4; - const itemHeight = isCompact ? 44 : 56; const measure = () => { - // Ignore zero-height measurements (e.g. a hidden/not-yet-laid-out mount) - // so we don't briefly fold every item into the "More" menu. - if (list.clientHeight <= 0) { - return; + if (region.clientHeight > 0) { + setRegionHeight(region.clientHeight); } - const slots = Math.floor((list.clientHeight + GAP) / (itemHeight + GAP)); - setMaxNavSlots(Math.max(0, slots)); }; measure(); const observer = new ResizeObserver(measure); - observer.observe(list); + observer.observe(region); return () => observer.disconnect(); - }, [isCompact]); + }, []); - const overflowOrder = useMemo( - () => - [ - isLoggedIn ? SidebarCategory.Notifications : null, - SidebarCategory.GameCenter, - SidebarCategory.Saved, - ].filter(Boolean) as SidebarCategoryId[], - [isLoggedIn], + const { resolved: shortcutItems } = useSidebarShortcutItems(); + const shortcutCount = isLoggedIn ? shortcutItems.length : 0; + const iconRowPx = 40 + 4; // shortcut dot row (height + gap) + const tabRowPx = (isCompact ? 44 : 56) + 4; // tab / "More" row (height + gap) + const SEP_PX = 12; // framing separator + its vertical margins + const tabCount = railOrder.length; + const minDockPx = + shortcutCount > 0 ? SEP_PX + SHORTCUTS_MIN_INLINE * iconRowPx : 0; + // Progressive overflow (a "priority+" rail). Stage 1: everything fits — all + // tabs inline plus a usefully-sized, scrollable shortcuts dock; no "More". + const fitsAllInline = regionHeight >= tabCount * tabRowPx + minDockPx; + // Otherwise a "More" tab (same row height) collects the overflow. Keep as + // many tabs inline as fit ABOVE that row and drop the lowest-priority tabs + // (end of railOrder) into More ONE AT A TIME as the viewport shrinks — never + // all at once, which left the rail looking empty. The inline dock is dropped + // here; its shortcuts move into the same More menu (one combined dropdown). + const tabsThatFitWithMore = Math.max( + 0, + Math.floor((regionHeight - tabRowPx) / tabRowPx), + ); + const visibleTabCount = fitsAllInline + ? tabCount + : Math.min(tabCount, tabsThatFitWithMore); + const visibleCategoryIds = railOrder.slice(0, visibleTabCount); + const overflowTabIds = fitsAllInline ? [] : railOrder.slice(visibleTabCount); + // More is needed when any tab overflows, or when all tabs still fit inline + // but the shortcuts can't get a usable inline dock (so they collapse in). + const moreNeeded = + isLoggedIn && + (overflowTabIds.length > 0 || (!fitsAllInline && shortcutCount > 0)); + // Render the dock whenever it fits inline — even with zero shortcuts — so its + // customize "•••" button is present and can reveal on rail hover to let you + // add the first shortcut. (Gating on shortcutCount>0 hid the only entry point + // when empty.) The framing separator shows whenever the dock is rendered (not + // gated on shortcutCount) so adding the first shortcut doesn't shift anything: + // the empty "•••" and the with-shortcuts state look identical. + const showInlineDock = isLoggedIn && fitsAllInline; + + const railSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ); + const isDraggingRef = useRef(false); + // Shared "any sidebar drag active" flag. Every drag system (tab reorder, the + // shortcuts dock, and panel-row→dock) flips this so tooltips, hover-card + // panels and the panel-preview all stand down for the duration of a drag — + // they were rendering over the drag ghost and making it feel broken. + const [isAnyDragging, setIsAnyDragging] = useState(false); + const setSidebarDragging = useCallback((value: boolean) => { + isDraggingRef.current = value; + setIsAnyDragging(value); + }, []); + const dragStateValue = useMemo( + () => ({ isDragging: isAnyDragging, setDragging: setSidebarDragging }), + [isAnyDragging, setSidebarDragging], + ); + const handleRailDragStart = useCallback(() => { + setSidebarDragging(true); + }, [setSidebarDragging]); + const handleRailDragEnd = useCallback( + (event: DragEndEvent) => { + setSidebarDragging(false); + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const oldIndex = railOrder.indexOf(active.id as SidebarCategoryId); + const newIndex = railOrder.indexOf(over.id as SidebarCategoryId); + if (oldIndex === -1 || newIndex === -1) { + return; + } + setStoredRailOrder(arrayMove(railOrder, oldIndex, newIndex)); + }, + [railOrder, setStoredRailOrder, setSidebarDragging], ); - // Home, Squads and (logged-in) New post never fold. The 3-dots "More" - // button only appears when something overflows, so it costs a slot then. - const fixedNavSlots = 2 + (isLoggedIn ? 1 : 0); - const isNavOverflowing = maxNavSlots < fixedNavSlots + overflowOrder.length; - const visibleOverflowCount = isNavOverflowing - ? Math.max( - 0, - Math.min(overflowOrder.length, maxNavSlots - fixedNavSlots - 1), - ) - : overflowOrder.length; - const foldedNavIds = overflowOrder.slice(visibleOverflowCount); const activePage = activePageProp || router.asPath || router.pathname || ''; const isFeedPage = activePage.includes('/feeds/'); - - const resolvedCategory = useMemo((): SidebarCategoryId => { + // When the For You feed is the current page, the Home button reads as + // selected — fill its icon (secondary) instead of the outline. + const isHomeActive = isSidebarItemActive(activePage, myFeedPath); + + const resolvedBaseCategory = useMemo((): SidebarCategoryId => { + // The home / For You feed is a logged-in user's personal hub, so it + // defaults to the Profile panel rather than Explore. Anonymous users (no + // profile panel) fall back to Explore. + if (isLoggedIn && isHomeActive) { + return SidebarCategory.Profile; + } + // The user's own profile page (`/` and its sub-pages) also keeps + // the Profile panel — the avatar navigates here, so it must resolve back to + // Profile (otherwise the optimistic pending category never clears). + const path = activePage.split('?')[0]; + const ownProfileBase = user?.username ? `/${user.username}` : null; + if ( + isLoggedIn && + ownProfileBase && + (path === ownProfileBase || path.startsWith(`${ownProfileBase}/`)) + ) { + return SidebarCategory.Profile; + } if (isFeedPage) { return SidebarCategory.Main; } return getSidebarCategoryForPath(activePage); - }, [activePage, isFeedPage]); + }, [activePage, isFeedPage, isHomeActive, isLoggedIn, user?.username]); + + // Opening a single post (`/posts/[id]`) shouldn't change the sidebar context + // — the panel behind the post page keeps whatever you came from (History, + // a Squad, etc.). Remember the last non-post category (committed renders + // only, so it's concurrent-safe) and reuse it on posts. + const isPostPage = router.pathname === '/posts/[id]'; + const lastNonPostCategoryRef = useRef( + SidebarCategory.Main, + ); + useEffect(() => { + if (!isPostPage) { + lastNonPostCategoryRef.current = resolvedBaseCategory; + } + }, [isPostPage, resolvedBaseCategory]); + const resolvedCategory = isPostPage + ? lastNonPostCategoryRef.current + : resolvedBaseCategory; // Optimistic override so a rail click feels instant even when // router.push is async. Cleared once the URL catches up. @@ -766,12 +856,19 @@ export const SidebarDesktopV2 = ({ // Hovering the "+" previews the create-post options panel (takes precedence // over a hovered category). Clicking "+" opens the composer modal instead. const [isCreateHovered, setIsCreateHovered] = useState(false); - + // Set on a "New post" click and held until the composer modal fully closes, + // so the panel stays on the create options through the open transition + // instead of briefly flashing back to the resolved category. + const [createPinned, setCreatePinned] = useState(false); + + // Clear the optimistic override once the route actually settles (activePage + // changed). The category resolved from the URL is now authoritative — keeping + // a stale pending value would strand the panel on the wrong category until a + // refresh (e.g. after the avatar navigates and you then open Settings). The + // pending value still bridges the click→route-change gap for instant feedback. useEffect(() => { - if (pendingCategory !== null && pendingCategory === resolvedCategory) { - setPendingCategory(null); - } - }, [pendingCategory, resolvedCategory]); + setPendingCategory(null); + }, [activePage]); // Settings load client-side, so on a hard refresh `sidebarExpanded` // flips from its `false` default to the user's stored value once @@ -804,6 +901,61 @@ export const SidebarDesktopV2 = ({ ? undefined : '!transition-none'; + // Shared "selected pill" that slides between rail tabs (FLIP-style shared + // layout) instead of the background jumping instantly. It tracks whichever + // tab carries `aria-selected` — robust across the heterogeneous tabs (avatar, + // category buttons, notifications bell) without each owning its own pill. We + // measure with getBoundingClientRect (transform-aware) relative to the + // tablist, so reorder/overflow just re-anchor it. + const tablistRef = useRef(null); + const [selectedPill, setSelectedPill] = useState({ y: 0, h: 0, show: false }); + const [pillReady, setPillReady] = useState(false); + const visibleTabKey = visibleCategoryIds.join('|'); + useEffect(() => { + const container = tablistRef.current; + if (!container) { + return undefined; + } + // Hide the pill mid-drag — its layout slot is in flux and the dragged tab + // is lifted; it re-anchors (and fades back) once the order settles. + if (isAnyDragging) { + setSelectedPill((prev) => (prev.show ? { ...prev, show: false } : prev)); + return undefined; + } + const measure = () => { + const selected = container.querySelector('[aria-selected="true"]'); + if (!(selected instanceof HTMLElement)) { + setSelectedPill((prev) => ({ ...prev, show: false })); + return; + } + const containerRect = container.getBoundingClientRect(); + const rect = selected.getBoundingClientRect(); + setSelectedPill({ + y: rect.top - containerRect.top, + h: rect.height, + show: true, + }); + }; + // rAF: measure after layout settles. setTimeout: re-measure after dnd-kit's + // drop animation (~250ms) finishes, in case the selected tab was the one + // just dragged and is still transforming toward its final slot. + const raf = requestAnimationFrame(measure); + const settle = setTimeout(measure, 300); + return () => { + cancelAnimationFrame(raf); + clearTimeout(settle); + }; + }, [selectedCategory, visibleTabKey, isCompact, isAnyDragging]); + // Enable the slide transition only after the first placement so the pill + // doesn't animate in from the top on mount (it just appears in place). + useEffect(() => { + if (!selectedPill.show || pillReady) { + return undefined; + } + const raf = requestAnimationFrame(() => setPillReady(true)); + return () => cancelAnimationFrame(raf); + }, [selectedPill.show, pillReady]); + // Escape resets the pinned panel back to Main so the keyboard story // mirrors the click model — Tab+Enter opens a panel, Escape backs out. // Scoped to when focus is inside the sidebar; otherwise a global Escape @@ -879,13 +1031,26 @@ export const SidebarDesktopV2 = ({ [getCategoryDefaultPath, router], ); - // Profile-dropdown links navigate via `` and bypass `onSelectCategory`, - // so the panel would otherwise wait for the route to resolve before swapping. - // Map the link's path to its category and switch optimistically on click. - const onPreviewHref = useCallback((href: string) => { - const { pathname } = new URL(href, 'http://_'); - setPendingCategory(getSidebarCategoryForPath(pathname)); - }, []); + // Avatar click opens the Profile panel and navigates to the user's profile + // page. Like a rail tab, it sets the pending category for instant feedback. + const onSelectProfile = useCallback(() => { + setPendingCategory(SidebarCategory.Profile); + if (!user) { + return; + } + const targetPath = `${webappUrl}${user.username}`; + Promise.resolve(router.push(targetPath)).catch(() => undefined); + }, [router, user]); + + // The flat Home button switches to the "For You" feed. It mirrors the rail + // tabs' optimistic panel switch (Main = Explore) while the route resolves. + const onHomeClick = useCallback(() => { + // Home opens the Profile panel by default (logged in); Explore for anon. + setPendingCategory( + isLoggedIn ? SidebarCategory.Profile : SidebarCategory.Main, + ); + onNavTabClick?.(isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/'); + }, [isCustomDefaultFeed, isLoggedIn, onNavTabClick]); // Remember the last non-settings location so "Back to app" returns the user // where they were rather than always dumping them on the home feed. @@ -941,19 +1106,9 @@ export const SidebarDesktopV2 = ({ return () => window.removeEventListener('keydown', handleKeyDown); }, [onToggleExpanded]); - const handleRailMouseEnter = useCallback(() => { - if (peekSuppressedRef.current) { - return; - } - setIsRailHovered(true); - }, []); - const exitSafeZone = useCallback(() => { safeBlockedRef.current = false; safePolyRef.current = null; - if (navListRef.current) { - navListRef.current.style.pointerEvents = ''; - } }, []); const handleRailMouseLeave = useCallback(() => { @@ -965,11 +1120,28 @@ export const SidebarDesktopV2 = ({ exitSafeZone(); }, [exitSafeZone]); - // --- Prediction cone via pointer-events blocking ----------------------- + // --- Prediction cone --------------------------------------------------- // `commitPreview` maps a rail trigger key to the panel preview it shows. + // Hovering a panel-bearing icon is also what opens the collapsed peek — the + // rail no longer expands just because the cursor entered it, so empty space + // and panel-less icons (logo, Home, Search, Invite, Support, Settings) never + // pop the panel open. const commitPreview = useCallback((key: string) => { + // While arcing toward the panel, ignore hover-switches but DON'T block + // pointer events — blocking the tabs swallowed real clicks (the panel is + // already open, so there's nothing to re-open here). Also ignore the hover + // that fires under the cursor mid-drag so reordering doesn't flip panels. + if (safeBlockedRef.current || isDraggingRef.current) { + return; + } + if (!peekSuppressedRef.current) { + setIsRailHovered(true); + } if (key === 'create') { setIsCreateHovered(true); + // Clear any category preview so a previously-hovered tab (e.g. Quests) + // doesn't keep its hover/preview state while the New post panel shows. + setHoveredCategory(null); return; } setIsCreateHovered(false); @@ -982,16 +1154,14 @@ export const SidebarDesktopV2 = ({ return; } // Triangle from the pointer to the panel's near (left) edge, padded - // vertically. While the pointer stays inside it the tabs are inert. + // vertically. While the pointer stays inside it, hover-switches are + // ignored (via commitPreview's guard) — but tabs stay clickable. safePolyRef.current = [ [x, y], [panel.left, panel.top - SAFE_ZONE_BUFFER], [panel.left, panel.bottom + SAFE_ZONE_BUFFER], ]; safeBlockedRef.current = true; - if (navListRef.current) { - navListRef.current.style.pointerEvents = 'none'; - } }, []); const pointInPolygon = ( @@ -1081,11 +1251,6 @@ export const SidebarDesktopV2 = ({ /> ); } - if (category === SidebarCategory.Saved) { - return ( - - ); - } if (category === SidebarCategory.Settings) { return ( ; } if (category === SidebarCategory.GameCenter) { + return ; + } + if (category === SidebarCategory.Profile) { return ( - ); } return ( - <> - - - - - + ); }; @@ -1146,10 +1305,49 @@ export const SidebarDesktopV2 = ({ if (!category) { return null; } - const isSelected = activeCategory === category.id; + // The "selected" (white) indicator tracks the committed category so it + // never moves while you hover/preview other tabs — you always know where + // you are. Hovering only previews the panel and shows the row's hover + // background; it doesn't claim the selected state. + const isSelected = selectedCategory === category.id; + const isPreviewing = !isSelected && activeCategory === category.id; + // The gamification tab. With reading streaks on it's the "Streak" tab: the + // state-driven StreakBadge stands in for the glyph and the day count is the + // label. With streaks off (but other gamification on) it reads as the + // broader "Quests"/Game Center — a joystick glyph + "Quests" label. + const isStreakTab = category.id === SidebarCategory.GameCenter; + const showStreakBadge = + isStreakTab && isStreakTabAStreak && isStreakEnabled; + const displayLabel = isStreakTab ? gameCenterLabel : category.label; + // The hover card + aria use the fuller panel title ("Current Streak"); + // the tab icon's own label stays the short `displayLabel`. + const panelTitle = isStreakTab ? gameCenterPanelTitle : category.label; + let iconNode: ReactElement; + if (showStreakBadge) { + iconNode = ( + + ); + } else if (isStreakTab && !isStreakTabAStreak) { + iconNode = ( + + ); + } else { + iconNode = category.icon(isSelected); + } + const labelText = + showStreakBadge && streakCount > 0 ? `${streakCount}` : displayLabel; + const ariaLabel = showStreakBadge ? streakCopy : panelTitle; return ( @@ -1159,7 +1357,7 @@ export const SidebarDesktopV2 = ({ id={`sidebar-category-${category.id}`} data-sidebar-preview={category.id} aria-controls="sidebar-context-panel" - aria-label={category.label} + aria-label={ariaLabel} aria-selected={isSelected} onClick={() => onSelectCategory(category.id)} onMouseEnter={() => { @@ -1175,15 +1373,23 @@ export const SidebarDesktopV2 = ({ }} className={classNames( railTabClass, - isSelected && 'bg-background-default !text-text-primary', + // The selected pill is the shared sliding indicator in the tablist; + // the button only owns its text color (a bg here would paint over + // the sliding pill and kill the morph). Every tab — streak included — + // uses the same white selected label. + isSelected && '!text-text-primary', + // `group/streaktab` scopes the StreakBadge's hover-white border to + // this tab. The tab background matches every other tab (float on + // hover, neutral pill when selected) — the streak's pink lives only + // on its square StreakBadge. + isStreakTab && 'group/streaktab', + isPreviewing && 'bg-surface-hover text-text-primary', )} > - {category.icon(isSelected)} + {iconNode} - {!isCompact && ( - {category.label} - )} + {!isCompact && {labelText}} {category.id === SidebarCategory.GameCenter && showQuestBadge && ( // Pin the badge to the button's top-right corner (not the icon's) // so the quest level ring + number stay fully visible. @@ -1196,43 +1402,9 @@ export const SidebarDesktopV2 = ({ ); }; - const renderMorePanel = (): ReactElement => { - const rows = foldedNavIds.map((id) => { - if (id === SidebarCategory.Notifications) { - return { - key: id as string, - label: 'Notifications', - href: `${webappUrl}notifications`, - icon: , - }; - } - const category = sidebarCategories.find((entry) => entry.id === id); - return { - key: id as string, - label: category?.label ?? '', - href: category?.defaultPath ?? webappUrl, - icon: category?.icon(false) ?? null, - }; - }); - return ( -
    - {rows.map((row) => ( - - - - {row.icon} - - {row.label} - - - ))} -
    - ); - }; - const createMenuItems = useMemo( - () => - createMenuOptions.map(({ title, kind, icon }) => ({ + () => [ + ...createMenuOptions.map(({ title, kind, icon }) => ({ icon, title, // SidebarItem/ClickableNavItem dispatches `action` (not `onClick`) and @@ -1243,13 +1415,35 @@ export const SidebarDesktopV2 = ({ props: { initialKind: kind }, }), })), + // Divider below the post types, then the Posting settings page shortcut + // (→ /settings/composition) — its open-link icon reveals on row hover. + createSidebarSeparatorItem('create-settings-divider'), + { + title: 'Posting settings', + path: `${settingsUrl}/composition`, + isForcedLink: true, + showOpenLinkIcon: true, + icon: (active: boolean) => ( + } /> + ), + }, + ], [openModal], ); - // The panel reflects the create-post options when hovering "+", otherwise - // the active category (hovered preview, else the selected/pinned one). + // The panel reflects the create-post options when hovering "+" OR while the + // composer modal it opens is still open — otherwise clicking "+" would shift + // the background panel back to the feed as focus leaves the rail (a glitch). + const isComposerOpen = modal?.type === LazyModal.SmartComposer; + const showCreatePanel = isCreateHovered || isComposerOpen || createPinned; + // Release the pin once any modal opened from "New post" has fully closed. + useEffect(() => { + if (!modal) { + setCreatePinned(false); + } + }, [modal]); const renderSelectedSection = (): ReactElement => - isCreateHovered ? ( + showCreatePanel ? (
    category.id === activeCategory, - )?.label; - const isNotificationsSelected = + const activeLabel = + activeCategory === SidebarCategory.GameCenter + ? gameCenterPanelTitle + : sidebarCategories.find((category) => category.id === activeCategory) + ?.label; + // Preview state (panel content/title) vs committed selection (the bell's + // filled indicator) — kept separate so hover-preview never moves the + // selected indicator. + const isNotificationsActive = activeCategory === SidebarCategory.Notifications; + const isNotificationsSelected = + selectedCategory === SidebarCategory.Notifications; + + // Resolve a rail tab by id. Notifications is the one tab that isn't a plain + // category (it renders the bell with its unread badge); everything else goes + // through renderCategoryTab. + const renderRailTab = (id: SidebarCategoryId): ReactElement => { + if (id === SidebarCategory.Profile) { + return ( + commitPreview(SidebarCategory.Profile)} + onPreviewLeave={(event) => + handlePreviewLeave(SidebarCategory.Profile, event) + } + /> + ); + } + if (id === SidebarCategory.Notifications) { + return ( + } + enabled={!isExpanded} + > +
    commitPreview(SidebarCategory.Notifications)} + onMouseLeave={(event) => + handlePreviewLeave(SidebarCategory.Notifications, event) + } + > + +
    +
    + ); + } + return renderCategoryTab(id) ?? <>; + }; const isHomePanel = - !isCreateHovered && activeCategory === SidebarCategory.Main; + !showCreatePanel && activeCategory === SidebarCategory.Main; const isUtilityPanelSelected = !isHomePanel; + // The streak/quests panel owns its own height: the hero stays fixed up top and + // the quest list fills the rest of the panel and scrolls inside that area + // (rather than a fixed-height scroll box leaving dead space below). For that + // the Nav must stretch to the scroll wrapper, so the section can flex-fill it. + const isStreakPanel = + !showCreatePanel && activeCategory === SidebarCategory.GameCenter; const utilityPanelTitle = (() => { - if (isCreateHovered) { + if (showCreatePanel) { return 'New post'; } if (activeCategory === SidebarCategory.Settings) { return 'Settings'; } - if (isNotificationsSelected) { + if (isNotificationsActive) { return 'Notifications'; } return activeLabel ?? ''; })(); - // Single-section panels (Squads/Saved) host their "+" add action in the panel - // title strip — next to the title — rather than as a row inside the section. - const panelAddAction = (() => { - if (isCreateHovered) { - return null; - } - if (activeCategory === SidebarCategory.Squads) { + // Content of the rail "More" menu: each overflowed tab as a navigable row, + // then a Shortcuts category listing every pinned shortcut. Only the tabs that + // didn't fit inline are listed here — the inline tabs aren't repeated. + const renderMoreMenuContent = ( + overflowIds: SidebarCategoryId[], + ): ReactElement => { + const rowClass = + 'focus-outline flex items-center gap-3 rounded-10 px-3 py-2 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary'; + const tabRows = overflowIds.map((id) => { + if (id === SidebarCategory.Notifications) { + return { + key: id as string, + label: 'Notifications', + href: `${webappUrl}notifications`, + icon: , + }; + } + if (id === SidebarCategory.GameCenter) { + const category = sidebarCategories.find((entry) => entry.id === id); + return { + key: id as string, + label: gameCenterLabel, + href: category?.defaultPath ?? webappUrl, + icon: isStreakTabAStreak ? ( + + ) : ( + + ), + }; + } + const category = sidebarCategories.find((entry) => entry.id === id); + const href = + id === SidebarCategory.Profile && user?.username + ? `${webappUrl}${user.username}` + : category?.defaultPath ?? webappUrl; return { - label: 'New Squad', - onClick: () => openNewSquad({ origin: Origin.Sidebar }), + key: id as string, + label: category?.label ?? '', + href, + icon: category?.icon(false) ?? null, }; - } - if (activeCategory === SidebarCategory.Saved) { - return { label: 'New folder', onClick: addBookmarkFolder }; - } - return null; - })(); - - return ( - - {isExpanded && !isSettingsSelected && ( - - } - collisionPadding={RAIL_TOOLTIP_COLLISION_PADDING} - > - - - )} - - - + + {isLoggedIn && !isUtilityPanelSelected && additionalButtons && ( +
    + {additionalButtons} +
    + )} + + + + + + {!isUtilityPanelSelected && } + {showFeedbackWidget && !isUtilityPanelSelected && ( +
    + +
    + )} +
    + + ); }; diff --git a/packages/shared/src/components/sidebar/SidebarEntityIcon.tsx b/packages/shared/src/components/sidebar/SidebarEntityIcon.tsx new file mode 100644 index 00000000000..598ed2d9736 --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarEntityIcon.tsx @@ -0,0 +1,68 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Image, ImageType } from '../image/Image'; +import { EarthIcon, HashtagIcon, LinkIcon, SquadIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { useSquad } from '../../hooks/squads/useSquad'; + +const handleFromPath = (path: string): string => + path.split('?')[0].split('#')[0].split('/').filter(Boolean).pop() ?? ''; + +const stripOrigin = (path: string): string => + path.replace(/^https?:\/\/[^/]+/, ''); + +// Resolves the right glyph/image for a pinned page shortcut from its path — a +// squad shows its actual logo, sources/tags get their icon — so a pinned page +// never falls back to a generic link icon when we can do better. The squad +// lookup self-disables for non-squad paths, so only squad shortcuts fetch. +// When `image` is provided (captured at drag time) it renders immediately and +// the fetch is skipped entirely, so there's no placeholder flash. +export const SidebarEntityIcon = ({ + path, + image, +}: { + path: string; + image?: string; +}): ReactElement => { + const normalized = stripOrigin(path); + const isSquad = normalized.startsWith('/squads/'); + const isSource = normalized.startsWith('/sources/'); + const isTag = normalized.startsWith('/tags/'); + // Only fetch when we don't already have the image in hand. + const { squad } = useSquad({ + handle: isSquad && !image ? handleFromPath(path) : '', + }); + + if (image) { + return ( + + ); + } + + if (isSquad) { + return squad?.image ? ( + + ) : ( + + ); + } + if (isSource) { + return ; + } + if (isTag) { + return ; + } + return ; +}; diff --git a/packages/shared/src/components/sidebar/SidebarItem.tsx b/packages/shared/src/components/sidebar/SidebarItem.tsx index a34fc05b80d..09be7939dbd 100644 --- a/packages/shared/src/components/sidebar/SidebarItem.tsx +++ b/packages/shared/src/components/sidebar/SidebarItem.tsx @@ -4,11 +4,35 @@ import classNames from 'classnames'; import { ClickableNavItem } from './ClickableNavItem'; import type { AuthTriggersType } from '../../lib/auth'; import type { SidebarMenuItem } from './common'; -import { isSidebarItemActive, ItemInner, NavItem } from './common'; +import { + isSidebarItemActive, + ItemInner, + NavItem, + SHORTCUT_DRAG_MIME, +} from './common'; import AuthContext from '../../contexts/AuthContext'; import type { SidebarSectionProps } from './sections/common'; import { SimpleTooltip } from '../tooltips'; import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; +import { useSidebarDragState } from './useSidebarDragState'; + +// Build a drag image that matches the dock's add-ghost chip exactly (a solid +// chip with a 1px dashed brand border + the row's own icon), so dragging a +// panel row into the shortcuts area looks the same as dragging a catalogue icon +// within the dock. Appended off-screen just long enough for the browser to +// snapshot it during dragstart, then removed. +const buildDockDragImage = (iconEl: Element): HTMLElement => { + const chip = document.createElement('div'); + chip.className = + 'flex size-10 items-center justify-center rounded-12 border border-dashed border-accent-cabbage-default bg-background-default text-text-primary shadow-3'; + chip.style.position = 'fixed'; + chip.style.top = '-1000px'; + chip.style.left = '-1000px'; + chip.style.pointerEvents = 'none'; + chip.appendChild(iconEl.cloneNode(true)); + document.body.appendChild(chip); + return chip; +}; type SidebarItemProps = Pick< SidebarSectionProps, @@ -25,9 +49,46 @@ export const SidebarItem = ({ }: SidebarItemProps): ReactElement => { const { user, showLogin } = useContext(AuthContext); const { isV2 } = useLayoutVariant(); + const { setDragging } = useSidebarDragState(); const isActive = - item.active || (!!item.path && isSidebarItemActive(activePage, item.path)); + !item.disableActiveState && + (item.active || + (!!item.path && isSidebarItemActive(activePage, item.path))); const isCollapsed = !shouldShowLabel; + // Opt-in per item: only rows that leave the sidebar (e.g. Feed settings, + // DevCard → /settings) reveal the "open link" icon on hover. Requires + // `group` on the row. + const showLinkIconOnHover = !!item.showOpenLinkIcon; + + // v2 only: any row with a path can be dragged into the shortcuts dock to pin + // it. Uses native drag (the row's anchor is the drag source); the dock reads + // the payload off dataTransfer. Inert in v1 (draggable stays unset). + const canPinToDock = isV2 && !!item.path; + const handleDragStart = canPinToDock + ? (event: React.DragEvent) => { + const iconEl = event.currentTarget.querySelector('span'); + // Capture the row's image (squad/source logo) so the pinned shortcut + // shows it instantly instead of re-fetching and flashing a placeholder. + const image = + iconEl?.querySelector('img')?.getAttribute('src') ?? undefined; + event.dataTransfer.setData( + SHORTCUT_DRAG_MIME, + JSON.stringify({ title: item.title, path: item.path, image }), + ); + // eslint-disable-next-line no-param-reassign + event.dataTransfer.effectAllowed = 'copy'; + // Drag with the same chip the dock uses (dashed brand border + the + // row's own squad logo / source / tag glyph) under the cursor, instead + // of the browser's default text-row snapshot. + if (iconEl) { + const chip = buildDockDragImage(iconEl); + event.dataTransfer.setDragImage(chip, 20, 20); + window.setTimeout(() => chip.remove(), 0); + } + setDragging(true); + } + : undefined; + const handleDragEnd = canPinToDock ? () => setDragging(false) : undefined; const navItem = ( diff --git a/packages/shared/src/components/sidebar/SidebarShortcutsDock.tsx b/packages/shared/src/components/sidebar/SidebarShortcutsDock.tsx new file mode 100644 index 00000000000..652e72fcfd2 --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarShortcutsDock.tsx @@ -0,0 +1,1104 @@ +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useRouter } from 'next/router'; +import classNames from 'classnames'; +import { + DndContext, + DragOverlay, + PointerSensor, + closestCenter, + defaultDropAnimationSideEffects, + pointerWithin, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { + CollisionDetection, + DragEndEvent, + DragMoveEvent, + DragOverEvent, + DragStartEvent, + DropAnimation, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + AnalyticsIcon, + BookmarkIcon, + BriefIcon, + CompassIcon, + CoreIcon, + DiscussIcon, + EarthIcon, + EyeIcon, + HashtagIcon, + JobIcon, + MegaphoneIcon, + MenuIcon, + SquadIcon, + TrashIcon, + UserIcon, +} from '../icons'; +import { IconSize } from '../Icon'; +import { Tooltip } from '../tooltip/Tooltip'; +import { RootPortal } from '../tooltips/Portal'; +import Link from '../utilities/Link'; +import { HorizontalSeparator } from '../utilities'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import type { ShortcutDragData } from './common'; +import { SHORTCUT_DRAG_MIME, isSidebarItemActive } from './common'; +import { useSidebarDragState } from './useSidebarDragState'; +import { SidebarEntityIcon } from './SidebarEntityIcon'; +import { useAnchoredRailPopup } from './useAnchoredRailPopup'; +import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; +import { useOutsideClick } from '../../hooks/utils/useOutsideClick'; +import usePersistentContext from '../../hooks/usePersistentContext'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { briefingUrl, walletUrl, webappUrl } from '../../lib/constants'; + +type ShortcutIcon = (active: boolean) => ReactElement; + +interface ShortcutDef { + id: string; + label: string; + path: string; + icon: ShortcutIcon; +} + +// The catalog of pages a user can pin from the tray. Dragging an arbitrary +// panel row in also resolves to one of these (by path) when it matches. +export const SHORTCUT_CATALOG: ShortcutDef[] = [ + { + id: 'explore', + label: 'Explore', + path: `${webappUrl}posts`, + icon: (a) => ( + + ), + }, + { + id: 'tags', + label: 'Tags', + path: `${webappUrl}tags`, + icon: (a) => ( + + ), + }, + { + id: 'sources', + label: 'Sources', + path: `${webappUrl}sources`, + icon: (a) => , + }, + { + id: 'leaderboard', + label: 'Leaderboard', + path: `${webappUrl}users`, + icon: (a) => , + }, + { + id: 'discussed', + label: 'Discussions', + path: `${webappUrl}discussed`, + icon: (a) => ( + + ), + }, + { + id: 'highlights', + label: 'Happening Now', + path: `${webappUrl}highlights`, + icon: (a) => ( + + ), + }, + { + id: 'following', + label: 'Following', + path: '/following', + icon: (a) => , + }, + { + id: 'bookmarks', + label: 'Bookmarks', + path: `${webappUrl}bookmarks`, + icon: (a) => ( + + ), + }, + { + id: 'history', + label: 'History', + path: `${webappUrl}history`, + icon: (a) => , + }, + { + id: 'analytics', + label: 'Analytics', + path: `${webappUrl}analytics`, + icon: (a) => ( + + ), + }, + { + id: 'jobs', + label: 'Jobs', + path: `${webappUrl}jobs`, + icon: (a) => , + }, + { + id: 'briefing', + label: 'Briefing', + path: briefingUrl, + icon: (a) => , + }, + { + id: 'cores', + label: 'Cores', + path: walletUrl, + icon: (a) => , + }, +]; + +const CATALOG_BY_ID = new Map(SHORTCUT_CATALOG.map((item) => [item.id, item])); + +// Strip origin/query so a panel row's path (which may be absolute or relative) +// can be matched against a catalog entry. +const normalizePath = (path: string): string => + path + .replace(/^https?:\/\/[^/]+/, '') + .split('?')[0] + .split('#')[0] || '/'; + +const CATALOG_BY_PATH = new Map( + SHORTCUT_CATALOG.map((item) => [normalizePath(item.path), item]), +); + +// A stored shortcut is either a catalog id (string) or an arbitrary pinned page +// ({title, path}) dragged in from a panel. +type StoredShortcut = string | ShortcutDragData; + +const SHORTCUTS_KEY = 'sidebar_shortcuts'; +const DOCK_DROPPABLE_ID = 'sidebar-shortcuts-dock'; + +const keyOf = (entry: StoredShortcut): string => + typeof entry === 'string' ? entry : entry.path; + +export interface ResolvedShortcut { + key: string; + label: string; + path: string; + icon: ShortcutIcon; +} + +const resolveShortcut = (entry: StoredShortcut): ResolvedShortcut | null => { + if (typeof entry === 'string') { + const def = CATALOG_BY_ID.get(entry); + if (!def) { + return null; + } + return { key: def.id, label: def.label, path: def.path, icon: def.icon }; + } + return { + key: entry.path, + label: entry.title, + path: entry.path, + // Prefer the image captured at drag time (instant, no flash); fall back to + // resolving a glyph/image from the path. + icon: () => , + }; +}; + +// Matches the Home/Search rail buttons exactly. Transitions transform too so +// the press (active:scale) on the clickable buttons that use this eases rather +// than snaps; the sortable shortcut anchors don't add a press scale (it would +// distort the drag-start rect measurement). +const dockButtonClass = + 'focus-outline flex size-10 items-center justify-center rounded-12 text-text-tertiary transition-[background-color,color,transform] duration-150 ease-out hover:bg-surface-hover hover:text-text-primary motion-reduce:transition-none'; + +// Reorder smoothly while the pointer is inside the dock, but report NO target +// (over === null) once it leaves the rail — so dragging a shortcut off the +// sidebar is detected as a remove. (closestCenter always returns the nearest +// droppable, so over was never null and drag-out-to-remove never fired.) +const dockCollisionDetection: CollisionDetection = (args) => + pointerWithin(args).length > 0 ? closestCenter(args) : []; + +const SortableShortcut = ({ + shortcut, + active, +}: { + shortcut: ResolvedShortcut; + active: boolean; +}): ReactElement => { + const { + setNodeRef, + listeners, + attributes, + transform, + transition, + isDragging, + } = useSortable({ id: shortcut.key }); + const { isDragging: isAnyDragging } = useSidebarDragState(); + + return ( + + + + ); +}; + +const TrayItem = ({ + def, + added, + onAdd, + onRemove, +}: { + def: ShortcutDef; + added: boolean; + onAdd: (id: string) => void; + onRemove: (id: string) => void; +}): ReactElement => { + // Added items aren't draggable (they already live in the dock); they're a + // click-to-remove toggle. Unadded items are draggable into the dock and also + // click-to-add — the reliable, accessible path. + const { setNodeRef, listeners, attributes, isDragging } = useDraggable({ + id: def.id, + disabled: added, + }); + + return ( + + ); +}; + +export interface SidebarShortcutsApi { + items: StoredShortcut[]; + keys: string[]; + pinnedPaths: Set; + resolved: ResolvedShortcut[]; + persist: (next: StoredShortcut[]) => void; + addCatalog: (id: string, index?: number) => void; + removeShortcut: (key: string) => void; + pinPage: (payload: ShortcutDragData, index?: number) => void; + isPinned: (path: string) => boolean; + togglePin: (payload: ShortcutDragData) => void; +} + +// Shortcuts state + mutations, shared by the dock and the rail's "More" menu +// (which lists shortcuts when the rail is too short to show the dock inline). +// usePersistentContext is react-query backed, so calling this in both places +// reads the same cached source of truth. +export const useSidebarShortcutItems = (): SidebarShortcutsApi => { + const { displayToast } = useToastNotification(); + const [stored, setStored] = usePersistentContext( + SHORTCUTS_KEY, + [], + ); + const items = useMemo(() => { + // Drop invalid entries AND de-duplicate by key. Duplicate keys would make + // React/dnd-kit treat several rows as the same node (all reporting + // isDragging), so a single corrupt write must never cascade into a runaway + // list. The next mutation persists this cleaned array, healing storage. + const seen = new Set(); + return (stored ?? []).filter((entry) => { + const valid = + typeof entry === 'string' ? CATALOG_BY_ID.has(entry) : !!entry?.path; + if (!valid) { + return false; + } + const key = keyOf(entry); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + }, [stored]); + const keys = useMemo(() => items.map(keyOf), [items]); + const pinnedPaths = useMemo( + () => new Set(items.map((entry) => normalizePath(keyOf(entry)))), + [items], + ); + const resolved = useMemo( + () => + items + .map(resolveShortcut) + .filter((entry): entry is ResolvedShortcut => !!entry), + [items], + ); + + const persist = useCallback( + (next: StoredShortcut[]) => { + setStored(next).catch(() => undefined); + }, + [setStored], + ); + + const addCatalog = useCallback( + (id: string, index?: number) => { + if (!CATALOG_BY_ID.has(id) || keys.includes(id)) { + return; + } + const next = [...items]; + next.splice( + typeof index === 'number' + ? Math.max(0, Math.min(index, next.length)) + : next.length, + 0, + id, + ); + persist(next); + }, + [items, keys, persist], + ); + + const removeShortcut = useCallback( + (key: string) => { + const index = items.findIndex((entry) => keyOf(entry) === key); + if (index === -1) { + return; + } + const previous = items; + const label = resolveShortcut(items[index])?.label ?? 'Shortcut'; + persist(items.filter((entry) => keyOf(entry) !== key)); + displayToast(`${label} removed`, { + // Always auto-dismiss (even if the global setting is off); a touch + // longer so there's time to reach the Undo action before it clears. + forceAutoDismiss: true, + timer: 6000, + action: { copy: 'Undo', onClick: () => persist(previous) }, + }); + }, + [displayToast, items, persist], + ); + + // Pin a page dragged in from a panel: resolve to a catalog entry by path when + // possible (so it gets the proper icon), else store it as an arbitrary page. + const pinPage = useCallback( + (payload: ShortcutDragData, index?: number) => { + const normalized = normalizePath(payload.path); + if (pinnedPaths.has(normalized)) { + return; + } + const catalogDef = CATALOG_BY_PATH.get(normalized); + const entry: StoredShortcut = catalogDef + ? catalogDef.id + : { title: payload.title, path: payload.path, image: payload.image }; + const next = [...items]; + next.splice( + typeof index === 'number' + ? Math.max(0, Math.min(index, next.length)) + : next.length, + 0, + entry, + ); + persist(next); + displayToast(`${catalogDef?.label ?? payload.title} pinned to sidebar`, { + forceAutoDismiss: true, + }); + }, + [displayToast, items, persist, pinnedPaths], + ); + + // Is this page already pinned to the dock (by normalised path)? + const isPinned = useCallback( + (path: string) => pinnedPaths.has(normalizePath(path)), + [pinnedPaths], + ); + + // Pin a page if it isn't pinned, or remove it if it is — the click action + // behind the squad/source "pin" buttons. + const togglePin = useCallback( + (payload: ShortcutDragData) => { + const normalized = normalizePath(payload.path); + const existing = items.find( + (entry) => normalizePath(keyOf(entry)) === normalized, + ); + if (existing) { + removeShortcut(keyOf(existing)); + } else { + pinPage(payload); + } + }, + [items, pinPage, removeShortcut], + ); + + return { + items, + keys, + pinnedPaths, + resolved, + persist, + addCatalog, + removeShortcut, + pinPage, + isPinned, + togglePin, + }; +}; + +// A customizable "dock" of single-icon page shortcuts below the rail tabs. +// Add from the tray (drag-from or tap), drag a panel row in to pin it, reorder +// by dragging, and remove by dragging an icon off the rail — all with an Undo +// toast. Persisted per-user. +export const SidebarShortcutsDock = (): ReactElement | null => { + const router = useRouter(); + const { items, persist, addCatalog, removeShortcut, pinPage } = + useSidebarShortcutItems(); + // Render from a local order mirror during (and just after) a reorder drop so + // the DOM is already in the new order when dnd-kit measures the drop landing. + // The persisted store updates asynchronously and would otherwise lag a frame, + // springing the ghost back to the old slot before the list re-renders. The + // override is dropped once the store catches up (or the set changes). + const [orderOverride, setOrderOverride] = useState( + null, + ); + const orderedItems = orderOverride ?? items; + const keys = useMemo(() => orderedItems.map(keyOf), [orderedItems]); + useEffect(() => { + if (!orderOverride) { + return; + } + const storeKeys = items.map(keyOf); + const overrideKeys = orderOverride.map(keyOf); + const sameSet = + storeKeys.length === overrideKeys.length && + storeKeys.every((key) => overrideKeys.includes(key)); + if (!sameSet || storeKeys.join('|') === overrideKeys.join('|')) { + setOrderOverride(null); + } + }, [items, orderOverride]); + + const { isDragging: isAnyDragging, setDragging } = useSidebarDragState(); + // Share the rail popup group so the customize menu is mutually exclusive with + // the Support/Settings popups and behaves like them. ('sidebar-rail' must + // match RAIL_POPUP_GROUP in SidebarDesktopV2.) + const { + isOpen: trayOpen, + onUpdate: setTrayOpen, + wrapHandler, + } = useInteractivePopup('sidebar-rail'); + const trayRef = useRef(null); + const customizeBtnRef = useRef(null); + useOutsideClick(trayRef, () => setTrayOpen(false), trayOpen); + // The tray is portaled to the body so the scrollable rail's overflow (which + // forces overflow-x to clip) can't hide it; we anchor it to the customize + // button's rect once on open (and on resize). It is NOT re-positioned on rail + // scroll — like the Support/Settings popups it stays put, which avoids the + // jittery shift the live scroll-tracking caused. + const trayPos = useAnchoredRailPopup(customizeBtnRef, trayOpen); + const [activeId, setActiveId] = useState(null); + const [overId, setOverId] = useState(null); + const [willRemove, setWillRemove] = useState(false); + const [isPageDropActive, setIsPageDropActive] = useState(false); + // The slot a panel row being dragged in (native drag) would drop into, so it + // lands exactly where you release — same skeleton indicator as reordering — + // instead of always appending. null while not over the dock. + const [pageDropIndex, setPageDropIndex] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ); + const { setNodeRef: setDockRef } = useDroppable({ id: DOCK_DROPPABLE_ID }); + + const isOverDock = (target?: string | number | null): boolean => + target === DOCK_DROPPABLE_ID || + (typeof target === 'string' && keys.includes(target)); + + // Drop-time flag (not state) so the DragOverlay's dropAnimation keyframes — + // which run *after* onDragEnd resets willRemove — can tell whether this drop + // was a remove (poof in place) or a settle/reorder (animate to slot). + const removeOnDropRef = useRef(false); + // Bounds of the rail column. Remove only fires once the whole icon clears the + // rail, not the moment the cursor nudges past the edge. + const dockAreaRef = useRef(null); + // The live (in-progress) reorder, mirrored in a ref so onDragEnd reads the + // final order without a stale-closure risk. + const liveOrderRef = useRef(null); + // Whether the drag began on an existing dock icon (reorder) vs the tray + // (add). Captured at drag START where the order is stable — recomputing it at + // drop from the live `keys` could misclassify a reorder as an add (which adds + // a duplicate), because live reorder mutates `keys` mid-drag. + const fromDockRef = useRef(false); + + const isIconOutsideRail = (event: DragMoveEvent | DragEndEvent): boolean => { + const rail = dockAreaRef.current?.getBoundingClientRect(); + const icon = event.active.rect.current.translated; + if (!rail || !icon) { + return false; + } + // Horizontal-only: the rail spans the full viewport height, so "outside" is + // the icon's box no longer overlapping the rail's column on the x-axis. + return icon.left >= rail.right || icon.right <= rail.left; + }; + + // The slot an add/drop at vertical position `y` would land in: count the + // shortcut slots whose midpoint sits above `y`. Shared by the native panel + // drag (cursor Y) and the dnd-kit tray drag (dragged icon's centre Y). + const dropIndexAtY = (y: number): number => { + const slots = + dockAreaRef.current?.querySelectorAll('[data-shortcut-slot]') ?? []; + let index = slots.length; + for (let i = 0; i < slots.length; i += 1) { + const rect = slots[i].getBoundingClientRect(); + if (y < rect.top + rect.height / 2) { + index = i; + break; + } + } + return index; + }; + + // Centre Y of the currently dragged dnd-kit item (null if unavailable). + const draggedCenterY = ( + event: DragMoveEvent | DragEndEvent, + ): number | null => { + const rect = event.active.rect.current.translated; + return rect ? rect.top + rect.height / 2 : null; + }; + + // Is the dragged dnd-kit item's centre within the dock's column? + const isCenterOverDock = (event: DragMoveEvent | DragEndEvent): boolean => { + const rect = event.active.rect.current.translated; + const dock = dockAreaRef.current?.getBoundingClientRect(); + if (!rect || !dock) { + return false; + } + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + return ( + cx >= dock.left && cx <= dock.right && cy >= dock.top && cy <= dock.bottom + ); + }; + + const onDragStart = (event: DragStartEvent) => { + const id = event.active.id as string; + const fromDock = keys.includes(id); + fromDockRef.current = fromDock; + setActiveId(id); + setOverId(null); + setWillRemove(false); + setPageDropIndex(null); + removeOnDropRef.current = false; + // Snapshot the order so the list can reorder live (in onDragOver) without + // touching the persisted store until drop. + liveOrderRef.current = fromDock ? orderedItems : null; + setDragging(true); + }; + + // For a reorder/remove (fromDock) drive the remove indicator off the icon's + // geometry; for an add from the tray show the insertion skeleton at the slot + // the icon is over (same indicator the panel drag and reorder use). + const onDragMove = (event: DragMoveEvent) => { + if (!fromDockRef.current) { + const y = draggedCenterY(event); + setPageDropIndex( + isCenterOverDock(event) && y !== null ? dropIndexAtY(y) : null, + ); + return; + } + setWillRemove(isIconOutsideRail(event)); + }; + + // Live reorder: as the icon passes over a slot, reorder the rendered list so + // the dragged item's own placeholder moves into the target slot (the landing + // skeleton). Persisted only on drop. + const onDragOver = (event: DragOverEvent) => { + const id = event.active.id as string; + const over = (event.over?.id as string) ?? null; + setOverId(over); + if (!keys.includes(id) || !over || over === id || !keys.includes(over)) { + return; + } + const base = liveOrderRef.current ?? items; + const baseKeys = base.map(keyOf); + const from = baseKeys.indexOf(id); + const to = baseKeys.indexOf(over); + if (from === -1 || to === -1 || from === to) { + return; + } + const next = arrayMove(base, from, to); + liveOrderRef.current = next; + setOrderOverride(next); + }; + + const onDragEnd = (event: DragEndEvent) => { + const { active } = event; + const id = active.id as string; + // Use the start-of-drag classification, not the live `keys` (which the + // live reorder mutates) — recomputing here can misread a reorder as an add. + const fromDock = fromDockRef.current; + const liveOrder = liveOrderRef.current; + liveOrderRef.current = null; + setActiveId(null); + setOverId(null); + setWillRemove(false); + setDragging(false); + + if (!fromDock) { + if (isOverDock(event.over?.id)) { + // Insert at the slot the icon is over (fresh from the rect to avoid a + // stale state read), not appended. + const y = draggedCenterY(event); + addCatalog(id, y !== null ? dropIndexAtY(y) : undefined); + } + setPageDropIndex(null); + return; + } + + // Remove only when the whole icon cleared the rail; a cursor that merely + // slipped past the edge (icon still overlapping) snaps back instead. + if (isIconOutsideRail(event)) { + removeOnDropRef.current = true; + removeShortcut(id); + return; + } + + // Commit the live reorder (already reflected in orderOverride). Compare to + // the STORE order (not the live keys, which already include the override); + // if nothing actually moved, drop the override and fall back to the store. + const storeKeys = items.map(keyOf).join('|'); + if (liveOrder && liveOrder.map(keyOf).join('|') !== storeKeys) { + persist(liveOrder); + } else { + setOrderOverride(null); + } + }; + + const onDragCancel = () => { + liveOrderRef.current = null; + setActiveId(null); + setOverId(null); + setWillRemove(false); + setPageDropIndex(null); + setOrderOverride(null); + setDragging(false); + }; + + // Native drag of a panel row over the dock → allow drop, highlight the area, + // and track the slot it would land in (so it drops exactly there). + const onPageDragOver = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes(SHORTCUT_DRAG_MIME)) { + return; + } + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.dataTransfer.dropEffect = 'copy'; + if (!isPageDropActive) { + setIsPageDropActive(true); + } + setPageDropIndex(dropIndexAtY(event.clientY)); + }; + + const resetPageDrop = () => { + setIsPageDropActive(false); + setPageDropIndex(null); + }; + + const onPageDrop = (event: React.DragEvent) => { + const raw = event.dataTransfer.getData(SHORTCUT_DRAG_MIME); + const dropIndex = dropIndexAtY(event.clientY); + resetPageDrop(); + if (!raw) { + return; + } + event.preventDefault(); + try { + const payload = JSON.parse(raw) as ShortcutDragData; + if (payload?.path && payload?.title) { + pinPage(payload, dropIndex); + } + } catch { + // ignore malformed payloads + } + }; + + // An empty dock keeps the customize (•••) button out of the way: it only + // appears on sidebar hover (the SidebarAside `group`). Once a shortcut is + // pinned the button stays visible by default. The tray being open or a page + // being dragged in always reveals it regardless of hover. + const revealOnHover = + orderedItems.length === 0 && !trayOpen && !isPageDropActive; + + const activeEntry = activeId + ? orderedItems.find((entry) => keyOf(entry) === activeId) + : undefined; + let activeResolved: ResolvedShortcut | null = null; + if (activeEntry) { + activeResolved = resolveShortcut(activeEntry); + } else if (activeId && CATALOG_BY_ID.has(activeId)) { + activeResolved = resolveShortcut(activeId); + } + + // macOS-dock feel on release: a removed icon poofs out (fades + shrinks) right + // where you let go instead of flying back to its old slot then vanishing. + // Reorders/adds keep the default settle-into-place animation. Collapsed to an + // instant settle when the user prefers reduced motion. + const prefersReducedMotion = + typeof window !== 'undefined' && + !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + const dropAnimation: DropAnimation = { + duration: prefersReducedMotion ? 0 : 200, + easing: 'cubic-bezier(0.2, 0, 0, 1)', + // Keep the placeholder dimmed at its drag opacity (0.4) through the whole + // landing instead of the default flip-to-visible, which made the icon flash + // back at its old slot mid-drop (the spring-back glitch). + sideEffects: defaultDropAnimationSideEffects({ + styles: { active: { opacity: '0.4' } }, + }), + keyframes: ({ transform }) => { + const from = CSS.Transform.toString(transform.initial); + if (removeOnDropRef.current) { + return [ + { opacity: 1, transform: from }, + { + opacity: 0, + transform: CSS.Transform.toString({ + ...transform.initial, + scaleX: 0.4, + scaleY: 0.4, + }), + }, + ]; + } + return [ + { transform: from }, + { transform: CSS.Transform.toString(transform.final) }, + ]; + }, + }; + + const isReordering = !!activeId && keys.includes(activeId); + // A catalog item dragged from the tray (not yet in the dock) lands by hovering + // over the dock — highlight the whole dock as an "add here" target. Native + // page drags reuse the same highlight via isPageDropActive. + const showAddZone = + isPageDropActive || (!!activeId && !isReordering && isOverDock(overId)); + // The whole dock reads as an active drop area whenever an icon is being + // dragged inside it — both adding a new one and reordering an existing one + // (but not while dragging one out to remove). + const showDragArea = showAddZone || (isReordering && !willRemove); + + // Drag-overlay chip look: solid red while removing; solid bordered chip while + // reordering an existing icon; solid chip with a dashed brand border while + // *adding* a new icon (dragged from the tray) so it reads as "being placed". + let ghostStateClass = + 'scale-110 border border-border-subtlest-tertiary bg-background-default !text-text-primary'; + if (willRemove) { + ghostStateClass = 'scale-90 !bg-status-error text-white'; + } else if (!isReordering) { + ghostStateClass = + 'scale-110 border border-dashed border-accent-cabbage-default bg-background-default !text-text-primary'; + } + + return ( + +
    +
    + {/* The customize (•••) button always starts the dock; pinned + shortcuts stack below it. */} + + + + + {orderedItems.map((entry, index) => { + const shortcut = resolveShortcut(entry); + if (!shortcut) { + return null; + } + return ( + + {/* Float skeleton at the slot a dragged-in panel row would + land — same indicator as reordering. */} + {pageDropIndex === index && ( +
    + )} + + + ); + })} + {/* Dropping below the last shortcut → skeleton at the end. */} + {pageDropIndex !== null && pageDropIndex >= orderedItems.length && ( +
    + )} + +
    + + {trayOpen && trayPos && ( + +
    + + Customize shortcuts + + {/* All pinned shortcuts, clickable here too — so they're reachable + even when the rail is full. */} + {orderedItems.length > 0 && ( + + )} + {orderedItems.length > 0 && } + + Drag a page from any panel onto the sidebar, or add one: + +
    + {SHORTCUT_CATALOG.filter((def) => !keys.includes(def.id)).map( + (def) => ( + + ), + )} +
    +
    +
    + )} +
    + + + {activeResolved ? ( +
    +
    + {/* Always the shortcut's own icon — the solid red chip + "Remove" + pill convey the remove intent, so we keep showing exactly what + you're about to remove (white-tinted on red for vector icons). */} + {activeResolved.icon(false)} +
    + {/* Kept mounted so it scales + fades in/out as you cross the remove + boundary instead of popping (origin-left so it grows out of the + chip). */} + + Remove + +
    + ) : null} +
    + + ); +}; diff --git a/packages/shared/src/components/sidebar/SquadShortcutPinButton.tsx b/packages/shared/src/components/sidebar/SquadShortcutPinButton.tsx new file mode 100644 index 00000000000..3fb15161f91 --- /dev/null +++ b/packages/shared/src/components/sidebar/SquadShortcutPinButton.tsx @@ -0,0 +1,50 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; +import type { Squad } from '../../graphql/sources'; +import { PinIcon } from '../icons'; +import { webappUrl } from '../../lib/constants'; +import { useSidebarShortcutItems } from './SidebarShortcutsDock'; + +// v2 reframes a squad "pin" as adding it to the sidebar shortcuts dock (the old +// backend favorite is no longer used in v2). Clicking toggles the squad in/out +// of the dock; it can also be dragged in. Hidden until row hover while unpinned +// to keep the list clean; stays visible (filled) once pinned so the state reads. +export const SquadShortcutPinButton = ({ + squad, +}: { + squad: Squad; +}): ReactElement => { + const { isPinned, togglePin } = useSidebarShortcutItems(); + const path = `${webappUrl}squads/${squad.handle}`; + const pinned = isPinned(path); + + const onClick = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + togglePin({ + title: squad.name, + path, + image: squad.image ?? undefined, + }); + }, + [togglePin, squad.name, squad.image, path], + ); + + return ( + + ); +}; diff --git a/packages/shared/src/components/sidebar/StreakBadge.tsx b/packages/shared/src/components/sidebar/StreakBadge.tsx new file mode 100644 index 00000000000..eebc10db0af --- /dev/null +++ b/packages/shared/src/components/sidebar/StreakBadge.tsx @@ -0,0 +1,110 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { HotIcon } from '../icons'; +import { IconSize } from '../Icon'; +import type { StreakRingState } from '../../hooks/streaks/useStreakRingState'; + +// StreakBadge owns its own state visuals (it no longer borrows StreakRing's +// shared maps) so the v2 rail badge can evolve independently: +// - Calm/neutral states (new/pending/read-today/rest day): a SUBTLE GRAY border +// by default; it turns WHITE when the tab is hovered and PINK (the reading- +// streak brand colour) when the tab is the selected one. Read-today also gets +// a pink flame so it still reads as "read". +// - Just earned (`celebration`): the existing earn pop/wash animation (pink fill +// washes in with a white flame), then settles into the read-today look. +// - Danger states (at-risk/critical) keep their amber/red colour + pulse so the +// urgency still reads (they don't react to hover/selected). +const CALM_STATES = new Set([ + 'none', + 'pending', + 'safe', + 'freeze', +]); + +// Only the non-calm states have a fixed frame; calm states are computed from the +// hover/selected context below. +const fixedFrameByState: Partial> = { + celebration: 'animate-streak-earn-border border-accent-bacon-default', + at_risk: 'border-dashed border-status-warning', + critical: 'animate-streak-border-pulse border-dashed border-status-error', +}; + +const fillByState: Record = { + none: 'bg-transparent', + pending: 'bg-transparent', + safe: 'bg-transparent', + celebration: 'animate-streak-earn-fill', + at_risk: 'animate-streak-fade bg-status-warning opacity-20', + critical: 'animate-streak-pulse bg-status-error opacity-40', + freeze: 'bg-accent-blueCheese-flat', +}; + +const flameByState: Record = { + none: 'text-text-quaternary', + pending: 'text-text-tertiary', + // Read today: pink flame on the empty tile (the original, lighter look). + safe: 'text-accent-bacon-default', + // The earn celebration keeps a white flame during its pink fill-wash pop. + celebration: 'text-white', + at_risk: 'text-status-warning', + critical: 'text-status-error', + freeze: 'text-accent-blueCheese-default', +}; + +interface StreakBadgeProps { + state: StreakRingState; + hasReadToday: boolean; + // When this is the selected rail tab, the calm-state border goes pink. + selected?: boolean; + className?: string; +} + +// Small square reading-streak indicator for the rail tab — sized like the other +// tabs' glyph icons so it sits in the same icon+label rhythm. Presentational +// only; never calls the hook. The hover-white border relies on a +// `group/streaktab` on the tab button (see SidebarDesktopV2). +export const StreakBadge = ({ + state, + hasReadToday, + selected = false, + className, +}: StreakBadgeProps): ReactElement => { + const frameClass = CALM_STATES.has(state) + ? classNames( + state === 'none' && 'border-dashed', + selected + ? 'border-accent-bacon-default' + : 'border-border-subtlest-tertiary group-hover/streaktab:border-text-primary', + ) + : fixedFrameByState[state]; + + return ( + + + + + + ); +}; diff --git a/packages/shared/src/components/sidebar/StreakPopover.tsx b/packages/shared/src/components/sidebar/StreakPopover.tsx deleted file mode 100644 index 66be34b94db..00000000000 --- a/packages/shared/src/components/sidebar/StreakPopover.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from 'react'; -import { RootPortal } from '../tooltips/Portal'; -import { ReadingStreakPopup } from '../streak/popup/ReadingStreakPopup'; -import type { UserStreak } from '../../graphql/users'; - -type StreakPopoverProps = { - streak: UserStreak; - triggerRef: React.RefObject; - onClose: () => void; -}; - -// Manually positioned portal popover: read the trigger's bounding rect -// and render the panel via a body-level portal, dropping below the trigger. -// This keeps the popover stable (no Tippy auto-flip surprises inside the -// sidebar's transform / overflow context) and ensures it always drops from -// the streak button as expected. -export const StreakPopover = ({ - streak, - triggerRef, - onClose, -}: StreakPopoverProps): ReactElement | null => { - const [position, setPosition] = useState<{ - top: number; - left: number; - } | null>(null); - const popoverRef = useRef(null); - - const updatePosition = useCallback(() => { - const trigger = triggerRef.current; - if (!trigger) { - return; - } - const rect = trigger.getBoundingClientRect(); - setPosition({ top: rect.bottom + 8, left: rect.left }); - }, [triggerRef]); - - useLayoutEffect(() => { - updatePosition(); - }, [updatePosition]); - - useEffect(() => { - window.addEventListener('resize', updatePosition); - window.addEventListener('scroll', updatePosition, true); - return () => { - window.removeEventListener('resize', updatePosition); - window.removeEventListener('scroll', updatePosition, true); - }; - }, [updatePosition]); - - useEffect(() => { - const handleClickOutside = (event: globalThis.MouseEvent) => { - const target = event.target as Node | null; - if ( - target && - !popoverRef.current?.contains(target) && - !triggerRef.current?.contains(target) - ) { - onClose(); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [onClose, triggerRef]); - - if (!position) { - return null; - } - - return ( - -
    - -
    -
    - ); -}; diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index 39663303d14..17fbae502ac 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -8,6 +8,20 @@ import React, { forwardRef } from 'react'; import classNames from 'classnames'; import classed from '../../lib/classed'; import type { TooltipProps } from '../tooltips/BaseTooltip'; +import { OpenLinkIcon, PlusIcon } from '../icons'; +import { IconSize } from '../Icon'; + +// Drag payload used when a v2 sidebar panel row is dragged into the shortcuts +// dock to pin it. Carried on the native dataTransfer under this MIME type. +export const SHORTCUT_DRAG_MIME = 'application/x-dailydev-shortcut'; +export interface ShortcutDragData { + title: string; + path: string; + // The icon image URL of the dragged row (e.g. a squad/source logo), captured + // at drag time so the pinned shortcut renders it immediately instead of + // re-fetching it (which flashed a placeholder for a beat). + image?: string; +} export interface SidebarMenuItem { icon: ((active: boolean) => ReactElement) | ReactNode; @@ -30,6 +44,15 @@ export interface SidebarMenuItem { navItemRef?: MutableRefObject; color?: string; disableDefaultBackground?: boolean; + // Skip the path-based active highlight (e.g. Recent rows, which mirror pages + // you're already on and shouldn't read as the selected nav item). + disableActiveState?: boolean; + // Reveal an "open link" icon on hover — only for rows that leave the sidebar + // (e.g. Feed settings, DevCard → /settings). Opt-in, not for in-panel feeds. + showOpenLinkIcon?: boolean; + // Render a horizontal divider instead of a nav row (groups options like the + // settings dropdown). Build via `createSidebarSeparatorItem`. + isSeparator?: boolean; } interface ListIconProps { @@ -40,6 +63,9 @@ export interface ItemInnerProps { item: SidebarMenuItem; shouldShowLabel: boolean; active?: boolean; + // Reveal an "open link" icon on hover/focus for link rows (v2 panels), the + // same affordance the ProfileMenu rows use. The row must carry `group`. + showLinkIconOnHover?: boolean; } interface NavItemProps { color?: string; @@ -47,15 +73,24 @@ interface NavItemProps { children?: ReactNode; className?: string; disableDefaultBackground?: boolean; + // Opt-in native drag passthrough (used by the v2 sidebar to let panel rows be + // dragged into the shortcuts dock). Inert unless a caller sets `draggable`. + draggable?: boolean; + onDragStart?: (event: React.DragEvent) => void; + onDragEnd?: (event: React.DragEvent) => void; } export const navBtnClass = 'flex flex-1 items-center pl-2 laptop:pl-0 pr-5 laptop:pr-3 h-10 laptop:h-9 overflow-hidden'; // Vertical icon+label item used on the v2 desktop rail. Shared so the // notifications bell matches the hard-coded category tabs. Callers append -// the active state (`bg-background-default !text-text-primary`). +// the active state (the selected pill is a shared sliding indicator now, so +// callers only add `!text-text-primary`). `active:scale-95` gives the same +// tactile press feedback as the Home/Search/More buttons; it sits on the inner +// button (a child of the dnd sortable wrapper), so it never distorts the +// drag-start rect measurement. export const railTabClass = - 'focus-outline group relative flex w-full flex-col items-center gap-1 rounded-12 px-1 py-2 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary'; + 'focus-outline group relative flex w-full flex-col items-center gap-1 rounded-12 px-1 py-2 text-text-tertiary transition-[background-color,color,transform] duration-150 ease-out hover:bg-surface-hover hover:text-text-primary active:scale-95 motion-reduce:transition-none'; export const railTabLabelClass = 'typo-caption2 leading-tight text-center'; export const SidebarAside = classed( 'aside', @@ -81,6 +116,28 @@ export const ListIcon = ({ Icon }: ListIconProps): ReactElement => ( ); +// A Slack-style "add" row that leads a v2 panel list to encourage creating the +// next squad / folder / feed. Pass either an `onClick` (button row, e.g. opens +// a modal) or an `href` (link row, e.g. a create page). +export const createSidebarAddItem = ( + title: string, + target: { onClick: () => void } | { href: string }, +): SidebarMenuItem => ({ + icon: () => } />, + title, + ...('href' in target + ? { path: target.href, isForcedLink: true } + : { action: target.onClick }), +}); + +// A horizontal divider row that groups options inside a v2 panel, mirroring the +// settings dropdown. `key` just needs to be unique within the item list. +export const createSidebarSeparatorItem = (key: string): SidebarMenuItem => ({ + icon: null, + title: key, + isSeparator: true, +}); + // Compares a (possibly absolute, possibly query-bearing) menu href against // the current page so v2 rail panels can flag the active row with a single // shared rule instead of each panel rolling its own check. @@ -128,6 +185,7 @@ export const ItemInner = ({ item, shouldShowLabel, active, + showLinkIconOnHover, }: ItemInnerProps): ReactElement => { const isLabelHidden = !shouldShowLabel; @@ -136,7 +194,9 @@ export const ItemInner = ({ + )} + {shouldShowLabel && showLinkIconOnHover && !item.rightIcon && ( + // Named group (`openLink`) so it reveals on hovering THIS row only — + // an unnamed group-hover would also match the SidebarAside's `group` + // and show on hover anywhere in the sidebar. + )} @@ -158,7 +228,16 @@ export const ItemInner = ({ export const NavItem = forwardRef( ( - { className, color, active, children, disableDefaultBackground }, + { + className, + color, + active, + children, + disableDefaultBackground, + draggable, + onDragStart, + onDragEnd, + }, ref, ): ReactElement => { const baseClasses = active @@ -177,6 +256,9 @@ export const NavItem = forwardRef( return ( 0 && + compact && + createSidebarSeparatorItem('folders-divider'), ...(folders ?? []).map((folder) => ({ icon: folder.icon || @@ -83,7 +95,9 @@ export const BookmarkSection = ({ isItemsButton={isItemsButton} flag={SidebarSettingsFlags.BookmarksExpanded} isAlwaysOpenOnMobile - onAdd={handleAddFolder} + // v2 (`compact`) uses the leading "New folder" row instead of a header + // "+"; v1 keeps its header add button. + onAdd={compact ? undefined : handleAddFolder} /> ); }; diff --git a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx index 6e00d16463b..6b91eb83e11 100644 --- a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx +++ b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; import type { SidebarMenuItem } from '../common'; +import { createSidebarAddItem } from '../common'; import { HashtagIcon, StarIcon } from '../../icons'; import { Section } from '../Section'; import { webappUrl } from '../../../lib/constants'; @@ -73,13 +74,21 @@ export const CustomFeedSection = ({ onNavTabClick, ]); + // v2 rail panels (`compact`) lead with a "New feed" row instead of a header + // "+"; v1 keeps its header add button. + const { compact } = defaultRenderSectionProps; + const addHref = `${webappUrl}feeds/new`; + const items = compact + ? [createSidebarAddItem('New feed', { href: addHref }), ...menuItems] + : menuItems; + return (
    ); }; diff --git a/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx b/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx index 4dda8cdadfb..a52f443ec39 100644 --- a/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx +++ b/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx @@ -3,6 +3,7 @@ import React, { useMemo } from 'react'; import type { SidebarMenuItem } from '../common'; import { ListIcon } from '../common'; import { + CompassIcon, DiscussIcon, EarthIcon, HashtagIcon, @@ -24,11 +25,19 @@ import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant'; interface DiscoverSectionProps extends SidebarSectionProps { onNavTabClick?: (tab: string) => void; + // Hot Takes is a modal launcher rather than a hub section; the v2 Explore + // panel opts out of it. Defaults on so the v1 sidebar is unchanged. + showHotTakes?: boolean; + // Extra rows injected right after "Explore" (e.g. the v2 Explore panel slots + // Happening Now between Explore and Tags). + itemsAfterExplore?: SidebarMenuItem[]; } export const DiscoverSection = ({ isItemsButton, onNavTabClick, + showHotTakes = true, + itemsAfterExplore, ...defaultRenderSectionProps }: DiscoverSectionProps): ReactElement => { const { completeAction } = useActions(); @@ -40,7 +49,7 @@ export const DiscoverSection = ({ return [ { icon: (active: boolean) => ( - } /> + } /> ), title: 'Explore', // Bare path (not webappUrl) so it active-matches the in-place Explore @@ -49,6 +58,7 @@ export const DiscoverSection = ({ path: '/posts', action: () => onNavTabClick?.(OtherFeedPage.Explore), }, + ...(itemsAfterExplore ?? []), { icon: (active: boolean) => ( } /> @@ -86,7 +96,7 @@ export const DiscoverSection = ({ } }, }, - { + showHotTakes && { icon: (active: boolean) => ( } /> ), @@ -98,8 +108,16 @@ export const DiscoverSection = ({ logEvent({ event_name: LogEvent.OpenHotAndCold }); }, }, - ].filter(Boolean); - }, [completeAction, user, logEvent, onNavTabClick, HotTakesIcon]); + ].filter(Boolean) as SidebarMenuItem[]; + }, [ + completeAction, + user, + logEvent, + onNavTabClick, + HotTakesIcon, + showHotTakes, + itemsAfterExplore, + ]); return (
    { + const itemsAfterExplore: SidebarMenuItem[] = useMemo( + () => [ + { + title: 'Happening Now', + path: `${webappUrl}highlights`, + isForcedLink: true, + icon: (active: boolean) => ( + } /> + ), + }, + ], + [], + ); + + return ( + + ); +}; diff --git a/packages/shared/src/components/sidebar/sections/GameCenterSection.tsx b/packages/shared/src/components/sidebar/sections/GameCenterSection.tsx deleted file mode 100644 index 284bc7ca096..00000000000 --- a/packages/shared/src/components/sidebar/sections/GameCenterSection.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; -import type { SidebarMenuItem } from '../common'; -import { ListIcon, isSidebarItemActive } from '../common'; -import { JoystickIcon, SettingsIcon } from '../../icons'; -import { Section } from '../Section'; -import type { SidebarSectionProps } from './common'; -import { webappUrl } from '../../../lib/constants'; -import { QuestRailIcon } from '../../quest/QuestRailIcon'; - -// Daily quests (rail-icon landing) → Game Center hub → Quests settings. -// Rendered as a regular Section so its rows share the exact same layout, -// spacing, and active-state treatment as every other v2 rail panel. -export const GameCenterSection = ({ - isItemsButton, - ...defaultRenderSectionProps -}: SidebarSectionProps): ReactElement => { - const { activePage } = defaultRenderSectionProps; - const menuItems: SidebarMenuItem[] = useMemo(() => { - const dailyQuestsPath = `${webappUrl}daily-quests`; - const gameCenterPath = `${webappUrl}game-center`; - // Category-owned settings shortcut: keeps the Game Center panel active - // (the canonical /settings/customization/gamification page keeps Settings). - const questsSettingsPath = `${webappUrl}game-center/settings`; - - return [ - { - title: 'Daily quests', - path: dailyQuestsPath, - active: isSidebarItemActive(activePage, dailyQuestsPath), - icon: (active: boolean) => , - }, - { - title: 'Game Center', - path: gameCenterPath, - active: isSidebarItemActive(activePage, gameCenterPath), - icon: (active: boolean) => ( - } /> - ), - }, - { - title: 'Quests settings', - path: questsSettingsPath, - active: isSidebarItemActive(activePage, questsSettingsPath), - icon: (active: boolean) => ( - } /> - ), - }, - ]; - }, [activePage]); - - return ( -
    - ); -}; diff --git a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx index fe2ef6139a5..7bdb7bdb0e4 100644 --- a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx @@ -1,7 +1,11 @@ import type { ReactElement } from 'react'; import React, { useCallback, useMemo } from 'react'; import type { SidebarMenuItem } from '../common'; -import { ListIcon } from '../common'; +import { + createSidebarAddItem, + createSidebarSeparatorItem, + ListIcon, +} from '../common'; import { SourceIcon, TimerIcon } from '../../icons'; import { Section } from '../Section'; import { Origin } from '../../../lib/log'; @@ -22,6 +26,9 @@ export const NetworkSection = ({ asPin = false, ...defaultRenderSectionProps }: SidebarSectionProps & { asPin?: boolean }): ReactElement => { + // `compact` marks the v2 rail panels, where the add action also appears as a + // dedicated bottom row (Slack-style). v1 keeps only its header "+". + const { compact } = defaultRenderSectionProps; const { squads } = useAuthContext(); const { openNewSquad } = useSquadNavigation(); const { count, isModeratorInAnySquad } = useSquadPendingPosts({ @@ -63,9 +70,16 @@ export const NetworkSection = ({ ), }), }, + compact && createSidebarAddItem('New Squad', { onClick: handleAddSquad }), + // Border between the discovery/moderation actions and the squad list, + // matching the settings-dropdown grouping. Skip it when there are no + // squads so the list never ends on a dangling divider. + squadItems.length > 0 && + compact && + createSidebarSeparatorItem('squads-divider'), ...squadItems, ].filter(Boolean) as SidebarMenuItem[]; - }, [squads, isModeratorInAnySquad, count, asPin]); + }, [squads, isModeratorInAnySquad, count, asPin, compact, handleAddSquad]); return (
    { - const { squads } = useAuthContext(); - - const pinnedSquads = useMemo( - () => squads?.filter((squad) => !!squad.favoritedAt) ?? [], - [squads], - ); - - const menuItems: SidebarMenuItem[] = useMemo( - () => pinnedSquads.map((squad) => createSquadMenuItem(squad, true)), - [pinnedSquads], - ); - - // Always show the "Pinned" header (even when empty) so users discover the - // feature and understand what the rows below represent. - return ( -
    - ); -}; diff --git a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx new file mode 100644 index 00000000000..b12b9c99512 --- /dev/null +++ b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx @@ -0,0 +1,199 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { useRouter } from 'next/router'; +import classNames from 'classnames'; +import type { SidebarMenuItem } from '../common'; +import { ListIcon } from '../common'; +import { Section } from '../Section'; +import { BookmarkSection } from './BookmarkSection'; +import { RecentSection } from './RecentSection'; +import { + AnalyticsIcon, + AppIcon, + DevCardIcon, + DevPlusIcon, + EyeIcon, + JobIcon, + SquadIcon, +} from '../../icons'; +import type { SidebarSectionProps } from './common'; +import { OtherFeedPage } from '../../../lib/query'; +import { plusUrl, settingsUrl, webappUrl } from '../../../lib/constants'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { usePlusSubscription } from '../../../hooks'; +import Link from '../../utilities/Link'; +import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { PlusUser } from '../../PlusUser'; +import { SidebarProfileStats } from '../SidebarProfileStats'; +import { UpgradeToPlus } from '../../UpgradeToPlus'; +import { ButtonSize } from '../../buttons/Button'; +import { TargetId } from '../../../lib/log'; + +// The avatar tab panel. Everything "you": identity + your feeds/activity, your +// pinned squads and custom feeds. Account/app controls live in the bottom +// settings gear instead, so this stays profile-focused. +export const ProfilePanelSection = ({ + isItemsButton, + onNavTabClick, + ...defaultRenderSectionProps +}: SidebarSectionProps): ReactElement | null => { + const { user } = useAuthContext(); + const { isPlus } = usePlusSubscription(); + const router = useRouter(); + + // The header links to your profile, so highlight it as the active row (same + // look as the Explore/Squads rows) whenever you're on your profile page or + // any of its sub-pages — mirrors how the rail resolves the Profile tab. + const currentPath = (router?.asPath || '').split('?')[0]; + const profileBase = user?.username ? `/${user.username}` : null; + const isProfileActive = + !!profileBase && + (currentPath === profileBase || currentPath.startsWith(`${profileBase}/`)); + + const menuItems: SidebarMenuItem[] = useMemo( + () => + [ + { + title: 'Following', + path: '/following', + action: () => onNavTabClick?.(OtherFeedPage.Following), + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'History', + path: `${webappUrl}history`, + isForcedLink: true, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Analytics', + path: `${webappUrl}analytics`, + isForcedLink: true, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Jobs', + path: `${webappUrl}jobs`, + isForcedLink: true, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Feed settings', + path: `${settingsUrl}/feed/general`, + isForcedLink: true, + // Leaves the sidebar for the Settings page → show the open-link hint. + showOpenLinkIcon: true, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'DevCard', + path: `${settingsUrl}/customization/devcard`, + isForcedLink: true, + showOpenLinkIcon: true, + icon: (active: boolean) => ( + } /> + ), + }, + // Non-Plus only: a purple "Get API Access" upgrade CTA (API access is a + // Plus perk). Plus users already have it, so it's hidden for them. + !isPlus && { + title: 'Get API Access', + path: plusUrl, + isForcedLink: true, + requiresLogin: true, + color: 'text-action-plus-default', + itemClassName: 'bg-action-plus-float/50 hover:bg-action-plus-float', + disableDefaultBackground: true, + icon: (active: boolean) => ( + } /> + ), + }, + ].filter(Boolean) as SidebarMenuItem[], + [onNavTabClick, isPlus], + ); + + if (!user) { + return null; + } + + return ( + <> + {/* The list rows below inset their icon inside a w-9 column (≈8px), so + pl-5 lines the avatar/stats left edge up with those icons; pr-6 + matches the rows' right content edge. mb-4 gives the menu list below + a clear section-level gap from the stats/header. */} +
    + {/* Clickable to the profile, but without the open-link icon — the + avatar isn't a "leaves the sidebar" destination in that sense. */} + + + {/* No @handle here — clicking through to the profile page already + shows it. Just the avatar + name, centered against each other. */} +
    + +
    + + {user.name} + + {isPlus && } +
    +
    +
    + + + +
    +
    + + {/* Recently visited pages — moved here from the Explore panel so all of + "your" stuff lives under the avatar tab. */} + + + ); +}; diff --git a/packages/shared/src/components/sidebar/sections/RecentSection.tsx b/packages/shared/src/components/sidebar/sections/RecentSection.tsx index c46bc5c1c80..926df7a6062 100644 --- a/packages/shared/src/components/sidebar/sections/RecentSection.tsx +++ b/packages/shared/src/components/sidebar/sections/RecentSection.tsx @@ -1,21 +1,139 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; import type { SidebarMenuItem } from '../common'; import { ListIcon } from '../common'; -import { EarthIcon, HashtagIcon, SourceIcon } from '../../icons'; +import { + AnalyticsIcon, + BellIcon, + BookmarkIcon, + BriefIcon, + CompassIcon, + HashtagIcon, + HomeIcon, + HotIcon, + JobIcon, + SettingsIcon, + SourceIcon, + SquadIcon, + TimerIcon, + UserIcon, +} from '../../icons'; +import { Image, ImageType } from '../../image/Image'; import { Section } from '../Section'; import { SidebarSettingsFlags } from '../../../graphql/settings'; +import { sourceQueryOptions } from '../../../graphql/sources'; import type { SidebarSectionProps } from './common'; +import type { RecentPage, RecentPageType } from '../../../lib/recentPages'; import { useRecentPages } from '../../../hooks/useRecentPages'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useSquad } from '../../../hooks/squads/useSquad'; +import { useUserShortByIdQuery } from '../../../hooks/user/useUserShortByIdQuery'; -const iconForPath = (path: string): ReactElement => { - if (path.startsWith('/tags/')) { - return ; +// Older stored entries predate `type`; fall back to the path prefix so they +// still get a recognizable icon until they're re-recorded with a type. +const resolveType = (page: RecentPage): RecentPageType => { + if (page.type) { + return page.type; } - if (path.startsWith('/squads/')) { - return ; + if (page.path.startsWith('/tags/')) { + return 'tag'; } - return ; + if (page.path.startsWith('/sources/')) { + return 'source'; + } + if (page.path.startsWith('/squads/')) { + return 'squad'; + } + return 'page'; +}; + +const firstSegment = (path: string): string => + path.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] ?? ''; + +// Recognizable glyphs for known internal destinations, keyed by the leading +// path segment, so a recent page reads as itself (Game Center, Settings, +// Notifications…) instead of the generic "history" timer. Anything unmapped +// (and any page with no better icon/image) keeps the timer fallback. +const PAGE_ICON_BY_SEGMENT: Record ReactElement> = { + 'game-center': () => , + 'daily-quests': () => , + settings: () => , + notifications: () => , + bookmarks: () => , + briefing: () => , + analytics: () => , + jobs: () => , + following: () => , + posts: () => , + squads: () => , +}; + +const iconForType = (page: RecentPage, type: RecentPageType): ReactElement => { + switch (type) { + case 'user': + return ; + case 'source': + return ; + case 'squad': + return ; + case 'tag': + return ; + default: { + const makeIcon = PAGE_ICON_BY_SEGMENT[firstSegment(page.path)]; + return makeIcon ? makeIcon() : ; + } + } +}; + +const handleFromPath = (path: string): string => + path.split('?')[0].split('#')[0].split('/').filter(Boolean).pop() ?? ''; + +// Renders the real entity avatar (squad logo / profile picture) when we can +// resolve it — the entity is usually already cached from the visit — and falls +// back to the typed vector icon while loading or when it can't be resolved. +const RecentItemIcon = ({ page }: { page: RecentPage }): ReactElement => { + const type = resolveType(page); + const handle = handleFromPath(page.path); + const { user } = useAuthContext(); + const isOwnProfile = + type === 'user' && !!user?.username && handle === user.username; + + // Each query self-disables when handed an empty id/handle, so only the row's + // matching entity is fetched. + const { squad } = useSquad({ handle: type === 'squad' ? handle : '' }); + const { data: otherUser } = useUserShortByIdQuery({ + id: type === 'user' && !isOwnProfile ? handle : '', + }); + const { data: source } = useQuery( + sourceQueryOptions({ sourceId: type === 'source' ? handle : '' }), + ); + + let image: string | undefined; + if (isOwnProfile) { + image = user?.image; + } else if (type === 'user') { + image = otherUser?.image; + } else if (type === 'squad') { + image = squad?.image; + } else if (type === 'source') { + image = source?.image; + } + + if (image) { + return ( + + ); + } + + return iconForType(page, type)} />; }; // v2 Home panel: the last few non-post pages the user visited (profiles, @@ -29,9 +147,12 @@ export const RecentSection = ({ const menuItems: SidebarMenuItem[] = useMemo( () => recentPages.map((page) => ({ - icon: () => iconForPath(page.path)} />, + icon: () => , title: page.title, path: page.path, + // Recent mirrors pages you've already visited (often the current one), + // so it should never render as the active nav item. + disableActiveState: true, })), [recentPages], ); diff --git a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx index eca460246ae..58f4342274a 100644 --- a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx @@ -11,7 +11,6 @@ import { EyeIcon, FeatherIcon, HashtagIcon, - HotIcon, InviteIcon, JobIcon, MagicIcon, @@ -213,19 +212,14 @@ export const SettingsPanelSection = ({ title: 'Gamification', items: [ { + // The streak settings live on this same combined page, so there's + // no separate "Streaks" entry. title: 'Feature visibility', path: `${settingsUrl}/customization/gamification`, icon: (active: boolean) => ( } /> ), }, - { - title: 'Streaks', - path: `${settingsUrl}/customization/streaks`, - icon: (active: boolean) => ( - } /> - ), - }, ], }, { diff --git a/packages/shared/src/components/sidebar/sections/StreakQuestsSection.tsx b/packages/shared/src/components/sidebar/sections/StreakQuestsSection.tsx new file mode 100644 index 00000000000..e2869f4b981 --- /dev/null +++ b/packages/shared/src/components/sidebar/sections/StreakQuestsSection.tsx @@ -0,0 +1,163 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { format } from 'date-fns'; +import { ArrowIcon, SettingsIcon } from '../../icons'; +import { webappUrl } from '../../../lib/constants'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useStreakRingState } from '../../../hooks/streaks/useStreakRingState'; +import { StreakMonthCalendar } from '../../streak/popup/StreakMonthCalendar'; +import { CompactQuestList } from '../../quest/CompactQuestList'; +import { HorizontalSeparator } from '../../utilities'; +import Link from '../../utilities/Link'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { IconSize } from '../../Icon'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { DEFAULT_TIMEZONE } from '../../../lib/timezones'; + +// The streak rail tab's panel: a big current-streak hero with the 30-day +// calendar, then today's quests. The single gear opens the combined +// Streaks & gamification settings (kept under /game-center/* so this panel +// stays active). The Game Center and separate Quests-settings links are gone — +// clicking the tab itself opens Game Center, and the two settings pages are now +// one, so neither needs repeating here. +export const StreakQuestsSection = (): ReactElement => { + const { user } = useAuthContext(); + const { isEnabled: isStreakEnabled, count, streak } = useStreakRingState(); + const timezone = user?.timezone ?? DEFAULT_TIMEZONE; + // Compact label (e.g. "Jun 23") — no year; the full date with year lives in + // the hover tooltip so the strip stays tidy day-to-day. + const todayLabel = useMemo(() => format(new Date(), 'MMM d'), []); + const fullDateLabel = useMemo(() => format(new Date(), 'MMMM d, yyyy'), []); + const [questsOpen, setQuestsOpen] = useState(true); + // The streak hero only shows when reading streaks are on. When it's off the + // panel is quests-only (and the panel title is already "Daily Quests"). + const heroShown = isStreakEnabled && !!streak; + + return ( + // h-full + flex so the hero stays fixed and the quest list fills the rest of + // the panel, scrolling inside its own area (the Nav is stretched to the + // scroll wrapper for this panel — see SidebarDesktopV2 `isStreakPanel`). +
    + {/* Reading streak — the hero. */} + {heroShown && ( + <> +
    +
    + + {count} + + + + + + +
    + {/* The panel title already reads "Current Streak", so this is just + the supporting longest/total stat under the big count. */} + + {streak.max} Longest · {streak.total} Total + +
    + {/* The Tooltip trigger MUST be a stable DOM element. Using the + Typography component directly here crashed the panel: Typography + re-creates its rendered element every render (classed()), so as + Radix's asChild trigger it remounted the node each render and + thrashed Radix's anchor ref → "Maximum update depth exceeded". A + plain trigger is stable; the Typography lives inside it. */} + +
    + + Today, {todayLabel} + +
    +
    + + {timezone} + +
    + +
    + + + )} + + {/* Today's quests. When the streak hero is shown they get their own + "Daily quests" header (the panel title is "Current Streak"); when + streaks are off the panel title is already "Daily Quests", so the list + stands alone with no redundant header. */} + {heroShown ? ( +
    +
    + +
    +
    +
    + {/* px-3 so the quest rows' hover pills inset like every other v2 + panel list (mx-3 ≈ 12px). */} +
    + +
    +
    +
    +
    + ) : ( +
    + +
    + )} +
    + ); +}; diff --git a/packages/shared/src/components/sidebar/sections/squadMenuItem.tsx b/packages/shared/src/components/sidebar/sections/squadMenuItem.tsx index ce1ff406ede..ef40b966848 100644 --- a/packages/shared/src/components/sidebar/sections/squadMenuItem.tsx +++ b/packages/shared/src/components/sidebar/sections/squadMenuItem.tsx @@ -3,12 +3,13 @@ import type { SidebarMenuItem } from '../common'; import { DefaultSquadIcon } from '../../icons'; import { SquadImage } from '../../squads/SquadImage'; import { SquadFavoriteButton } from '../../squads/SquadFavoriteButton'; +import { SquadShortcutPinButton } from '../SquadShortcutPinButton'; import { webappUrl } from '../../../lib/constants'; import type { Squad } from '../../../graphql/sources'; -// Shared squad row for the sidebar (the full Squads list and the Home -// "Pinned" section render identical rows). `asPin` switches the favorite -// button to the v2 pin icon/label. +// Shared squad row for the sidebar. `asPin` (v2) swaps the backend "favorite" +// star for a pin that adds the squad to the sidebar shortcuts dock; v1 keeps +// the favorite star. export const createSquadMenuItem = ( squad: Squad, asPin: boolean, @@ -24,6 +25,11 @@ export const createSquadMenuItem = ( title: name, path: `${webappUrl}squads/${handle}`, itemClassName: 'group/squad-row', - rightIcon: () => , + rightIcon: () => + asPin ? ( + + ) : ( + + ), }; }; diff --git a/packages/shared/src/components/sidebar/sidebarCategory.spec.ts b/packages/shared/src/components/sidebar/sidebarCategory.spec.ts index 5faa61bed48..b35fd59cfdb 100644 --- a/packages/shared/src/components/sidebar/sidebarCategory.spec.ts +++ b/packages/shared/src/components/sidebar/sidebarCategory.spec.ts @@ -27,6 +27,45 @@ describe('getSidebarCategoryForPath', () => { SidebarCategory.Notifications, ); }); + + it('maps profile-panel pages to the Profile category', () => { + // The avatar panel hosts these feeds/activity, so the panel should stay + // on Profile while the user browses them. + expect(getSidebarCategoryForPath('/following')).toBe( + SidebarCategory.Profile, + ); + expect(getSidebarCategoryForPath('/history')).toBe(SidebarCategory.Profile); + expect(getSidebarCategoryForPath('/analytics')).toBe( + SidebarCategory.Profile, + ); + // Bookmarks now live in the Profile panel rather than a Saved rail tab. + expect(getSidebarCategoryForPath('/bookmarks')).toBe( + SidebarCategory.Profile, + ); + expect(getSidebarCategoryForPath('/bookmarks/later')).toBe( + SidebarCategory.Profile, + ); + }); + + it('falls back to the Main (Explore) category for feed pages', () => { + expect(getSidebarCategoryForPath('/posts')).toBe(SidebarCategory.Main); + expect(getSidebarCategoryForPath('/')).toBe(SidebarCategory.Main); + // Happening Now lives under Explore, not Profile. + expect(getSidebarCategoryForPath('/highlights')).toBe(SidebarCategory.Main); + }); + + it('matches the leading segment, not a substring', () => { + // A tag/source whose slug contains a profile keyword must stay on Explore, + // not jump to the Profile panel. + expect(getSidebarCategoryForPath('/tags/jobs')).toBe(SidebarCategory.Main); + expect(getSidebarCategoryForPath('/sources/history-channel')).toBe( + SidebarCategory.Main, + ); + // A category's own settings shortcut keeps that category (not Settings). + expect(getSidebarCategoryForPath('/notifications/settings')).toBe( + SidebarCategory.Notifications, + ); + }); }); describe('isSidebarSettingsPath', () => { diff --git a/packages/shared/src/components/sidebar/sidebarCategory.ts b/packages/shared/src/components/sidebar/sidebarCategory.ts index fb59daecf66..68c33e15cf6 100644 --- a/packages/shared/src/components/sidebar/sidebarCategory.ts +++ b/packages/shared/src/components/sidebar/sidebarCategory.ts @@ -2,10 +2,15 @@ // stored in local state for click-driven overrides — intentionally NOT // persisted to SettingsContext / localStorage / IndexedDB. export const SidebarCategory = { + // The primary feed/discovery category. Surfaced on the rail as "Explore" + // (its panel lists the /posts sub-tabs); the id stays `main` so it remains + // the default/fallback category. Main: 'main', + // The avatar tab. Opens the profile panel (your feeds, activity, bookmarks, + // pins, custom feeds, account shortcuts) instead of a dropdown menu. + Profile: 'profile', Squads: 'squads', Notifications: 'notifications', - Saved: 'saved', GameCenter: 'gameCenter', Settings: 'settings', } as const; @@ -13,37 +18,54 @@ export const SidebarCategory = { export type SidebarCategoryId = (typeof SidebarCategory)[keyof typeof SidebarCategory]; +// Profile-panel destinations keyed by their top-level route segment: your feed +// (Following), activity (History, Analytics, Jobs) and saved content (Bookmarks, +// briefings). (Happening Now lives under Explore, so /highlights is not here.) +const PROFILE_SEGMENTS = new Set([ + 'analytics', + 'jobs', + 'history', + 'following', + 'bookmarks', + 'briefing', +]); + +// The leading path segment (origin/query/hash stripped). Matching on the first +// segment — not a loose substring — so e.g. a tag named "jobs" (/tags/jobs) or a +// source slug containing "history" doesn't get misrouted to the Profile panel. +const firstPathSegment = (activePage: string): string => + activePage + .replace(/^https?:\/\/[^/]+/, '') + .split('?')[0] + .split('#')[0] + .split('/') + .filter(Boolean)[0] ?? ''; + export const getSidebarCategoryForPath = ( activePage: string, ): SidebarCategoryId => { - // Notification/gamification *settings* live under /settings and keep the - // Settings panel. Each category exposes its own settings shortcut under its - // own path instead (e.g. /notifications/settings, /game-center/settings), - // which keeps that category's panel. The general /settings fallback below - // catches the settings pages. - if ( - activePage.includes('/notifications') && - !activePage.includes('/settings/notifications') - ) { + const segment = firstPathSegment(activePage); + // Settings owns its sub-pages, checked first so notification/gamification + // *settings* (/settings/notifications, etc.) keep the Settings panel. A + // category's own settings shortcut lives under that category's segment + // instead (e.g. /notifications/settings), so it keeps that category's panel. + if (segment === 'settings') { + return SidebarCategory.Settings; + } + if (segment === 'notifications') { return SidebarCategory.Notifications; } - if ( - activePage.includes('/game-center') || - activePage.includes('/daily-quests') - ) { + if (segment === 'game-center' || segment === 'daily-quests') { return SidebarCategory.GameCenter; } - if (activePage.includes('/bookmarks') || activePage.includes('/briefing')) { - return SidebarCategory.Saved; - } - if (activePage.includes('/squads')) { + if (segment === 'squads') { return SidebarCategory.Squads; } - if (activePage.includes('/settings')) { - return SidebarCategory.Settings; + if (PROFILE_SEGMENTS.has(segment)) { + return SidebarCategory.Profile; } // Explore and its sub-pages (/posts, /tags, /sources, /users, /discussed) - // now live under Home, so they fall through to the Main category. + // fall through to the Main ("Explore") category. return SidebarCategory.Main; }; diff --git a/packages/shared/src/components/sidebar/useAnchoredRailPopup.ts b/packages/shared/src/components/sidebar/useAnchoredRailPopup.ts new file mode 100644 index 00000000000..4e1d1a50773 --- /dev/null +++ b/packages/shared/src/components/sidebar/useAnchoredRailPopup.ts @@ -0,0 +1,54 @@ +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; + +export interface AnchoredRailPopupPosition { + top?: number; + bottom?: number; + maxHeight: number; +} + +// Vertical placement for a portaled rail dropdown (customize tray, More menu). +// Horizontal X is fixed via the `left-20 ml-2` class — the exact same X the +// Support/Settings popups use — so all rail dropdowns sit the same distance +// from the rail. Vertically it anchors to the trigger's top, flipping to open +// upward (bottom-anchored) when there isn't room below, so it's never cut off +// short screens. Capped to the available height so the content scrolls. +// Recomputed on open and on viewport resize (not on rail scroll — it stays put +// like the Support/Settings popups, avoiding a jittery shift). +export const useAnchoredRailPopup = ( + triggerRef: RefObject, + isOpen: boolean, +): AnchoredRailPopupPosition | null => { + const [position, setPosition] = useState( + null, + ); + + useEffect(() => { + if (!isOpen) { + setPosition(null); + return undefined; + } + const update = () => { + const rect = triggerRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + const margin = 12; + const vh = window.innerHeight; + const spaceBelow = vh - rect.top - margin; + const spaceAbove = rect.bottom - margin; + const openUp = spaceBelow < 320 && spaceAbove > spaceBelow; + setPosition({ + maxHeight: Math.max(160, openUp ? spaceAbove : spaceBelow), + ...(openUp ? { bottom: vh - rect.bottom } : { top: rect.top }), + }); + }; + update(); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('resize', update); + }; + }, [isOpen, triggerRef]); + + return position; +}; diff --git a/packages/shared/src/components/sidebar/useSidebarDragState.tsx b/packages/shared/src/components/sidebar/useSidebarDragState.tsx new file mode 100644 index 00000000000..8b02b054e7a --- /dev/null +++ b/packages/shared/src/components/sidebar/useSidebarDragState.tsx @@ -0,0 +1,31 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { createContext, useContext } from 'react'; + +interface SidebarDragState { + // True while ANY sidebar drag is in progress (rail tab reorder, shortcut + // reorder/remove, or a panel row being dragged toward the dock). Consumers + // use it to suppress tooltips, hover-card panels and panel-previews so they + // never render over the drag ghost. + isDragging: boolean; + setDragging: (value: boolean) => void; +} + +const SidebarDragContext = createContext({ + isDragging: false, + setDragging: () => undefined, +}); + +export const useSidebarDragState = (): SidebarDragState => + useContext(SidebarDragContext); + +export const SidebarDragStateProvider = ({ + value, + children, +}: { + value: SidebarDragState; + children: ReactNode; +}): ReactElement => ( + + {children} + +); diff --git a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx index dc1fe8f4006..45b41c1c3ec 100644 --- a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx +++ b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx @@ -1,20 +1,20 @@ import type { ReactElement } from 'react'; import React, { useEffect, useMemo } from 'react'; -import { addDays, subDays } from 'date-fns'; -import { useQuery } from '@tanstack/react-query'; import classNames from 'classnames'; import { useRouter } from 'next/router'; import { StreakSection } from './StreakSection'; -import { DayStreak, Streak } from './DayStreak'; -import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; -import type { ReadingDay, UserStreak } from '../../../graphql/users'; -import { getReadingStreak30Days } from '../../../graphql/users'; +import { DayStreak } from './DayStreak'; +import { + getStreak, + getStreakDays, + useReadingStreak30Days, +} from '../../../hooks/streaks/useStreakDays'; +import type { UserStreak } from '../../../graphql/users'; import { useAuthContext } from '../../../contexts/AuthContext'; import { useActions, useViewSize, ViewSize } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { SettingsIcon, VIcon, WarningIcon } from '../../icons'; -import { isWeekend, DayOfWeek } from '../../../lib/date'; import { DEFAULT_TIMEZONE, getTimezoneOffsetLabel, @@ -48,60 +48,6 @@ import { usePushNotificationContext } from '../../../contexts/PushNotificationCo import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; -const getStreak = ({ - value, - today, - history, - startOfWeek = DayOfWeek.Monday, - timezone, -}: { - value: Date; - today: Date; - history?: ReadingDay[]; - startOfWeek?: number; - timezone?: string; -}): Streak => { - const isFreezeDay = isWeekend(value, startOfWeek, timezone); - const isToday = isSameDayInTimezone(value, today, timezone); - const isFuture = value > today; - const isCompleted = - !isFuture && - history?.some(({ date: historyDate, reads }) => { - const dateToCompare = new Date(historyDate); - const sameDate = isSameDayInTimezone(dateToCompare, value, timezone); - - return sameDate && reads > 0; - }); - - if (isCompleted) { - return Streak.Completed; - } - - if (isFreezeDay) { - return Streak.Freeze; - } - - if (isToday) { - return Streak.Pending; - } - - return Streak.Upcoming; -}; - -const getStreakDays = (today: Date) => { - return [ - subDays(today, 4), - subDays(today, 3), - subDays(today, 2), - subDays(today, 1), - today, - addDays(today, 1), - addDays(today, 2), - addDays(today, 3), - addDays(today, 4), - ]; // these dates will then be compared to the user's post views -}; - interface ReadingStreakPopupProps { streak: UserStreak; fullWidth?: boolean; @@ -116,14 +62,8 @@ export function ReadingStreakPopup({ const isMobile = useViewSize(ViewSize.MobileL); const { user } = useAuthContext(); const { completeAction } = useActions(); - const userId = user?.id; const timezone = user?.timezone ?? DEFAULT_TIMEZONE; - const { data: history } = useQuery({ - queryKey: generateQueryKey(RequestKey.ReadingStreak30Days, user), - queryFn: () => getReadingStreak30Days(userId ?? ''), - staleTime: StaleTime.Default, - enabled: !!userId, - }); + const history = useReadingStreak30Days(); const isTimezoneOk = useStreakTimezoneOk(); const { showPrompt } = usePrompt(); const { logEvent } = useLogContext(); diff --git a/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx b/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx new file mode 100644 index 00000000000..c4088beac53 --- /dev/null +++ b/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx @@ -0,0 +1,102 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { addDays, subDays } from 'date-fns'; +import { ReadingStreakIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { Streak } from './DayStreak'; +import type { UserStreak } from '../../../graphql/users'; +import { + getStreak, + useReadingStreak30Days, +} from '../../../hooks/streaks/useStreakDays'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { DEFAULT_TIMEZONE, isSameDayInTimezone } from '../../../lib/timezones'; + +const DAYS = 30; +// How many days before today to start the grid, so today sits early in the +// first row and the rest of the period reads ahead (matches the streak design). +const LEADING_DAYS = 4; + +// A compact 30-day reading calendar (10 × 3 grid of dots): read days carry the +// filled flame on a brand-pink dot, weekends use a dashed diagonal pattern +// (auto-frozen), today is ringed, and untouched days are empty dotted circles. +// Rebuilt on our real history + existing day-state logic. +export const StreakMonthCalendar = ({ + streak, +}: { + streak?: UserStreak; +}): ReactElement => { + const { user } = useAuthContext(); + const timezone = user?.timezone ?? DEFAULT_TIMEZONE; + const history = useReadingStreak30Days(); + + const days = useMemo(() => { + const today = new Date(); + const start = subDays(today, LEADING_DAYS); + return Array.from({ length: DAYS }, (_, index) => { + const date = addDays(start, index); + return { + date, + state: getStreak({ + value: date, + today, + history, + startOfWeek: streak?.weekStart, + timezone, + }), + isToday: isSameDayInTimezone(date, today, timezone), + }; + }); + }, [history, streak?.weekStart, timezone]); + + return ( +
    + {days.map(({ date, state, isToday }) => { + const isRead = state === Streak.Completed; + const isFreeze = state === Streak.Freeze; + // Every cell is the same size-4 circle. A read day is the solid pink + // disc (the secondary flame glyph fills its whole box), so its border is + // dropped and the glyph is sized to the cell — otherwise the icon's + // default XSmall (20px) overflows the 16px cell and the read dot reads + // visibly bigger than the others. Today gets a ring, weekends the + // dashed pattern. + let stateClass = 'border-border-subtlest-tertiary'; + if (isToday || isRead) { + // Today's ring is a separate overlay (below), so today drops its own + // border too. + stateClass = 'border-transparent'; + } else if (isFreeze) { + stateClass = + 'bg-[repeating-linear-gradient(135deg,currentColor_0_1.5px,transparent_1.5px_4px)] text-border-subtlest-tertiary'; + } + return ( +
    + {isRead && ( + // `size` (not a w/h className) controls the real glyph size — a + // className loses to the Icon's size class. Size16 matches the + // size-4 cell so the disc is the same diameter as every other dot. + + )} + {isToday && ( + // "Today" ring as a TOP overlay (z-1) so it stays visible over the + // read-day flame disc. A 1px white border hugging the OUTSIDE of + // the dot (no inset, no offset gap) so the flame fills the circle + // and the ring is a clean thin border around it. + + )} +
    + ); + })} +
    + ); +}; diff --git a/packages/shared/src/components/tags/TagPageNavbar.tsx b/packages/shared/src/components/tags/TagPageNavbar.tsx index adfdf2fb7d2..8f740964250 100644 --- a/packages/shared/src/components/tags/TagPageNavbar.tsx +++ b/packages/shared/src/components/tags/TagPageNavbar.tsx @@ -50,9 +50,9 @@ export function TagPageNavbar({ {tags.map((tag) => ( { toggleOptOutCompanion: () => Promise; isGamificationEnabled: boolean; toggleAllGamification: () => Promise; + // The quest experience as one switch: levels + quests + achievements together + // (reading streaks stay separate). Needed because the individual toggles each + // setState off the same captured snapshot, so they can't be chained. + isQuestExperienceEnabled: boolean; + toggleQuestExperience: () => Promise; toggleAutoDismissNotifications: () => Promise; toggleShowFeedbackButton: () => Promise; loadedSettings: boolean; @@ -304,6 +309,22 @@ export const SettingsContextProvider = ({ optOutAchievements: anyEnabled, }); }, + isQuestExperienceEnabled: + !settings.optOutLevelSystem || + !settings.optOutQuestSystem || + !settings.optOutAchievements, + toggleQuestExperience: () => { + const anyEnabled = + !settings.optOutLevelSystem || + !settings.optOutQuestSystem || + !settings.optOutAchievements; + return setSettings({ + ...settings, + optOutLevelSystem: anyEnabled, + optOutQuestSystem: anyEnabled, + optOutAchievements: anyEnabled, + }); + }, toggleOptOutCompanion: () => setSettings({ ...settings, diff --git a/packages/shared/src/hooks/streaks/useStreakDays.ts b/packages/shared/src/hooks/streaks/useStreakDays.ts new file mode 100644 index 00000000000..0bb7b2b43b0 --- /dev/null +++ b/packages/shared/src/hooks/streaks/useStreakDays.ts @@ -0,0 +1,78 @@ +import { addDays, subDays } from 'date-fns'; +import { useQuery } from '@tanstack/react-query'; +import { Streak } from '../../components/streak/popup/DayStreak'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; +import type { ReadingDay } from '../../graphql/users'; +import { getReadingStreak30Days } from '../../graphql/users'; +import { DayOfWeek, isWeekend } from '../../lib/date'; +import { isSameDayInTimezone } from '../../lib/timezones'; + +// Classify a single day against the user's reading history. Extracted from +// ReadingStreakPopup so the popup, the compact sidebar calendar and the rail +// all share one implementation. +export const getStreak = ({ + value, + today, + history, + startOfWeek = DayOfWeek.Monday, + timezone, +}: { + value: Date; + today: Date; + history?: ReadingDay[]; + startOfWeek?: number; + timezone?: string; +}): Streak => { + const isFreezeDay = isWeekend(value, startOfWeek, timezone); + const isToday = isSameDayInTimezone(value, today, timezone); + const isFuture = value > today; + const isCompleted = + !isFuture && + history?.some(({ date: historyDate, reads }) => { + const dateToCompare = new Date(historyDate); + const sameDate = isSameDayInTimezone(dateToCompare, value, timezone); + + return sameDate && reads > 0; + }); + + if (isCompleted) { + return Streak.Completed; + } + + if (isFreezeDay) { + return Streak.Freeze; + } + + if (isToday) { + return Streak.Pending; + } + + return Streak.Upcoming; +}; + +// The 4-days-before → today → 4-days-after window the popup calendar shows. +export const getStreakDays = (today: Date): Date[] => [ + subDays(today, 4), + subDays(today, 3), + subDays(today, 2), + subDays(today, 1), + today, + addDays(today, 1), + addDays(today, 2), + addDays(today, 3), + addDays(today, 4), +]; + +// The user's last-30-days reading history (one shared cache key, so callers +// never trigger a duplicate request). +export const useReadingStreak30Days = (): ReadingDay[] | undefined => { + const { user } = useAuthContext(); + const userId = user?.id; + return useQuery({ + queryKey: generateQueryKey(RequestKey.ReadingStreak30Days, user), + queryFn: () => getReadingStreak30Days(userId ?? ''), + staleTime: StaleTime.Default, + enabled: !!userId, + }).data; +}; diff --git a/packages/shared/src/hooks/useRecentPages.ts b/packages/shared/src/hooks/useRecentPages.ts index fc73945e8a0..cf4d6780abf 100644 --- a/packages/shared/src/hooks/useRecentPages.ts +++ b/packages/shared/src/hooks/useRecentPages.ts @@ -1,6 +1,6 @@ import { useEffect, useSyncExternalStore } from 'react'; import { useRouter } from 'next/router'; -import type { RecentPage } from '../lib/recentPages'; +import type { RecentPage, RecentPageType } from '../lib/recentPages'; import { getRecentPages, recordRecentPage, @@ -17,6 +17,25 @@ const brandSuffix = /\s*[|·\-–—]\s*daily\.dev\s*$/i; const cleanTitle = (title: string): string => title.replace(brandSuffix, '').trim(); +// Classify by the route template (router.pathname), not the resolved URL — only +// the template distinguishes a user profile (`/[userId]`) from other top-level +// pages like `/jobs`. +const typeForPathname = (pathname: string): RecentPageType => { + if (pathname === '/[userId]') { + return 'user'; + } + if (pathname.startsWith('/sources/')) { + return 'source'; + } + if (pathname.startsWith('/squads/')) { + return 'squad'; + } + if (pathname.startsWith('/tags/')) { + return 'tag'; + } + return 'page'; +}; + // Records the last visited non-post pages for the v2 Home "Recent" section. // Individual post permalinks (`/posts/[id]`) are skipped — those belong to // reading History. Reads document.title after a short delay so the page's @@ -41,7 +60,11 @@ export const useRecordRecentPages = (enabled: boolean): void => { timeoutId = window.setTimeout(() => { const title = cleanTitle(document.title); if (title) { - recordRecentPage({ path, title }); + recordRecentPage({ + path, + title, + type: typeForPathname(router.pathname), + }); } }, 250); }; diff --git a/packages/shared/src/hooks/useToastNotification.ts b/packages/shared/src/hooks/useToastNotification.ts index 67cc3a23c66..bae6fae4222 100644 --- a/packages/shared/src/hooks/useToastNotification.ts +++ b/packages/shared/src/hooks/useToastNotification.ts @@ -37,6 +37,9 @@ export interface ToastNotification { variant?: ToastType; subject?: ToastSubject; persistent?: boolean; + // Force the auto-dismiss countdown on for this toast even when the global + // `autoDismissNotifications` setting is off. Ignored when `persistent`. + forceAutoDismiss?: boolean; onClose?: AnyFunction; action?: { onClick: AnyFunction; @@ -50,7 +53,13 @@ export const TOAST_NOTIF_KEY = ['toast_notif']; export type NotifyOptionalProps = Partial< Pick< ToastNotification, - 'timer' | 'variant' | 'subject' | 'persistent' | 'onClose' | 'action' + | 'timer' + | 'variant' + | 'subject' + | 'persistent' + | 'forceAutoDismiss' + | 'onClose' + | 'action' > >; diff --git a/packages/shared/src/lib/recentPages.ts b/packages/shared/src/lib/recentPages.ts index 70138997740..00b039498ce 100644 --- a/packages/shared/src/lib/recentPages.ts +++ b/packages/shared/src/lib/recentPages.ts @@ -1,6 +1,12 @@ +// What kind of entity the recent page points at, so the sidebar can pick a +// recognizable icon instead of a generic one. Captured at record time from the +// route template (a bare `/` path can't be classified after the fact). +export type RecentPageType = 'user' | 'source' | 'squad' | 'tag' | 'page'; + export type RecentPage = { path: string; title: string; + type?: RecentPageType; }; const STORAGE_KEY = 'dailydev:recentPages'; diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index fe0085d4e80..624b80a03f6 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -110,6 +110,85 @@ animation: slide-down 0.3s ease-out; } +/* + * Rail dropdown feel (More / Support / Settings / customize tray / hover + * panels). Enter: fade + a small slide out of the rail + de-blur, on the + * GPU-friendly properties only (transform, opacity, filter) so it never + * triggers layout/paint. `will-change` pre-promotes the layer so the first + * frame doesn't stutter (most noticeable in Safari). Exit is deliberately more + * subtle than the enter (opacity + blur, no slide) — exits should be quieter. + */ +@keyframes rail-popup-in { + from { + opacity: 0; + transform: translateX(-4px); + filter: blur(4px); + } + + to { + opacity: 1; + transform: translateX(0); + filter: blur(0); + } +} + +@keyframes rail-popup-out { + from { + opacity: 1; + filter: blur(0); + } + + to { + opacity: 0; + filter: blur(3px); + } +} + +.animate-rail-popup-in { + transform-origin: left center; + will-change: transform, opacity, filter; + animation: rail-popup-in 160ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* + * Radix HoverCard panels expose data-state, so they can animate in AND out. + * Radix/Popper positions these with an inline transform, so we must NOT animate + * transform here (it would fight the positioning) — fade + de-blur only, which + * also keeps fast tab-to-tab previews crisp rather than sliding around. + */ +@keyframes rail-popup-panel-in { + from { + opacity: 0; + filter: blur(4px); + } + + to { + opacity: 1; + filter: blur(0); + } +} + +.rail-popup-panel { + will-change: opacity, filter; +} + +.rail-popup-panel[data-state='open'] { + animation: rail-popup-panel-in 150ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.rail-popup-panel[data-state='closed'] { + animation: rail-popup-out 120ms ease-in both; +} + +@media (prefers-reduced-motion: reduce) { + .animate-rail-popup-in, + .rail-popup-panel[data-state='open'], + .rail-popup-panel[data-state='closed'] { + animation: none; + will-change: auto; + } +} + @keyframes live-room-reaction-fly { 0% { opacity: 0; diff --git a/packages/storybook/stories/components/StreakBadge.stories.tsx b/packages/storybook/stories/components/StreakBadge.stories.tsx new file mode 100644 index 00000000000..71ed85d97c4 --- /dev/null +++ b/packages/storybook/stories/components/StreakBadge.stories.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { StreakBadge } from '@dailydotdev/shared/src/components/sidebar/StreakBadge'; +import { railTabLabelClass } from '@dailydotdev/shared/src/components/sidebar/common'; +import type { StreakRingState } from '@dailydotdev/shared/src/hooks/streaks/useStreakRingState'; + +// The v2 rail's reading-streak indicator (replaces the old avatar StreakRing). +// A small square badge: state-driven border + fill, with the flame filled once +// you've read today. Presentational — driven entirely by `state` + `hasReadToday`. + +const STATES: StreakRingState[] = [ + 'none', + 'pending', + 'safe', + 'celebration', + 'at_risk', + 'critical', + 'freeze', +]; + +const STATE_LABEL: Record = { + none: 'New (no streak)', + pending: 'Pending — not read yet', + safe: 'Read today', + celebration: 'Just earned (pop)', + at_risk: 'At risk (≤6h left)', + critical: 'Critical (≤2h left)', + freeze: 'Rest day (frozen)', +}; + +// Which states represent "already read today" (flame filled) in real usage. +const READ_STATES = new Set(['safe', 'celebration', 'freeze']); + +const meta: Meta = { + title: 'Components/Sidebar/StreakBadge', + component: StreakBadge, + args: { + state: 'safe', + hasReadToday: true, + selected: false, + }, + argTypes: { + state: { control: 'select', options: STATES }, + hasReadToday: { control: 'boolean' }, + // When this is the selected rail tab, the calm-state border turns pink. + selected: { control: 'boolean' }, + className: { table: { disable: true } }, + }, + // Render on the dark rail background so the badge reads the way it does in the + // app's left rail. + decorators: [ + (Story) => ( +
    + +
    + ), + ], + parameters: { layout: 'fullscreen', controls: { expanded: true } }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +// Toggle state / hasReadToday from the controls panel. +export const Playground: Story = {}; + +// Every state side by side, each shown the way it sits in a rail tab (badge + +// day-count label below). This is the full state matrix for design review. +export const AllStates: Story = { + argTypes: { + state: { table: { disable: true } }, + hasReadToday: { table: { disable: true } }, + }, + render: () => ( +
    + {STATES.map((state) => ( +
    + {/* Mirror the rail tab: badge glyph + count label underneath. */} + + + + {state === 'none' ? 'Streak' : '73'} + + + + {STATE_LABEL[state]} + +
    + ))} +
    + ), +}; + +// The same matrix with `selected` on — the calm-state borders turn pink (the +// reading-streak brand colour). Note: the hover-white border can't show here +// because it depends on the tab's `group/streaktab`, which only exists in the +// real rail. +export const Selected: Story = { + argTypes: { + state: { table: { disable: true } }, + hasReadToday: { table: { disable: true } }, + selected: { table: { disable: true } }, + }, + render: () => ( +
    + {STATES.map((state) => ( +
    + + + + {state === 'none' ? 'Streak' : '73'} + + + + {STATE_LABEL[state]} + +
    + ))} +
    + ), +}; + +// The same states zoomed in so the border/fill/flame details are easy to +// inspect and tweak. +export const Zoomed: Story = { + argTypes: { + state: { table: { disable: true } }, + hasReadToday: { table: { disable: true } }, + }, + render: () => ( +
    + {STATES.map((state) => ( +
    + + + + + {STATE_LABEL[state]} + +
    + ))} +
    + ), +}; diff --git a/packages/storybook/stories/components/StreakMonthCalendar.stories.tsx b/packages/storybook/stories/components/StreakMonthCalendar.stories.tsx new file mode 100644 index 00000000000..8f8fd0449cc --- /dev/null +++ b/packages/storybook/stories/components/StreakMonthCalendar.stories.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { subDays } from 'date-fns'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { StreakMonthCalendar } from '@dailydotdev/shared/src/components/streak/popup/StreakMonthCalendar'; +import { generateQueryKey, RequestKey } from '@dailydotdev/shared/src/lib/query'; +import { DayOfWeek } from '@dailydotdev/shared/src/lib/date'; +import type { + ReadingDay, + UserStreak, +} from '@dailydotdev/shared/src/graphql/users'; + +// Minimal auth + streak so the calendar can resolve a timezone and week start. +const mockUser = { id: 'storybook-user', timezone: 'America/New_York' }; +const streak: UserStreak = { + current: 12, + max: 30, + total: 120, + weekStart: DayOfWeek.Monday, + lastViewAt: new Date(), +}; + +// A realistic spread of recently-read days. Weekends left unread show the +// frozen dashed pattern; future days render as upcoming. `todayRead` toggles +// whether today carries a read flame (so we can see the today ring both over a +// filled cell and on an empty one). +const READ_OFFSETS = [0, 1, 2, 3, 5, 6, 8, 9, 10, 13, 14]; +const buildHistory = (todayRead: boolean): ReadingDay[] => { + const today = new Date(); + return READ_OFFSETS.filter((offset) => todayRead || offset !== 0).map( + (offset) => ({ date: subDays(today, offset).toISOString(), reads: 1 }), + ); +}; + +// Seeds the 30-day reading-history query once (useState initializer) so the +// calendar reads it straight from the cache without a network call. +const CalendarDemo = ({ todayRead }: { todayRead: boolean }): ReactElement => { + const [client] = useState(() => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + generateQueryKey(RequestKey.ReadingStreak30Days, mockUser), + buildHistory(todayRead), + ); + return queryClient; + }); + + return ( + + +
    + +
    +
    +
    + ); +}; + +const meta: Meta = { + title: 'Components/Streak/MonthCalendar', + component: StreakMonthCalendar, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +// Today read: the white "today" ring sits ON TOP of the read-day pink flame so +// you can still tell which day is today. +export const Default: Story = { + render: () => , +}; + +// Today not yet read: the ring sits on an empty cell. +export const TodayNotRead: Story = { + render: () => , +}; diff --git a/packages/webapp/pages/posts/best-of/index.tsx b/packages/webapp/pages/posts/best-of/index.tsx index 72ce1479308..d0523065b6a 100644 --- a/packages/webapp/pages/posts/best-of/index.tsx +++ b/packages/webapp/pages/posts/best-of/index.tsx @@ -17,7 +17,9 @@ import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { PageWrapperLayout } from '@dailydotdev/shared/src/components/layout/PageWrapperLayout'; import { ArchiveIndexPage } from '@dailydotdev/shared/src/components/archive/ArchiveIndexPage'; import { ArchiveBreadcrumbs } from '@dailydotdev/shared/src/components/archive/ArchiveBreadcrumbs'; -import { ExploreHubHeader } from '@dailydotdev/shared/src/components/header/ExploreHubHeader'; +import classNames from 'classnames'; +import { FeedExploreTabs } from '@dailydotdev/shared/src/components/header/FeedExploreTabs'; +import { pageHeaderClassName } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import { buildBreadcrumbListJsonLd } from '@dailydotdev/shared/src/lib/archive'; import { getLayout as getFooterNavBarLayout } from '../../../components/layouts/FooterNavBarLayout'; @@ -63,7 +65,13 @@ const GlobalArchiveIndexPage = ({ archives }: PageProps): ReactElement => { return ( <> - {isV2 && } + {/* Render the Explore tab bar (with "Best of" active) rather than a + standalone title, so the explore tabs persist when you land here. */} + {isV2 && ( +
    + +
    + )}