mirror of
https://github.com/ChiChou/entdb.git
synced 2026-06-10 23:07:47 +02:00
Redesign header layout and compact filter controls
- Move Keys/Files button group left-aligned after version dropdown - Add icons to Keys/Files buttons with longer labels - Use compact icon-only expand/collapse buttons on files and find pages - Make platform filters (iOS/mac/osx) toggleable instead of anchors - Optimize find page: filter controls right-aligned on same row as title - Simplify keys page to flat grid layout without tree grouping
This commit is contained in:
+36
-35
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Search, X, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -47,7 +47,7 @@ export default function Files() {
|
||||
|
||||
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="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
|
||||
@@ -68,39 +68,40 @@ export default function Files() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && files.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(true)}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(false)}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && (
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{isFiltering ? (
|
||||
<>
|
||||
{filtered.length} of {files.length} paths
|
||||
</>
|
||||
) : (
|
||||
<>{files.length} paths</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
|
||||
+68
-68
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { redirect, useSearchParams } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Search, X, ChevronsUpDown, ChevronsDownUp } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -68,18 +68,14 @@ export default function FindByKey() {
|
||||
}, [isFiltering]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="mb-4">
|
||||
<h1 className="text-foreground">
|
||||
Binaries that have the following entitlement:
|
||||
</h1>
|
||||
<p>
|
||||
<code className="text-sm break-all text-red-700 dark:text-red-400">{key}</code>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<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">
|
||||
<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"
|
||||
@@ -99,64 +95,68 @@ export default function FindByKey() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && paths.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(true)}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExpandAll(false)}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && (
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{isFiltering ? (
|
||||
<>
|
||||
{filtered.length} of {paths.length} paths
|
||||
</>
|
||||
) : (
|
||||
<>{paths.length} paths</>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 py-1">
|
||||
<div className="h-4 w-4 bg-muted rounded animate-pulse" />
|
||||
<div
|
||||
className="h-5 bg-muted rounded animate-pulse"
|
||||
style={{ width: `${120 + Math.random() * 200}px` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{paths.length === 0 ? (
|
||||
<p>No binaries found with this entitlement.</p>
|
||||
) : (
|
||||
<p>No paths match "{keyword}"</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FileSystem os={os} list={filtered} expandAll={expandAll} />
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 py-1">
|
||||
<div className="h-4 w-4 bg-muted rounded animate-pulse" />
|
||||
<div
|
||||
className="h-5 bg-muted rounded animate-pulse"
|
||||
style={{ width: `${120 + Math.random() * 200}px` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{paths.length === 0 ? (
|
||||
<p>No binaries found with this entitlement.</p>
|
||||
) : (
|
||||
<p>No paths match "{keyword}"</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FileSystem os={os} list={filtered} expandAll={expandAll} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+33
-280
@@ -1,152 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, memo } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Search, X, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function useColumnCount() {
|
||||
const [cols, setCols] = useState(3);
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const update = () => {
|
||||
const w = window.innerWidth;
|
||||
setCols(w < 640 ? 1 : w < 1024 ? 2 : 3);
|
||||
};
|
||||
const debouncedUpdate = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(update, 100);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", debouncedUpdate);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener("resize", debouncedUpdate);
|
||||
};
|
||||
}, []);
|
||||
return cols;
|
||||
}
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
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(".");
|
||||
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;
|
||||
}
|
||||
|
||||
const KeyBadge = memo(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="block py-1 font-mono text-muted-foreground hover:text-foreground transition-colors group truncate"
|
||||
title={keyName}
|
||||
>
|
||||
{suffix ? (
|
||||
<>
|
||||
<span className="text-muted-foreground/60 group-hover:text-muted-foreground text-sm">
|
||||
{prefix}
|
||||
</span>
|
||||
<span className="text-foreground/80 group-hover:text-foreground">{suffix}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{keyName}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
const KeyGroup = memo(function KeyGroup({
|
||||
prefix,
|
||||
keys,
|
||||
os,
|
||||
cols,
|
||||
isOpen,
|
||||
onToggle,
|
||||
isFiltering,
|
||||
}: {
|
||||
prefix: string;
|
||||
keys: string[];
|
||||
os: string;
|
||||
cols: number;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
isFiltering: boolean;
|
||||
}) {
|
||||
const autoExpand = keys.length <= 8 || isFiltering;
|
||||
const expanded = isOpen || autoExpand;
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 px-2 py-2 hover:bg-accent rounded transition-colors text-left w-full"
|
||||
>
|
||||
{expanded ? (
|
||||
<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>
|
||||
{expanded && (
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-1 pl-8 pb-3 pr-2"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{keys.map((key) => (
|
||||
<KeyBadge key={key} keyName={key} prefix={prefix} os={os} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function Keys() {
|
||||
const params = useSearchParams();
|
||||
const os = params.get("os") as string;
|
||||
@@ -156,9 +19,6 @@ export default function Keys() {
|
||||
const [keys, setKeys] = useState<string[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const cols = useColumnCount();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -187,51 +47,8 @@ export default function Keys() {
|
||||
[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;
|
||||
|
||||
const toggleGroup = useCallback((prefix: string) => {
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(prefix)) {
|
||||
next.delete(prefix);
|
||||
} else {
|
||||
next.add(prefix);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExpandAll = useCallback(() => {
|
||||
setOpenGroups(new Set(sortedPrefixes));
|
||||
}, [sortedPrefixes]);
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setOpenGroups(new Set());
|
||||
}, []);
|
||||
|
||||
// Separate single keys from groups
|
||||
const { groups, singles } = useMemo(() => {
|
||||
const groups: { prefix: string; keys: string[] }[] = [];
|
||||
const singles: string[] = [];
|
||||
|
||||
for (const prefix of sortedPrefixes) {
|
||||
const prefixKeys = grouped[prefix];
|
||||
if (prefixKeys.length === 1 && prefixKeys[0] === prefix) {
|
||||
singles.push(prefixKeys[0]);
|
||||
} else {
|
||||
groups.push({ prefix, keys: prefixKeys });
|
||||
}
|
||||
}
|
||||
|
||||
return { groups, singles };
|
||||
}, [sortedPrefixes, grouped]);
|
||||
|
||||
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">
|
||||
@@ -255,80 +72,27 @@ export default function Keys() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && sortedPrefixes.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExpandAll}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCollapseAll}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
Collapse All
|
||||
</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="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-4">
|
||||
{[
|
||||
{ prefix: 180, items: [140, 160, 120] },
|
||||
{ prefix: 220, items: [180, 140, 200, 160] },
|
||||
{ prefix: 160, items: [120, 180] },
|
||||
{ prefix: 200, items: [160, 140, 180, 120, 200] },
|
||||
{ prefix: 140, items: [100, 140, 120] },
|
||||
{ prefix: 240, items: [180, 160, 200, 140] },
|
||||
].map((group, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-2"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 bg-muted rounded animate-pulse" />
|
||||
<div
|
||||
className="h-5 bg-muted rounded animate-pulse"
|
||||
style={{ width: group.prefix }}
|
||||
/>
|
||||
<div className="h-4 w-8 bg-muted rounded-full animate-pulse ml-1" />
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-1.5 pl-6"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{group.items.map((width, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 bg-muted rounded animate-pulse"
|
||||
style={{
|
||||
width,
|
||||
animationDelay: `${index * 100 + i * 50}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{Array.from({ length: 30 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-7"
|
||||
style={{ width: `${60 + Math.random() * 40}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
@@ -341,29 +105,18 @@ export default function Keys() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{singles.length > 0 && (
|
||||
<div
|
||||
className="grid gap-x-6 gap-y-1 px-2 pb-4"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{singles.map((key) => (
|
||||
<KeyBadge key={key} keyName={key} prefix="" os={os} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map(({ prefix, keys: groupKeys }) => (
|
||||
<KeyGroup
|
||||
key={prefix}
|
||||
prefix={prefix}
|
||||
keys={groupKeys}
|
||||
os={os}
|
||||
cols={cols}
|
||||
isOpen={openGroups.has(prefix)}
|
||||
onToggle={() => toggleGroup(prefix)}
|
||||
isFiltering={isFiltering}
|
||||
/>
|
||||
))}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{filtered.map((key) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={`/os/find?key=${encodeURIComponent(key)}&os=${os}`}
|
||||
className="block py-1 font-mono text-sm text-muted-foreground hover:text-foreground transition-colors truncate"
|
||||
title={key}
|
||||
>
|
||||
{key}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-70
@@ -1,20 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { VersionSwitcher } from "@/components/version-switcher";
|
||||
|
||||
import { withBase } from "@/lib/env";
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function OSDetailLayout({
|
||||
children,
|
||||
@@ -22,69 +9,14 @@ export default function OSDetailLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const params = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const os = params.get("os") || "";
|
||||
const binaryPath = params.get("path") || "";
|
||||
const binaryName = binaryPath ? binaryPath.split("/").pop() : "";
|
||||
|
||||
const currentPage = pathname.includes("/files")
|
||||
? "files"
|
||||
: pathname.includes("/bin")
|
||||
? "bin"
|
||||
: pathname.includes("/find")
|
||||
? "find"
|
||||
: "keys";
|
||||
|
||||
useEffect(() => {
|
||||
if (os) document.title = `${os || ""} - Entitlement Database`;
|
||||
if (os) document.title = `${os} - Entitlement Database`;
|
||||
}, [os]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 p-4 md:p-6" suppressHydrationWarning>
|
||||
<header className="flex flex-col sm:flex-row sm:items-center gap-2 mb-4 shrink-0">
|
||||
<div className="flex items-center">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href={withBase("/")}>Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{os?.split("/")[0]}</span>
|
||||
<VersionSwitcher currentOs={os} />
|
||||
</div>
|
||||
</BreadcrumbItem>
|
||||
{currentPage === "bin" && binaryName && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<span className="text-foreground font-medium">
|
||||
{binaryName}
|
||||
</span>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-4 text-sm sm:ml-auto">
|
||||
<Link
|
||||
href={`/os/keys?os=${os}`}
|
||||
className={currentPage === "keys" ? "font-medium" : "text-muted-foreground hover:text-foreground"}
|
||||
>
|
||||
Entitlement Keys
|
||||
</Link>
|
||||
<Link
|
||||
href={`/os/files?os=${os}`}
|
||||
className={currentPage === "files" ? "font-medium" : "text-muted-foreground hover:text-foreground"}
|
||||
>
|
||||
Browse Files
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0 p-4 md:p-6">
|
||||
<div className="flex-1 min-h-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+154
-10
@@ -2,8 +2,10 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Moon, Sun, Key, Folder } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { VersionSwitcher } from "./version-switcher";
|
||||
|
||||
function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -20,7 +22,7 @@ function ThemeToggle() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
@@ -32,15 +34,157 @@ function ThemeToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
export function NavTop() {
|
||||
function OSBreadcrumb({ os, currentPage }: { os: string; currentPage: string }) {
|
||||
const [platform] = os.split("/");
|
||||
|
||||
return (
|
||||
<header className="flex flex-row justify-between items-center px-4 md:px-8 h-14 w-full border-b border-border bg-background text-foreground">
|
||||
<h1 className="text-2xl font-bold">
|
||||
<Link href="/" className="hover:text-muted-foreground">
|
||||
entdb
|
||||
</Link>
|
||||
</h1>
|
||||
<ThemeToggle />
|
||||
<div className="flex items-center gap-4 text-sm min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-muted-foreground">{platform}</span>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeControls() {
|
||||
const searchParams = useSearchParams();
|
||||
const showAll = searchParams.get("view") === "all";
|
||||
|
||||
return (
|
||||
<div className="inline-flex rounded-lg border border-border p-1 bg-muted/30">
|
||||
<Link
|
||||
href="/"
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
!showAll
|
||||
? "bg-background text-foreground shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Major Releases
|
||||
</Link>
|
||||
<Link
|
||||
href="/?view=all"
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
showAll
|
||||
? "bg-background text-foreground shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
All Builds
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavTop() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const isHome = pathname === "/";
|
||||
const isOSPage = pathname.startsWith("/os/");
|
||||
const os = searchParams.get("os") || "";
|
||||
|
||||
const currentPage = pathname.includes("/files")
|
||||
? "files"
|
||||
: pathname.includes("/bin")
|
||||
? "bin"
|
||||
: pathname.includes("/find")
|
||||
? "find"
|
||||
: "keys";
|
||||
|
||||
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">
|
||||
<Link href="/" className="hover:text-muted-foreground transition-colors">
|
||||
entdb
|
||||
</Link>
|
||||
</h1>
|
||||
<ThemeToggle />
|
||||
</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>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
+118
-122
@@ -2,9 +2,10 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { Group, OS } from "@/lib/types";
|
||||
import { withBase, dataURL } from "@/lib/env";
|
||||
import { dataURL } from "@/lib/env";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
function responseOK(r: Response) {
|
||||
@@ -53,10 +54,13 @@ function groupByMajor(list: OS[]): MajorGroup[] {
|
||||
}
|
||||
|
||||
export default function OSList() {
|
||||
const [showLess, setShowLess] = useState(true);
|
||||
const searchParams = useSearchParams();
|
||||
const showAll = searchParams.get("view") === "all";
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [highlights, setHighlights] = useState<Set<string>>(new Set());
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const set: Set<string> = new Set();
|
||||
@@ -76,30 +80,27 @@ export default function OSList() {
|
||||
bucket.get(key)!.push(item);
|
||||
}
|
||||
});
|
||||
// For iOS and mac, determine the latest 2 major versions
|
||||
const isIOSOrMac = group.name === "iOS" || group.name === "mac";
|
||||
let latestTwoMajors = new Set<string>();
|
||||
if (isIOSOrMac) {
|
||||
const sortedMajors = Array.from(bucket.keys())
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a)
|
||||
.slice(0, 2)
|
||||
.map(String);
|
||||
latestTwoMajors = new Set(sortedMajors);
|
||||
}
|
||||
|
||||
bucket.forEach((items, major) => {
|
||||
items.sort((a, b) => compareVersion(b.version, a.version));
|
||||
|
||||
if (isIOSOrMac && latestTwoMajors.has(major)) {
|
||||
// For latest 2 majors of iOS/macOS, show all minor versions
|
||||
items.forEach((item) => set.add(item.build));
|
||||
} else {
|
||||
// For older majors or other OS types, show only the latest
|
||||
const [first] = items;
|
||||
set.add(first?.build);
|
||||
const isIOSOrMac = group.name === "iOS" || group.name === "mac";
|
||||
let latestTwoMajors = new Set<string>();
|
||||
if (isIOSOrMac) {
|
||||
const sortedMajors = Array.from(bucket.keys())
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a)
|
||||
.slice(0, 2)
|
||||
.map(String);
|
||||
latestTwoMajors = new Set(sortedMajors);
|
||||
}
|
||||
});
|
||||
|
||||
bucket.forEach((items, major) => {
|
||||
items.sort((a, b) => compareVersion(b.version, a.version));
|
||||
|
||||
if (isIOSOrMac && latestTwoMajors.has(major)) {
|
||||
items.forEach((item) => set.add(item.build));
|
||||
} else {
|
||||
const [first] = items;
|
||||
set.add(first?.build);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
setHighlights(set);
|
||||
@@ -129,30 +130,39 @@ export default function OSList() {
|
||||
)
|
||||
.then((groups) => {
|
||||
setGroups(groups);
|
||||
// Enable all platforms by default
|
||||
setSelectedPlatforms(new Set(groups.map((g) => g.name)));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const togglePlatform = (name: string) => {
|
||||
setSelectedPlatforms((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
// Don't allow deselecting all
|
||||
if (next.size > 1) next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
return groups.filter((g) => selectedPlatforms.has(g.name));
|
||||
}, [groups, selectedPlatforms]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-4 flex items-center">
|
||||
<Skeleton className="h-4 w-4 mr-2" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
|
||||
{[1, 2, 3].map((group) => (
|
||||
<section key={group} className="my-6">
|
||||
<Skeleton className="h-8 w-32 my-4" />
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
|
||||
<div key={item} className="p-4 border rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<section key={group} className="my-4">
|
||||
<Skeleton className="h-6 w-24 mb-3" />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||
<Skeleton key={item} className="h-14" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -161,107 +171,93 @@ export default function OSList() {
|
||||
)}
|
||||
|
||||
{!loading && groups.length === 0 && (
|
||||
<div className="text-center">Failed to fetch OS list</div>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Failed to fetch OS list
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<header className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
Major Releases
|
||||
</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 Builds
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex items-center gap-3 text-sm">
|
||||
{!loading && groups.length > 0 && (
|
||||
<>
|
||||
{/* Platform filters */}
|
||||
<div className="flex items-center gap-1 mb-6">
|
||||
{groups.map((group) => (
|
||||
<a
|
||||
<button
|
||||
key={group.name}
|
||||
href={`#${group.name}`}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => togglePlatform(group.name)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
selectedPlatforms.has(group.name)
|
||||
? "bg-foreground text-background font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
{group.name}
|
||||
</a>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.map((group) => {
|
||||
const majorGroups = groupByMajor(group.list);
|
||||
{filteredGroups.map((group) => {
|
||||
const majorGroups = groupByMajor(group.list);
|
||||
|
||||
return (
|
||||
<section key={group.name} id={group.name} className="my-6 scroll-mt-20">
|
||||
<h2 className="text-2xl font-light my-4">{group.name}</h2>
|
||||
return (
|
||||
<section key={group.name} id={group.name} className="mb-6">
|
||||
<h2 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-2">
|
||||
{group.name}
|
||||
<span className="flex-1 border-t border-border" />
|
||||
</h2>
|
||||
|
||||
{showLess ? (
|
||||
<ul className="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{group.list
|
||||
.filter((os) => highlights.has(os.build))
|
||||
.map((os, index) => (
|
||||
<li key={index} className="list-none">
|
||||
<Link
|
||||
href={`/os/keys?os=${group.name}/${os.version}_${os.build}`}
|
||||
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-start">
|
||||
<h2 className="text-lg font-medium">{group.name === "iOS" ? os.version : os.name}</h2>
|
||||
<div className="text-right space-y-1">
|
||||
{group.name !== "iOS" && <div className="text-sm font-medium">{os.version}</div>}
|
||||
<div className="text-xs text-muted-foreground font-mono">{os.build}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{majorGroups.map((majorGroup) => (
|
||||
<div key={majorGroup.major}>
|
||||
<h3 className="text-lg font-medium text-muted-foreground mb-3">
|
||||
{group.name === "iOS" ? "iOS" : group.name === "mac" ? "macOS" : "OS X"} {majorGroup.major}
|
||||
</h3>
|
||||
<ul className="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{majorGroup.versions.map((os, index) => (
|
||||
{!showAll ? (
|
||||
<ul className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{group.list
|
||||
.filter((os) => highlights.has(os.build))
|
||||
.map((os, index) => (
|
||||
<li key={index} className="list-none">
|
||||
<Link
|
||||
href={`/os/keys?os=${group.name}/${os.version}_${os.build}`}
|
||||
className="block p-4 border border-border rounded-lg hover:border-foreground/20 transition-colors hover:bg-accent/50"
|
||||
className="block p-3 border border-border rounded-lg hover:border-foreground/20 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<h2 className="text-lg font-medium">{group.name === "iOS" ? os.version : os.name}</h2>
|
||||
<div className="text-right space-y-1">
|
||||
{group.name !== "iOS" && <div className="text-sm font-medium">{os.version}</div>}
|
||||
<div className="text-xs text-muted-foreground font-mono">{os.build}</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">{os.version}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{os.build}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{majorGroups.map((majorGroup) => (
|
||||
<div key={majorGroup.major}>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{group.name} {majorGroup.major}
|
||||
</h3>
|
||||
<ul className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{majorGroup.versions.map((os, index) => (
|
||||
<li key={index} className="list-none">
|
||||
<Link
|
||||
href={`/os/keys?os=${group.name}/${os.version}_${os.build}`}
|
||||
className="block p-3 border border-border rounded-lg hover:border-foreground/20 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">{os.version}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{os.build}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user