151 lines
5.5 KiB
TypeScript
151 lines
5.5 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { pluginRegistry } from "../core/plugins/plugin-registry";
|
|
import type { UnknownPlimiPlugin } from "../core/plugins/plugin-types";
|
|
import { useTheme } from "../app/useTheme";
|
|
import { PlimiSearch, CategoryChips, ToolTile, SectionHeader } from "../components/directory/DirectoryComponents";
|
|
|
|
const PLIMI_CATEGORIES = [
|
|
{ id: "all", label: "All tools" },
|
|
{ id: "developer", label: "Developer" },
|
|
{ id: "image", label: "Image" },
|
|
{ id: "text", label: "Text" },
|
|
{ id: "pdf", label: "PDF" },
|
|
{ id: "crypto", label: "Crypto" },
|
|
{ id: "privacy", label: "Privacy" },
|
|
];
|
|
|
|
export function ToolsPage() {
|
|
const navigate = useNavigate();
|
|
const { dark } = useTheme();
|
|
|
|
const [q, setQ] = useState("");
|
|
const [cat, setCat] = useState("all");
|
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
|
|
const filtered = useMemo(() => {
|
|
return pluginRegistry.filter(
|
|
(p) =>
|
|
(cat === "all" || p.manifest.category === cat) &&
|
|
(p.manifest.name.toLowerCase().includes(q.toLowerCase()) ||
|
|
p.manifest.description.toLowerCase().includes(q.toLowerCase()))
|
|
);
|
|
}, [q, cat]);
|
|
|
|
const safeFocusedIndex =
|
|
filtered.length === 0 ? -1 : Math.min(focusedIndex, filtered.length - 1);
|
|
|
|
const counts = useMemo(() => {
|
|
const out: Record<string, number> = { all: 0 };
|
|
PLIMI_CATEGORIES.forEach((c) => { out[c.id] = 0; });
|
|
|
|
pluginRegistry.forEach((p) => {
|
|
const matchSearch = p.manifest.name.toLowerCase().includes(q.toLowerCase()) ||
|
|
p.manifest.description.toLowerCase().includes(q.toLowerCase());
|
|
if (!matchSearch) return;
|
|
|
|
out.all += 1;
|
|
if (out[p.manifest.category] !== undefined) {
|
|
out[p.manifest.category] += 1;
|
|
} else {
|
|
out[p.manifest.category] = 1;
|
|
}
|
|
});
|
|
return out;
|
|
}, [q]);
|
|
|
|
const grouped = useMemo(() => {
|
|
if (cat !== 'all' || q) return null;
|
|
const map: Record<string, typeof pluginRegistry> = {};
|
|
filtered.forEach((t) => {
|
|
(map[t.manifest.category] = map[t.manifest.category] || []).push(t);
|
|
});
|
|
return PLIMI_CATEGORIES.filter((c) => c.id !== 'all' && map[c.id]?.length).map((c) => ({ cat: c, tools: map[c.id] }));
|
|
}, [filtered, cat, q]);
|
|
|
|
const handleOpenTool = (plugin: UnknownPlimiPlugin) => {
|
|
navigate(`/tools/${plugin.manifest.id}`);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-7 max-w-[1200px] mx-auto pb-10">
|
|
<div className="grid grid-cols-1 md:grid-cols-[1.1fr_1fr] gap-7 md:gap-12 items-end">
|
|
<div className="flex flex-col gap-3.5">
|
|
<span className="font-mono text-[11px] text-[var(--p-muted)] tracking-[0.12em] uppercase">
|
|
your digital pencil case
|
|
</span>
|
|
<h1 className="m-0 font-sans text-4xl md:text-[56px] font-bold tracking-tight leading-[1.02] text-[var(--p-text)] text-balance">
|
|
Small tools.<br />
|
|
<span className="text-[var(--p-muted)]">Big trust.</span>
|
|
</h1>
|
|
<p className="m-0 max-w-[480px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
|
|
{pluginRegistry.length} utilities for files, text and code — running entirely in your browser. No upload. No account. No server.
|
|
</p>
|
|
</div>
|
|
|
|
<PlimiSearch
|
|
value={q}
|
|
onChange={(nextQuery) => {
|
|
setQ(nextQuery);
|
|
setFocusedIndex(0);
|
|
}}
|
|
count={filtered.length}
|
|
total={pluginRegistry.length}
|
|
onArrow={(dir) => {
|
|
if (filtered.length === 0) return;
|
|
setFocusedIndex((prev) => {
|
|
const next = prev + dir;
|
|
if (next < 0) return filtered.length - 1;
|
|
if (next >= filtered.length) return 0;
|
|
return next;
|
|
});
|
|
}}
|
|
onEnter={() => {
|
|
if (safeFocusedIndex >= 0 && safeFocusedIndex < filtered.length) {
|
|
handleOpenTool(filtered[safeFocusedIndex]);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<CategoryChips
|
|
active={cat}
|
|
onPick={(nextCategory) => {
|
|
setCat(nextCategory);
|
|
setFocusedIndex(0);
|
|
}}
|
|
counts={counts}
|
|
categories={PLIMI_CATEGORIES}
|
|
/>
|
|
|
|
{grouped ? (
|
|
<div className="flex flex-col gap-7 mt-4">
|
|
{grouped.map(({ cat: c, tools }) => (
|
|
<div key={c.id} className="flex flex-col gap-3.5">
|
|
<SectionHeader catLabel={c.label} catId={c.id} n={tools.length} />
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{tools.map((t) => {
|
|
const flatIdx = filtered.indexOf(t);
|
|
return (
|
|
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} index={flatIdx} />
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="py-20 text-center text-[var(--p-muted)] font-sans">
|
|
No tools found matching "{q}".
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
|
{filtered.map((t, flatIdx) => (
|
|
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} index={flatIdx} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|