Improve navigation and keys UI

- Add version switcher dropdown to quickly jump between OS versions
- Redesign keys page with grouped, collapsible sections
- Dim common prefix and highlight unique suffix for better scanning
- Add tab navigation between Entitlement Keys and Browse Files
- Show key counts per group and total
This commit is contained in:
cc
2026-04-14 17:31:03 +02:00
parent c13f83e3f7
commit 59f2c0484b
3 changed files with 393 additions and 63 deletions
+205 -39
View File
@@ -1,15 +1,135 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useDebounce } from "use-debounce";
import Link from "next/link";
import { Search, X, ChevronRight, ChevronDown } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { createEngine } from "@/lib/engine";
interface GroupedKeys {
[prefix: string]: string[];
}
function groupKeysByPrefix(keys: string[]): GroupedKeys {
const groups: GroupedKeys = {};
for (const key of keys) {
const parts = key.split(".");
// Use first 3 segments for com.apple.*, otherwise use the whole key
let prefix: string;
if (parts.length >= 3 && parts[0] === "com" && parts[1] === "apple") {
prefix = `${parts[0]}.${parts[1]}.${parts[2]}`;
} else if (parts.length >= 2) {
prefix = `${parts[0]}.${parts[1]}`;
} else {
prefix = key;
}
if (!groups[prefix]) {
groups[prefix] = [];
}
groups[prefix].push(key);
}
return groups;
}
function KeyBadge({
keyName,
prefix,
os,
}: {
keyName: string;
prefix: string;
os: string;
}) {
const suffix = keyName.startsWith(prefix + ".")
? keyName.slice(prefix.length)
: keyName === prefix
? ""
: keyName;
return (
<Link
href={`/os/find?key=${encodeURIComponent(keyName)}&os=${os}`}
className="inline-flex items-baseline px-2 py-1 bg-muted hover:bg-accent rounded text-sm font-mono transition-colors group"
title={keyName}
>
{suffix ? (
<>
<span className="text-muted-foreground group-hover:text-muted-foreground/70 text-xs">
{prefix}
</span>
<span className="text-foreground font-medium">{suffix}</span>
</>
) : (
<span className="text-foreground">{keyName}</span>
)}
</Link>
);
}
function KeyGroup({
prefix,
keys,
os,
defaultOpen,
}: {
prefix: string;
keys: string[];
os: string;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
// Single standalone key - just show it inline
if (keys.length === 1 && keys[0] === prefix) {
return (
<div className="py-1">
<KeyBadge keyName={keys[0]} prefix="" os={os} />
</div>
);
}
return (
<Collapsible open={open} onOpenChange={setOpen} className="py-1">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 px-2 py-1 hover:bg-accent rounded transition-colors text-left">
{open ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="font-mono text-sm text-muted-foreground">
{prefix}
<span className="text-foreground font-medium">.*</span>
</span>
<span className="text-xs text-muted-foreground bg-background border px-1.5 py-0.5 rounded-full">
{keys.length}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-wrap gap-1.5 pl-6 pt-1.5 pb-2">
{keys.map((key) => (
<KeyBadge key={key} keyName={key} prefix={prefix} os={os} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}
export default function Keys() {
const params = useSearchParams();
const os = params.get("os") as string;
@@ -17,15 +137,15 @@ export default function Keys() {
const [loading, setLoading] = useState(true);
const [keys, setKeys] = useState<string[]>([]);
const [filtered, setFiltered] = useState<string[]>([]);
const [keyword, setKeyword] = useState("");
const [value] = useDebounce(keyword, 200);
const [debouncedKeyword] = useDebounce(keyword, 200);
useEffect(() => {
async function load() {
const engine = await createEngine(group);
const allKeys = await engine.getKeys(build);
allKeys.sort((a, b) => a.localeCompare(b));
setKeys(allKeys);
}
@@ -33,52 +153,98 @@ export default function Keys() {
load().finally(() => setLoading(false));
}, [group, build]);
useEffect(() => {
setFiltered(
keys.filter((key) => key.toLowerCase().includes(value.toLowerCase())),
);
}, [value, keys]);
const filtered = useMemo(
() =>
keys.filter((key) =>
key.toLowerCase().includes(debouncedKeyword.toLowerCase())
),
[debouncedKeyword, keys]
);
const grouped = useMemo(() => groupKeysByPrefix(filtered), [filtered]);
const sortedPrefixes = useMemo(
() => Object.keys(grouped).sort((a, b) => a.localeCompare(b)),
[grouped]
);
const isFiltering = debouncedKeyword.length > 0;
return (
<div>
<div className="relative w-full max-w-md mb-4">
<Input
type="text"
placeholder="Filter keys..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="p-2 border rounded w-full inset-shadow-accent pr-10"
/>
{keyword && (
<button
onClick={() => setKeyword("")}
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
</button>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
<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">
{isFiltering ? (
<>
{filtered.length} of {keys.length} keys
</>
) : (
<>{keys.length} entitlement keys</>
)}
</div>
)}
</div>
{loading ? (
<div className="space-y-2">
<div className="space-y-3">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="h-8 bg-gray-200 rounded animate-pulse"
style={{ width: `${60 + Math.random() * 40}%` }}
/>
<div key={index} className="space-y-2">
<div
className="h-6 bg-muted rounded animate-pulse"
style={{ width: `${20 + Math.random() * 30}%` }}
/>
<div className="flex flex-wrap gap-1.5 pl-6">
{Array.from({ length: 3 + Math.floor(Math.random() * 4) }).map(
(_, i) => (
<div
key={i}
className="h-7 bg-muted rounded animate-pulse"
style={{ width: `${80 + Math.random() * 120}px` }}
/>
)
)}
</div>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{keys.length === 0 ? (
<p>No entitlement keys found for this OS version.</p>
) : (
<p>No keys match "{keyword}"</p>
)}
</div>
) : (
<div className="flex w-full flex-wrap gap-2 overflow-x-clip">
{filtered.map((key, index) => (
<Badge
variant="outline"
key={index}
className="font-mono break-all text-sm"
>
<Link href={`/os/find?key=${key}&os=${os}`}>{key}</Link>
</Badge>
<div className="space-y-0.5">
{sortedPrefixes.map((prefix) => (
<KeyGroup
key={prefix}
prefix={prefix}
keys={grouped[prefix]}
os={os}
defaultOpen={isFiltering || grouped[prefix].length <= 8}
/>
))}
</div>
)}
+50 -24
View File
@@ -9,9 +9,12 @@ import {
BreadcrumbList,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VersionSwitcher } from "@/components/version-switcher";
import { addBasePath } from "@/lib/env";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
export default function OSDetailLayout({
children,
@@ -19,36 +22,59 @@ export default function OSDetailLayout({
children: React.ReactNode;
}) {
const params = useSearchParams();
const os = params.get("os");
const pathname = usePathname();
const router = useRouter();
const os = params.get("os") || "";
const currentTab = pathname.includes("/files")
? "files"
: pathname.includes("/bin")
? "bin"
: pathname.includes("/find")
? "find"
: "keys";
useEffect(() => {
if (os) document.title = `${os || ""} - Entitlement Database`;
}, [os]);
const handleTabChange = (tab: string) => {
if (tab === "bin") return;
router.push(addBasePath(`/os/${tab}?os=${os}`));
};
return (
<div className="p-8" suppressHydrationWarning>
<header className="mb-8">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href={addBasePath("/")}>Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={addBasePath(`/os/keys?os=${os}`)}>
{os}
</BreadcrumbLink>
|
<BreadcrumbLink href={addBasePath(`/os/keys?os=${os}`)}>
Search Keys
</BreadcrumbLink>
|
<BreadcrumbLink href={addBasePath(`/os/files?os=${os}`)}>
Search Paths
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="p-4 md:p-8" suppressHydrationWarning>
<header className="mb-6 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href={addBasePath("/")}>Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<span className="text-muted-foreground">
{os?.split("/")[0]}
</span>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<VersionSwitcher currentOs={os} />
</div>
<Tabs value={currentTab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="keys">Entitlement Keys</TabsTrigger>
<TabsTrigger value="files">Browse Files</TabsTrigger>
{currentTab === "find" && (
<TabsTrigger value="find">Search Results</TabsTrigger>
)}
{currentTab === "bin" && (
<TabsTrigger value="bin">Binary Detail</TabsTrigger>
)}
</TabsList>
</Tabs>
</header>
<div>{children}</div>
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ChevronDown, Check } from "lucide-react";
import type { OS } from "@/lib/types";
import { addBasePath } from "@/lib/env";
function compareVersion(a: string, b: string) {
const l1 = a.split(".").map(Number);
const l2 = b.split(".").map(Number);
const len = Math.max(l1.length, l2.length);
for (let i = 0; i < len; i++) {
const v1 = l1[i] || 0;
const v2 = l2[i] || 0;
if (v1 !== v2) return v2 - v1;
}
return 0;
}
export function VersionSwitcher({ currentOs }: { currentOs: string }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [group, currentBuild] = currentOs ? currentOs.split("/") : ["", ""];
const [open, setOpen] = useState(false);
const [versions, setVersions] = useState<OS[]>([]);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!group) return;
fetch(addBasePath(`/data/${group}/list.json`))
.then((r) => r.json())
.then((list: OS[]) => {
list.sort((a, b) => compareVersion(a.version, b.version));
setVersions(list);
})
.finally(() => setLoading(false));
}, [group]);
const currentVersion = versions.find(
(v) => v.build === currentBuild || `${v.version}_${v.build}` === currentBuild
);
const filteredVersions = versions.filter((v) =>
`${v.version} ${v.build} ${v.name}`.toLowerCase().includes(filter.toLowerCase())
);
const handleSelect = (os: OS) => {
const newTag = `${os.version}_${os.build}`;
const newParams = new URLSearchParams(searchParams.toString());
newParams.set("os", `${group}/${newTag}`);
router.push(`${pathname}?${newParams.toString()}`);
setOpen(false);
setFilter("");
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="justify-between min-w-[200px] font-mono"
>
{loading ? (
"Loading..."
) : currentVersion ? (
<span>
{currentVersion.version}{" "}
<span className="text-muted-foreground text-xs">
({currentVersion.build})
</span>
</span>
) : (
currentBuild
)}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<div className="p-2 border-b">
<Input
placeholder="Filter versions..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto p-1">
{filteredVersions.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
No versions found
</div>
)}
{filteredVersions.map((os) => {
const isSelected =
os.build === currentBuild ||
`${os.version}_${os.build}` === currentBuild;
return (
<button
key={os.build}
onClick={() => handleSelect(os)}
className={`w-full flex items-center justify-between px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer ${
isSelected ? "bg-accent" : ""
}`}
>
<span className="font-mono">
{os.version}{" "}
<span className="text-muted-foreground text-xs">
({os.build})
</span>
</span>
{isSelected && <Check className="h-4 w-4" />}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}