mirror of
https://github.com/ChiChou/entdb.git
synced 2026-06-10 23:07:47 +02:00
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:
+205
-39
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user