+
{children}
diff --git a/packages/webapp/components/tools/ToolSquadCard.tsx b/packages/webapp/components/tools/ToolSquadCard.tsx
new file mode 100644
index 00000000000..4b0cd3c0cb9
--- /dev/null
+++ b/packages/webapp/components/tools/ToolSquadCard.tsx
@@ -0,0 +1,68 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import Link from '@dailydotdev/shared/src/components/utilities/Link';
+import {
+ Image,
+ ImageType,
+} from '@dailydotdev/shared/src/components/image/Image';
+import {
+ Typography,
+ TypographyColor,
+ TypographyTag,
+ TypographyType,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import { Separator } from '@dailydotdev/shared/src/components/cards/common/common';
+import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat';
+import type { ToolTopSquad } from '@dailydotdev/shared/src/graphql/user/userStack';
+
+interface ToolSquadCardProps {
+ squad: ToolTopSquad;
+ onClick?: () => void;
+}
+
+export const ToolSquadCard = ({
+ squad,
+ onClick,
+}: ToolSquadCardProps): ReactElement => (
+
+
+
+
+ {squad.name}
+
+ {squad.description && (
+
+ {squad.description}
+
+ )}
+
+ @{squad.handle}
+ {largeNumberFormat(squad.membersCount)} members
+
+
+
+);
diff --git a/packages/webapp/components/tools/useAddToolToStack.tsx b/packages/webapp/components/tools/useAddToolToStack.tsx
new file mode 100644
index 00000000000..a4fa2a16ebb
--- /dev/null
+++ b/packages/webapp/components/tools/useAddToolToStack.tsx
@@ -0,0 +1,80 @@
+import type { ReactElement } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
+import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext';
+import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth';
+import type { PublicProfile } from '@dailydotdev/shared/src/lib/user';
+import { useUserStack } from '@dailydotdev/shared/src/features/profile/hooks/useUserStack';
+import { UserStackModal } from '@dailydotdev/shared/src/features/profile/components/stack/UserStackModal';
+import type { AddUserStackInput } from '@dailydotdev/shared/src/graphql/user/userStack';
+import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification';
+import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext';
+import type { Origin } from '@dailydotdev/shared/src/lib/log';
+import { LogEvent } from '@dailydotdev/shared/src/lib/log';
+
+export interface StackableTool {
+ id: string;
+ title: string;
+ slug: string;
+}
+
+interface UseAddToolToStack {
+ stackedToolIds: Set
;
+ openAddModal: (tool: StackableTool) => void;
+ modal: ReactElement | null;
+}
+
+export function useAddToolToStack(origin: Origin): UseAddToolToStack {
+ const { user, showLogin } = useAuthContext();
+ const { displayToast } = useToastNotification();
+ const { logEvent } = useLogContext();
+ const { stackItems, add } = useUserStack(
+ (user ?? null) as PublicProfile | null,
+ );
+ const [pendingTool, setPendingTool] = useState(null);
+
+ const stackedToolIds = useMemo(
+ () => new Set(stackItems.map((item) => item.tool.id)),
+ [stackItems],
+ );
+
+ const openAddModal = useCallback(
+ (tool: StackableTool) => {
+ if (!user) {
+ showLogin({ trigger: AuthTriggers.AddToStack });
+ return;
+ }
+ logEvent({
+ event_name: LogEvent.StartAddUserStack,
+ target_id: tool.slug,
+ extra: JSON.stringify({ origin }),
+ });
+ setPendingTool(tool);
+ },
+ [user, showLogin, logEvent, origin],
+ );
+
+ const handleAdd = useCallback(
+ async (input: AddUserStackInput) => {
+ try {
+ await add(input);
+ displayToast('Added to your stack');
+ } catch (error) {
+ displayToast('Failed to add item');
+ throw error;
+ }
+ },
+ [add, displayToast],
+ );
+
+ const modal = pendingTool ? (
+ setPendingTool(null)}
+ onSubmit={handleAdd}
+ defaultTitle={pendingTool.title}
+ modalTitle="Add stack/tool to profile"
+ />
+ ) : null;
+
+ return { stackedToolIds, openAddModal, modal };
+}
diff --git a/packages/webapp/pages/tools/[slug].tsx b/packages/webapp/pages/tools/[slug].tsx
index 8b3f1244146..e33850d725b 100644
--- a/packages/webapp/pages/tools/[slug].tsx
+++ b/packages/webapp/pages/tools/[slug].tsx
@@ -47,11 +47,9 @@ import {
generateQueryKey,
RequestKey,
StaleTime,
+ OtherFeedPage,
} from '@dailydotdev/shared/src/lib/query';
-import type {
- ToolTopSquad,
- AddUserStackInput,
-} from '@dailydotdev/shared/src/graphql/user/userStack';
+import type { ToolTopSquad } from '@dailydotdev/shared/src/graphql/user/userStack';
import { getTopSquadsForTool } from '@dailydotdev/shared/src/graphql/user/userStack';
import type { ApiErrorResult } from '@dailydotdev/shared/src/graphql/common';
import {
@@ -72,7 +70,6 @@ import {
ButtonSize,
ButtonVariant,
} from '@dailydotdev/shared/src/components/buttons/Button';
-import { CardAction } from '@dailydotdev/shared/src/components/buttons/CardAction';
import { DataTile } from '@dailydotdev/shared/src/components/DataTile';
import {
DiscussIcon,
@@ -86,9 +83,6 @@ import {
import { IconSize } from '@dailydotdev/shared/src/components/Icon';
import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext';
import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth';
-import { useUserStack } from '@dailydotdev/shared/src/features/profile/hooks/useUserStack';
-import { UserStackModal } from '@dailydotdev/shared/src/features/profile/components/stack/UserStackModal';
-import type { PublicProfile } from '@dailydotdev/shared/src/lib/user';
import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification';
import type { PromptOptions } from '@dailydotdev/shared/src/hooks/usePrompt';
import { usePrompt } from '@dailydotdev/shared/src/hooks/usePrompt';
@@ -107,13 +101,19 @@ import { ProfilePictureGroup } from '@dailydotdev/shared/src/components/ProfileP
import { ToolLogo } from '@dailydotdev/shared/src/components/tools/ToolLogo';
import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext';
import { LogEvent, Origin, TargetType } from '@dailydotdev/shared/src/lib/log';
-import classNames from 'classnames';
+import { ActiveFeedNameContext } from '@dailydotdev/shared/src/contexts';
+import { FeedLayoutProvider } from '@dailydotdev/shared/src/contexts/FeedContext';
+import { TAG_FEED_QUERY } from '@dailydotdev/shared/src/graphql/feed';
+import HorizontalFeed from '@dailydotdev/shared/src/components/feeds/HorizontalFeed';
+import { EntityRailWithFade } from '@dailydotdev/shared/src/components/entity/EntityRailWithFade';
import { getLayout } from '../../components/layouts/MainLayout';
import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout';
import { defaultOpenGraph, noindexSeoProps } from '../../next-seo';
import { getPageSeoTitles } from '../../components/layouts/utils';
import { getAppOrigin } from '../../lib/seo';
import { ToolDiscussion } from '../../components/tools/ToolDiscussion';
+import { useAddToolToStack } from '../../components/tools/useAddToolToStack';
+import { ToolSquadCard } from '../../components/tools/ToolSquadCard';
import { ToolCard } from '../../components/tools/ToolCard';
import { ToolPageNavbar } from '../../components/tools/ToolPageNavbar';
import { ToolSection } from '../../components/tools/ToolSection';
@@ -131,6 +131,9 @@ const getToolPageJsonLd = (
topPosts: ToolTopPost[],
): string => {
const toolUrl = `${appOrigin}/tools/${tool.slug}`;
+ // Mirrors the server-rendered "Top reads" list exactly - structured data
+ // must describe content that is actually in the HTML.
+ const linkablePosts = topPosts.filter((post) => !!post.title);
const breadcrumbItems = [
{ name: 'Tools', item: `${appOrigin}/tools` },
...(tool.category
@@ -165,17 +168,17 @@ const getToolPageJsonLd = (
item: item.item,
})),
},
- ...(topPosts.length
+ ...(linkablePosts.length
? [
{
'@type': 'ItemList',
'@id': `${toolUrl}#posts`,
- numberOfItems: topPosts.length,
- itemListElement: topPosts.map((post, index) => ({
+ numberOfItems: linkablePosts.length,
+ itemListElement: linkablePosts.map((post, index) => ({
'@type': 'ListItem',
position: index + 1,
url: `${appOrigin}/posts/${post.slug || post.id}`,
- name: post.title || '',
+ name: post.title,
})),
},
]
@@ -298,11 +301,8 @@ const applyOptimisticVote = (
return { ...state, upvotes, downvotes, userVote: vote === 0 ? null : vote };
};
-// Row chrome shared by the tool page's lists — the filled, hoverable row the
-// profile page uses for stack items and hot takes.
const rowClassName =
- 'flex items-center gap-4 rounded-16 bg-surface-float p-4 transition-colors';
-const linkRowClassName = classNames(rowClassName, 'hover:bg-surface-hover');
+ 'flex items-center gap-4 rounded-16 border border-border-subtlest-tertiary p-4 transition-colors';
const MetaSeparator = (): ReactElement => · ;
@@ -320,21 +320,25 @@ const ToolPage = ({
facts,
}: ToolPageProps): ReactElement => {
const { user, showLogin } = useAuthContext();
- const { stackItems, add } = useUserStack(user as PublicProfile);
+ const {
+ stackedToolIds,
+ openAddModal,
+ modal: stackModal,
+ } = useAddToolToStack(Origin.ToolPage);
const { displayToast } = useToastNotification();
const { logEvent } = useLogContext();
const { showPrompt } = usePrompt();
const { userCompanies } = useUserCompaniesQuery();
- const [isModalOpen, setIsModalOpen] = useState(false);
const [claimedByState, setClaimedByState] = useState(claimedBy);
- const isInStack = useMemo(
- () => stackItems.some((item) => item.tool.id === tool.id),
- [stackItems, tool.id],
- );
+ const isInStack = stackedToolIds.has(tool.id);
const websiteHost = tool.url ? getDomainFromUrl(tool.url) : null;
+ const topPostsQueryVariables = useMemo(
+ () => ({ tag: tool.keyword, ranking: 'POPULARITY' }),
+ [tool.keyword],
+ );
const descriptionFact = useMemo(
() => getToolFact(facts, 'description'),
[facts],
@@ -541,36 +545,14 @@ const ToolPage = ({
sendClaimTool,
]);
- const totalVotes = (voteState?.upvotes ?? 0) + (voteState?.downvotes ?? 0);
+ const upvoteCount = voteState?.upvotes ?? 0;
+ const totalVotes = upvoteCount + (voteState?.downvotes ?? 0);
const sentiment =
- totalVotes > 0
- ? Math.round(((voteState?.upvotes ?? 0) / totalVotes) * 100)
- : null;
+ totalVotes > 0 ? Math.round((upvoteCount / totalVotes) * 100) : null;
- const handleAddClick = useCallback(() => {
- if (!user) {
- showLogin({ trigger: AuthTriggers.AddToStack });
- return;
- }
- logEvent({
- event_name: LogEvent.StartAddUserStack,
- target_id: tool.slug,
- extra: JSON.stringify({ origin: Origin.ToolPage }),
- });
- setIsModalOpen(true);
- }, [user, showLogin, logEvent, tool.slug]);
-
- const handleAdd = useCallback(
- async (input: AddUserStackInput) => {
- try {
- await add(input);
- displayToast('Added to your stack');
- } catch (error) {
- displayToast('Failed to add item');
- throw error;
- }
- },
- [add, displayToast],
+ const handleAddClick = useCallback(
+ () => openAddModal({ id: tool.id, title: tool.title, slug: tool.slug }),
+ [openAddModal, tool.id, tool.title, tool.slug],
);
const handleDiscussClick = useCallback(() => {
@@ -593,18 +575,6 @@ const ToolPage = ({
[logEvent],
);
- const handleTopPostClick = useCallback(
- (post: ToolTopPost) => {
- logEvent({
- event_name: LogEvent.Click,
- target_type: TargetType.Post,
- target_id: post.id,
- extra: JSON.stringify({ origin: Origin.ToolPage }),
- });
- },
- [logEvent],
- );
-
const handleOfficialSourceClick = useCallback(() => {
if (!officialSource) {
return;
@@ -699,7 +669,8 @@ const ToolPage = ({
faviconUrl={tool.faviconUrl}
url={tool.url}
size={160}
- className="size-20 rounded-16 border border-border-subtlest-tertiary p-3 typo-title2"
+ className="size-20 rounded-16 border border-border-subtlest-tertiary typo-title2"
+ plateClassName="bg-white p-3"
/>
: }
disabled={isInStack}
onClick={handleAddClick}
>
{isInStack ? 'In your stack' : 'Add to my stack'}
-
- }
- iconPressed={ }
- color={ButtonColor.Avocado}
- pressed={voteState?.userVote === 1}
- count={voteState?.upvotes}
- onClick={() => handleVote(1)}
- />
- }
- iconPressed={ }
- color={ButtonColor.Ketchup}
- pressed={voteState?.userVote === -1}
- onClick={() => handleVote(-1)}
- />
-
+ }
+ onClick={() => handleVote(1)}
+ >
+ {upvoteCount > 0
+ ? `Upvote ${largeNumberFormat(upvoteCount) ?? upvoteCount}`
+ : 'Upvote'}
+
+ }
+ aria-label="Downvote"
+ onClick={() => handleVote(-1)}
+ />
}
onClick={handleDiscussClick}
>
@@ -869,7 +841,7 @@ const ToolPage = ({
}
onClick={() => onShareOrCopy()}
>
@@ -892,126 +864,133 @@ const ToolPage = ({
-
-
- 0 && (
-
-
- {stackers.map((stacker) => (
-
+
+
+
+
0 && (
+
+
+ {stackers.map((stacker) => (
+
+ ))}
+
+ {!!followedStackers?.length && (
+
+ {followedStackers.length} you follow
+
+ )}
+
+ )
+ }
+ />
+ {sentiment !== null && (
+
+
- ))}
-
- {!!followedStackers?.length && (
+
+ }
+ />
+ )}
+ {adoption && adoption.percentile !== null && (
+
- {followedStackers.length} you follow
+ of all tools on daily.dev
- )}
-
- )
- }
- />
- {sentiment !== null && (
-
-
-
- }
- />
- )}
- {adoption && adoption.percentile !== null && (
-
+ )}
+ {!!adoption?.quarterGrowth && adoption.quarterGrowth > 0 && (
+
+ new stack additions
+
+ }
+ />
+ )}
+
+ {adoption && adoption.monthly.length > 0 && sparklinePoints && (
+
+
- of all tools on daily.dev
-
- }
- />
- )}
- {!!adoption?.quarterGrowth && adoption.quarterGrowth > 0 && (
-
+
+
- new stack additions
+ Stack additions, trailing 12 months
- }
- />
- )}
-
-
- {adoption && adoption.monthly.length > 0 && sparklinePoints && (
-
-
-
-
-
-
- Stack additions, trailing 12 months
-
- {sparklineTrendDescription && (
- {sparklineTrendDescription}
+ {sparklineTrendDescription && (
+ {sparklineTrendDescription}
+ )}
+
)}
-
- )}
+
+
{topPosts.length > 0 && tool.keyword && (
}
>
-
)}
@@ -1087,30 +1048,8 @@ const ToolPage = ({
@@ -1171,6 +1110,7 @@ const ToolPage = ({
faviconUrl={related.faviconUrl}
url={related.url}
className="size-6 rounded-6"
+ plateClassName="bg-white p-0.5"
/>
{related.title}
@@ -1205,15 +1145,7 @@ const ToolPage = ({
- {isModalOpen && (
- setIsModalOpen(false)}
- onSubmit={handleAdd}
- defaultTitle={tool.title}
- modalTitle="Add stack/tool to profile"
- />
- )}
+ {stackModal}
>
);
diff --git a/packages/webapp/pages/tools/index.tsx b/packages/webapp/pages/tools/index.tsx
index 5be0b230119..754a4f15eca 100644
--- a/packages/webapp/pages/tools/index.tsx
+++ b/packages/webapp/pages/tools/index.tsx
@@ -1,14 +1,21 @@
import type { ReactElement } from 'react';
-import React from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import type { GetStaticPropsResult } from 'next';
import Head from 'next/head';
import type { NextSeoProps } from 'next-seo';
+import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { DirectoryTool } from '@dailydotdev/shared/src/graphql/tools';
import {
getToolCategories,
getToolCategoryAnchor,
getTopTools,
} from '@dailydotdev/shared/src/graphql/tools';
+import { StaleTime } from '@dailydotdev/shared/src/lib/query';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
import {
Typography,
TypographyColor,
@@ -17,6 +24,9 @@ import {
} from '@dailydotdev/shared/src/components/typography/Typography';
import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext';
import { LogEvent, Origin, TargetType } from '@dailydotdev/shared/src/lib/log';
+import { CharmEmptyState } from '@dailydotdev/shared/src/components/charm/CharmEmptyState';
+import { MIN_SEARCH_QUERY_LENGTH } from '@dailydotdev/shared/src/hooks/useTagSearch';
+import { cloudinaryCharmSearchNoResults } from '@dailydotdev/shared/src/lib/image';
import { getLayout } from '../../components/layouts/MainLayout';
import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout';
import { defaultOpenGraph, noindexSeoProps } from '../../next-seo';
@@ -25,12 +35,27 @@ import { getAppOrigin } from '../../lib/seo';
import { ToolCard } from '../../components/tools/ToolCard';
import { ToolPageNavbar } from '../../components/tools/ToolPageNavbar';
import { ToolSection } from '../../components/tools/ToolSection';
+import { ToolDirectorySearch } from '../../components/tools/ToolDirectorySearch';
+import { useAddToolToStack } from '../../components/tools/useAddToolToStack';
const TOOLS_PER_SECTION = 6;
const TRENDING_COUNT = 6;
+// Matches the API's topTools cap, so a category at the limit means tools
+// were actually dropped.
+const CATEGORY_FETCH_LIMIT = 100;
+const SEARCH_RESULTS_LIMIT = 100;
+const RECOMMENDED_COUNT = 5;
+// Catch-all section for stacked tools without a curated category, rendered
+// below the curated ones. Only sees tools inside the overall top-N fetch.
+const OTHER_CATEGORY = 'Other';
const appOrigin = getAppOrigin();
+interface CategorySection {
+ category: string;
+ tools: DirectoryTool[];
+}
+
const getToolsDirectoryJsonLd = (sections: CategorySection[]): string => {
const directoryUrl = `${appOrigin}/tools`;
const tools = sections.flatMap(({ tools: sectionTools }) => sectionTools);
@@ -66,45 +91,124 @@ const getToolsDirectoryJsonLd = (sections: CategorySection[]): string => {
});
};
-interface CategorySection {
- category: string;
- tools: DirectoryTool[];
-}
-
interface ToolsDirectoryProps {
- trending: DirectoryTool[];
- sections: CategorySection[];
- fallbackTop: DirectoryTool[];
+ tools: DirectoryTool[];
+ trendingIds: string[];
+ sections: { category: string; toolIds: string[] }[];
+ fallbackTopIds: string[];
}
-const ToolGrid = ({ tools }: { tools: DirectoryTool[] }): ReactElement => {
+const ToolsDirectoryPage = ({
+ tools,
+ trendingIds,
+ sections,
+ fallbackTopIds,
+}: ToolsDirectoryProps): ReactElement => {
const { logEvent } = useLogContext();
+ const { stackedToolIds, openAddModal, modal } = useAddToolToStack(
+ Origin.ToolsDirectory,
+ );
+ const [inputValue, setInputValue] = useState('');
+ const [search, setSearch] = useState('');
+ const [expandedCategories, setExpandedCategories] = useState>(
+ () => new Set(),
+ );
- return (
-
- {tools.map((tool) => (
-
- logEvent({
- event_name: LogEvent.Click,
- target_type: TargetType.Tool,
- target_id: tool.slug,
- extra: JSON.stringify({ origin: Origin.ToolsDirectory }),
- })
- }
- />
- ))}
-
+ const toolsById = useMemo(
+ () => new Map(tools.map((tool) => [tool.id, tool])),
+ [tools],
+ );
+ const pickTools = useCallback(
+ (ids: string[]): DirectoryTool[] =>
+ ids.flatMap((id) => toolsById.get(id) ?? []),
+ [toolsById],
+ );
+
+ const trending = useMemo(
+ () => pickTools(trendingIds),
+ [pickTools, trendingIds],
+ );
+ const categorySections = useMemo(
+ () =>
+ sections.map(({ category, toolIds }) => ({
+ category,
+ tools: pickTools(toolIds),
+ })),
+ [sections, pickTools],
+ );
+ const fallbackTop = useMemo(
+ () => pickTools(fallbackTopIds),
+ [pickTools, fallbackTopIds],
+ );
+
+ const normalizedSearch = search.trim();
+ const isSearching = normalizedSearch.length >= MIN_SEARCH_QUERY_LENGTH;
+ const { data: apiResults, isPending: isSearchPending } = useQuery({
+ queryKey: ['toolsDirectorySearch', normalizedSearch.toLowerCase()],
+ // Logged inside queryFn (like useTagSearch) so the logged term and result
+ // count always come from the same response.
+ queryFn: async () => {
+ const result = await getTopTools({
+ query: normalizedSearch,
+ first: SEARCH_RESULTS_LIMIT,
+ });
+ logEvent({
+ event_name: LogEvent.SearchTools,
+ extra: JSON.stringify({
+ query: normalizedSearch.toLowerCase(),
+ resultCount: result.length,
+ }),
+ });
+ return result;
+ },
+ enabled: isSearching,
+ staleTime: StaleTime.Default,
+ placeholderData: keepPreviousData,
+ });
+
+ const searchResults = useMemo(
+ () => (isSearching ? apiResults ?? [] : []),
+ [isSearching, apiResults],
+ );
+ const isSearchLoading = isSearching && isSearchPending;
+
+ const clearSearch = useCallback(() => {
+ setInputValue('');
+ setSearch('');
+ }, []);
+
+ const renderGrid = useCallback(
+ (gridTools: DirectoryTool[], extraProps?: Record) => (
+
+ {gridTools.map((tool) => (
+
+ logEvent({
+ event_name: LogEvent.Click,
+ target_type: TargetType.Tool,
+ target_id: tool.slug,
+ extra: JSON.stringify({
+ origin: Origin.ToolsDirectory,
+ ...extraProps,
+ }),
+ })
+ }
+ />
+ ))}
+
+ ),
+ [stackedToolIds, openAddModal, logEvent],
+ );
+
+ const recommendedTools = useMemo(
+ () => trending.slice(0, RECOMMENDED_COUNT),
+ [trending],
);
-};
-const ToolsDirectoryPage = ({
- trending,
- sections,
- fallbackTop,
-}: ToolsDirectoryProps): ReactElement => {
return (
<>
@@ -114,7 +218,7 @@ const ToolsDirectoryPage = ({
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
- __html: getToolsDirectoryJsonLd(sections),
+ __html: getToolsDirectoryJsonLd(categorySections),
}}
/>
@@ -134,9 +238,16 @@ const ToolsDirectoryPage = ({
The tools developers actually run — ranked by real stacks on
daily.dev, not vendor pitches.
- {sections.length > 1 && (
+
+ {!isSearching && categorySections.length > 1 && (
- {sections.map(({ category }) => (
+ {categorySections.map(({ category }) => (
-
+ {isSearching && !isSearchLoading && searchResults.length === 0 && (
+
+ )}
-
- {trending.length > 0 && (
-
-
-
- )}
+ {isSearching && searchResults.length > 0 && (
+
+ {renderGrid(searchResults, { searched: true })}
+
+ )}
- {sections.map(({ category, tools }) => (
-
-
-
- ))}
-
- {sections.length === 0 && fallbackTop.length > 0 && (
-
-
-
- )}
-
+ {!isSearching && (
+
+ {trending.length > 0 && (
+
+ {renderGrid(trending)}
+
+ )}
+
+ {categorySections.map(({ category, tools: categoryTools }) => {
+ const isExpanded = expandedCategories.has(category);
+ const visibleTools = isExpanded
+ ? categoryTools
+ : categoryTools.slice(0, TOOLS_PER_SECTION);
+
+ return (
+
+ {renderGrid(visibleTools)}
+ {!isExpanded &&
+ categoryTools.length > visibleTools.length && (
+
+ setExpandedCategories((previous) =>
+ new Set(previous).add(category),
+ )
+ }
+ >
+ Show all {categoryTools.length} {category} tools
+
+ )}
+
+ );
+ })}
+
+ {categorySections.length === 0 && fallbackTop.length > 0 && (
+
+ {renderGrid(fallbackTop)}
+
+ )}
+
+ )}
+
+ {modal}
>
);
@@ -194,21 +346,57 @@ export async function getStaticProps(): Promise<
// social queries), so a failure here should fail the revalidation and let
// Next keep serving the last good ISR output, rather than caching an
// empty page.
- const [categories, trending, fallbackTop] = await Promise.all([
+ const [categories, trending, allTop] = await Promise.all([
getToolCategories(),
getTopTools({ first: TRENDING_COUNT, trending: true }),
- getTopTools({ first: 12 }),
+ getTopTools({ first: CATEGORY_FETCH_LIMIT }),
]);
+ const fallbackTop = allTop.slice(0, 12);
- const sections = (
+ const fullCategories = (
await Promise.all(
categories.map(async ({ category }) => ({
category,
- tools: await getTopTools({ first: TOOLS_PER_SECTION, category }),
+ tools: await getTopTools({ first: CATEGORY_FETCH_LIMIT, category }),
})),
)
).filter(({ tools }) => tools.length > 0);
+ const uncategorized = allTop.filter((tool) => !tool.category);
+ if (uncategorized.length > 0) {
+ fullCategories.push({ category: OTHER_CATEGORY, tools: uncategorized });
+ }
+ if (allTop.length >= CATEGORY_FETCH_LIMIT) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `tools directory: the overall top-tools fetch hit the limit; the "${OTHER_CATEGORY}" section may be missing uncategorized tools`,
+ );
+ }
+
+ fullCategories.forEach(({ category, tools }) => {
+ if (category !== OTHER_CATEGORY && tools.length >= CATEGORY_FETCH_LIMIT) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `tools directory: category "${category}" hit the fetch limit; some tools are not listed`,
+ );
+ }
+ });
+
+ const tools = Array.from(
+ new Map(
+ [
+ ...fullCategories.flatMap(({ tools: categoryTools }) => categoryTools),
+ ...trending,
+ ...allTop,
+ ].map((tool) => [tool.id, tool]),
+ ).values(),
+ ).sort((a, b) => a.title.localeCompare(b.title));
+
+ const sections = fullCategories.map(({ category, tools: categoryTools }) => ({
+ category,
+ toolIds: categoryTools.map(({ id }) => id),
+ }));
+
const seoTitles = getPageSeoTitles(
'Developer tools directory — ranked by real stacks',
);
@@ -216,9 +404,10 @@ export async function getStaticProps(): Promise<
return {
props: {
- trending,
+ tools,
+ trendingIds: trending.map(({ id }) => id),
sections,
- fallbackTop,
+ fallbackTopIds: fallbackTop.map(({ id }) => id),
seo: {
title: seoTitles.title,
openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph },