Improve keys and paths UI

Keys page:
- Add Expand All / Collapse All buttons
- Use grid layout for better space usage
- Improved loading skeleton with staggered animations

Paths page:
- Add search filter with path count
- Folder/file icons with chevrons
- Smart collapse (top level expanded, nested collapsed)
- Show item counts on hover
This commit is contained in:
cc
2026-04-14 17:37:22 +02:00
parent 175d532bad
commit 52b377b6a7
3 changed files with 252 additions and 84 deletions
+70 -25
View File
@@ -1,8 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useDebounce } from "use-debounce";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import FileSystem from "@/components/filesystem";
import { createEngine } from "@/lib/engine";
@@ -13,6 +17,9 @@ export default function Files() {
const [loading, setLoading] = useState(true);
const [files, setFiles] = useState<string[]>([]);
const [keyword, setKeyword] = useState("");
const [debouncedKeyword] = useDebounce(keyword, 200);
useEffect(() => {
setLoading(true);
@@ -22,36 +29,74 @@ export default function Files() {
.finally(() => setLoading(false));
}, [group, build]);
const filtered = useMemo(
() =>
files.filter((path) =>
path.toLowerCase().includes(debouncedKeyword.toLowerCase())
),
[debouncedKeyword, files]
);
const isFiltering = debouncedKeyword.length > 0;
return (
<div className="text-left">
<div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Filter paths..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-9 pr-9"
/>
{keyword && (
<Button
variant="ghost"
size="sm"
onClick={() => setKeyword("")}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{!loading && (
<div className="text-sm text-muted-foreground whitespace-nowrap">
{isFiltering ? (
<>
{filtered.length} of {files.length} paths
</>
) : (
<>{files.length} paths</>
)}
</div>
)}
</div>
{loading ? (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-gray-300 rounded animate-pulse"></div>
<div className="h-4 bg-gray-300 rounded w-32 animate-pulse"></div>
</div>
<div className="ml-6 space-y-2">
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-gray-300 rounded animate-pulse"></div>
<div className="h-4 bg-gray-300 rounded w-24 animate-pulse"></div>
{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 className="ml-6 space-y-1">
<div className="h-3 bg-gray-300 rounded w-20 animate-pulse"></div>
<div className="h-3 bg-gray-300 rounded w-16 animate-pulse"></div>
<div className="h-3 bg-gray-300 rounded w-28 animate-pulse"></div>
</div>
<div className="flex items-center space-x-2">
<div className="w-4 h-4 bg-gray-300 rounded animate-pulse"></div>
<div className="h-4 bg-gray-300 rounded w-20 animate-pulse"></div>
</div>
<div className="ml-6 space-y-1">
<div className="h-3 bg-gray-300 rounded w-24 animate-pulse"></div>
<div className="h-3 bg-gray-300 rounded w-18 animate-pulse"></div>
</div>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{files.length === 0 ? (
<p>No paths found for this OS version.</p>
) : (
<p>No paths match &quot;{keyword}&quot;</p>
)}
</div>
) : (
<FileSystem os={os} list={files} />
<FileSystem os={os} list={filtered} expandAll={isFiltering} />
)}
</div>
);
+98 -36
View File
@@ -1,10 +1,16 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useDebounce } from "use-debounce";
import Link from "next/link";
import { Search, X, ChevronRight, ChevronDown } from "lucide-react";
import {
Search,
X,
ChevronRight,
ChevronDown,
ChevronsUpDown,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -62,7 +68,7 @@ function KeyBadge({
return (
<Link
href={`/os/find?key=${encodeURIComponent(keyName)}&os=${os}`}
className="inline-flex items-baseline px-2 py-1 bg-muted hover:bg-accent rounded text-sm font-mono transition-colors group"
className="inline-block px-2 py-1 bg-muted hover:bg-accent rounded text-sm font-mono transition-colors group"
title={keyName}
>
{suffix ? (
@@ -83,14 +89,20 @@ function KeyGroup({
prefix,
keys,
os,
defaultOpen,
forceOpen,
}: {
prefix: string;
keys: string[];
os: string;
defaultOpen: boolean;
forceOpen: boolean | null;
}) {
const [open, setOpen] = useState(defaultOpen);
const [open, setOpen] = useState(forceOpen ?? keys.length <= 8);
useEffect(() => {
if (forceOpen !== null) {
setOpen(forceOpen);
}
}, [forceOpen]);
// Single standalone key - just show it inline
if (keys.length === 1 && keys[0] === prefix) {
@@ -120,7 +132,7 @@ function KeyGroup({
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-wrap gap-1.5 pl-6 pt-1.5 pb-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1.5 pl-6 pt-1.5 pb-2">
{keys.map((key) => (
<KeyBadge key={key} keyName={key} prefix={prefix} os={os} />
))}
@@ -138,6 +150,7 @@ export default function Keys() {
const [loading, setLoading] = useState(true);
const [keys, setKeys] = useState<string[]>([]);
const [keyword, setKeyword] = useState("");
const [forceOpen, setForceOpen] = useState<boolean | null>(null);
const [debouncedKeyword] = useDebounce(keyword, 200);
@@ -168,6 +181,17 @@ export default function Keys() {
);
const isFiltering = debouncedKeyword.length > 0;
const hasGroups = sortedPrefixes.some(
(p) => grouped[p].length > 1 || grouped[p][0] !== p
);
const handleExpandAll = useCallback(() => setForceOpen(true), []);
const handleCollapseAll = useCallback(() => setForceOpen(false), []);
// Reset forceOpen when filter changes
useEffect(() => {
setForceOpen(isFiltering ? true : null);
}, [isFiltering]);
return (
<div>
@@ -192,37 +216,75 @@ export default function Keys() {
</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 className="flex items-center gap-3">
{!loading && hasGroups && (
<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>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="space-y-2">
<div
className="h-6 bg-muted rounded animate-pulse"
style={{ width: `${20 + Math.random() * 30}%` }}
/>
<div className="flex flex-wrap gap-1.5 pl-6">
{Array.from({ length: 3 + Math.floor(Math.random() * 4) }).map(
(_, i) => (
<div
key={i}
className="h-7 bg-muted rounded animate-pulse"
style={{ width: `${80 + Math.random() * 120}px` }}
/>
)
)}
<div 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 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1.5 pl-6">
{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>
))}
@@ -243,7 +305,7 @@ export default function Keys() {
prefix={prefix}
keys={grouped[prefix]}
os={os}
defaultOpen={isFiltering || grouped[prefix].length <= 8}
forceOpen={forceOpen}
/>
))}
</div>
+84 -23
View File
@@ -1,45 +1,63 @@
import { useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { ChevronRight, ChevronDown, FileText, Folder } from "lucide-react";
import filesToTree, { type TreeWithFullPath } from "@/lib/tree";
import Link from "next/link";
function Tree({ item, os }: { item: TreeWithFullPath; os: string }) {
function countItems(item: TreeWithFullPath): number {
let count = 0;
for (const value of Object.values(item)) {
if (typeof value === "string") {
count++;
} else {
count += countItems(value);
}
}
return count;
}
function Tree({
item,
os,
depth,
expandAll,
}: {
item: TreeWithFullPath;
os: string;
depth: number;
expandAll: boolean;
}) {
return (
<ul className="ml-2 pl-2 overflow-x-hidden">
<ul className="space-y-0.5">
{Object.entries(item).map(([key, value]) => {
if (typeof value === "string") {
return (
<li key={value} className="font-mono break-all text-sm m-2">
<li key={value} className="flex items-center gap-2 py-1 pl-1">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<Link
href={`/os/bin?path=${encodeURIComponent(value)}&os=${os}`}
className="hover:underline"
className="font-mono text-sm hover:underline hover:text-foreground text-muted-foreground truncate"
title={value}
>
/{key}
{key}
</Link>
</li>
);
} else {
return (
<li key={key}>
<ul className="pl-2 border-l ml-2">
<Collapsible defaultOpen={true}>
<CollapsibleTrigger asChild>
<Button className="break-all" variant="outline">
/{key}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<Tree item={value} os={os} />
</CollapsibleContent>
</Collapsible>
</ul>
</li>
<TreeFolder
key={key}
name={key}
item={value}
os={os}
depth={depth}
expandAll={expandAll}
/>
);
}
})}
@@ -47,17 +65,60 @@ function Tree({ item, os }: { item: TreeWithFullPath; os: string }) {
);
}
function TreeFolder({
name,
item,
os,
depth,
expandAll,
}: {
name: string;
item: TreeWithFullPath;
os: string;
depth: number;
expandAll: boolean;
}) {
const [open, setOpen] = useState(expandAll || depth < 1);
const itemCount = countItems(item);
return (
<li>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 py-1 pl-1 w-full hover:bg-accent rounded transition-colors text-left group">
{open ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-mono text-sm truncate">{name}</span>
<span className="ml-auto text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
{itemCount}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-4 pl-2 border-l border-border">
<Tree item={item} os={os} depth={depth + 1} expandAll={expandAll} />
</div>
</CollapsibleContent>
</Collapsible>
</li>
);
}
export default function FileSystem({
list,
os,
expandAll = false,
}: {
list: string[];
os: string;
expandAll?: boolean;
}) {
const tree = filesToTree(list);
return (
<div className="mt-8">
<Tree item={tree} os={os}></Tree>
<div>
<Tree item={tree} os={os} depth={0} expandAll={expandAll} />
</div>
);
}