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:
cc
2026-04-15 19:06:23 +02:00
parent 7d6fea9ef0
commit 5e30a8b930
8 changed files with 271 additions and 225 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+21
View File
@@ -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
View File
@@ -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>
);
+34
View File
@@ -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 }));
}