From 71562c9e916514edfafffcef5b1d10c8ff8e837d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 22:03:04 +0300 Subject: [PATCH 001/124] feat(layout-v2): rework sidebar rail and add-action affordances Restructure the v2 dual-sidebar per the latest design review: - Move the daily.dev logo to the top of the rail. - Turn the avatar into a tab that opens a Profile panel (your feeds, history, happening now, analytics, jobs, devcard, pins, custom feeds) instead of a dropdown menu. - Add a flat Home button that goes to the For You feed. - Replace the Home category with Explore (panel lists the /posts sub-tabs + recent pages). - Add a bottom settings gear holding the leftover account/app actions (theme, settings, appearance, feed settings, billing, log out). Add Slack-style add affordances to the squad/folder/feed panels: a dedicated "add" row at the bottom of each list, with the existing top "+" surfacing only once the list grows past a few entries. Co-Authored-By: Claude Opus 4.8 --- .../components/sidebar/SidebarDesktopV2.tsx | 562 +++++++++--------- .../shared/src/components/sidebar/common.tsx | 20 + .../sidebar/sections/BookmarkSection.tsx | 5 +- .../sidebar/sections/CustomFeedSection.tsx | 14 +- .../sidebar/sections/ExploreSection.tsx | 83 +++ .../sidebar/sections/NetworkSection.tsx | 8 +- .../sidebar/sections/ProfilePanelSection.tsx | 125 ++++ .../sidebar/sidebarCategory.spec.ts | 20 + .../src/components/sidebar/sidebarCategory.ts | 20 +- 9 files changed, 580 insertions(+), 277 deletions(-) create mode 100644 packages/shared/src/components/sidebar/sections/ExploreSection.tsx create mode 100644 packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 066b1959b63..2746886ae79 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -14,6 +14,7 @@ import { Nav, railTabClass, railTabLabelClass, + SIDEBAR_ADD_TOP_THRESHOLD, SidebarAside, SidebarScrollWrapper, } from './common'; @@ -24,10 +25,8 @@ 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'; @@ -39,13 +38,11 @@ import { NetworkSection } from './sections/NetworkSection'; import { GameCenterSection } from './sections/GameCenterSection'; import { HelpWidget } from '../help/HelpWidget'; import { - AnalyticsIcon, AppIcon, BellIcon, BookmarkIcon, BrowserGroupIcon, CreditCardIcon, - DevCardIcon, DocsIcon, EditIcon, ExitIcon, @@ -54,8 +51,7 @@ import { GiftIcon, HelpIcon, HomeIcon, - InviteIcon, - JobIcon, + HotIcon, LinkIcon, MegaphoneIcon, MenuIcon, @@ -74,7 +70,7 @@ import { TrendingIcon, } from '../icons'; import { useSettingsBooleanFlag } from '../../hooks/useSettingsBooleanFlag'; -import { Origin, TargetId } from '../../lib/log'; +import { Origin } from '../../lib/log'; import { IconSize } from '../Icon'; import { Tooltip } from '../tooltip/Tooltip'; import { RailHoverPanel } from './RailHoverPanel'; @@ -85,8 +81,8 @@ 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,7 +94,7 @@ 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, @@ -106,18 +102,17 @@ import InteractivePopup, { import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; 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 { useBookmarkFolderList } from '../../hooks/bookmark'; +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'; type SidebarCategoryConfig = { @@ -130,8 +125,18 @@ 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, + label: 'Profile', icon: (active) => ( ), @@ -376,22 +381,112 @@ 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 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 billingItems: ProfileSectionItemProps[] = [ + { + title: 'Subscriptions', + href: `${settingsUrl}/subscription`, + icon: CreditCardIcon, + }, + ...(canPurchaseCores + ? [ + { + title: 'Ads dashboard', + icon: TrendingIcon, + onClick: () => openModal({ type: LazyModal.AdsDashboard }), + } satisfies ProfileSectionItemProps, + ] + : []), + { + title: 'Advertise', + href: businessWebsiteUrl, + icon: MegaphoneIcon, + external: true, + }, + ]; + + const logoutItems: ProfileSectionItemProps[] = [ + { + title: 'Log out', + icon: ExitIcon, + onClick: () => logout(LogoutReason.ManualLogout), + }, + ]; + + return ( + <> + + + + {isOpen && ( + 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" + > + + + + + + + + + )} + + ); +}; + +// 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. +// The streak chip is still its own button, opening the streak calendar. +const SidebarProfileButton = ({ + isSelected, + isExpanded, + panel, + onSelect, + onPreview, + onPreviewLeave, +}: { + isSelected: boolean; + isExpanded: boolean; + panel: ReactElement; + onSelect: () => void; + onPreview: () => void; + onPreviewLeave: (event: React.MouseEvent) => void; +}): ReactElement | null => { + const { user } = useAuthContext(); const { isEnabled: isStreakEnabled, isLoading: isStreakLoading, @@ -431,148 +526,73 @@ const SidebarProfileButton = ({ 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 billingItems: ProfileSectionItemProps[] = [ - { - title: 'Subscriptions', - href: `${settingsUrl}/subscription`, - icon: CreditCardIcon, - }, - ...(canPurchaseCores - ? [ - { - title: 'Ads dashboard', - icon: TrendingIcon, - onClick: () => openModal({ type: LazyModal.AdsDashboard }), - } satisfies ProfileSectionItemProps, - ] - : []), - { - title: 'Advertise', - href: businessWebsiteUrl, - icon: MegaphoneIcon, - external: true, - }, - ]; - - 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', - icon: ExitIcon, - onClick: () => logout(LogoutReason.ManualLogout), - }, - ]; + const avatarButton = ( + + + + ); 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={ - - - - } - /> - ) : ( - - )} -
+ ) : ( + avatarButton + )} + + {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" - > - - - - - - - - - - - - )} ); }; @@ -670,9 +637,17 @@ export const SidebarDesktopV2 = ({ const { isAvailable: isBannerAvailable } = useBanner(); const { open: openSpotlight } = useSpotlight(); const { openModal } = useLazyModal(); - const { isLoggedIn } = useAuthContext(); + const { isLoggedIn, user, squads } = useAuthContext(); const { openNewSquad } = useSquadNavigation(); const addBookmarkFolder = useAddBookmarkFolder(); + const { folders } = useBookmarkFolderList(); + 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 @@ -879,13 +854,23 @@ 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(() => { + setPendingCategory(SidebarCategory.Main); + onNavTabClick?.(isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/'); + }, [isCustomDefaultFeed, 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. @@ -1105,22 +1090,21 @@ export const SidebarDesktopV2 = ({ /> ); } - return ( - <> - - - - - + ); + } + return ( + ); }; @@ -1280,19 +1264,26 @@ export const SidebarDesktopV2 = ({ 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. + // Squads/Saved expose their add action as a bottom row inside the panel + // (Slack-style). The title-strip "+" is a shortcut that only appears once the + // list is long enough that the bottom row would require scrolling. const panelAddAction = (() => { if (isCreateHovered) { return null; } - if (activeCategory === SidebarCategory.Squads) { + if ( + activeCategory === SidebarCategory.Squads && + (squads?.length ?? 0) > SIDEBAR_ADD_TOP_THRESHOLD + ) { return { label: 'New Squad', onClick: () => openNewSquad({ origin: Origin.Sidebar }), }; } - if (activeCategory === SidebarCategory.Saved) { + if ( + activeCategory === SidebarCategory.Saved && + (folders?.length ?? 0) > SIDEBAR_ADD_TOP_THRESHOLD + ) { return { label: 'New folder', onClick: addBookmarkFolder }; } return null; @@ -1345,7 +1336,55 @@ export const SidebarDesktopV2 = ({ railNavWidth, )} > - {isLoggedIn && } + +
+ + + + + +
+
+ + {isLoggedIn && ( + commitPreview(SidebarCategory.Profile)} + onPreviewLeave={(event) => + handlePreviewLeave(SidebarCategory.Profile, event) + } + /> + )} + + + + + + + + -
-
- - -
- -
- - - - - -
-
+
+ + + {isLoggedIn && }
)} diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index 39663303d14..c2bc39dbb5f 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -8,6 +8,7 @@ import React, { forwardRef } from 'react'; import classNames from 'classnames'; import classed from '../../lib/classed'; import type { TooltipProps } from '../tooltips/BaseTooltip'; +import { PlusIcon } from '../icons'; export interface SidebarMenuItem { icon: ((active: boolean) => ReactElement) | ReactNode; @@ -81,6 +82,25 @@ export const ListIcon = ({ Icon }: ListIconProps): ReactElement => ( ); +// Once a v2 panel list holds more than this many entries, the inline "+" add +// row is far enough down that we also surface a "+" at the top of the panel so +// the add action stays reachable without scrolling. +export const SIDEBAR_ADD_TOP_THRESHOLD = 5; + +// A Slack-style "add" row that sits at the bottom of 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 }), +}); + // 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. diff --git a/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx b/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx index 28733c3875e..cd4171f7cd7 100644 --- a/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import type { SidebarMenuItem } from '../common'; -import { ListIcon } from '../common'; +import { createSidebarAddItem, ListIcon } from '../common'; import { ArrowIcon, BookmarkIcon, BriefIcon } from '../../icons'; import { Section } from '../Section'; import { briefingUrl, webappUrl } from '../../../lib/constants'; @@ -22,6 +22,8 @@ export const BookmarkSection = ({ const briefUIFeatureValue = useFeature(briefUIFeature); const { folders } = useBookmarkFolderList(); const handleAddFolder = useAddBookmarkFolder(); + // v2 rail panels (`compact`) also expose the add action as a bottom row. + const { compact } = defaultRenderSectionProps; const isLaptop = useViewSize(ViewSize.Laptop); const rightIcon = !isLaptop @@ -71,6 +73,7 @@ export const BookmarkSection = ({ requiresLogin: true, rightIcon, })), + compact && createSidebarAddItem('New folder', { onClick: handleAddFolder }), ]; const menuItems: SidebarMenuItem[] = allMenuItems.filter( Boolean, diff --git a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx index 6e00d16463b..30bb962463c 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, SIDEBAR_ADD_TOP_THRESHOLD } from '../common'; import { HashtagIcon, StarIcon } from '../../icons'; import { Section } from '../Section'; import { webappUrl } from '../../../lib/constants'; @@ -73,13 +74,22 @@ export const CustomFeedSection = ({ onNavTabClick, ]); + // v2 rail panels (`compact`) get a Slack-style "New feed" row at the bottom; + // the header "+" only stays once the list grows past a few entries. + const { compact } = defaultRenderSectionProps; + const addHref = `${webappUrl}feeds/new`; + const showTopAdd = !compact || menuItems.length > SIDEBAR_ADD_TOP_THRESHOLD; + const items = compact + ? [...menuItems, createSidebarAddItem('New feed', { href: addHref })] + : menuItems; + return (
); }; diff --git a/packages/shared/src/components/sidebar/sections/ExploreSection.tsx b/packages/shared/src/components/sidebar/sections/ExploreSection.tsx new file mode 100644 index 00000000000..14dd3483a88 --- /dev/null +++ b/packages/shared/src/components/sidebar/sections/ExploreSection.tsx @@ -0,0 +1,83 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import type { SidebarMenuItem } from '../common'; +import { ListIcon } from '../common'; +import { Section } from '../Section'; +import { RecentSection } from './RecentSection'; +import { + CalendarIcon, + DiscussIcon, + HotIcon, + MedalBadgeIcon, + UpvoteIcon, +} from '../../icons'; +import type { SidebarSectionProps } from './common'; +import type { OtherFeedPage } from '../../../lib/query'; +import { + ExploreTabs, + tabToUrl, + tabsToFeedMap, +} from '../../header/FeedExploreHeader'; + +// Reuse the single source of truth for Explore tab → URL (FeedExploreHeader) +// so the rail panel never drifts from the feed header. +const exploreTabIcons: Record ReactElement> = + { + [ExploreTabs.Popular]: (active) => ( + } /> + ), + [ExploreTabs.MostUpvoted]: (active) => ( + } /> + ), + [ExploreTabs.BestDiscussions]: (active) => ( + } /> + ), + [ExploreTabs.ByDate]: (active) => ( + } /> + ), + [ExploreTabs.BestOf]: (active) => ( + } /> + ), + }; + +// The feed page each Explore tab maps to, for the extension's feed-switch +// callback. Best of has no dedicated feed enum, so it relies on the link path. +const exploreTabFeedPage: Partial> = + Object.entries(tabsToFeedMap).reduce( + (result, [feed, tab]) => ({ ...result, [tab]: feed as OtherFeedPage }), + {}, + ); + +// Explore tab panel: the /posts sub-tabs (Popular, By upvotes, By comments, +// By date, Best of) followed by the user's recently visited pages. +export const ExploreSection = ({ + isItemsButton, + onNavTabClick, + ...defaultRenderSectionProps +}: SidebarSectionProps): ReactElement => { + const menuItems: SidebarMenuItem[] = useMemo( + () => + (Object.values(ExploreTabs) as ExploreTabs[]).map((tab) => { + const feedPage = exploreTabFeedPage[tab]; + return { + title: tab, + path: tabToUrl[tab], + icon: exploreTabIcons[tab], + action: feedPage ? () => onNavTabClick?.(feedPage) : undefined, + }; + }), + [onNavTabClick], + ); + + return ( + <> +
+ + + ); +}; diff --git a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx index fe2ef6139a5..4f4f16a29cf 100644 --- a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React, { useCallback, useMemo } from 'react'; import type { SidebarMenuItem } from '../common'; -import { ListIcon } from '../common'; +import { createSidebarAddItem, ListIcon } from '../common'; import { SourceIcon, TimerIcon } from '../../icons'; import { Section } from '../Section'; import { Origin } from '../../../lib/log'; @@ -22,6 +22,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({ @@ -64,8 +67,9 @@ export const NetworkSection = ({ }), }, ...squadItems, + compact && createSidebarAddItem('New Squad', { onClick: handleAddSquad }), ].filter(Boolean) as SidebarMenuItem[]; - }, [squads, isModeratorInAnySquad, count, asPin]); + }, [squads, isModeratorInAnySquad, count, asPin, compact, handleAddSquad]); return (
{ + const { user } = useAuthContext(); + + 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: 'Happening Now', + path: `${webappUrl}highlights`, + 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: 'DevCard', + path: `${settingsUrl}/customization/devcard`, + isForcedLink: true, + icon: (active: boolean) => ( + } /> + ), + }, + ], + [onNavTabClick], + ); + + if (!user) { + return null; + } + + return ( + <> +
+ + + +
+
+ + + + ); +}; diff --git a/packages/shared/src/components/sidebar/sidebarCategory.spec.ts b/packages/shared/src/components/sidebar/sidebarCategory.spec.ts index 5faa61bed48..ff74e1b53d8 100644 --- a/packages/shared/src/components/sidebar/sidebarCategory.spec.ts +++ b/packages/shared/src/components/sidebar/sidebarCategory.spec.ts @@ -27,6 +27,26 @@ 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('/highlights')).toBe( + SidebarCategory.Profile, + ); + expect(getSidebarCategoryForPath('/analytics')).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); + }); }); describe('isSidebarSettingsPath', () => { diff --git a/packages/shared/src/components/sidebar/sidebarCategory.ts b/packages/shared/src/components/sidebar/sidebarCategory.ts index fb59daecf66..ee9f49f5604 100644 --- a/packages/shared/src/components/sidebar/sidebarCategory.ts +++ b/packages/shared/src/components/sidebar/sidebarCategory.ts @@ -2,7 +2,13 @@ // 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, account + // shortcuts) instead of a dropdown menu. + Profile: 'profile', Squads: 'squads', Notifications: 'notifications', Saved: 'saved', @@ -42,8 +48,20 @@ export const getSidebarCategoryForPath = ( if (activePage.includes('/settings')) { return SidebarCategory.Settings; } + // Profile-panel destinations: your feeds (Following, Happening Now) and + // activity (History, Analytics, Jobs). Visiting any of them keeps the + // Profile panel selected so the panel matches the page. + if ( + activePage.includes('/analytics') || + activePage.includes('/jobs') || + activePage.includes('/history') || + activePage.includes('/following') || + activePage.includes('/highlights') + ) { + 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; }; From f6ca603a73c928bd777b562b7a78333cb020a20a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 22:22:06 +0300 Subject: [PATCH 002/124] feat(layout-v2): move Happening Now from Profile to Explore panel Happening Now is a discovery feed, so it belongs alongside the Explore sub-tabs rather than in the personal Profile panel. Also remap /highlights to the Explore (Main) category so the active panel matches. Co-Authored-By: Claude Opus 4.8 --- .../sidebar/sections/ExploreSection.tsx | 15 +++++++++++++-- .../sidebar/sections/ProfilePanelSection.tsx | 9 --------- .../components/sidebar/sidebarCategory.spec.ts | 5 ++--- .../src/components/sidebar/sidebarCategory.ts | 9 ++++----- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/components/sidebar/sections/ExploreSection.tsx b/packages/shared/src/components/sidebar/sections/ExploreSection.tsx index 14dd3483a88..5fde2c79e44 100644 --- a/packages/shared/src/components/sidebar/sections/ExploreSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ExploreSection.tsx @@ -9,10 +9,12 @@ import { DiscussIcon, HotIcon, MedalBadgeIcon, + MegaphoneIcon, UpvoteIcon, } from '../../icons'; import type { SidebarSectionProps } from './common'; import type { OtherFeedPage } from '../../../lib/query'; +import { webappUrl } from '../../../lib/constants'; import { ExploreTabs, tabToUrl, @@ -56,8 +58,8 @@ export const ExploreSection = ({ ...defaultRenderSectionProps }: SidebarSectionProps): ReactElement => { const menuItems: SidebarMenuItem[] = useMemo( - () => - (Object.values(ExploreTabs) as ExploreTabs[]).map((tab) => { + () => [ + ...(Object.values(ExploreTabs) as ExploreTabs[]).map((tab) => { const feedPage = exploreTabFeedPage[tab]; return { title: tab, @@ -66,6 +68,15 @@ export const ExploreSection = ({ action: feedPage ? () => onNavTabClick?.(feedPage) : undefined, }; }), + { + title: 'Happening Now', + path: `${webappUrl}highlights`, + isForcedLink: true, + icon: (active: boolean) => ( + } /> + ), + }, + ], [onNavTabClick], ); diff --git a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx index 883ae292803..ef2bc48258e 100644 --- a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx @@ -10,7 +10,6 @@ import { DevCardIcon, EyeIcon, JobIcon, - MegaphoneIcon, SquadIcon, } from '../../icons'; import type { SidebarSectionProps } from './common'; @@ -52,14 +51,6 @@ export const ProfilePanelSection = ({ } /> ), }, - { - title: 'Happening Now', - path: `${webappUrl}highlights`, - isForcedLink: true, - icon: (active: boolean) => ( - } /> - ), - }, { title: 'Analytics', path: `${webappUrl}analytics`, diff --git a/packages/shared/src/components/sidebar/sidebarCategory.spec.ts b/packages/shared/src/components/sidebar/sidebarCategory.spec.ts index ff74e1b53d8..076d5e9acec 100644 --- a/packages/shared/src/components/sidebar/sidebarCategory.spec.ts +++ b/packages/shared/src/components/sidebar/sidebarCategory.spec.ts @@ -35,9 +35,6 @@ describe('getSidebarCategoryForPath', () => { SidebarCategory.Profile, ); expect(getSidebarCategoryForPath('/history')).toBe(SidebarCategory.Profile); - expect(getSidebarCategoryForPath('/highlights')).toBe( - SidebarCategory.Profile, - ); expect(getSidebarCategoryForPath('/analytics')).toBe( SidebarCategory.Profile, ); @@ -46,6 +43,8 @@ describe('getSidebarCategoryForPath', () => { 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); }); }); diff --git a/packages/shared/src/components/sidebar/sidebarCategory.ts b/packages/shared/src/components/sidebar/sidebarCategory.ts index ee9f49f5604..f4e69082e45 100644 --- a/packages/shared/src/components/sidebar/sidebarCategory.ts +++ b/packages/shared/src/components/sidebar/sidebarCategory.ts @@ -48,15 +48,14 @@ export const getSidebarCategoryForPath = ( if (activePage.includes('/settings')) { return SidebarCategory.Settings; } - // Profile-panel destinations: your feeds (Following, Happening Now) and - // activity (History, Analytics, Jobs). Visiting any of them keeps the - // Profile panel selected so the panel matches the page. + // Profile-panel destinations: your feed (Following) and activity (History, + // Analytics, Jobs). Visiting any of them keeps the Profile panel selected so + // the panel matches the page. (Happening Now lives under Explore.) if ( activePage.includes('/analytics') || activePage.includes('/jobs') || activePage.includes('/history') || - activePage.includes('/following') || - activePage.includes('/highlights') + activePage.includes('/following') ) { return SidebarCategory.Profile; } From b119dc7c956ac0908d59a56d55d535f5ab619c39 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 18 Jun 2026 23:43:12 +0300 Subject: [PATCH 003/124] feat(layout-v2): show discovery hub sections in the Explore panel Reuse DiscoverSection (Explore, Tags, Sources, Leaderboard, Discussions) in the v2 Explore panel, replacing the per-sort tabs (those remain in the in-page Explore header). Add a showHotTakes opt-out so the panel keeps the five hub sections without the Hot Takes modal launcher. Happening Now and Recent still follow. Co-Authored-By: Claude Opus 4.8 --- .../sidebar/sections/DiscoverSection.tsx | 17 ++++- .../sidebar/sections/ExploreSection.tsx | 74 ++++--------------- 2 files changed, 30 insertions(+), 61 deletions(-) diff --git a/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx b/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx index 4dda8cdadfb..b3319c96428 100644 --- a/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx +++ b/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx @@ -24,11 +24,15 @@ 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; } export const DiscoverSection = ({ isItemsButton, onNavTabClick, + showHotTakes = true, ...defaultRenderSectionProps }: DiscoverSectionProps): ReactElement => { const { completeAction } = useActions(); @@ -86,7 +90,7 @@ export const DiscoverSection = ({ } }, }, - { + showHotTakes && { icon: (active: boolean) => ( } /> ), @@ -98,8 +102,15 @@ 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, + ]); return (
ReactElement> = - { - [ExploreTabs.Popular]: (active) => ( - } /> - ), - [ExploreTabs.MostUpvoted]: (active) => ( - } /> - ), - [ExploreTabs.BestDiscussions]: (active) => ( - } /> - ), - [ExploreTabs.ByDate]: (active) => ( - } /> - ), - [ExploreTabs.BestOf]: (active) => ( - } /> - ), - }; - -// The feed page each Explore tab maps to, for the extension's feed-switch -// callback. Best of has no dedicated feed enum, so it relies on the link path. -const exploreTabFeedPage: Partial> = - Object.entries(tabsToFeedMap).reduce( - (result, [feed, tab]) => ({ ...result, [tab]: feed as OtherFeedPage }), - {}, - ); - -// Explore tab panel: the /posts sub-tabs (Popular, By upvotes, By comments, -// By date, Best of) followed by the user's recently visited pages. +// Explore tab panel: the discovery hub sections (Explore, Tags, Sources, +// Leaderboard, Discussions — reused from DiscoverSection), then Happening Now +// and the user's recently visited pages. The per-sort tabs (Popular, By +// upvotes, …) live in the in-page Explore header, not here. export const ExploreSection = ({ isItemsButton, onNavTabClick, ...defaultRenderSectionProps }: SidebarSectionProps): ReactElement => { - const menuItems: SidebarMenuItem[] = useMemo( + const extraItems: SidebarMenuItem[] = useMemo( () => [ - ...(Object.values(ExploreTabs) as ExploreTabs[]).map((tab) => { - const feedPage = exploreTabFeedPage[tab]; - return { - title: tab, - path: tabToUrl[tab], - icon: exploreTabIcons[tab], - action: feedPage ? () => onNavTabClick?.(feedPage) : undefined, - }; - }), { title: 'Happening Now', path: `${webappUrl}highlights`, @@ -77,15 +29,21 @@ export const ExploreSection = ({ ), }, ], - [onNavTabClick], + [], ); return ( <> -
+
From 3cede731bffe56ca81f677f26d91d6af29f6c7af Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 22:43:32 +0300 Subject: [PATCH 004/124] feat(layout-v2): move bookmarks into the Profile panel Bookmarks now live as a "Bookmarks" section in the Profile panel alongside pins and feeds, and the standalone Saved rail tab is removed (remap /bookmarks + /briefing to the Profile category). The folder add action follows the same pattern as feeds: a bottom "New folder" row, with the header "+" only once there are more than a few folders. Co-Authored-By: Claude Opus 4.8 --- .../components/sidebar/SidebarDesktopV2.tsx | 35 +++---------------- .../sidebar/sections/BookmarkSection.tsx | 13 +++++-- .../sidebar/sections/ProfilePanelSection.tsx | 6 ++++ .../sidebar/sidebarCategory.spec.ts | 7 ++++ .../src/components/sidebar/sidebarCategory.ts | 19 +++++----- 5 files changed, 37 insertions(+), 43 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 2746886ae79..8c8fe6e44fe 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -33,14 +33,12 @@ 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 { HelpWidget } from '../help/HelpWidget'; import { AppIcon, BellIcon, - BookmarkIcon, BrowserGroupIcon, CreditCardIcon, DocsIcon, @@ -108,8 +106,6 @@ 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 { useBookmarkFolderList } from '../../hooks/bookmark'; import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; import { useStreakRingState } from '../../hooks/streaks/useStreakRingState'; import { FeedbackWidget } from '../feedback'; @@ -159,14 +155,6 @@ const sidebarCategories: SidebarCategoryConfig[] = [ defaultPath: `${webappUrl}daily-quests`, icon: (active) => , }, - { - id: SidebarCategory.Saved, - label: 'Saved', - defaultPath: `${webappUrl}bookmarks`, - icon: (active) => ( - - ), - }, ]; const railButtonClass = @@ -639,8 +627,6 @@ export const SidebarDesktopV2 = ({ const { openModal } = useLazyModal(); const { isLoggedIn, user, squads } = useAuthContext(); const { openNewSquad } = useSquadNavigation(); - const addBookmarkFolder = useAddBookmarkFolder(); - const { folders } = useBookmarkFolderList(); 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. @@ -663,10 +649,9 @@ export const SidebarDesktopV2 = ({ // --- 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. + // items fold into a 3-dots "More" dropdown. Fold order: Quests, then + // Notifications. Explore, 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); useEffect(() => { @@ -696,7 +681,6 @@ export const SidebarDesktopV2 = ({ [ isLoggedIn ? SidebarCategory.Notifications : null, SidebarCategory.GameCenter, - SidebarCategory.Saved, ].filter(Boolean) as SidebarCategoryId[], [isLoggedIn], ); @@ -1066,11 +1050,6 @@ export const SidebarDesktopV2 = ({ /> ); } - if (category === SidebarCategory.Saved) { - return ( - - ); - } if (category === SidebarCategory.Settings) { return ( { @@ -1280,12 +1259,6 @@ export const SidebarDesktopV2 = ({ onClick: () => openNewSquad({ origin: Origin.Sidebar }), }; } - if ( - activeCategory === SidebarCategory.Saved && - (folders?.length ?? 0) > SIDEBAR_ADD_TOP_THRESHOLD - ) { - return { label: 'New folder', onClick: addBookmarkFolder }; - } return null; })(); diff --git a/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx b/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx index cd4171f7cd7..b257197d6e4 100644 --- a/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx @@ -1,7 +1,11 @@ import type { ReactElement } from 'react'; import React from 'react'; import type { SidebarMenuItem } from '../common'; -import { createSidebarAddItem, ListIcon } from '../common'; +import { + createSidebarAddItem, + ListIcon, + SIDEBAR_ADD_TOP_THRESHOLD, +} from '../common'; import { ArrowIcon, BookmarkIcon, BriefIcon } from '../../icons'; import { Section } from '../Section'; import { briefingUrl, webappUrl } from '../../../lib/constants'; @@ -79,6 +83,11 @@ export const BookmarkSection = ({ Boolean, ) as SidebarMenuItem[]; + // In v2 (`compact`) the header "+" only stays once there are more than a few + // folders; otherwise the bottom "New folder" row is the single add affordance. + const showTopAdd = + !compact || (folders?.length ?? 0) > SIDEBAR_ADD_TOP_THRESHOLD; + return (
); }; diff --git a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx index ef2bc48258e..763a4c4ee96 100644 --- a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx @@ -4,6 +4,7 @@ import type { SidebarMenuItem } from '../common'; import { ListIcon } from '../common'; import { Section } from '../Section'; import { PinnedSection } from './PinnedSection'; +import { BookmarkSection } from './BookmarkSection'; import { CustomFeedSection } from './CustomFeedSection'; import { AnalyticsIcon, @@ -105,6 +106,11 @@ export const ProfilePanelSection = ({ className="!mt-0" /> + { 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', () => { diff --git a/packages/shared/src/components/sidebar/sidebarCategory.ts b/packages/shared/src/components/sidebar/sidebarCategory.ts index f4e69082e45..eefd7140618 100644 --- a/packages/shared/src/components/sidebar/sidebarCategory.ts +++ b/packages/shared/src/components/sidebar/sidebarCategory.ts @@ -6,12 +6,11 @@ export const SidebarCategory = { // (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, account - // shortcuts) instead of a dropdown menu. + // 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; @@ -39,23 +38,23 @@ export const getSidebarCategoryForPath = ( ) { return SidebarCategory.GameCenter; } - if (activePage.includes('/bookmarks') || activePage.includes('/briefing')) { - return SidebarCategory.Saved; - } if (activePage.includes('/squads')) { return SidebarCategory.Squads; } if (activePage.includes('/settings')) { return SidebarCategory.Settings; } - // Profile-panel destinations: your feed (Following) and activity (History, - // Analytics, Jobs). Visiting any of them keeps the Profile panel selected so - // the panel matches the page. (Happening Now lives under Explore.) + // Profile-panel destinations: your feed (Following), activity (History, + // Analytics, Jobs) and saved content (Bookmarks, briefings). Visiting any of + // them keeps the Profile panel selected so the panel matches the page. + // (Happening Now lives under Explore.) if ( activePage.includes('/analytics') || activePage.includes('/jobs') || activePage.includes('/history') || - activePage.includes('/following') + activePage.includes('/following') || + activePage.includes('/bookmarks') || + activePage.includes('/briefing') ) { return SidebarCategory.Profile; } From 41cd170cd289782155dcd7fa364009d6b71dff3f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 22:52:49 +0300 Subject: [PATCH 005/124] =?UTF-8?q?feat(layout-v2):=20rail=20tweaks=20?= =?UTF-8?q?=E2=80=94=20add=20rows,=20header=20cleanup,=20recent=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename the rail notifications label "Alerts" → "Activity". - Replace the per-panel "+" add icon with a leading "New …" row in the Squads, Bookmarks and Feeds panels (v2 only); v1 keeps its header "+". - Remove the Explore hub section tabs (Explore/Tags/Sources/Leaderboard/ Discussions) from the page headers — they live in the sidebar Explore panel now. Drop the now-dead ExploreSectionTabs component. - Recent rows no longer render an active highlight (new disableActiveState flag on SidebarMenuItem). - Move the Home and Search buttons above the profile avatar in the rail. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/MainFeedLayout.tsx | 22 +++--- .../components/header/ExploreHubHeader.tsx | 19 +++-- .../components/header/ExploreSectionTabs.tsx | 70 ------------------- .../notifications/NotificationsBell.tsx | 2 +- .../components/sidebar/SidebarDesktopV2.tsx | 68 ++++-------------- .../src/components/sidebar/SidebarItem.tsx | 4 +- .../shared/src/components/sidebar/common.tsx | 14 ++-- .../sidebar/sections/BookmarkSection.tsx | 17 ++--- .../sidebar/sections/CustomFeedSection.tsx | 11 ++- .../sidebar/sections/NetworkSection.tsx | 2 +- .../sidebar/sections/RecentSection.tsx | 3 + 11 files changed, 57 insertions(+), 175 deletions(-) delete mode 100644 packages/shared/src/components/header/ExploreSectionTabs.tsx diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index e87af9e1af4..77f9eb0facf 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -85,7 +85,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 @@ -752,9 +747,10 @@ export default function MainFeedLayout({ return ( <> {showExploreV2PageHeader && ( -
- - {isAnyExplore && } +
+
)} {showFeedV2PageHeader && ( diff --git a/packages/shared/src/components/header/ExploreHubHeader.tsx b/packages/shared/src/components/header/ExploreHubHeader.tsx index d8c81cfbd12..7959664d139 100644 --- a/packages/shared/src/components/header/ExploreHubHeader.tsx +++ b/packages/shared/src/components/header/ExploreHubHeader.tsx @@ -1,20 +1,19 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; 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). +// Leaderboard, Best of). The section tabs now live in the sidebar's Explore +// panel, so this only renders when a page supplies header actions (e.g. the +// "Suggest source" button) — otherwise there's no header strip at all. export function ExploreHubHeader({ children, }: { children?: ReactNode; -}): ReactElement { - return ( - } className="!py-0"> - {children} - - ); +}): ReactElement | null { + if (!children) { + return null; + } + + return {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/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 0b17ebff20a..1bdbdbac02c 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -84,7 +84,7 @@ function NotificationsBell({ )} {!railHideLabel && ( - Alerts + Activity )} diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 8c8fe6e44fe..4479eb02c0b 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -14,7 +14,6 @@ import { Nav, railTabClass, railTabLabelClass, - SIDEBAR_ADD_TOP_THRESHOLD, SidebarAside, SidebarScrollWrapper, } from './common'; @@ -57,7 +56,6 @@ import { MoveToIcon, NewPostIcon, PhoneIcon, - PlusIcon, PollIcon, PrivacyIcon, SearchIcon, @@ -68,7 +66,6 @@ import { TrendingIcon, } from '../icons'; import { useSettingsBooleanFlag } from '../../hooks/useSettingsBooleanFlag'; -import { Origin } from '../../lib/log'; import { IconSize } from '../Icon'; import { Tooltip } from '../tooltip/Tooltip'; import { RailHoverPanel } from './RailHoverPanel'; @@ -105,7 +102,6 @@ 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 useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; import { useStreakRingState } from '../../hooks/streaks/useStreakRingState'; import { FeedbackWidget } from '../feedback'; @@ -625,8 +621,7 @@ export const SidebarDesktopV2 = ({ const { isAvailable: isBannerAvailable } = useBanner(); const { open: openSpotlight } = useSpotlight(); const { openModal } = useLazyModal(); - const { isLoggedIn, user, squads } = useAuthContext(); - const { openNewSquad } = useSquadNavigation(); + 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. @@ -1243,25 +1238,6 @@ export const SidebarDesktopV2 = ({ return activeLabel ?? ''; })(); - // Squads exposes its add action as a bottom row inside the panel - // (Slack-style). The title-strip "+" is a shortcut that only appears once the - // list is long enough that the bottom row would require scrolling. - const panelAddAction = (() => { - if (isCreateHovered) { - return null; - } - if ( - activeCategory === SidebarCategory.Squads && - (squads?.length ?? 0) > SIDEBAR_ADD_TOP_THRESHOLD - ) { - return { - label: 'New Squad', - onClick: () => openNewSquad({ origin: Origin.Sidebar }), - }; - } - return null; - })(); - return ( - {isLoggedIn && ( - commitPreview(SidebarCategory.Profile)} - onPreviewLeave={(event) => - handlePreviewLeave(SidebarCategory.Profile, event) - } - /> - )} - )} + {isLoggedIn && ( + commitPreview(SidebarCategory.Profile)} + onPreviewLeave={(event) => + handlePreviewLeave(SidebarCategory.Profile, event) + } + /> + )} +
) : ( -
+
{utilityPanelTitle} - {panelAddAction && ( - - - - )}
)}
diff --git a/packages/shared/src/components/sidebar/SidebarItem.tsx b/packages/shared/src/components/sidebar/SidebarItem.tsx index a34fc05b80d..13fd9034cd7 100644 --- a/packages/shared/src/components/sidebar/SidebarItem.tsx +++ b/packages/shared/src/components/sidebar/SidebarItem.tsx @@ -26,7 +26,9 @@ export const SidebarItem = ({ const { user, showLogin } = useContext(AuthContext); const { isV2 } = useLayoutVariant(); const isActive = - item.active || (!!item.path && isSidebarItemActive(activePage, item.path)); + !item.disableActiveState && + (item.active || + (!!item.path && isSidebarItemActive(activePage, item.path))); const isCollapsed = !shouldShowLabel; const navItem = ( diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index c2bc39dbb5f..4d28ae65af9 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -31,6 +31,9 @@ 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; } interface ListIconProps { @@ -82,14 +85,9 @@ export const ListIcon = ({ Icon }: ListIconProps): ReactElement => ( ); -// Once a v2 panel list holds more than this many entries, the inline "+" add -// row is far enough down that we also surface a "+" at the top of the panel so -// the add action stays reachable without scrolling. -export const SIDEBAR_ADD_TOP_THRESHOLD = 5; - -// A Slack-style "add" row that sits at the bottom of 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). +// 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 }, diff --git a/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx b/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx index b257197d6e4..7bf7a31ac41 100644 --- a/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx @@ -1,11 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import type { SidebarMenuItem } from '../common'; -import { - createSidebarAddItem, - ListIcon, - SIDEBAR_ADD_TOP_THRESHOLD, -} from '../common'; +import { createSidebarAddItem, ListIcon } from '../common'; import { ArrowIcon, BookmarkIcon, BriefIcon } from '../../icons'; import { Section } from '../Section'; import { briefingUrl, webappUrl } from '../../../lib/constants'; @@ -35,6 +31,7 @@ export const BookmarkSection = ({ : undefined; const allMenuItems = [ + compact && createSidebarAddItem('New folder', { onClick: handleAddFolder }), { icon: (active: boolean) => ( } /> @@ -77,17 +74,11 @@ export const BookmarkSection = ({ requiresLogin: true, rightIcon, })), - compact && createSidebarAddItem('New folder', { onClick: handleAddFolder }), ]; const menuItems: SidebarMenuItem[] = allMenuItems.filter( Boolean, ) as SidebarMenuItem[]; - // In v2 (`compact`) the header "+" only stays once there are more than a few - // folders; otherwise the bottom "New folder" row is the single add affordance. - const showTopAdd = - !compact || (folders?.length ?? 0) > SIDEBAR_ADD_TOP_THRESHOLD; - return (
); }; diff --git a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx index 30bb962463c..6b91eb83e11 100644 --- a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx +++ b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; import type { SidebarMenuItem } from '../common'; -import { createSidebarAddItem, SIDEBAR_ADD_TOP_THRESHOLD } from '../common'; +import { createSidebarAddItem } from '../common'; import { HashtagIcon, StarIcon } from '../../icons'; import { Section } from '../Section'; import { webappUrl } from '../../../lib/constants'; @@ -74,13 +74,12 @@ export const CustomFeedSection = ({ onNavTabClick, ]); - // v2 rail panels (`compact`) get a Slack-style "New feed" row at the bottom; - // the header "+" only stays once the list grows past a few entries. + // 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 showTopAdd = !compact || menuItems.length > SIDEBAR_ADD_TOP_THRESHOLD; const items = compact - ? [...menuItems, createSidebarAddItem('New feed', { href: addHref })] + ? [createSidebarAddItem('New feed', { href: addHref }), ...menuItems] : menuItems; return ( @@ -89,7 +88,7 @@ export const CustomFeedSection = ({ items={items} isItemsButton={isItemsButton} flag={SidebarSettingsFlags.CustomFeedsExpanded} - addHref={showTopAdd ? addHref : undefined} + addHref={compact ? undefined : addHref} /> ); }; diff --git a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx index 4f4f16a29cf..e214e6a154a 100644 --- a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx @@ -39,6 +39,7 @@ export const NetworkSection = ({ const squadItems = squads?.map((squad) => createSquadMenuItem(squad, asPin)) ?? []; return [ + compact && createSidebarAddItem('New Squad', { onClick: handleAddSquad }), { icon: (active: boolean) => ( } /> @@ -67,7 +68,6 @@ export const NetworkSection = ({ }), }, ...squadItems, - compact && createSidebarAddItem('New Squad', { onClick: handleAddSquad }), ].filter(Boolean) as SidebarMenuItem[]; }, [squads, isModeratorInAnySquad, count, asPin, compact, handleAddSquad]); diff --git a/packages/shared/src/components/sidebar/sections/RecentSection.tsx b/packages/shared/src/components/sidebar/sections/RecentSection.tsx index c46bc5c1c80..a9b243fb39b 100644 --- a/packages/shared/src/components/sidebar/sections/RecentSection.tsx +++ b/packages/shared/src/components/sidebar/sections/RecentSection.tsx @@ -32,6 +32,9 @@ export const RecentSection = ({ icon: () => iconForPath(page.path)} />, 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], ); From a0edae12affbb93602bfbf3ad735ab6f06a8dfab Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 22:55:00 +0300 Subject: [PATCH 006/124] feat(layout-v2): fill Home icon when active, move Feed settings to Profile - The Home rail button now uses the filled (secondary) icon when the For You feed is the current page, and the outline otherwise. - Move "Feed settings" out of the settings gear into the Profile panel, just above DevCard. Co-Authored-By: Claude Opus 4.8 --- .../src/components/sidebar/SidebarDesktopV2.tsx | 16 +++++++++------- .../sidebar/sections/ProfilePanelSection.tsx | 9 +++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 4479eb02c0b..46bd01bff67 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -10,6 +10,7 @@ import React, { import { useRouter } from 'next/router'; import * as HoverCardPrimitive from '@radix-ui/react-hover-card'; import { + isSidebarItemActive, ListIcon, Nav, railTabClass, @@ -36,7 +37,6 @@ import { NetworkSection } from './sections/NetworkSection'; import { GameCenterSection } from './sections/GameCenterSection'; import { HelpWidget } from '../help/HelpWidget'; import { - AppIcon, BellIcon, BrowserGroupIcon, CreditCardIcon, @@ -378,11 +378,6 @@ const SidebarSettingsButton = (): ReactElement => { 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 billingItems: ProfileSectionItemProps[] = [ @@ -692,6 +687,9 @@ export const SidebarDesktopV2 = ({ const foldedNavIds = overflowOrder.slice(visibleOverflowCount); const activePage = activePageProp || router.asPath || router.pathname || ''; const isFeedPage = activePage.includes('/feeds/'); + // 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 resolvedCategory = useMemo((): SidebarCategoryId => { if (isFeedPage) { @@ -1317,7 +1315,11 @@ export const SidebarDesktopV2 = ({ className="focus-outline flex size-10 items-center justify-center rounded-12 text-text-primary" onClick={onHomeClick} > - + diff --git a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx index 763a4c4ee96..4624bc37b22 100644 --- a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx @@ -8,6 +8,7 @@ import { BookmarkSection } from './BookmarkSection'; import { CustomFeedSection } from './CustomFeedSection'; import { AnalyticsIcon, + AppIcon, DevCardIcon, EyeIcon, JobIcon, @@ -68,6 +69,14 @@ export const ProfilePanelSection = ({ } /> ), }, + { + title: 'Feed settings', + path: `${settingsUrl}/feed/general`, + isForcedLink: true, + icon: (active: boolean) => ( + } /> + ), + }, { title: 'DevCard', path: `${settingsUrl}/customization/devcard`, From 8e2ab5736e5e18a5639d76c3370420830165a97e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 23:01:53 +0300 Subject: [PATCH 007/124] =?UTF-8?q?feat(layout-v2):=20fold=20=E2=8C=98K=20?= =?UTF-8?q?hint=20into=20Search=20tooltip,=20drop=20avatar=20below=20separ?= =?UTF-8?q?ator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show the search shortcut keys inside the Search tooltip and remove the standalone ⌘K row from the rail to reclaim vertical space. - Move the profile avatar below the separator, so it sits at the top of the Explore tab group rather than with the logo/home/search cluster. Co-Authored-By: Claude Opus 4.8 --- .../components/sidebar/SidebarDesktopV2.tsx | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 46bd01bff67..2d16292b547 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -1326,7 +1326,23 @@ export const SidebarDesktopV2 = ({ + Search + + {shortcutKeys.map((key) => ( + + {key} + + ))} + + + } collisionPadding={RAIL_TOOLTIP_COLLISION_PADDING} > - {!isCompact && ( -
- {shortcutKeys.map((key) => ( - - {key} - - ))} -
- )} + +
{isLoggedIn && ( )} -
-
Date: Sat, 20 Jun 2026 23:10:02 +0300 Subject: [PATCH 008/124] feat(layout-v2): open collapsed peek only from panel-bearing icons The collapsed sidebar no longer expands just because the cursor enters the rail. The hover-peek now opens only when hovering an icon that has a context panel (Explore, Squads, Activity, Quests, Profile, New post) via commitPreview. Empty space and panel-less icons (logo, Home, Search, Invite, Support, Settings) don't open it, and hovering the bottom utility cluster closes any open peek. Co-Authored-By: Claude Opus 4.8 --- .../components/sidebar/SidebarDesktopV2.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 2d16292b547..4acbd5dec8c 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -903,13 +903,6 @@ 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; @@ -929,7 +922,14 @@ export const SidebarDesktopV2 = ({ // --- Prediction cone via pointer-events blocking ----------------------- // `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) => { + if (!peekSuppressedRef.current) { + setIsRailHovered(true); + } if (key === 'create') { setIsCreateHovered(true); return; @@ -1240,7 +1240,6 @@ export const SidebarDesktopV2 = ({ + {/* Utility actions (not tabs) — Invite/Support/Settings open their + own popups, so this is a plain group rather than a tablist. + Hovering it closes any open collapsed-peek so these panel-less + icons never leave a stale panel showing. */}
From 68b227344d02bdb9722eb4b896dd4d5ce9306091 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 23:23:01 +0300 Subject: [PATCH 009/124] feat(layout-v2): compass Explore icon + restore explore hub page headers - Add a CompassIcon and use it for Explore everywhere it was a flame (rail tab, Discover panel row, explore breadcrumb). - Restore a consistent breadcrumb header (Home / icon + title) on the Explore hub pages (Explore, Tags, Sources, Leaderboard, Best of) via ExploreHubHeader/ExploreHubBreadcrumb, replacing the removed section tabs so the pages no longer render header-less. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/MainFeedLayout.tsx | 10 +- .../components/header/ExploreHubHeader.tsx | 92 +++++++++++++++++-- .../components/header/FeedExploreHeader.tsx | 4 +- .../src/components/icons/Compass/filled.svg | 7 ++ .../src/components/icons/Compass/index.tsx | 12 +++ .../src/components/icons/Compass/outlined.svg | 8 ++ packages/shared/src/components/icons/index.ts | 1 + .../components/sidebar/SidebarDesktopV2.tsx | 4 +- .../sidebar/sections/DiscoverSection.tsx | 3 +- 9 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 packages/shared/src/components/icons/Compass/filled.svg create mode 100644 packages/shared/src/components/icons/Compass/index.tsx create mode 100644 packages/shared/src/components/icons/Compass/outlined.svg diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 77f9eb0facf..ba7b10719e8 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -86,6 +86,7 @@ import { checkIsExtension } from '../lib/func'; import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; import { ExploreSortDropdown } from './header/ExploreSortDropdown'; +import { ExploreHubBreadcrumb } from './header/ExploreHubHeader'; const FeedExploreHeader = dynamic( () => @@ -747,10 +748,11 @@ export default function MainFeedLayout({ return ( <> {showExploreV2PageHeader && ( -
- +
+ + + +
)} {showFeedV2PageHeader && ( diff --git a/packages/shared/src/components/header/ExploreHubHeader.tsx b/packages/shared/src/components/header/ExploreHubHeader.tsx index 7959664d139..7ada289f86a 100644 --- a/packages/shared/src/components/header/ExploreHubHeader.tsx +++ b/packages/shared/src/components/header/ExploreHubHeader.tsx @@ -1,19 +1,93 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; +import { useRouter } from 'next/router'; import { PageHeader } from '../layout/PageHeader'; +import { BreadCrumbs } from './BreadCrumbs'; +import { IconSize } from '../Icon'; +import type { IconProps } from '../Icon'; +import { + CompassIcon, + DiscussIcon, + EarthIcon, + HashtagIcon, + MedalBadgeIcon, + SquadIcon, +} from '../icons'; + +type HubEntry = { + label: string; + Icon: (props: IconProps) => ReactElement; + // Most specific paths first (e.g. /posts/best-of before /posts). + match: (path: string) => boolean; +}; + +// The Explore hub sections now live in the sidebar's Explore panel; the page +// header keeps a consistent breadcrumb (Home / icon + title) so every hub page +// reads the same way as the rest of the app instead of going header-less. +const hubEntries: HubEntry[] = [ + { + label: 'Best of', + Icon: MedalBadgeIcon, + match: (path) => path.startsWith('/posts/best-of'), + }, + { + label: 'Tags', + Icon: HashtagIcon, + match: (path) => path.startsWith('/tags'), + }, + { + label: 'Sources', + Icon: EarthIcon, + match: (path) => path.startsWith('/sources'), + }, + { + label: 'Leaderboard', + Icon: SquadIcon, + match: (path) => path.startsWith('/users'), + }, + { + label: 'Discussions', + Icon: DiscussIcon, + match: (path) => path.startsWith('/discussed'), + }, + { + label: 'Explore', + Icon: CompassIcon, + match: (path) => path.startsWith('/posts'), + }, +]; + +const defaultEntry: HubEntry = { + label: 'Explore', + Icon: CompassIcon, + match: () => true, +}; + +export function ExploreHubBreadcrumb(): ReactElement { + const router = useRouter(); + const path = (router.pathname || router.asPath || '').split('?')[0]; + const { label, Icon } = + hubEntries.find((entry) => entry.match(path)) ?? defaultEntry; + + return ( + + + {label} + + ); +} // Shared v2 header for the Explore hub's directory pages (Tags, Sources, -// Leaderboard, Best of). The section tabs now live in the sidebar's Explore -// panel, so this only renders when a page supplies header actions (e.g. the -// "Suggest source" button) — otherwise there's no header strip at all. +// Leaderboard, Best of). Optional children render as header actions (e.g. the +// "Suggest source" button). export function ExploreHubHeader({ children, }: { children?: ReactNode; -}): ReactElement | null { - if (!children) { - return null; - } - - return {children}; +}): ReactElement { + return ( + } className="!py-0"> + {children} + + ); } 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 )}
+ + Icon/Compass/Filled + + + + 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..71c7799437c --- /dev/null +++ b/packages/shared/src/components/icons/Compass/outlined.svg @@ -0,0 +1,8 @@ + + + Icon/Compass/Outline + + + + + 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/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 4acbd5dec8c..75f5b2f665c 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -39,6 +39,7 @@ import { HelpWidget } from '../help/HelpWidget'; import { BellIcon, BrowserGroupIcon, + CompassIcon, CreditCardIcon, DocsIcon, EditIcon, @@ -48,7 +49,6 @@ import { GiftIcon, HelpIcon, HomeIcon, - HotIcon, LinkIcon, MegaphoneIcon, MenuIcon, @@ -120,7 +120,7 @@ const sidebarCategories: SidebarCategoryConfig[] = [ label: 'Explore', defaultPath: `${webappUrl}posts`, icon: (active) => ( - + ), }, { diff --git a/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx b/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx index b3319c96428..43fc6506011 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, @@ -44,7 +45,7 @@ export const DiscoverSection = ({ return [ { icon: (active: boolean) => ( - } /> + } /> ), title: 'Explore', // Bare path (not webappUrl) so it active-matches the in-place Explore From ebdcc908defdab98693c43e6e80ca1e76b502663 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 23:32:13 +0300 Subject: [PATCH 010/124] feat(layout-v2): hover open-link on panel links, align profile header, default to Profile - Reveal an "open link" icon on hover/focus for link rows in the v2 panels (and the profile header), matching the ProfileMenu rows. - Align the profile header/stats block's right edge with the list rows' content (pr-6) so it's no longer wider than the items below. - Default the panel to Profile on the home / For You feed for logged-in users (Home click + initial load) instead of Explore; Explore still works when its tab is selected. Anonymous users keep Explore. Co-Authored-By: Claude Opus 4.8 --- .../components/ProfileMenu/ProfileMenuHeader.tsx | 15 +++++++++++++-- .../shared/src/components/sidebar/Section.tsx | 1 + .../src/components/sidebar/SidebarDesktopV2.tsx | 15 ++++++++++++--- .../shared/src/components/sidebar/SidebarItem.tsx | 8 +++++++- packages/shared/src/components/sidebar/common.tsx | 14 +++++++++++++- .../sidebar/sections/ProfilePanelSection.tsx | 5 ++++- 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx index 747eb71c3aa..50f48568329 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx @@ -23,6 +23,8 @@ type Props = WithClassNameProps & { profileImageSize?: ProfileImageSize; // v2 sidebar dropdown tightens the name/handle gap; defaults to the v1 value. compact?: boolean; + // Reveal the open-link icon on hover/focus only (v2 profile panel). + linkIconHoverOnly?: boolean; }; export const ProfileMenuHeader = ({ @@ -30,6 +32,7 @@ export const ProfileMenuHeader = ({ shouldOpenProfile = false, profileImageSize = ProfileImageSize.Large, compact = false, + linkIconHoverOnly = false, }: Props): ReactElement | null => { const { user } = useAuthContext(); const { isPlus } = usePlusSubscription(); @@ -48,7 +51,11 @@ export const ProfileMenuHeader = ({ )} >
)} diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx index f5fe7fcff4e..90222116144 100644 --- a/packages/shared/src/components/sidebar/Section.tsx +++ b/packages/shared/src/components/sidebar/Section.tsx @@ -175,6 +175,7 @@ export function Section({ activePage={activePage} isItemsButton={isItemsButton} shouldShowLabel={shouldShowLabel} + compact={compact} /> ))}
diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 75f5b2f665c..edf024e2cf7 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -692,11 +692,17 @@ export const SidebarDesktopV2 = ({ const isHomeActive = isSidebarItemActive(activePage, myFeedPath); const resolvedCategory = 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; + } if (isFeedPage) { return SidebarCategory.Main; } return getSidebarCategoryForPath(activePage); - }, [activePage, isFeedPage]); + }, [activePage, isFeedPage, isHomeActive, isLoggedIn]); // Optimistic override so a rail click feels instant even when // router.push is async. Cleared once the URL catches up. @@ -845,9 +851,12 @@ export const SidebarDesktopV2 = ({ // 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(() => { - setPendingCategory(SidebarCategory.Main); + // Home opens the Profile panel by default (logged in); Explore for anon. + setPendingCategory( + isLoggedIn ? SidebarCategory.Profile : SidebarCategory.Main, + ); onNavTabClick?.(isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/'); - }, [isCustomDefaultFeed, onNavTabClick]); + }, [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. diff --git a/packages/shared/src/components/sidebar/SidebarItem.tsx b/packages/shared/src/components/sidebar/SidebarItem.tsx index 13fd9034cd7..2ab51e8bf0a 100644 --- a/packages/shared/src/components/sidebar/SidebarItem.tsx +++ b/packages/shared/src/components/sidebar/SidebarItem.tsx @@ -12,7 +12,7 @@ import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; type SidebarItemProps = Pick< SidebarSectionProps, - 'activePage' | 'isItemsButton' | 'shouldShowLabel' + 'activePage' | 'isItemsButton' | 'shouldShowLabel' | 'compact' > & { item: SidebarMenuItem; }; @@ -22,6 +22,7 @@ export const SidebarItem = ({ activePage, isItemsButton, shouldShowLabel, + compact, }: SidebarItemProps): ReactElement => { const { user, showLogin } = useContext(AuthContext); const { isV2 } = useLayoutVariant(); @@ -30,6 +31,9 @@ export const SidebarItem = ({ (item.active || (!!item.path && isSidebarItemActive(activePage, item.path))); const isCollapsed = !shouldShowLabel; + // v2 panels reveal an "open link" icon on hover for link rows (no custom + // right icon of their own). Requires `group` on the row. + const showLinkIconOnHover = !!compact && !!item.path && !item.rightIcon; const navItem = ( diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index 4d28ae65af9..4f9880182f6 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -8,7 +8,8 @@ import React, { forwardRef } from 'react'; import classNames from 'classnames'; import classed from '../../lib/classed'; import type { TooltipProps } from '../tooltips/BaseTooltip'; -import { PlusIcon } from '../icons'; +import { OpenLinkIcon, PlusIcon } from '../icons'; +import { IconSize } from '../Icon'; export interface SidebarMenuItem { icon: ((active: boolean) => ReactElement) | ReactNode; @@ -44,6 +45,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; @@ -146,6 +150,7 @@ export const ItemInner = ({ item, shouldShowLabel, active, + showLinkIconOnHover, }: ItemInnerProps): ReactElement => { const isLabelHidden = !shouldShowLabel; @@ -170,6 +175,13 @@ export const ItemInner = ({ iconClassName="relative flex items-center justify-center" /> )} + {shouldShowLabel && showLinkIconOnHover && !item.rightIcon && ( + + )} ); }; diff --git a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx index 4624bc37b22..1b1341035d9 100644 --- a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx @@ -95,9 +95,12 @@ export const ProfilePanelSection = ({ return ( <> -
+ {/* pr-6 (vs pl-3) lines the header/stats right edge up with the list + rows' content, which is inset by the row's own right padding. */} +
From 45239410f12a8873e5bb6503ad6900669f1371d6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 23:38:25 +0300 Subject: [PATCH 011/124] feat(layout-v2): keep selected indicator on hover; rail polish; trim profile panel - Decouple the selected (white) tab indicator from hover-preview: the indicator tracks the committed category and no longer jumps to whichever tab you hover, so you always know where you are. The hovered tab shows a distinct "previewing" background instead. - Make the Home rail button match the Search icon (size, hover background, tertiary color), filling/going primary when the For You feed is active. - Nudge the daily.dev logo down to line up with the panel title. - Remove the custom feeds section from the Profile panel (it already lives in the For You feed's top header). Prediction-cone review: the safe-zone logic is sound; the felt "issue" was the indicator tracking the preview state, now fixed. Co-Authored-By: Claude Opus 4.8 --- .../components/sidebar/SidebarDesktopV2.tsx | 32 +++++++++++++++---- .../sidebar/sections/ProfilePanelSection.tsx | 7 ---- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index edf024e2cf7..6a9534dbcfa 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -1111,7 +1111,12 @@ 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; return ( @@ -1227,8 +1233,13 @@ export const SidebarDesktopV2 = ({ const activeLabel = sidebarCategories.find( (category) => category.id === activeCategory, )?.label; - const isNotificationsSelected = + // 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; const isHomePanel = !isCreateHovered && activeCategory === SidebarCategory.Main; const isUtilityPanelSelected = !isHomePanel; @@ -1239,7 +1250,7 @@ export const SidebarDesktopV2 = ({ if (activeCategory === SidebarCategory.Settings) { return 'Settings'; } - if (isNotificationsSelected) { + if (isNotificationsActive) { return 'Notifications'; } return activeLabel ?? ''; @@ -1296,7 +1307,9 @@ export const SidebarDesktopV2 = ({ content="daily.dev" collisionPadding={RAIL_TOOLTIP_COLLISION_PADDING} > -
+ {/* mt nudges the logo down so it lines up vertically with the + panel title row (which sits at pt-6). */} +
- ); }; From 609d3bedc31291d0ec229b1eaa51b3b927cc4154 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sat, 20 Jun 2026 23:45:23 +0300 Subject: [PATCH 012/124] feat(layout-v2): opt-in open-link icon; group panel options with dividers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open-link icon (regression fix): - Make the hover "open link" icon opt-in per item instead of showing on every panel link. Only rows that leave the sidebar carry it — Feed settings and DevCard (→ /settings). Following/History/Analytics/Jobs and the profile header no longer show it. Dividers (settings-dropdown style grouping): - Add a `createSidebarSeparatorItem` helper; Section renders it as a HorizontalSeparator. - Notifications panel: divider between the activity list and Settings. - Squads panel: divider after Find Squads/Pending Posts, before the squads. - New post panel: divider before "Live" (a live room, not a post type). Co-Authored-By: Claude Opus 4.8 --- .../ProfileMenu/ProfileMenuHeader.tsx | 15 ++------- .../notifications/NotificationsRailPanel.tsx | 7 +++- .../shared/src/components/sidebar/Section.tsx | 27 ++++++++++------ .../components/sidebar/SidebarDesktopV2.tsx | 32 ++++++++++++------- .../src/components/sidebar/SidebarItem.tsx | 10 +++--- .../shared/src/components/sidebar/common.tsx | 14 ++++++++ .../sidebar/sections/NetworkSection.tsx | 12 ++++++- .../sidebar/sections/ProfilePanelSection.tsx | 20 ++++++++---- 8 files changed, 90 insertions(+), 47 deletions(-) diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx index 50f48568329..747eb71c3aa 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenuHeader.tsx @@ -23,8 +23,6 @@ type Props = WithClassNameProps & { profileImageSize?: ProfileImageSize; // v2 sidebar dropdown tightens the name/handle gap; defaults to the v1 value. compact?: boolean; - // Reveal the open-link icon on hover/focus only (v2 profile panel). - linkIconHoverOnly?: boolean; }; export const ProfileMenuHeader = ({ @@ -32,7 +30,6 @@ export const ProfileMenuHeader = ({ shouldOpenProfile = false, profileImageSize = ProfileImageSize.Large, compact = false, - linkIconHoverOnly = false, }: Props): ReactElement | null => { const { user } = useAuthContext(); const { isPlus } = usePlusSubscription(); @@ -51,11 +48,7 @@ export const ProfileMenuHeader = ({ )} >
)} diff --git a/packages/shared/src/components/notifications/NotificationsRailPanel.tsx b/packages/shared/src/components/notifications/NotificationsRailPanel.tsx index b2e2c556699..5e5de830dcb 100644 --- a/packages/shared/src/components/notifications/NotificationsRailPanel.tsx +++ b/packages/shared/src/components/notifications/NotificationsRailPanel.tsx @@ -6,7 +6,11 @@ import { Typography, TypographyType } from '../typography/Typography'; import { webappUrl } from '../../lib/constants'; import { BellIcon, SettingsIcon } from '../icons'; import type { SidebarMenuItem } from '../sidebar/common'; -import { ListIcon, isSidebarItemActive } from '../sidebar/common'; +import { + createSidebarSeparatorItem, + ListIcon, + isSidebarItemActive, +} from '../sidebar/common'; import { Section } from '../sidebar/Section'; // Compact menu in the rail hover card. Lists the destinations the @@ -45,6 +49,7 @@ export const NotificationsRailPanel = (): ReactElement => { ), }), }, + createSidebarSeparatorItem('notifications-settings'), { title: 'Settings', path: settingsPath, diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx index 90222116144..9836742caa7 100644 --- a/packages/shared/src/components/sidebar/Section.tsx +++ b/packages/shared/src/components/sidebar/Section.tsx @@ -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 { @@ -168,16 +169,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 6a9534dbcfa..80a6344259f 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -10,6 +10,7 @@ import React, { import { useRouter } from 'next/router'; import * as HoverCardPrimitive from '@radix-ui/react-hover-card'; import { + createSidebarSeparatorItem, isSidebarItemActive, ListIcon, Nav, @@ -1203,17 +1204,26 @@ export const SidebarDesktopV2 = ({ const createMenuItems = useMemo( () => - createMenuOptions.map(({ title, kind, icon }) => ({ - icon, - title, - // SidebarItem/ClickableNavItem dispatches `action` (not `onClick`) and - // requires a `path` for link items — a path-less `onClick` row throws. - action: () => - openModal({ - type: LazyModal.SmartComposer, - props: { initialKind: kind }, - }), - })), + createMenuOptions.flatMap(({ title, kind, icon }) => { + const item: SidebarMenuItem = { + icon, + title, + // SidebarItem/ClickableNavItem dispatches `action` (not `onClick`) + // and requires a `path` for link items — a path-less `onClick` row + // throws. + action: () => + openModal({ + type: LazyModal.SmartComposer, + props: { initialKind: kind }, + }), + }; + // "Live" opens a live room rather than composing a post, so a divider + // sets it apart from the post types above (settings-dropdown style). + if (kind === 'standup') { + return [createSidebarSeparatorItem('create-live-divider'), item]; + } + return [item]; + }), [openModal], ); diff --git a/packages/shared/src/components/sidebar/SidebarItem.tsx b/packages/shared/src/components/sidebar/SidebarItem.tsx index 2ab51e8bf0a..e0fedd58485 100644 --- a/packages/shared/src/components/sidebar/SidebarItem.tsx +++ b/packages/shared/src/components/sidebar/SidebarItem.tsx @@ -12,7 +12,7 @@ import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; type SidebarItemProps = Pick< SidebarSectionProps, - 'activePage' | 'isItemsButton' | 'shouldShowLabel' | 'compact' + 'activePage' | 'isItemsButton' | 'shouldShowLabel' > & { item: SidebarMenuItem; }; @@ -22,7 +22,6 @@ export const SidebarItem = ({ activePage, isItemsButton, shouldShowLabel, - compact, }: SidebarItemProps): ReactElement => { const { user, showLogin } = useContext(AuthContext); const { isV2 } = useLayoutVariant(); @@ -31,9 +30,10 @@ export const SidebarItem = ({ (item.active || (!!item.path && isSidebarItemActive(activePage, item.path))); const isCollapsed = !shouldShowLabel; - // v2 panels reveal an "open link" icon on hover for link rows (no custom - // right icon of their own). Requires `group` on the row. - const showLinkIconOnHover = !!compact && !!item.path && !item.rightIcon; + // 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; const navItem = ( ({ + 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. diff --git a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx index e214e6a154a..353eb783056 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 { createSidebarAddItem, ListIcon } from '../common'; +import { + createSidebarAddItem, + createSidebarSeparatorItem, + ListIcon, +} from '../common'; import { SourceIcon, TimerIcon } from '../../icons'; import { Section } from '../Section'; import { Origin } from '../../../lib/log'; @@ -67,6 +71,12 @@ export const NetworkSection = ({ ), }), }, + // 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, compact, handleAddSquad]); diff --git a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx index 2f9cbd0cd99..c3ab2fc5c4c 100644 --- a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx @@ -17,6 +17,7 @@ import type { SidebarSectionProps } from './common'; import { OtherFeedPage } from '../../../lib/query'; import { settingsUrl, webappUrl } from '../../../lib/constants'; import { useAuthContext } from '../../../contexts/AuthContext'; +import Link from '../../utilities/Link'; import { ProfileMenuHeader } from '../../ProfileMenu/ProfileMenuHeader'; import { ProfileImageSize } from '../../ProfilePicture'; import { SidebarProfileStats } from '../SidebarProfileStats'; @@ -72,6 +73,8 @@ export const ProfilePanelSection = ({ 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) => ( } /> ), @@ -80,6 +83,7 @@ export const ProfilePanelSection = ({ title: 'DevCard', path: `${settingsUrl}/customization/devcard`, isForcedLink: true, + showOpenLinkIcon: true, icon: (active: boolean) => ( } /> ), @@ -97,12 +101,16 @@ export const ProfilePanelSection = ({ {/* pr-6 (vs pl-3) lines the header/stats right edge up with the list rows' content, which is inset by the row's own right padding. */}
- + {/* Clickable to the profile, but without the open-link icon — the + avatar isn't a "leaves the sidebar" destination in that sense. */} + + + + + Date: Sat, 20 Jun 2026 23:57:16 +0300 Subject: [PATCH 013/124] feat(layout-v2): standard title headers, refined compass, profile alignment - Replace the explore-hub breadcrumb with the standard plain title header (like Analytics/Settings): Explore, Sources, Leaderboard, Best of. Discussions already uses its feed heading. The Explore feed shows an "Explore" PageHeader title with the sort dropdown as a header action. - Tags: drop the header title and keep the TagPageNavbar tabs; rename its leading "Explore" tab to "All tags" (the old name clashed with the Explore feed). - Redraw the Compass icon to match the design (tilted needle + center dot, ring outline / solid disc). - Nudge the profile header/stats right (pl-5) so they line up with the panel's list-row icons instead of sticking out to the left. The selected-tab indicator already behaves Slack-style (committed tab keeps white text + filled icon; hovered tab gets a background + white outline icon). Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/MainFeedLayout.tsx | 12 +-- .../components/header/ExploreHubHeader.tsx | 93 ++++--------------- .../src/components/icons/Compass/filled.svg | 2 +- .../src/components/icons/Compass/outlined.svg | 2 +- .../sidebar/sections/ProfilePanelSection.tsx | 7 +- .../src/components/tags/TagPageNavbar.tsx | 4 +- packages/webapp/pages/tags/index.tsx | 6 +- 7 files changed, 31 insertions(+), 95 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index ba7b10719e8..8247cf06738 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -57,7 +57,7 @@ import { ViewSize, } from '../hooks'; import { feedNameToHeading } from './feeds/FeedContainer'; -import { pageHeaderClassName } from './layout/PageHeader'; +import { PageHeader, pageHeaderClassName } from './layout/PageHeader'; import { customFeedVersion, discussedFeedVersion, @@ -86,7 +86,6 @@ import { checkIsExtension } from '../lib/func'; import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; import { ExploreSortDropdown } from './header/ExploreSortDropdown'; -import { ExploreHubBreadcrumb } from './header/ExploreHubHeader'; const FeedExploreHeader = dynamic( () => @@ -748,12 +747,9 @@ export default function MainFeedLayout({ return ( <> {showExploreV2PageHeader && ( -
- - - - -
+ + + )} {showFeedV2PageHeader && (
diff --git a/packages/shared/src/components/header/ExploreHubHeader.tsx b/packages/shared/src/components/header/ExploreHubHeader.tsx index 7ada289f86a..5e03155422c 100644 --- a/packages/shared/src/components/header/ExploreHubHeader.tsx +++ b/packages/shared/src/components/header/ExploreHubHeader.tsx @@ -2,91 +2,32 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import { useRouter } from 'next/router'; import { PageHeader } from '../layout/PageHeader'; -import { BreadCrumbs } from './BreadCrumbs'; -import { IconSize } from '../Icon'; -import type { IconProps } from '../Icon'; -import { - CompassIcon, - DiscussIcon, - EarthIcon, - HashtagIcon, - MedalBadgeIcon, - SquadIcon, -} from '../icons'; -type HubEntry = { - label: string; - Icon: (props: IconProps) => ReactElement; - // Most specific paths first (e.g. /posts/best-of before /posts). - match: (path: string) => boolean; -}; - -// The Explore hub sections now live in the sidebar's Explore panel; the page -// header keeps a consistent breadcrumb (Home / icon + title) so every hub page -// reads the same way as the rest of the app instead of going header-less. -const hubEntries: HubEntry[] = [ - { - label: 'Best of', - Icon: MedalBadgeIcon, - match: (path) => path.startsWith('/posts/best-of'), - }, - { - label: 'Tags', - Icon: HashtagIcon, - match: (path) => path.startsWith('/tags'), - }, - { - label: 'Sources', - Icon: EarthIcon, - match: (path) => path.startsWith('/sources'), - }, - { - label: 'Leaderboard', - Icon: SquadIcon, - match: (path) => path.startsWith('/users'), - }, - { - label: 'Discussions', - Icon: DiscussIcon, - match: (path) => path.startsWith('/discussed'), - }, - { - label: 'Explore', - Icon: CompassIcon, - match: (path) => path.startsWith('/posts'), - }, +// 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('/posts/best-of'), label: 'Best of' }, + { match: (path) => path.startsWith('/sources'), label: 'Sources' }, + { match: (path) => path.startsWith('/users'), label: 'Leaderboard' }, + { match: (path) => path.startsWith('/discussed'), label: 'Discussions' }, + { match: (path) => path.startsWith('/posts'), label: 'Explore' }, ]; -const defaultEntry: HubEntry = { - label: 'Explore', - Icon: CompassIcon, - match: () => true, -}; - -export function ExploreHubBreadcrumb(): ReactElement { - const router = useRouter(); - const path = (router.pathname || router.asPath || '').split('?')[0]; - const { label, Icon } = - hubEntries.find((entry) => entry.match(path)) ?? defaultEntry; - - return ( - - - {label} - - ); -} - -// Shared v2 header for the Explore hub's directory pages (Tags, Sources, -// Leaderboard, Best of). Optional children render as header actions (e.g. the -// "Suggest source" button). +// Shared v2 header for the Explore hub's directory pages (Sources, Leaderboard, +// Best of). Optional children render as header actions (e.g. "Suggest source"). export function ExploreHubHeader({ children, }: { children?: ReactNode; }): ReactElement { + const router = useRouter(); + const path = (router.pathname || router.asPath || '').split('?')[0]; + const title = + hubTitles.find((entry) => entry.match(path))?.label ?? 'Explore'; + return ( - } className="!py-0"> + {children} ); diff --git a/packages/shared/src/components/icons/Compass/filled.svg b/packages/shared/src/components/icons/Compass/filled.svg index 8914cf6c9f0..1faecf5a832 100644 --- a/packages/shared/src/components/icons/Compass/filled.svg +++ b/packages/shared/src/components/icons/Compass/filled.svg @@ -2,6 +2,6 @@ Icon/Compass/Filled - + diff --git a/packages/shared/src/components/icons/Compass/outlined.svg b/packages/shared/src/components/icons/Compass/outlined.svg index 71c7799437c..27cb9b706d3 100644 --- a/packages/shared/src/components/icons/Compass/outlined.svg +++ b/packages/shared/src/components/icons/Compass/outlined.svg @@ -3,6 +3,6 @@ Icon/Compass/Outline - + diff --git a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx index c3ab2fc5c4c..444080e6904 100644 --- a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx @@ -98,9 +98,10 @@ export const ProfilePanelSection = ({ return ( <> - {/* pr-6 (vs pl-3) lines the header/stats right edge up with the list - rows' content, which is inset by the row's own right padding. */} -
+ {/* 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. */} +
{/* Clickable to the profile, but without the open-link icon — the avatar isn't a "leaves the sidebar" destination in that sense. */} 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) => ( { const { isFallback: isLoading } = useRouter(); - const { isV2: isV2Laptop } = useLayoutVariant(); if (isLoading) { return <>; @@ -73,7 +70,8 @@ const TagsPage = ({ return ( <> - {isV2Laptop && } + {/* No page-header title here — the TagPageNavbar tabs (rendered by + TagsDirectoryPage) are the header for the tags directory. */}