feat(sections): dedicated 3 sections of tools

This commit is contained in:
BraveOPotato
2026-07-22 18:04:04 -05:00
parent db8691ee88
commit 528d84c233
10 changed files with 1953 additions and 517 deletions
+4 -2
View File
@@ -1,3 +1,4 @@
from datetime import date
from json import JSONDecodeError, dump, load
from re import sub
@@ -53,10 +54,11 @@ def getToolInput():
"github": "",
"license": "",
"stars": 0,
"featured": False,
"addedAt": date.today().isoformat(),
"section": "meets-criteria",
}
keys_for_input = get_non_excluded_keys(tool_obj, ["id", "featured"])
keys_for_input = get_non_excluded_keys(tool_obj, ["id", "section", "addedAt"])
for key in keys_for_input:
if isinstance(tool_obj[key], str):
+4 -1
View File
@@ -4,6 +4,8 @@
# tools.json file. The tool will also send requests to GitHub to fetch
# the accurate count of stars and license info.
from datetime import date
from addTool import (
JSON_PATH,
getArrayInput,
@@ -45,7 +47,8 @@ def createToolDict(fields):
"github": repoBridge.getURL(),
"license": repoBridge.getLicense(),
"stars": repoBridge.getStars(),
"featured": False,
"addedAt": date.today().isoformat(),
"section": "meets-criteria",
}
new_tool_json["category"] = getInput("Category: ")
+9 -6
View File
@@ -13,6 +13,7 @@ export default function App() {
const {
tools,
filteredTools,
sections,
categories,
loadStatus,
errorMessage,
@@ -23,10 +24,6 @@ export default function App() {
} = useTools();
const activeCat = categories.find((c) => c.id === activeCategory);
const sectionLabel =
activeCat && activeCat.id !== "all"
? `${activeCat.icon} ${activeCat.name}`
: "All Tools";
return (
<>
@@ -46,14 +43,20 @@ export default function App() {
onCategoryChange={setActiveCategory}
onSearchChange={setSearchQuery}
/>
<div className="section-divider">{sectionLabel}</div>
{activeCat && activeCat.id !== "all" && (
<div className="section-divider">
{`${activeCat.icon} ${activeCat.name}`}
</div>
)}
<ReportProvider>
<ToolGrid
tools={filteredTools}
sections={sections}
categories={categories}
loadStatus={loadStatus}
errorMessage={errorMessage}
searchQuery={searchQuery}
activeCategory={activeCategory}
setSearchQuery={setSearchQuery}
/>
+50 -27
View File
@@ -47,6 +47,7 @@ export function ToolCard({ tool, category, setSearchQuery }: ToolCardProps) {
<article
className={
"card" +
(tool.section === "featured" ? " featured" : "") +
(tool.notRecommendedReason !== undefined ? " not-recommended" : "") // if there the key exists in the object
}
data-highlighted={reportMode}
@@ -71,34 +72,56 @@ export function ToolCard({ tool, category, setSearchQuery }: ToolCardProps) {
<ExternalIcon />
</a>
</div>
{tool.featured && <span className="featured-badge">Featured</span>}
{tool.notRecommendedReason !== undefined && (
<div
onClick={(e) => {
e.stopPropagation();
handleTimedToast();
}}
title={tool.notRecommendedReason}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="feather feather-alert-circle"
<div>
{tool.section === "featured" && (
<span className="featured-badge">Featured</span>
)}
{tool.section === "editors-pick" && (
<span className="editors-badge">Editor's Pick</span>
)}
{tool.flag === "new" && (
<span className="flag-badge flag-new" title="Brand new project">
New
</span>
)}
{tool.flag === "abandoned" && (
<span
className="flag-badge flag-abandoned"
title="This project is no longer maintained"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
)}
</div>
Abandoned
</span>
)}
{tool.notRecommendedReason !== undefined && (
<div
onClick={(e) => {
e.stopPropagation();
handleTimedToast();
}}
title={tool.notRecommendedReason}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="feather feather-alert-circle"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
)}
</div>
</div>
<p className="card-desc">{tool.description}</p>
+98 -10
View File
@@ -1,21 +1,34 @@
import { useEffect, useState } from "react";
import type { ToolSections } from "../hooks/useTools";
import type { Tool, Category, LoadStatus } from "../types";
import { ToolCard } from "./ToolCard";
interface ToolGridProps {
tools: Tool[];
sections: ToolSections;
categories: Category[];
loadStatus: LoadStatus;
errorMessage: string;
searchQuery: string;
activeCategory: string;
setSearchQuery: (query: string) => void;
}
export function ToolGrid({
tools,
sections,
categories,
loadStatus,
errorMessage,
searchQuery,
activeCategory,
setSearchQuery,
}: ToolGridProps) {
const [showMore, setShowMore] = useState(false);
// Collapse "Meets Criteria" again whenever the filters change
useEffect(() => {
setShowMore(false);
}, [searchQuery, activeCategory]);
if (loadStatus === "loading") {
return (
<main className="grid">
@@ -24,7 +37,10 @@ export function ToolGrid({
);
}
if (tools.length === 0) {
const { featured, editorsPicks, meetsCriteria } = sections;
const totalCount = featured.length + editorsPicks.length + meetsCriteria.length;
if (totalCount === 0) {
return (
<main className="grid">
<div className="empty">
@@ -35,8 +51,14 @@ export function ToolGrid({
);
}
// Meets Criteria stays behind "Show More" only while browsing; searching
// always expands it so no matches are hidden.
const hasCurated = featured.length > 0 || editorsPicks.length > 0;
const searching = searchQuery.trim().length > 0;
const meetsExpanded = !hasCurated || searching || showMore;
return (
<main className="grid border-glow">
<main className="tool-sections">
{loadStatus == "error" && (
<div className="error">
<h3>ERR_LOAD_FAILED</h3>
@@ -47,14 +69,80 @@ export function ToolGrid({
</div>
)}
{tools.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
category={categories.find((c) => c.id === tool.category)}
{featured.length > 0 && (
<Section
label="Featured"
variant="featured"
tools={featured}
categories={categories}
setSearchQuery={setSearchQuery}
/>
))}
)}
{editorsPicks.length > 0 && (
<Section
label="Editor's Picks"
variant="editors"
tools={editorsPicks}
categories={categories}
setSearchQuery={setSearchQuery}
/>
)}
{meetsCriteria.length > 0 &&
(meetsExpanded ? (
<Section
label="Meets Criteria"
variant="meets"
tools={meetsCriteria}
categories={categories}
setSearchQuery={setSearchQuery}
/>
) : (
<div className="show-more-wrap">
<button
type="button"
className="show-more-btn"
onClick={() => setShowMore(true)}
>
Show More - {meetsCriteria.length} more{" "}
{meetsCriteria.length === 1 ? "tool meets" : "tools meet"} the
criteria
</button>
</div>
))}
</main>
);
}
interface SectionProps {
label: string;
variant: "featured" | "editors" | "meets";
tools: Tool[];
categories: Category[];
setSearchQuery: (query: string) => void;
}
function Section({
label,
variant,
tools,
categories,
setSearchQuery,
}: SectionProps) {
return (
<section className={`tool-section tool-section--${variant}`}>
<div className="section-divider">{label}</div>
<div className="grid border-glow">
{tools.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
category={categories.find((c) => c.id === tool.category)}
setSearchQuery={setSearchQuery}
/>
))}
</div>
</section>
);
}
+30 -15
View File
@@ -51,7 +51,8 @@ export const FALLBACK_DATA: ToolsData = {
github: "https://github.com/excalidraw/excalidraw",
license: "MIT",
stars: 72000,
featured: true,
addedAt: "2026-05-07",
section: "featured",
},
{
id: "kleki",
@@ -61,7 +62,8 @@ export const FALLBACK_DATA: ToolsData = {
url: "https://kleki.com",
category: "design",
tags: ["paint", "layers", "brushes", "drawing"],
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
{
id: "pixel-craft",
@@ -74,7 +76,8 @@ export const FALLBACK_DATA: ToolsData = {
github: "https://github.com/pixelcraftstudio/pixelcraft",
license: "MIT",
stars: 650,
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
{
id: "photopea",
@@ -84,7 +87,8 @@ export const FALLBACK_DATA: ToolsData = {
url: "https://photopea.com",
category: "design",
tags: ["photoshop", "psd", "editor", "professional"],
featured: true,
addedAt: "2026-05-07",
section: "featured",
},
{
id: "waifu2x",
@@ -97,7 +101,8 @@ export const FALLBACK_DATA: ToolsData = {
github: "https://github.com/nagadomi/waifu2x",
license: "MIT",
stars: 21000,
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
{
id: "json-crack",
@@ -110,7 +115,8 @@ export const FALLBACK_DATA: ToolsData = {
github: "https://github.com/AykutSarac/jsoncrack.com",
license: "MIT",
stars: 28000,
featured: true,
addedAt: "2026-06-17",
section: "meets-criteria",
},
{
id: "regex101",
@@ -120,7 +126,8 @@ export const FALLBACK_DATA: ToolsData = {
url: "https://regex101.com",
category: "dev",
tags: ["regex", "testing", "debugger", "explain"],
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
{
id: "carbon",
@@ -133,7 +140,8 @@ export const FALLBACK_DATA: ToolsData = {
github: "https://github.com/carbon-app/carbon",
license: "MIT",
stars: 34000,
featured: false,
addedAt: "2026-06-28",
section: "meets-criteria",
},
{
id: "randommer",
@@ -143,7 +151,8 @@ export const FALLBACK_DATA: ToolsData = {
url: "https://randommer.io",
category: "utilities",
tags: ["random", "generator", "data", "testing"],
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
{
id: "tinywow",
@@ -153,7 +162,8 @@ export const FALLBACK_DATA: ToolsData = {
url: "https://tinywow.com",
category: "utilities",
tags: ["converter", "pdf", "tools", "free"],
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
{
id: "temp-mail",
@@ -163,7 +173,8 @@ export const FALLBACK_DATA: ToolsData = {
url: "https://temp-mail.org",
category: "privacy",
tags: ["temp-email", "anonymous", "inbox", "disposable"],
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
{
id: "duckduckgo-email",
@@ -173,7 +184,8 @@ export const FALLBACK_DATA: ToolsData = {
url: "https://duckduckgo.com/email",
category: "privacy",
tags: ["email", "privacy", "alias", "burner"],
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
{
id: "cryptpad",
@@ -186,7 +198,8 @@ export const FALLBACK_DATA: ToolsData = {
github: "https://github.com/cryptpad/cryptpad",
license: "AGPL-3.0",
stars: 5200,
featured: true,
addedAt: "2026-05-07",
section: "featured",
},
{
id: "draw-io",
@@ -199,7 +212,8 @@ export const FALLBACK_DATA: ToolsData = {
github: "https://github.com/jgraph/drawio",
license: "Apache-2.0",
stars: 42000,
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
{
id: "libreoffice-online",
@@ -211,7 +225,8 @@ export const FALLBACK_DATA: ToolsData = {
tags: ["office", "documents", "spreadsheets", "presentations"],
github: "https://github.com/LibreOffice/core",
license: "MPL-2.0",
featured: false,
addedAt: "2026-05-07",
section: "meets-criteria",
},
],
};
+60 -15
View File
@@ -6,9 +6,16 @@ import {
} from "../constants/fallbackData";
import type { Category, LoadStatus, Tool, ToolsData } from "../types";
export interface ToolSections {
featured: Tool[];
editorsPicks: Tool[];
meetsCriteria: Tool[];
}
interface UseToolsReturn {
tools: Tool[];
filteredTools: Tool[];
sections: ToolSections;
categories: Category[];
loadStatus: LoadStatus;
errorMessage: string;
@@ -32,6 +39,7 @@ export function useTools(): UseToolsReturn {
let data: ToolsData | null = null;
let error = "";
console.log(import.meta.env.DEV)
if (import.meta?.env?.DEV) {
data = await loadTools(DEV_JSON_URL);
}
@@ -61,34 +69,52 @@ export function useTools(): UseToolsReturn {
description: "All tools",
});
}
setAllTools(data.tools ?? []);
// Legacy schema fallback: tools.json fetched from prod may still carry
// the old `featured` boolean instead of `section`.
const tools = (data.tools ?? []).map((t) => {
if (t.section) return t;
const legacy = t as Tool & { featured?: boolean };
return {
...t,
section: legacy.featured ? "featured" : "meets-criteria",
} satisfies Tool;
});
setAllTools(tools);
setCategories(cats);
setLoadStatus(!error ? "success" : "error");
}
const filteredTools = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
const keywords = tokenize(searchQuery);
return allTools
.filter((t) => {
.map((tool) => ({ tool, score: matchScore(tool, keywords) }))
.filter(({ tool, score }) => {
const matchCat =
activeCategory === "all" || t.category === activeCategory;
const matchSearch =
!q ||
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.tags.some((tag) => tag.toLowerCase().includes(q));
return matchCat && matchSearch;
activeCategory === "all" || tool.category === activeCategory;
return matchCat && (keywords.length === 0 || score > 0);
})
.sort((a, b) => {
if (a.featured && !b.featured) return -1;
if (!a.featured && b.featured) return 1;
return (b.stars ?? 0) - (a.stars ?? 0);
});
.sort(
(a, b) =>
b.score - a.score || (b.tool.stars ?? 0) - (a.tool.stars ?? 0),
)
.map(({ tool }) => tool);
}, [allTools, activeCategory, searchQuery]);
const sections = useMemo<ToolSections>(
() => ({
featured: filteredTools.filter((t) => t.section === "featured"),
editorsPicks: filteredTools.filter((t) => t.section === "editors-pick"),
meetsCriteria: filteredTools.filter(
(t) => t.section !== "featured" && t.section !== "editors-pick",
),
}),
[filteredTools],
);
return {
tools: allTools,
filteredTools,
sections,
categories,
loadStatus,
errorMessage,
@@ -99,6 +125,25 @@ export function useTools(): UseToolsReturn {
};
}
// Split text into lowercase alphanumeric keywords, so "video-editor",
// "Video Editor" and "video_editor" all yield ["video", "editor"].
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[^a-z0-9+]+/)
.filter(Boolean);
}
// Number of query keywords found in the tool's name, description, or tags.
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);
+134 -1
View File
@@ -441,7 +441,19 @@ input[type="search"]:focus::placeholder {
}
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
width: 1.15rem;
height: 1.15rem;
cursor: pointer;
background: var(--accent);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E") no-repeat center / contain;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E") no-repeat center / contain;
transition: transform 0.15s ease;
}
input[type="search"]::-webkit-search-cancel-button:hover {
transform: scale(1.15);
}
input[type="text"]:focus {
@@ -537,7 +549,7 @@ input[type="text"]::placeholder {
gap: 1rem;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.1em;
}
@@ -567,6 +579,84 @@ input[type="text"]::placeholder {
}
}
/* TOOL SECTIONS */
.tool-sections {
display: flex;
flex-direction: column;
}
.tool-sections>.error {
max-width: 1400px;
width: calc(100% - 3rem);
margin: 0 auto;
grid-column: unset;
}
.tool-section {
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.tool-section:first-of-type {
border-top: none;
padding-top: 0.5rem;
}
.tool-section .section-divider {
padding-bottom: 0;
}
/* Surface shift per section: brightest frame for Featured,
dimmest for Meets Criteria */
.tool-section--featured {
background: var(--surface);
}
.tool-section--featured .grid {
background: var(--border-light);
}
.tool-section--editors {
background: #0a0a0a;
}
.tool-section--meets {
background: var(--bg);
}
.tool-section--meets .grid {
background: #161616;
}
/* SHOW MORE */
.show-more-wrap {
max-width: 1400px;
width: 100%;
margin: 0 auto;
padding: 1.5rem;
}
.show-more-btn {
width: 100%;
padding: 1rem 1.5rem;
background: transparent;
border: 1px dashed var(--accent);
border-radius: 1rem;
color: var(--text);
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
cursor: pointer;
transition: all 0.2s ease;
}
.show-more-btn:hover {
border-color: var(--accent);
color: var(--text);
background: var(--surface);
}
/* ===== CARD ===== */
.card {
background: var(--surface);
@@ -597,6 +687,14 @@ input[type="text"]::placeholder {
cursor: pointer;
}
.card.featured {
background: linear-gradient(120deg, rgba(31, 31, 31, 1) 0%, rgba(0, 0, 0, 1) 51%, rgba(31, 31, 31, 1) 100%);
}
.card.featured:hover {
background: linear-gradient(120deg, rgba(42, 42, 42, 1) 0%, rgba(10, 10, 10, 1) 51%, rgba(42, 42, 42, 1) 100%);
}
.card:hover::after {
width: 100%;
}
@@ -739,6 +837,41 @@ input[type="text"]::placeholder {
flex-shrink: 0;
}
.editors-badge {
background: var(--accent-3);
color: #000;
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 700;
padding: 0.15rem 0.4rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border-radius: 1px;
flex-shrink: 0;
}
.flag-badge {
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 700;
padding: 0.15rem 0.4rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border-radius: 1px;
flex-shrink: 0;
}
.flag-badge.flag-new {
background: var(--accent-2);
color: #000;
}
.flag-badge.flag-abandoned {
background: #3a3a3a;
color: #b5b5b5;
text-decoration: line-through;
}
.card-desc {
color: var(--text-dim);
font-size: 0.9rem;
+7 -1
View File
@@ -1,3 +1,7 @@
export type ToolSection = "featured" | "editors-pick" | "meets-criteria";
export type ToolFlag = "new" | "abandoned";
export interface Tool {
id: string;
name: string;
@@ -8,7 +12,9 @@ export interface Tool {
github?: string;
license?: string;
stars?: number;
featured?: boolean;
section?: ToolSection;
flag?: ToolFlag;
addedAt?: string;
notRecommendedReason?: string;
}
+1557 -439
View File
File diff suppressed because it is too large Load Diff