mirror of
https://github.com/BraveOPotato/FckSignups.git
synced 2026-10-02 05:44:32 +08:00
feat(sections): dedicated 3 sections of tools
This commit is contained in:
@@ -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,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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user