From 5e30a8b930d599dd41ee76070854598384f716d1 Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 15 Apr 2026 19:06:23 +0200 Subject: [PATCH] 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 --- src/app/globals.css | 2 +- src/app/os/files/page.tsx | 118 +++++++++++++++-------------- src/app/os/find/page.tsx | 112 ++++++++++++++-------------- src/app/os/keys/page.tsx | 79 +++++++++++++------- src/app/page.tsx | 6 +- src/components/header-portal.tsx | 21 ++++++ src/components/navtop.tsx | 124 +++++++++++-------------------- src/lib/tokenizer.ts | 34 +++++++++ 8 files changed, 271 insertions(+), 225 deletions(-) create mode 100644 src/components/header-portal.tsx create mode 100644 src/lib/tokenizer.ts diff --git a/src/app/globals.css b/src/app/globals.css index d9d76ac..71708f8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/app/os/files/page.tsx b/src/app/os/files/page.tsx index 0f31552..2ff5ba2 100644 --- a/src/app/os/files/page.tsx +++ b/src/app/os/files/page.tsx @@ -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 ( -
-
-
- - setKeyword(e.target.value)} - className="pl-9 pr-9" - /> - {keyword && ( - - )} -
- {!loading && files.length > 0 && ( -
- -
- -
- )} - {!loading && ( -
- {isFiltering ? ( - <> - {filtered.length} of {files.length} paths - - ) : ( - <>{files.length} paths - )} -
+ const filterControls = ( + <> +
+ + setKeyword(e.target.value)} + className="pl-9 pr-9" + /> + {keyword && ( + )}
+ {!loading && files.length > 0 && ( +
+ +
+ +
+ )} + + ); + + return ( +
+ {filterControls} + + {!loading && ( +
+ {isFiltering ? ( + <> + {filtered.length} of {files.length} paths + + ) : ( + <>{files.length} paths + )} +
+ )}
{loading ? ( diff --git a/src/app/os/find/page.tsx b/src/app/os/find/page.tsx index 89d9253..6d400b2 100644 --- a/src/app/os/find/page.tsx +++ b/src/app/os/find/page.tsx @@ -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 = ( + <> +
+ + setKeyword(e.target.value)} + className="pl-9 pr-9" + /> + {keyword && ( + + )} +
+ {!loading && paths.length > 0 && ( +
+ +
+ +
+ )} + + ); + return (
-
-
+ {filterControls} + +
+
Binaries with {key}
-
-
- - setKeyword(e.target.value)} - className="pl-9 pr-9" - /> - {keyword && ( - - )} -
- {!loading && paths.length > 0 && ( -
- -
- -
- )} {!loading && ( -
- {isFiltering ? ( - <> - {filtered.length} of {paths.length} paths - - ) : ( - <>{paths.length} paths - )} -
+ + {isFiltering ? `${filtered.length} of ${paths.length}` : paths.length} paths + )} -
diff --git a/src/app/os/keys/page.tsx b/src/app/os/keys/page.tsx index cc9abbb..201a5ab 100644 --- a/src/app/os/keys/page.tsx +++ b/src/app/os/keys/page.tsx @@ -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 = ( +
+ + setKeyword(e.target.value)} + className="pl-9 pr-9" + /> + {keyword && ( + + )} +
+ ); + return (
-
-
- - setKeyword(e.target.value)} - className="pl-9 pr-9" - /> - {keyword && ( - - )} -
- {!loading && ( -
+ {filterControls} + + {!loading && ( +
+ {isFiltering ? ( <> {filtered.length} of {keys.length} keys @@ -81,9 +94,23 @@ export default function Keys() { ) : ( <>{keys.length} entitlement keys )} -
- )} -
+ + {topTokens.length > 0 && !isFiltering && ( +
+ {topTokens.map(({ token }) => ( + + ))} +
+ )} +
+ )} {loading ? (
diff --git a/src/app/page.tsx b/src/app/page.tsx index f12793b..78ecc98 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,10 +2,8 @@ import OSList from "@/components/oslist"; export default async function Home() { return ( -
-
- -
+
+
); } diff --git a/src/components/header-portal.tsx b/src/components/header-portal.tsx new file mode 100644 index 0000000..192b141 --- /dev/null +++ b/src/components/header-portal.tsx @@ -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); +} diff --git a/src/components/navtop.tsx b/src/components/navtop.tsx index b6730bd..d02a97e 100644 --- a/src/components/navtop.tsx +++ b/src/components/navtop.tsx @@ -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 })
-
-
- - - Entitlement Keys - - - - Browse Files - -
+
+ + + Keys + + + + Files +
); @@ -120,70 +119,31 @@ export function NavTop() { ? "find" : "keys"; + const showHeaderControls = isOSPage && (currentPage === "keys" || currentPage === "files" || currentPage === "find"); + return ( -
- {/* Desktop: single row */} -
-

- - entdb - -

- -
- {isHome && } - {isOSPage && os && } -
- - -
- - {/* Mobile: two rows when needed */} -
-
-

+
+
+ {/* Left group: logo + breadcrumb */} +
+

entdb

- + {isHome && } + {isOSPage && os && }
- {(isHome || (isOSPage && os)) && ( -
- {isHome && } - {isOSPage && os && ( -
- {os.split("/")[0]} - -
- - - Keys - - - - Files - -
-
- )} -
- )} +
+ + {/* Right group: filter + theme */} +
+ {showHeaderControls && ( +
+ )} + +
); diff --git a/src/lib/tokenizer.ts b/src/lib/tokenizer.ts new file mode 100644 index 0000000..47f0c97 --- /dev/null +++ b/src/lib/tokenizer.ts @@ -0,0 +1,34 @@ +export function tokenizeKeys(keys: string[]): Map { + const freq = new Map(); + + 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, + 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 })); +}