mirror of
https://github.com/ChiChou/entdb.git
synced 2026-06-10 23:07:47 +02:00
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:
+70
-25
@@ -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 "{keyword}"</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FileSystem os={os} list={files} />
|
||||
<FileSystem os={os} list={filtered} expandAll={isFiltering} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+98
-36
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user