import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
DEFAULT_CATEGORY_VALUE,
DEFAULT_SORT_VALUE,
DEV_JSON_URL,
FALLBACK_DATA,
PROD_JSON_URL,
} from "../constants/fallbackData";
import type {
Category,
LoadStatus,
SortOption,
Tool,
ToolsData,
} from "../types";
import { DEBOUNCE_SEARCH_FIELD_MS } from "../constants/time";
export interface ToolSections {
featured: Tool[];
editorsPicks: Tool[];
meetsCriteria: Tool[];
}
interface UseToolsReturn {
tools: Tool[];
filteredTools: Tool[];
sections: ToolSections;
searchKeywords: string[];
isSearching: boolean;
categories: Category[];
loadStatus: LoadStatus;
errorMessage: string;
query: string;
activeCategory: string;
sortBy: string;
onCategoryChange: (id: string) => void;
onSearchChange: (args: { query: string; isInput?: boolean }) => void;
onSortChange: (option: SortOption) => void;
}
function sectionize(tools: Tool[]): ToolSections {
return {
featured: tools.filter((entry) => entry.section === "featured"),
editorsPicks: tools.filter((entry) => entry.section === "editors-pick"),
meetsCriteria: tools.filter(
(entry) => entry.section !== "featured" && entry.section !== "editors-pick",
),
};
}
export function useTools(): UseToolsReturn {
const [allTools, setAllTools] = useState<Tool[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loadStatus, setLoadStatus] = useState<LoadStatus>("loading");
const [errorMessage, setErrorMessage] = useState("");
const [searchParams, setSearchParams] = useSearchParams();
const activeCategory = searchParams.get("category") || DEFAULT_CATEGORY_VALUE;
const searchQuery = searchParams.get("q") || "";
const sortBy: SortOption =
(searchParams.get("sort") as SortOption | null) || DEFAULT_SORT_VALUE;
const [query, setQuery] = useState(searchQuery);
const debounceId: React.RefObject<null | number> = useRef(null);
useEffect(() => {
async function load() {
setLoadStatus("loading");
let data: ToolsData | null = null;
let error = "";
if (import.meta?.env?.DEV) {
data = await loadTools(DEV_JSON_URL);
}
if (!data) data = await loadTools(PROD_JSON_URL);
if (!data) {
data = FALLBACK_DATA;
error = `Failed to get the freshest data from prod`;
setErrorMessage(error);
}
hydrate(data, error);
}
load();
}, []);
function hydrate(data: ToolsData, error: string) {
const cats = data.categories ?? [];
if (!cats.find((c) => c.id === "all")) {
cats.unshift({
id: "all",
name: "All",
icon: "◈",
description: "All tools",
});
}
setAllTools(data.tools);
setCategories(cats);
setLoadStatus(!error ? "success" : "error");
}
const searchKeywords = useMemo(() => tokenize(searchQuery), [searchQuery]);
const isSearching = searchKeywords.length > 0;
const scoredTools = useMemo(() => {
const keywords = searchKeywords;
return allTools
.filter(
(tool) => activeCategory === "all" || tool.category === activeCategory,
)
.map((tool) => ({ tool, score: matchScore(tool, keywords) }))
.filter(({ score }) => keywords.length === 0 || score == keywords.length);
}, [allTools, activeCategory, searchQuery]);
const filteredTools = useMemo(
() =>
[...scoredTools]
.sort(
(a, b) =>
b.score - a.score || (b.tool.stars ?? 0) - (a.tool.stars ?? 0),
)
.map(({ tool }) => tool),
[scoredTools],
);
const sections = useMemo<ToolSections>(
() => sectionize(filteredTools),
[filteredTools],
);
function onCategoryChange(id: string) {
const params = new URLSearchParams(searchParams);
id === DEFAULT_CATEGORY_VALUE
? params.delete("category")
: params.set("category", id);
setSearchParams(params);
}
function onSearchChange({
query,
isInput = false,
}: {
query: string;
isInput?: boolean;
}) {
if (isInput) {
if (debounceId.current) {
clearTimeout(debounceId.current);
}
setQuery(query);
debounceId.current = setTimeout(
() => updateUrlQuery(query),
DEBOUNCE_SEARCH_FIELD_MS,
);
return;
}
setQuery(query);
updateUrlQuery(query);
}
function updateUrlQuery(query: string) {
const params = new URLSearchParams(searchParams);
query === "" ? params.delete("q") : params.set("q", query);
setSearchParams(params);
}
function onSortChange(option: SortOption) {
const params = new URLSearchParams(searchParams);
option === DEFAULT_SORT_VALUE
? params.delete("sort")
: params.set("sort", option);
setSearchParams(params);
}
return {
tools: allTools,
filteredTools,
sections,
searchKeywords,
isSearching,
categories,
loadStatus,
errorMessage,
query,
activeCategory,
sortBy,
onCategoryChange,
onSearchChange,
onSortChange,
};
}
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[^a-z0-9+]+/)
.filter(Boolean);
}
function matchScore(tool: Tool, keywords: string[]): number {
if (keywords.length === 0) return 0;
const haystack = [tool.name, tool.description, ...tool.tags]
.join(" ")
.toLowerCase()
.replace(/[^a-z0-9+]+/g, " ");
return keywords.filter((kw) => haystack.includes(kw)).length;
}
async function loadTools(JSON_URL: string): Promise<ToolsData | null> {
try {
const res = await fetch(JSON_URL);
if (!res.ok) throw new Error(`Failed to get data from ${JSON_URL}`);
return (await res.json()) as ToolsData;
} catch (err) {
console.error(`Couldn't parse tools from: ${JSON_URL}`, err);
return null;
}
}