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:
cc
2026-04-15 18:34:32 +02:00
parent 320951a50f
commit 7d6fea9ef0
6 changed files with 411 additions and 585 deletions
+36 -35
View File
@@ -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
View File
@@ -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 &quot;{keyword}&quot;</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 &quot;{keyword}&quot;</p>
)}
</div>
) : (
<FileSystem os={os} list={filtered} expandAll={expandAll} />
)}
</div>
</div>
);
}
+33 -280
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}