UI improvements: theme support, version history, layout fixes

- Add dark/light mode toggle with next-themes
- Redesign binary detail page with version history sidebar and diff support
- Improve keys page with multi-column grid layout
- Update navtop styling to match content padding
- Replace checkbox with segmented button for "Latest Only" toggle
- Upgrade to Next.js 16, React 19, TypeScript 6
This commit is contained in:
cc
2026-04-14 18:20:11 +02:00
parent c28311ceae
commit 868f871863
10 changed files with 2211 additions and 972 deletions
+1923 -785
View File
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.4.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -21,12 +22,12 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"lucide-react": "^1.8.0",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-syntax-highlighter": "^15.6.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-syntax-highlighter": "^16.1.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"use-debounce": "^10.0.5"
@@ -34,14 +35,14 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9",
"eslint-config-next": "15.5.0",
"eslint": "^10.2.0",
"eslint-config-next": "^16.2.3",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.7",
"typescript": "^5"
"typescript": "^6.0.2"
}
}
+9 -6
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import "./globals.css";
import { NavTop } from "@/components/navtop";
@@ -28,15 +29,17 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<NavTop />
<Toaster />
<Suspense>
<main className="flex flex-col">{children}</main>
</Suspense>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<NavTop />
<Toaster />
<Suspense>
<main className="flex flex-col">{children}</main>
</Suspense>
</ThemeProvider>
</body>
</html>
);
+120 -107
View File
@@ -1,13 +1,15 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { redirect, useSearchParams } from "next/navigation";
import { redirect, useSearchParams, useRouter } from "next/navigation";
import {
createElement,
Prism as SyntaxHighlighter,
} from "react-syntax-highlighter";
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
import Link from "next/link";
import { GitCompare } from "lucide-react";
import { CopyButton } from "@/components/copy-button";
import { DiffViewer } from "@/components/diff-viewer";
@@ -18,9 +20,9 @@ import { normalizePlist } from "@/lib/plist";
export default function BinaryDetail() {
const params = useSearchParams();
const router = useRouter();
const os = params.get("os");
const path = params.get("path");
const compareWith = params.get("compare");
const [group, build] = os ? os.split("/") : ["", ""];
@@ -38,9 +40,9 @@ export default function BinaryDetail() {
const [xml, setXML] = useState<string>("");
const [xmlKeys, setXMLKeys] = useState<Set<string>>(new Set());
const [history, setHistory] = useState<PathHistory[]>([]);
const [compareWith, setCompareWith] = useState<string | null>(null);
const [compareXml, setCompareXml] = useState<string>("");
const [compareLoading, setCompareLoading] = useState(false);
const [compareError, setCompareError] = useState<string>("");
useEffect(() => {
async function load() {
@@ -70,10 +72,10 @@ export default function BinaryDetail() {
}, [group, build, path]);
useEffect(() => {
if (!compareWith || !group) return;
setCompareError("");
setCompareXml("");
if (!compareWith || !group) {
setCompareXml("");
return;
}
async function loadCompare() {
const engine = await createEngine(group);
@@ -84,27 +86,12 @@ export default function BinaryDetail() {
setCompareLoading(true);
loadCompare()
.catch((err) => {
setCompareError(err.message || "Failed to load comparison");
})
.catch(() => setCompareXml(""))
.finally(() => setCompareLoading(false));
}, [group, compareWith, path]);
const normalizedXml = useMemo(
() => (xml ? normalizePlist(xml) : ""),
[xml],
);
const normalizedCompareXml = useMemo(
() => (compareXml ? normalizePlist(compareXml) : ""),
[compareXml],
);
const availableHistory = history.filter((h) => h.available);
const currentOs = history.find(
(h) => h.os.build === build || `${h.os.version}_${h.os.build}` === build,
);
// Group versions by major version
const groupedHistory = useMemo(() => {
const groups: { [major: string]: typeof availableHistory } = {};
for (const h of availableHistory) {
@@ -115,40 +102,11 @@ export default function BinaryDetail() {
return Object.entries(groups).sort(([a], [b]) => Number(b) - Number(a));
}, [availableHistory]);
const renderVersionLink = (h: typeof availableHistory[0]) => {
const isCurrent =
h.os.build === build || `${h.os.version}_${h.os.build}` === build;
const isComparing = compareWith === `${h.os.version}_${h.os.build}`;
const versionTag = `${h.os.version}_${h.os.build}`;
if (isCurrent) {
return (
<span
key={h.os.build}
className="block px-2 py-1 text-xs rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 font-medium"
>
{h.os.version} (current)
</span>
);
}
const href = isComparing
? addBasePath(`/os/bin?os=${encodeURIComponent(os!)}&path=${encodeURIComponent(path!)}`)
: addBasePath(`/os/bin?os=${encodeURIComponent(os!)}&path=${encodeURIComponent(path!)}&compare=${encodeURIComponent(versionTag)}`);
return (
<a
key={h.os.build}
href={href}
className={`block px-2 py-1 text-xs rounded transition-colors ${
isComparing
? "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
: "hover:bg-accent"
}`}
>
{h.os.version}
{isComparing && " (comparing)"}
</a>
const switchVersion = (versionTag: string) => {
router.push(
addBasePath(
`/os/bin?os=${encodeURIComponent(group + "/" + versionTag)}&path=${encodeURIComponent(path!)}`
)
);
};
@@ -156,6 +114,100 @@ export default function BinaryDetail() {
return (
<div className="flex flex-col lg:flex-row gap-6">
{/* Version history sidebar - now on left and wider */}
{hasVersionHistory && (
<aside className="lg:w-64 shrink-0 order-2 lg:order-first">
<div className="lg:sticky lg:top-4">
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
Version History ({availableHistory.length})
</h3>
<div className="space-y-1 max-h-[70vh] overflow-y-auto pr-2">
{groupedHistory.map(([major, versions]) => (
<details
key={major}
open={versions.some(
(h) =>
h.os.build === build ||
`${h.os.version}_${h.os.build}` === build
)}
className="group"
>
<summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground flex items-center gap-2 py-1.5 px-2 rounded hover:bg-accent">
<span className="group-open:rotate-90 transition-transform text-xs">
</span>
<span className="flex-1">{group === "iOS" ? "iOS" : group} {major}.x</span>
<span className="text-xs text-muted-foreground/60">
{versions.length}
</span>
</summary>
<div className="ml-4 mt-1 space-y-0.5 border-l border-border pl-3">
{versions.map((h) => {
const isCurrent =
h.os.build === build ||
`${h.os.version}_${h.os.build}` === build;
const versionTag = `${h.os.version}_${h.os.build}`;
const isComparing = compareWith === versionTag;
return (
<div
key={h.os.build}
className={`flex items-center gap-1 px-2 py-1 text-sm rounded transition-colors ${
isCurrent
? "bg-primary text-primary-foreground font-medium"
: isComparing
? "bg-yellow-100 dark:bg-yellow-900/50"
: "hover:bg-accent"
}`}
>
<button
onClick={() => !isCurrent && switchVersion(versionTag)}
disabled={isCurrent}
className={`flex-1 text-left ${
isCurrent
? "cursor-default"
: "text-muted-foreground hover:text-foreground"
}`}
>
{h.os.version}
{isCurrent && (
<span className="ml-1 text-xs opacity-70">
(current)
</span>
)}
{isComparing && (
<span className="ml-1 text-xs text-yellow-700 dark:text-yellow-300">
(diff)
</span>
)}
</button>
{!isCurrent && (
<button
onClick={(e) => {
e.stopPropagation();
setCompareWith(isComparing ? null : versionTag);
}}
className={`p-1 rounded transition-colors ${
isComparing
? "text-yellow-700 dark:text-yellow-300 hover:bg-yellow-200 dark:hover:bg-yellow-800"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
title={isComparing ? "Close diff" : `Compare with ${h.os.version}`}
>
<GitCompare className="h-3.5 w-3.5" />
</button>
)}
</div>
);
})}
</div>
</details>
))}
</div>
</div>
</aside>
)}
{/* Main content */}
<main className="flex-1 min-w-0 space-y-4">
<div className="flex items-start justify-between gap-4">
@@ -176,19 +228,16 @@ export default function BinaryDetail() {
</div>
)}
{!loading && compareWith && compareError && (
<div className="border border-red-300 bg-red-50 dark:bg-red-900/20 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300">
<p className="font-medium">Comparison failed</p>
<p className="text-sm mt-1">{compareError}</p>
</div>
{!loading && compareWith && compareLoading && (
<div className="h-64 bg-muted rounded animate-pulse" />
)}
{!loading && compareWith && !compareLoading && !compareError && normalizedCompareXml && (
{!loading && compareWith && !compareLoading && compareXml && (
<DiffViewer
oldXml={normalizedCompareXml}
newXml={normalizedXml}
oldLabel={`${compareWith}`}
newLabel={currentOs ? `${currentOs.os.version}_${currentOs.os.build}` : build}
oldXml={compareXml}
newXml={xml}
oldLabel={compareWith}
newLabel={build}
/>
)}
@@ -258,48 +307,12 @@ export default function BinaryDetail() {
</div>
)}
{compareLoading && (
<div className="h-64 bg-muted rounded animate-pulse" />
{!loading && !xml && (
<div className="text-center py-12 text-muted-foreground">
<p>No entitlement data found for this binary.</p>
</div>
)}
</main>
{/* Version history sidebar */}
{hasVersionHistory && (
<aside className="lg:w-48 shrink-0">
<div className="lg:sticky lg:top-4">
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">
History ({availableHistory.length})
</h3>
<div className="space-y-2 max-h-[60vh] overflow-y-auto pr-1">
{groupedHistory.map(([major, versions]) => (
<details
key={major}
open={versions.some(
(h) =>
h.os.build === build ||
`${h.os.version}_${h.os.build}` === build ||
compareWith === `${h.os.version}_${h.os.build}`
)}
className="group"
>
<summary className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground flex items-center gap-1 py-1">
<span className="group-open:rotate-90 transition-transform">
</span>
iOS {major}.x
<span className="ml-auto text-muted-foreground/60">
{versions.length}
</span>
</summary>
<div className="ml-3 mt-1 space-y-0.5 border-l pl-2">
{versions.map(renderVersionLink)}
</div>
</details>
))}
</div>
</div>
</aside>
)}
</div>
);
}
+43 -14
View File
@@ -62,18 +62,18 @@ function KeyBadge({
return (
<Link
href={`/os/find?key=${encodeURIComponent(keyName)}&os=${os}`}
className="inline-block px-2 py-1 bg-muted hover:bg-accent rounded text-sm font-mono transition-colors group"
className="block py-1 font-mono text-muted-foreground hover:text-foreground transition-colors group truncate"
title={keyName}
>
{suffix ? (
<>
<span className="text-muted-foreground group-hover:text-muted-foreground/70 text-xs">
<span className="text-muted-foreground/60 group-hover:text-muted-foreground text-sm">
{prefix}
</span>
<span className="text-foreground font-medium">{suffix}</span>
<span className="text-foreground/80 group-hover:text-foreground">{suffix}</span>
</>
) : (
<span className="text-foreground">{keyName}</span>
<span>{keyName}</span>
)}
</Link>
);
@@ -292,16 +292,45 @@ export default function Keys() {
)}
</div>
) : (
<div className="space-y-0.5">
{sortedPrefixes.map((prefix) => (
<KeyGroup
key={prefix}
prefix={prefix}
keys={grouped[prefix]}
os={os}
forceOpen={forceOpen}
/>
))}
<div>
{/* Single keys in multi-column grid */}
{(() => {
const singleKeys = sortedPrefixes.filter(
(p) => grouped[p].length === 1 && grouped[p][0] === p
);
const groupKeys = sortedPrefixes.filter(
(p) => grouped[p].length > 1 || grouped[p][0] !== p
);
return (
<>
{singleKeys.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-1.5 mb-4">
{singleKeys.map((prefix) => (
<KeyBadge
key={prefix}
keyName={grouped[prefix][0]}
prefix=""
os={os}
/>
))}
</div>
)}
{groupKeys.length > 0 && (
<div className="space-y-0.5">
{groupKeys.map((prefix) => (
<KeyGroup
key={prefix}
prefix={prefix}
keys={grouped[prefix]}
os={os}
forceOpen={forceOpen}
/>
))}
</div>
)}
</>
);
})()}
</div>
)}
</div>
-3
View File
@@ -3,9 +3,6 @@ import OSList from "@/components/oslist";
export default async function Home() {
return (
<div className="font-sans">
<h1 className="text-2xl md:text-4xl text-center md-mt-16 mt-8">
Entitlement Database
</h1>
<div className="items-center justify-center m-4 md:m-16">
<OSList />
</div>
+17 -24
View File
@@ -81,20 +81,15 @@ function Tree({
expandAll={expandAll}
/>
))}
{files.length > 0 && (
<li>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-x-4">
{files.map(([key, value]) => (
<FileItem
key={value as string}
name={key}
fullPath={value as string}
os={os}
/>
))}
</div>
{files.map(([key, value]) => (
<li key={value as string}>
<FileItem
name={key}
fullPath={value as string}
os={os}
/>
</li>
)}
))}
</ul>
);
}
@@ -139,18 +134,16 @@ function TreeFolder({
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className={isShallow ? "ml-6 mt-1" : "ml-4 pl-2 border-l border-border"}>
<div className={isShallow ? "ml-6 mt-1 space-y-0.5" : "ml-4 pl-2 border-l border-border"}>
{isShallow ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-x-4">
{Object.entries(item).map(([key, value]) => (
<FileItem
key={value as string}
name={key}
fullPath={value as string}
os={os}
/>
))}
</div>
Object.entries(item).map(([key, value]) => (
<FileItem
key={value as string}
name={key}
fullPath={value as string}
os={os}
/>
))
) : (
<Tree item={item} os={os} depth={depth + 1} expandAll={expandAll} />
)}
+38 -5
View File
@@ -1,19 +1,51 @@
"use client";
import Link from "next/link";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { useEffect, useState } from "react";
function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="w-8 h-8" />;
}
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
);
}
export function NavTop() {
return (
<header className="flex flex-row justify-between items-center p-4 w-full bg-gray-900 text-white">
<header className="flex flex-row justify-between items-center px-4 md:px-8 py-4 w-full border-b border-border bg-background text-foreground">
<h1 className="text-2xl font-bold">
<Link href="/" className="hover:text-gray-300">
<Link href="/" className="hover:text-muted-foreground">
entdb
</Link>
</h1>
<nav className="flex gap-4 text-sm">
<nav className="flex items-center gap-4 text-sm">
<a
href="https://github.com/chichou/entdb-indexer"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-300"
className="text-muted-foreground hover:text-foreground transition-colors"
>
GitHub
</a>
@@ -21,10 +53,11 @@ export function NavTop() {
href="https://infosec.exchange/@codecolorist"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-300"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Mastodon
</a>
<ThemeToggle />
</nav>
</header>
);
+27 -12
View File
@@ -123,16 +123,29 @@ export default function OSList() {
)}
{!loading && (
<header className="mb-4">
<Checkbox
id="select-all"
className="mr-2"
checked={showLess}
onCheckedChange={(checked) => setShowLess(Boolean(checked))}
/>
<label htmlFor="select-all" className="text-lg font-medium">
Show Less
</label>
<header className="mb-6 flex items-center gap-2">
<div className="inline-flex rounded-lg border border-border p-1 bg-muted/30">
<button
onClick={() => setShowLess(true)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
showLess
? "bg-background text-foreground shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
Latest Only
</button>
<button
onClick={() => setShowLess(false)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
!showLess
? "bg-background text-foreground shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
All Versions
</button>
</div>
</header>
)}
@@ -146,11 +159,13 @@ export default function OSList() {
<li key={index} className="list-none">
<Link
href={`/os/keys?os=${group.name}/${os.version}_${os.build}`}
className="block p-4 border rounded-lg shadow-sm hover:shadow-md transition-all hover:bg-gray-50"
className="block p-4 border border-border rounded-lg hover:border-foreground/20 transition-colors hover:bg-accent/50"
>
<div className="flex justify-between items-center">
<h2 className="text-lg">{os.name}</h2>
<div className="text-sm text-gray-500">{os.build}</div>
<div className="text-sm text-muted-foreground">
{os.build}
</div>
</div>
</Link>
</li>
+22 -5
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,22 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "public/data/**", "data/**", "out/**"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"public/data/**",
"data/**",
"out/**"
]
}