mirror of
https://github.com/ChiChou/entdb.git
synced 2026-06-11 07:17:47 +02:00
Add responsive header with quick filter tokens for entitlement keys
- Move filter controls into sticky header via React portal - Responsive layout: wraps to 2 rows on mobile, single row on desktop - Keys/Files nav shows icons only on mobile, icons+text on larger screens - Add tokenizer that extracts frequent terms from entitlement keys - Display clickable token pills for quick filtering (apple, security, etc.) - Move item counts below header, above lists - Fix light mode contrast (darken muted-foreground) - Reduce homepage spacing
This commit is contained in:
+1
-1
@@ -56,7 +56,7 @@
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--muted-foreground: oklch(0.45 0 0);
|
||||
--accent: oklch(0.96 0.02 250);
|
||||
--accent-foreground: oklch(0.25 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
|
||||
+62
-56
@@ -8,6 +8,7 @@ import { Search, X, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import FileSystem from "@/components/filesystem";
|
||||
import { HeaderPortal } from "@/components/header-portal";
|
||||
import { createEngine } from "@/lib/engine";
|
||||
|
||||
export default function Files() {
|
||||
@@ -45,64 +46,69 @@ export default function Files() {
|
||||
setExpandAll(isFiltering ? true : null);
|
||||
}, [isFiltering]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-4 shrink-0">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Filter paths..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{keyword && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setKeyword("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!loading && files.length > 0 && (
|
||||
<div className="flex items-center border border-border rounded-md">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(true)}
|
||||
className="h-8 px-2 rounded-r-none"
|
||||
title="Expand All"
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(false)}
|
||||
className="h-8 px-2 rounded-l-none"
|
||||
title="Collapse All"
|
||||
>
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && (
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{isFiltering ? (
|
||||
<>
|
||||
{filtered.length} of {files.length} paths
|
||||
</>
|
||||
) : (
|
||||
<>{files.length} paths</>
|
||||
)}
|
||||
</div>
|
||||
const filterControls = (
|
||||
<>
|
||||
<div className="relative flex-1 sm:flex-none sm:w-96">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Filter paths..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{keyword && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setKeyword("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!loading && files.length > 0 && (
|
||||
<div className="flex items-center border border-border rounded-md">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(true)}
|
||||
className="h-8 px-2 rounded-r-none"
|
||||
title="Expand All"
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(false)}
|
||||
className="h-8 px-2 rounded-l-none"
|
||||
title="Collapse All"
|
||||
>
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<HeaderPortal>{filterControls}</HeaderPortal>
|
||||
|
||||
{!loading && (
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
{isFiltering ? (
|
||||
<>
|
||||
{filtered.length} of {files.length} paths
|
||||
</>
|
||||
) : (
|
||||
<>{files.length} paths</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{loading ? (
|
||||
|
||||
+56
-56
@@ -7,6 +7,7 @@ import { Search, X, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import FileSystem from "@/components/filesystem";
|
||||
import { HeaderPortal } from "@/components/header-portal";
|
||||
import { createEngine } from "@/lib/engine";
|
||||
|
||||
export default function FindByKey() {
|
||||
@@ -67,69 +68,68 @@ export default function FindByKey() {
|
||||
setExpandAll(isFiltering ? true : null);
|
||||
}, [isFiltering]);
|
||||
|
||||
const filterControls = (
|
||||
<>
|
||||
<div className="relative flex-1 sm:flex-none sm:w-96">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Filter paths..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{keyword && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setKeyword("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!loading && paths.length > 0 && (
|
||||
<div className="flex items-center border border-border rounded-md">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(true)}
|
||||
className="h-8 px-2 rounded-r-none"
|
||||
title="Expand All"
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(false)}
|
||||
className="h-8 px-2 rounded-l-none"
|
||||
title="Collapse All"
|
||||
>
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 shrink-0">
|
||||
<div className="min-w-0 shrink-0">
|
||||
<HeaderPortal>{filterControls}</HeaderPortal>
|
||||
|
||||
<div className="mb-3 shrink-0 flex items-baseline justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Binaries with </span>
|
||||
<code className="text-sm break-all text-primary font-medium">{key}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:ml-auto">
|
||||
<div className="relative flex-1 sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Filter paths..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{keyword && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setKeyword("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!loading && paths.length > 0 && (
|
||||
<div className="flex items-center border border-border rounded-md">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(true)}
|
||||
className="h-8 px-2 rounded-r-none"
|
||||
title="Expand All"
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(false)}
|
||||
className="h-8 px-2 rounded-l-none"
|
||||
title="Collapse All"
|
||||
>
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && (
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{isFiltering ? (
|
||||
<>
|
||||
{filtered.length} of {paths.length} paths
|
||||
</>
|
||||
) : (
|
||||
<>{paths.length} paths</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{isFiltering ? `${filtered.length} of ${paths.length}` : paths.length} paths
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
|
||||
+53
-26
@@ -8,7 +8,9 @@ import { Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HeaderPortal } from "@/components/header-portal";
|
||||
import { createEngine } from "@/lib/engine";
|
||||
import { tokenizeKeys, getTopTokens } from "@/lib/tokenizer";
|
||||
|
||||
export default function Keys() {
|
||||
const params = useSearchParams();
|
||||
@@ -47,33 +49,44 @@ export default function Keys() {
|
||||
[debouncedKeyword, keys]
|
||||
);
|
||||
|
||||
const topTokens = useMemo(() => {
|
||||
if (keys.length === 0) return [];
|
||||
const freq = tokenizeKeys(keys);
|
||||
return getTopTokens(freq, 15, 10);
|
||||
}, [keys]);
|
||||
|
||||
const isFiltering = debouncedKeyword.length > 0;
|
||||
|
||||
const filterControls = (
|
||||
<div className="relative flex-1 sm:flex-none sm:w-96">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Filter entitlement keys..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{keyword && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setKeyword("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-4 shrink-0">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Filter entitlement keys..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{keyword && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setKeyword("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!loading && (
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
<HeaderPortal>{filterControls}</HeaderPortal>
|
||||
|
||||
{!loading && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isFiltering ? (
|
||||
<>
|
||||
{filtered.length} of {keys.length} keys
|
||||
@@ -81,9 +94,23 @@ export default function Keys() {
|
||||
) : (
|
||||
<>{keys.length} entitlement keys</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
{topTokens.length > 0 && !isFiltering && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{topTokens.map(({ token }) => (
|
||||
<button
|
||||
key={token}
|
||||
type="button"
|
||||
onClick={() => setKeyword(token)}
|
||||
className="px-2 py-0.5 text-xs rounded-full bg-muted hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{token}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
|
||||
+2
-4
@@ -2,10 +2,8 @@ import OSList from "@/components/oslist";
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<div className="font-sans">
|
||||
<div className="items-center justify-center m-4 md:m-16">
|
||||
<OSList />
|
||||
</div>
|
||||
<div className="p-4 md:p-6">
|
||||
<OSList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
export const HEADER_PORTAL_ID = "header-controls-portal";
|
||||
|
||||
export function HeaderPortal({ children }: { children: ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const target = document.getElementById(HEADER_PORTAL_ID);
|
||||
if (!target) return null;
|
||||
|
||||
return createPortal(children, target);
|
||||
}
|
||||
+42
-82
@@ -6,6 +6,7 @@ import { Moon, Sun, Key, Folder } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { VersionSwitcher } from "./version-switcher";
|
||||
import { HEADER_PORTAL_ID } from "./header-portal";
|
||||
|
||||
function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -44,31 +45,29 @@ function OSBreadcrumb({ os, currentPage }: { os: string; currentPage: string })
|
||||
<VersionSwitcher currentOs={os} />
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex">
|
||||
<div className="inline-flex rounded-lg border border-border p-1 bg-muted/30">
|
||||
<Link
|
||||
href={`/os/keys?os=${os}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
currentPage === "keys"
|
||||
? "bg-background text-foreground shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Key className="h-3.5 w-3.5" />
|
||||
Entitlement Keys
|
||||
</Link>
|
||||
<Link
|
||||
href={`/os/files?os=${os}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
currentPage === "files"
|
||||
? "bg-background text-foreground shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Browse Files
|
||||
</Link>
|
||||
</div>
|
||||
<div className="inline-flex rounded-lg border border-border p-1 bg-muted/30">
|
||||
<Link
|
||||
href={`/os/keys?os=${os}`}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-sm rounded-md transition-colors whitespace-nowrap ${
|
||||
currentPage === "keys"
|
||||
? "bg-background text-foreground shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="hidden sm:inline">Keys</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/os/files?os=${os}`}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-sm rounded-md transition-colors whitespace-nowrap ${
|
||||
currentPage === "files"
|
||||
? "bg-background text-foreground shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="hidden sm:inline">Files</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -120,70 +119,31 @@ export function NavTop() {
|
||||
? "find"
|
||||
: "keys";
|
||||
|
||||
const showHeaderControls = isOSPage && (currentPage === "keys" || currentPage === "files" || currentPage === "find");
|
||||
|
||||
return (
|
||||
<header className="shrink-0 border-b border-border bg-background">
|
||||
{/* Desktop: single row */}
|
||||
<div className="hidden sm:flex items-center gap-4 px-4 md:px-6 h-14">
|
||||
<h1 className="text-xl font-bold shrink-0">
|
||||
<Link href="/" className="hover:text-muted-foreground transition-colors">
|
||||
entdb
|
||||
</Link>
|
||||
</h1>
|
||||
|
||||
<div className="flex-1 flex items-center min-w-0">
|
||||
{isHome && <HomeControls />}
|
||||
{isOSPage && os && <OSBreadcrumb os={os} currentPage={currentPage} />}
|
||||
</div>
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Mobile: two rows when needed */}
|
||||
<div className="sm:hidden">
|
||||
<div className="flex items-center justify-between px-4 h-12">
|
||||
<h1 className="text-lg font-bold">
|
||||
<header className="sticky top-0 z-50 shrink-0 border-b border-border bg-background">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 md:px-6 py-3">
|
||||
{/* Left group: logo + breadcrumb */}
|
||||
<div className="flex items-center gap-4 min-w-0 shrink-0">
|
||||
<h1 className="text-xl font-bold shrink-0">
|
||||
<Link href="/" className="hover:text-muted-foreground transition-colors">
|
||||
entdb
|
||||
</Link>
|
||||
</h1>
|
||||
<ThemeToggle />
|
||||
{isHome && <HomeControls />}
|
||||
{isOSPage && os && <OSBreadcrumb os={os} currentPage={currentPage} />}
|
||||
</div>
|
||||
|
||||
{(isHome || (isOSPage && os)) && (
|
||||
<div className="flex items-center gap-2 px-4 pb-3 overflow-x-auto">
|
||||
{isHome && <HomeControls />}
|
||||
{isOSPage && os && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{os.split("/")[0]}</span>
|
||||
<VersionSwitcher currentOs={os} />
|
||||
<div className="inline-flex rounded-lg border border-border p-1 bg-muted/30 shrink-0">
|
||||
<Link
|
||||
href={`/os/keys?os=${os}`}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
currentPage === "keys"
|
||||
? "bg-background text-foreground shadow-sm font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Key className="h-3 w-3" />
|
||||
Keys
|
||||
</Link>
|
||||
<Link
|
||||
href={`/os/files?os=${os}`}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
currentPage === "files"
|
||||
? "bg-background text-foreground shadow-sm font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
Files
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden sm:block flex-1 min-w-0" />
|
||||
|
||||
{/* Right group: filter + theme */}
|
||||
<div className="flex items-center gap-2 flex-1 sm:flex-none justify-end">
|
||||
{showHeaderControls && (
|
||||
<div id={HEADER_PORTAL_ID} className="flex items-center gap-2 flex-1 sm:flex-none" />
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export function tokenizeKeys(keys: string[]): Map<string, number> {
|
||||
const freq = new Map<string, number>();
|
||||
|
||||
for (const key of keys) {
|
||||
// Split by dots, dashes, underscores
|
||||
const parts = key.split(/[.\-_]/);
|
||||
|
||||
for (const part of parts) {
|
||||
// Further split camelCase: "AppBundles" -> ["App", "Bundles"]
|
||||
const camelParts = part.split(/(?=[A-Z])/).filter((p) => p.length > 0);
|
||||
|
||||
for (const token of camelParts) {
|
||||
const lower = token.toLowerCase();
|
||||
// Skip very short or numeric tokens
|
||||
if (lower.length < 3 || /^\d+$/.test(lower)) continue;
|
||||
freq.set(lower, (freq.get(lower) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return freq;
|
||||
}
|
||||
|
||||
export function getTopTokens(
|
||||
freq: Map<string, number>,
|
||||
limit = 20,
|
||||
minCount = 5
|
||||
): Array<{ token: string; count: number }> {
|
||||
return Array.from(freq.entries())
|
||||
.filter(([, count]) => count >= minCount)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit)
|
||||
.map(([token, count]) => ({ token, count }));
|
||||
}
|
||||
Reference in New Issue
Block a user