feat: add category filter dropdown to service selection

Add a searchable category dropdown filter on the new resource page to help users filter services by category.

Features:
- Category dropdown positioned next to search input
- Auto-focus on search field when dropdown opens
- Case-insensitive category filtering
- Proper acronym formatting (AI, API, CI, etc. displayed in uppercase)
- Loading/disabled state while categories are being fetched
- Category search/filter within dropdown
- Alphabetical sorting (case-insensitive)

Backend changes:
- Extract unique categories from service templates
- Handle comma-separated categories
- Format common acronyms to uppercase
- Case-insensitive natural sorting

Frontend changes:
- Searchable dropdown component with Alpine.js
- Category filter integration with existing search
- Disabled state placeholder during loading
- Auto-focus behavior for better UX

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-10-23 21:02:12 +02:00
parent 2d3a980594
commit 4ef0a50e09
2 changed files with 106 additions and 10 deletions

View file

@ -102,6 +102,36 @@ public function loadServices()
: asset($default_logo),
] + (array) $service;
})->all();
// Extract unique categories from services
$categories = collect($services)
->pluck('category')
->filter()
->unique()
->map(function ($category) {
// Handle multiple categories separated by comma
if (str_contains($category, ',')) {
return collect(explode(',', $category))->map(fn ($cat) => trim($cat));
}
return [$category];
})
->flatten()
->unique()
->map(function ($category) {
// Format common acronyms to uppercase
$acronyms = ['ai', 'api', 'ci', 'cd', 'cms', 'crm', 'erp', 'iot', 'vpn', 'vps', 'dns', 'ssl', 'tls', 'ssh', 'ftp', 'http', 'https', 'smtp', 'imap', 'pop3', 'sql', 'nosql', 'json', 'xml', 'yaml', 'csv', 'pdf', 'sms', 'mfa', '2fa', 'oauth', 'saml', 'jwt', 'rest', 'soap', 'grpc', 'graphql', 'websocket', 'webrtc', 'p2p', 'b2b', 'b2c', 'seo', 'sem', 'ppc', 'roi', 'kpi', 'ui', 'ux', 'ide', 'sdk', 'api', 'cli', 'gui', 'cdn', 'ddos', 'dos', 'xss', 'csrf', 'sqli', 'rce', 'lfi', 'rfi', 'ssrf', 'xxe', 'idor', 'owasp', 'gdpr', 'hipaa', 'pci', 'dss', 'iso', 'nist', 'cve', 'cwe', 'cvss'];
$lower = strtolower($category);
if (in_array($lower, $acronyms)) {
return strtoupper($category);
}
return $category;
})
->sort(SORT_NATURAL | SORT_FLAG_CASE)
->values()
->all();
$gitBasedApplications = [
[
'id' => 'public',
@ -202,6 +232,7 @@ public function loadServices()
return [
'services' => $services,
'categories' => $categories,
'gitBasedApplications' => $gitBasedApplications,
'dockerBasedApplications' => $dockerBasedApplications,
'databases' => $databases,

View file

@ -13,9 +13,55 @@
<div x-data="searchResources()">
@if ($current_step === 'type')
<div x-init="window.addEventListener('scroll', () => isSticky = window.pageYOffset > 100)" class="sticky z-10 top-10 py-2">
<input autocomplete="off" x-ref="searchInput" class="input-sticky"
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
@keydown.window.slash.prevent="$refs.searchInput.focus()">
<div class="flex gap-2 items-start">
<input autocomplete="off" x-ref="searchInput" class="input-sticky flex-1"
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
@keydown.window.slash.prevent="$refs.searchInput.focus()">
<!-- Category Filter Dropdown -->
<div class="relative" x-data="{ openCategoryDropdown: false, categorySearch: '' }" @click.outside="openCategoryDropdown = false">
<!-- Loading/Disabled State -->
<div x-show="loading || categories.length === 0"
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-neutral-100 dark:bg-coolgray-200 cursor-not-allowed whitespace-nowrap opacity-50">
<span class="text-sm text-neutral-400 dark:text-neutral-600">Filter by category</span>
<svg class="w-4 h-4 text-neutral-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<!-- Active State -->
<div x-show="!loading && categories.length > 0"
@click="openCategoryDropdown = !openCategoryDropdown; $nextTick(() => { if (openCategoryDropdown) $refs.categorySearchInput.focus() })"
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-pointer hover:ring-coolgray-400 transition-all whitespace-nowrap">
<span class="text-sm truncate" x-text="selectedCategory === '' ? 'Filter by category' : selectedCategory" :class="selectedCategory === '' ? 'text-neutral-400 dark:text-neutral-600' : 'capitalize text-black dark:text-white'"></span>
<svg class="w-4 h-4 transition-transform text-neutral-400 shrink-0" :class="{ 'rotate-180': openCategoryDropdown }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<!-- Dropdown Menu -->
<div x-show="openCategoryDropdown" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg overflow-hidden">
<div class="sticky top-0 p-2 bg-white dark:bg-coolgray-100 border-b border-neutral-300 dark:border-coolgray-400">
<input type="text" x-ref="categorySearchInput" x-model="categorySearch" placeholder="Search categories..."
class="w-full px-2 py-1 text-sm rounded border border-neutral-300 dark:border-coolgray-400 bg-white dark:bg-coolgray-200 focus:outline-none focus:ring-2 focus:ring-coolgray-400"
@click.stop>
</div>
<div class="max-h-60 overflow-auto scrollbar">
<div @click="selectedCategory = ''; categorySearch = ''; openCategoryDropdown = false"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === '' }">
<span class="text-sm">All Categories</span>
</div>
<template x-for="category in categories.filter(cat => categorySearch === '' || cat.toLowerCase().includes(categorySearch.toLowerCase()))" :key="category">
<div @click="selectedCategory = category; categorySearch = ''; openCategoryDropdown = false"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 capitalize"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === category }">
<span class="text-sm" x-text="category"></span>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<div x-show="loading">Loading...</div>
<div x-show="!loading" class="flex flex-col gap-4 py-4">
@ -140,6 +186,8 @@ function sortFn(a, b) {
function searchResources() {
return {
search: '',
selectedCategory: '',
categories: [],
loading: false,
isSticky: false,
selecting: false,
@ -156,11 +204,13 @@ function searchResources() {
this.loading = true;
const {
services,
categories,
gitBasedApplications,
dockerBasedApplications,
databases
} = await this.$wire.loadServices();
this.services = services;
this.categories = categories || [];
this.gitBasedApplications = gitBasedApplications;
this.dockerBasedApplications = dockerBasedApplications;
this.databases = databases;
@ -171,15 +221,30 @@ function searchResources() {
},
filterAndSort(items, isSort = true) {
const searchLower = this.search.trim().toLowerCase();
let filtered = Object.values(items);
if (searchLower === '') {
return isSort ? Object.values(items).sort(sortFn) : Object.values(items);
// Filter by category if selected
if (this.selectedCategory !== '') {
const selectedCategoryLower = this.selectedCategory.toLowerCase();
filtered = filtered.filter(item => {
if (!item.category) return false;
// Handle comma-separated categories
const categories = item.category.includes(',')
? item.category.split(',').map(c => c.trim().toLowerCase())
: [item.category.toLowerCase()];
return categories.includes(selectedCategoryLower);
});
}
const filtered = Object.values(items).filter(item => {
return (item.name?.toLowerCase().includes(searchLower) ||
item.description?.toLowerCase().includes(searchLower) ||
item.slogan?.toLowerCase().includes(searchLower))
})
// Filter by search term
if (searchLower !== '') {
filtered = filtered.filter(item => {
return (item.name?.toLowerCase().includes(searchLower) ||
item.description?.toLowerCase().includes(searchLower) ||
item.slogan?.toLowerCase().includes(searchLower))
});
}
return isSort ? filtered.sort(sortFn) : filtered;
},
get filteredGitBasedApplications() {