mirror of
https://github.com/ChiChou/entdb.git
synced 2026-09-22 12:40:41 +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 { useState, useEffect, useMemo } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useDebounce } from "use-debounce";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -47,7 +47,7 @@ export default function Files() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -68,39 +68,40 @@ export default function Files() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
{!loading && files.length > 0 && (
|
||||||
{!loading && files.length > 0 && (
|
<div className="flex items-center border border-border rounded-md">
|
||||||
<div className="flex items-center gap-1">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setExpandAll(true)}
|
||||||
onClick={() => setExpandAll(true)}
|
className="h-8 px-2 rounded-r-none"
|
||||||
className="h-8 px-2 text-xs"
|
title="Expand All"
|
||||||
>
|
>
|
||||||
Expand All
|
<ChevronsUpDown className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<div className="w-px h-4 bg-border" />
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setExpandAll(false)}
|
size="sm"
|
||||||
className="h-8 px-2 text-xs"
|
onClick={() => setExpandAll(false)}
|
||||||
>
|
className="h-8 px-2 rounded-l-none"
|
||||||
Collapse All
|
title="Collapse All"
|
||||||
</Button>
|
>
|
||||||
</div>
|
<ChevronsDownUp className="h-4 w-4" />
|
||||||
)}
|
</Button>
|
||||||
{!loading && (
|
</div>
|
||||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
)}
|
||||||
{isFiltering ? (
|
{!loading && (
|
||||||
<>
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{filtered.length} of {files.length} paths
|
{isFiltering ? (
|
||||||
</>
|
<>
|
||||||
) : (
|
{filtered.length} of {files.length} paths
|
||||||
<>{files.length} paths</>
|
</>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<>{files.length} paths</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
|||||||
+68
-68
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { redirect, useSearchParams } from "next/navigation";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -68,18 +68,14 @@ export default function FindByKey() {
|
|||||||
}, [isFiltering]);
|
}, [isFiltering]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col h-full">
|
||||||
<header className="mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4 shrink-0">
|
||||||
<h1 className="text-foreground">
|
<div className="min-w-0 shrink-0">
|
||||||
Binaries that have the following entitlement:
|
<span className="text-sm text-muted-foreground">Binaries with </span>
|
||||||
</h1>
|
<code className="text-sm break-all text-primary font-medium">{key}</code>
|
||||||
<p>
|
</div>
|
||||||
<code className="text-sm break-all text-red-700 dark:text-red-400">{key}</code>
|
<div className="flex items-center gap-2 sm:ml-auto">
|
||||||
</p>
|
<div className="relative flex-1 sm:w-80">
|
||||||
</header>
|
|
||||||
|
|
||||||
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -99,64 +95,68 @@ export default function FindByKey() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
{!loading && paths.length > 0 && (
|
||||||
{!loading && paths.length > 0 && (
|
<div className="flex items-center border border-border rounded-md">
|
||||||
<div className="flex items-center gap-1">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setExpandAll(true)}
|
||||||
onClick={() => setExpandAll(true)}
|
className="h-8 px-2 rounded-r-none"
|
||||||
className="h-8 px-2 text-xs"
|
title="Expand All"
|
||||||
>
|
>
|
||||||
Expand All
|
<ChevronsUpDown className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<div className="w-px h-4 bg-border" />
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setExpandAll(false)}
|
size="sm"
|
||||||
className="h-8 px-2 text-xs"
|
onClick={() => setExpandAll(false)}
|
||||||
>
|
className="h-8 px-2 rounded-l-none"
|
||||||
Collapse All
|
title="Collapse All"
|
||||||
</Button>
|
>
|
||||||
</div>
|
<ChevronsDownUp className="h-4 w-4" />
|
||||||
)}
|
</Button>
|
||||||
{!loading && (
|
</div>
|
||||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
)}
|
||||||
{isFiltering ? (
|
{!loading && (
|
||||||
<>
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{filtered.length} of {paths.length} paths
|
{isFiltering ? (
|
||||||
</>
|
<>
|
||||||
) : (
|
{filtered.length} of {paths.length} paths
|
||||||
<>{paths.length} paths</>
|
</>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<>{paths.length} paths</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
<div className="space-y-2">
|
{loading ? (
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
<div className="space-y-2">
|
||||||
<div key={i} className="flex items-center gap-2 py-1">
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div className="h-4 w-4 bg-muted rounded animate-pulse" />
|
<div key={i} className="flex items-center gap-2 py-1">
|
||||||
<div
|
<div className="h-4 w-4 bg-muted rounded animate-pulse" />
|
||||||
className="h-5 bg-muted rounded animate-pulse"
|
<div
|
||||||
style={{ width: `${120 + Math.random() * 200}px` }}
|
className="h-5 bg-muted rounded animate-pulse"
|
||||||
/>
|
style={{ width: `${120 + Math.random() * 200}px` }}
|
||||||
</div>
|
/>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
) : filtered.length === 0 ? (
|
</div>
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
) : filtered.length === 0 ? (
|
||||||
{paths.length === 0 ? (
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
<p>No binaries found with this entitlement.</p>
|
{paths.length === 0 ? (
|
||||||
) : (
|
<p>No binaries found with this entitlement.</p>
|
||||||
<p>No paths match "{keyword}"</p>
|
) : (
|
||||||
)}
|
<p>No paths match "{keyword}"</p>
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<FileSystem os={os} list={filtered} expandAll={expandAll} />
|
) : (
|
||||||
)}
|
<FileSystem os={os} list={filtered} expandAll={expandAll} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-280
@@ -1,152 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback, memo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
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 { createEngine } from "@/lib/engine";
|
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() {
|
export default function Keys() {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const os = params.get("os") as string;
|
const os = params.get("os") as string;
|
||||||
@@ -156,9 +19,6 @@ export default function Keys() {
|
|||||||
const [keys, setKeys] = useState<string[]>([]);
|
const [keys, setKeys] = useState<string[]>([]);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||||
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const cols = useColumnCount();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -187,51 +47,8 @@ export default function Keys() {
|
|||||||
[debouncedKeyword, 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 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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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 flex-col sm:flex-row sm:items-center gap-2 mb-4 shrink-0">
|
||||||
@@ -255,80 +72,27 @@ export default function Keys() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
{!loading && (
|
||||||
{!loading && sortedPrefixes.length > 0 && (
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
<div className="flex items-center gap-1">
|
{isFiltering ? (
|
||||||
<Button
|
<>
|
||||||
variant="outline"
|
{filtered.length} of {keys.length} keys
|
||||||
size="sm"
|
</>
|
||||||
onClick={handleExpandAll}
|
) : (
|
||||||
className="h-8 px-2 text-xs"
|
<>{keys.length} entitlement keys</>
|
||||||
>
|
)}
|
||||||
Expand All
|
</div>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-4">
|
<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) => (
|
||||||
{ prefix: 180, items: [140, 160, 120] },
|
<Skeleton
|
||||||
{ prefix: 220, items: [180, 140, 200, 160] },
|
key={i}
|
||||||
{ prefix: 160, items: [120, 180] },
|
className="h-7"
|
||||||
{ prefix: 200, items: [160, 140, 180, 120, 200] },
|
style={{ width: `${60 + Math.random() * 40}%` }}
|
||||||
{ 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>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
@@ -341,29 +105,18 @@ export default function Keys() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{singles.length > 0 && (
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||||
<div
|
{filtered.map((key) => (
|
||||||
className="grid gap-x-6 gap-y-1 px-2 pb-4"
|
<Link
|
||||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
key={key}
|
||||||
>
|
href={`/os/find?key=${encodeURIComponent(key)}&os=${os}`}
|
||||||
{singles.map((key) => (
|
className="block py-1 font-mono text-sm text-muted-foreground hover:text-foreground transition-colors truncate"
|
||||||
<KeyBadge key={key} keyName={key} prefix="" os={os} />
|
title={key}
|
||||||
))}
|
>
|
||||||
</div>
|
{key}
|
||||||
)}
|
</Link>
|
||||||
|
))}
|
||||||
{groups.map(({ prefix, keys: groupKeys }) => (
|
</div>
|
||||||
<KeyGroup
|
|
||||||
key={prefix}
|
|
||||||
prefix={prefix}
|
|
||||||
keys={groupKeys}
|
|
||||||
os={os}
|
|
||||||
cols={cols}
|
|
||||||
isOpen={openGroups.has(prefix)}
|
|
||||||
onToggle={() => toggleGroup(prefix)}
|
|
||||||
isFiltering={isFiltering}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-70
@@ -1,20 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
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 { useEffect } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
|
|
||||||
export default function OSDetailLayout({
|
export default function OSDetailLayout({
|
||||||
children,
|
children,
|
||||||
@@ -22,69 +9,14 @@ export default function OSDetailLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const pathname = usePathname();
|
|
||||||
const os = params.get("os") || "";
|
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(() => {
|
useEffect(() => {
|
||||||
if (os) document.title = `${os || ""} - Entitlement Database`;
|
if (os) document.title = `${os} - Entitlement Database`;
|
||||||
}, [os]);
|
}, [os]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0 p-4 md:p-6" suppressHydrationWarning>
|
<div className="flex flex-col flex-1 min-h-0 p-4 md:p-6">
|
||||||
<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-1 min-h-0">{children}</div>
|
<div className="flex-1 min-h-0">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+154
-10
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTheme } from "next-themes";
|
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 { useEffect, useState } from "react";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import { VersionSwitcher } from "./version-switcher";
|
||||||
|
|
||||||
function ThemeToggle() {
|
function ThemeToggle() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -20,7 +22,7 @@ function ThemeToggle() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
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"}
|
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
@@ -32,15 +34,157 @@ function ThemeToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavTop() {
|
function OSBreadcrumb({ os, currentPage }: { os: string; currentPage: string }) {
|
||||||
|
const [platform] = os.split("/");
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-4 text-sm min-w-0 flex-1">
|
||||||
<h1 className="text-2xl font-bold">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Link href="/" className="hover:text-muted-foreground">
|
<span className="text-muted-foreground">{platform}</span>
|
||||||
entdb
|
<VersionSwitcher currentOs={os} />
|
||||||
</Link>
|
</div>
|
||||||
</h1>
|
|
||||||
<ThemeToggle />
|
<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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-122
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { Group, OS } from "@/lib/types";
|
import { Group, OS } from "@/lib/types";
|
||||||
import { withBase, dataURL } from "@/lib/env";
|
import { dataURL } from "@/lib/env";
|
||||||
import { Skeleton } from "./ui/skeleton";
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
|
||||||
function responseOK(r: Response) {
|
function responseOK(r: Response) {
|
||||||
@@ -53,10 +54,13 @@ function groupByMajor(list: OS[]): MajorGroup[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OSList() {
|
export default function OSList() {
|
||||||
const [showLess, setShowLess] = useState(true);
|
const searchParams = useSearchParams();
|
||||||
|
const showAll = searchParams.get("view") === "all";
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [highlights, setHighlights] = useState<Set<string>>(new Set());
|
const [highlights, setHighlights] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedPlatforms, setSelectedPlatforms] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const set: Set<string> = new Set();
|
const set: Set<string> = new Set();
|
||||||
@@ -76,30 +80,27 @@ export default function OSList() {
|
|||||||
bucket.get(key)!.push(item);
|
bucket.get(key)!.push(item);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// For iOS and mac, determine the latest 2 major versions
|
const isIOSOrMac = group.name === "iOS" || group.name === "mac";
|
||||||
const isIOSOrMac = group.name === "iOS" || group.name === "mac";
|
let latestTwoMajors = new Set<string>();
|
||||||
let latestTwoMajors = new Set<string>();
|
if (isIOSOrMac) {
|
||||||
if (isIOSOrMac) {
|
const sortedMajors = Array.from(bucket.keys())
|
||||||
const sortedMajors = Array.from(bucket.keys())
|
.map(Number)
|
||||||
.map(Number)
|
.sort((a, b) => b - a)
|
||||||
.sort((a, b) => b - a)
|
.slice(0, 2)
|
||||||
.slice(0, 2)
|
.map(String);
|
||||||
.map(String);
|
latestTwoMajors = new Set(sortedMajors);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
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);
|
setHighlights(set);
|
||||||
@@ -129,30 +130,39 @@ export default function OSList() {
|
|||||||
)
|
)
|
||||||
.then((groups) => {
|
.then((groups) => {
|
||||||
setGroups(groups);
|
setGroups(groups);
|
||||||
|
// Enable all platforms by default
|
||||||
|
setSelectedPlatforms(new Set(groups.map((g) => g.name)));
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="space-y-6">
|
<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) => (
|
{[1, 2, 3].map((group) => (
|
||||||
<section key={group} className="my-6">
|
<section key={group} className="my-4">
|
||||||
<Skeleton className="h-8 w-32 my-4" />
|
<Skeleton className="h-6 w-24 mb-3" />
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
|
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||||
<div key={item} className="p-4 border rounded-lg">
|
<Skeleton key={item} className="h-14" />
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Skeleton className="h-6 w-24" />
|
|
||||||
<Skeleton className="h-4 w-16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -161,107 +171,93 @@ export default function OSList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && groups.length === 0 && (
|
{!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 && (
|
{!loading && groups.length > 0 && (
|
||||||
<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">
|
{/* Platform filters */}
|
||||||
<button
|
<div className="flex items-center gap-1 mb-6">
|
||||||
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">
|
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<a
|
<button
|
||||||
key={group.name}
|
key={group.name}
|
||||||
href={`#${group.name}`}
|
onClick={() => togglePlatform(group.name)}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
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}
|
{group.name}
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</div>
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{groups.map((group) => {
|
{filteredGroups.map((group) => {
|
||||||
const majorGroups = groupByMajor(group.list);
|
const majorGroups = groupByMajor(group.list);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section key={group.name} id={group.name} className="my-6 scroll-mt-20">
|
<section key={group.name} id={group.name} className="mb-6">
|
||||||
<h2 className="text-2xl font-light my-4">{group.name}</h2>
|
<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 ? (
|
{!showAll ? (
|
||||||
<ul className="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
<ul className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
{group.list
|
{group.list
|
||||||
.filter((os) => highlights.has(os.build))
|
.filter((os) => highlights.has(os.build))
|
||||||
.map((os, index) => (
|
.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) => (
|
|
||||||
<li key={index} className="list-none">
|
<li key={index} className="list-none">
|
||||||
<Link
|
<Link
|
||||||
href={`/os/keys?os=${group.name}/${os.version}_${os.build}`}
|
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">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-lg font-medium">{group.name === "iOS" ? os.version : os.name}</h2>
|
<span className="font-medium">{os.version}</span>
|
||||||
<div className="text-right space-y-1">
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
{group.name !== "iOS" && <div className="text-sm font-medium">{os.version}</div>}
|
{os.build}
|
||||||
<div className="text-xs text-muted-foreground font-mono">{os.build}</div>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</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>
|
||||||
))}
|
)}
|
||||||
</div>
|
</section>
|
||||||
)}
|
);
|
||||||
</section>
|
})}
|
||||||
);
|
</>
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user