Skip to content

Commit 677729a

Browse files
authored
Add advanced filtering and sorting to ScriptsGrid (#38)
Introduces a new FilterBar component for ScriptsGrid, enabling filtering by search query, updatable status, script types, and sorting by name or creation date. Updates scripts API to include creation date in card data, improves deduplication and category counting logic, and adds error handling for missing script directories.
1 parent 51bad28 commit 677729a

File tree

4 files changed

+497
-40
lines changed

4 files changed

+497
-40
lines changed

src/app/_components/FilterBar.tsx

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
"use client";
2+
3+
import React, { useState } from "react";
4+
5+
export interface FilterState {
6+
searchQuery: string;
7+
showUpdatable: boolean | null; // null = all, true = only updatable, false = only non-updatable
8+
selectedTypes: string[]; // Array of selected types: 'lxc', 'vm', 'addon', 'pve'
9+
sortBy: "name" | "created"; // Sort criteria (removed 'updated')
10+
sortOrder: "asc" | "desc"; // Sort direction
11+
}
12+
13+
interface FilterBarProps {
14+
filters: FilterState;
15+
onFiltersChange: (filters: FilterState) => void;
16+
totalScripts: number;
17+
filteredCount: number;
18+
updatableCount?: number;
19+
}
20+
21+
const SCRIPT_TYPES = [
22+
{ value: "ct", label: "LXC Container", icon: "📦" },
23+
{ value: "vm", label: "Virtual Machine", icon: "💻" },
24+
{ value: "addon", label: "Add-on", icon: "🔧" },
25+
{ value: "pve", label: "PVE Host", icon: "🖥️" },
26+
];
27+
28+
export function FilterBar({
29+
filters,
30+
onFiltersChange,
31+
totalScripts,
32+
filteredCount,
33+
updatableCount = 0,
34+
}: FilterBarProps) {
35+
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
36+
37+
const updateFilters = (updates: Partial<FilterState>) => {
38+
onFiltersChange({ ...filters, ...updates });
39+
};
40+
41+
const clearAllFilters = () => {
42+
onFiltersChange({
43+
searchQuery: "",
44+
showUpdatable: null,
45+
selectedTypes: [],
46+
sortBy: "name",
47+
sortOrder: "asc",
48+
});
49+
};
50+
51+
const hasActiveFilters =
52+
filters.searchQuery ||
53+
filters.showUpdatable !== null ||
54+
filters.selectedTypes.length > 0 ||
55+
filters.sortBy !== "name" ||
56+
filters.sortOrder !== "asc";
57+
58+
const getUpdatableButtonText = () => {
59+
if (filters.showUpdatable === null) return "Updatable: All";
60+
if (filters.showUpdatable === true)
61+
return `Updatable: Yes (${updatableCount})`;
62+
return "Updatable: No";
63+
};
64+
65+
const getTypeButtonText = () => {
66+
if (filters.selectedTypes.length === 0) return "All Types";
67+
if (filters.selectedTypes.length === 1) {
68+
const type = SCRIPT_TYPES.find(
69+
(t) => t.value === filters.selectedTypes[0],
70+
);
71+
return type?.label ?? filters.selectedTypes[0];
72+
}
73+
return `${filters.selectedTypes.length} Types`;
74+
};
75+
76+
return (
77+
<div className="mb-6 rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
78+
{/* Search Bar */}
79+
<div className="mb-4">
80+
<div className="relative max-w-md">
81+
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
82+
<svg
83+
className="h-5 w-5 text-gray-400 dark:text-gray-500"
84+
fill="none"
85+
stroke="currentColor"
86+
viewBox="0 0 24 24"
87+
>
88+
<path
89+
strokeLinecap="round"
90+
strokeLinejoin="round"
91+
strokeWidth={2}
92+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
93+
/>
94+
</svg>
95+
</div>
96+
<input
97+
type="text"
98+
placeholder="Search scripts..."
99+
value={filters.searchQuery}
100+
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
101+
className="block w-full rounded-lg border border-gray-300 bg-white py-3 pr-10 pl-10 text-sm leading-5 text-gray-900 placeholder-gray-500 focus:border-blue-500 focus:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:placeholder-gray-300 dark:focus:ring-blue-400"
102+
/>
103+
{filters.searchQuery && (
104+
<button
105+
onClick={() => updateFilters({ searchQuery: "" })}
106+
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
107+
>
108+
<svg
109+
className="h-5 w-5"
110+
fill="none"
111+
stroke="currentColor"
112+
viewBox="0 0 24 24"
113+
>
114+
<path
115+
strokeLinecap="round"
116+
strokeLinejoin="round"
117+
strokeWidth={2}
118+
d="M6 18L18 6M6 6l12 12"
119+
/>
120+
</svg>
121+
</button>
122+
)}
123+
</div>
124+
</div>
125+
126+
{/* Filter Buttons */}
127+
<div className="mb-4 flex flex-wrap gap-3">
128+
{/* Updateable Filter */}
129+
<button
130+
onClick={() => {
131+
const next =
132+
filters.showUpdatable === null
133+
? true
134+
: filters.showUpdatable === true
135+
? false
136+
: null;
137+
updateFilters({ showUpdatable: next });
138+
}}
139+
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
140+
filters.showUpdatable === null
141+
? "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
142+
: filters.showUpdatable === true
143+
? "border border-green-300 bg-green-100 text-green-800 dark:border-green-700 dark:bg-green-900/50 dark:text-green-200"
144+
: "border border-red-300 bg-red-100 text-red-800 dark:border-red-700 dark:bg-red-900/50 dark:text-red-200"
145+
}`}
146+
>
147+
{getUpdatableButtonText()}
148+
</button>
149+
150+
{/* Type Dropdown */}
151+
<div className="relative">
152+
<button
153+
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
154+
className={`flex items-center space-x-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
155+
filters.selectedTypes.length === 0
156+
? "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
157+
: "border border-cyan-300 bg-cyan-100 text-cyan-800 dark:border-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-200"
158+
}`}
159+
>
160+
<span>{getTypeButtonText()}</span>
161+
<svg
162+
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
163+
fill="none"
164+
stroke="currentColor"
165+
viewBox="0 0 24 24"
166+
>
167+
<path
168+
strokeLinecap="round"
169+
strokeLinejoin="round"
170+
strokeWidth={2}
171+
d="M19 9l-7 7-7-7"
172+
/>
173+
</svg>
174+
</button>
175+
176+
{isTypeDropdownOpen && (
177+
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
178+
<div className="p-2">
179+
{SCRIPT_TYPES.map((type) => (
180+
<label
181+
key={type.value}
182+
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700"
183+
>
184+
<input
185+
type="checkbox"
186+
checked={filters.selectedTypes.includes(type.value)}
187+
onChange={(e) => {
188+
if (e.target.checked) {
189+
updateFilters({
190+
selectedTypes: [
191+
...filters.selectedTypes,
192+
type.value,
193+
],
194+
});
195+
} else {
196+
updateFilters({
197+
selectedTypes: filters.selectedTypes.filter(
198+
(t) => t !== type.value,
199+
),
200+
});
201+
}
202+
}}
203+
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
204+
/>
205+
<span className="text-lg">{type.icon}</span>
206+
<span className="text-sm text-gray-700 dark:text-gray-300">
207+
{type.label}
208+
</span>
209+
</label>
210+
))}
211+
</div>
212+
<div className="border-t border-gray-200 p-2 dark:border-gray-700">
213+
<button
214+
onClick={() => {
215+
updateFilters({ selectedTypes: [] });
216+
setIsTypeDropdownOpen(false);
217+
}}
218+
className="w-full rounded-md px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
219+
>
220+
Clear all
221+
</button>
222+
</div>
223+
</div>
224+
)}
225+
</div>
226+
227+
{/* Sort Options */}
228+
<div className="flex items-center space-x-2">
229+
{/* Sort By Dropdown */}
230+
<select
231+
value={filters.sortBy}
232+
onChange={(e) =>
233+
updateFilters({ sortBy: e.target.value as "name" | "created" })
234+
}
235+
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:focus:ring-blue-400"
236+
>
237+
<option value="name">📝 By Name</option>
238+
<option value="created">📅 By Created Date</option>
239+
</select>
240+
241+
{/* Sort Order Button */}
242+
<button
243+
onClick={() =>
244+
updateFilters({
245+
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
246+
})
247+
}
248+
className="flex items-center space-x-1 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
249+
>
250+
{filters.sortOrder === "asc" ? (
251+
<>
252+
<svg
253+
className="h-4 w-4"
254+
fill="none"
255+
stroke="currentColor"
256+
viewBox="0 0 24 24"
257+
>
258+
<path
259+
strokeLinecap="round"
260+
strokeLinejoin="round"
261+
strokeWidth={2}
262+
d="M7 11l5-5m0 0l5 5m-5-5v12"
263+
/>
264+
</svg>
265+
<span>
266+
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
267+
</span>
268+
</>
269+
) : (
270+
<>
271+
<svg
272+
className="h-4 w-4"
273+
fill="none"
274+
stroke="currentColor"
275+
viewBox="0 0 24 24"
276+
>
277+
<path
278+
strokeLinecap="round"
279+
strokeLinejoin="round"
280+
strokeWidth={2}
281+
d="M17 13l-5 5m0 0l-5-5m5 5V6"
282+
/>
283+
</svg>
284+
<span>
285+
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
286+
</span>
287+
</>
288+
)}
289+
</button>
290+
</div>
291+
</div>
292+
293+
{/* Filter Summary and Clear All */}
294+
<div className="flex items-center justify-between">
295+
<div className="text-sm text-gray-600 dark:text-gray-400">
296+
{filteredCount === totalScripts ? (
297+
<span>Showing all {totalScripts} scripts</span>
298+
) : (
299+
<span>
300+
{filteredCount} of {totalScripts} scripts{" "}
301+
{hasActiveFilters && (
302+
<span className="font-medium text-blue-600 dark:text-blue-400">
303+
(filtered)
304+
</span>
305+
)}
306+
</span>
307+
)}
308+
</div>
309+
310+
{hasActiveFilters && (
311+
<button
312+
onClick={clearAllFilters}
313+
className="flex items-center space-x-1 rounded-md px-3 py-1 text-sm text-red-600 transition-colors hover:bg-red-50 hover:text-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300"
314+
>
315+
<svg
316+
className="h-4 w-4"
317+
fill="none"
318+
stroke="currentColor"
319+
viewBox="0 0 24 24"
320+
>
321+
<path
322+
strokeLinecap="round"
323+
strokeLinejoin="round"
324+
strokeWidth={2}
325+
d="M6 18L18 6M6 6l12 12"
326+
/>
327+
</svg>
328+
<span>Clear all filters</span>
329+
</button>
330+
)}
331+
</div>
332+
333+
{/* Click outside to close dropdown */}
334+
{isTypeDropdownOpen && (
335+
<div
336+
className="fixed inset-0 z-0"
337+
onClick={() => setIsTypeDropdownOpen(false)}
338+
/>
339+
)}
340+
</div>
341+
);
342+
}

0 commit comments

Comments
 (0)