feat(app-store): category filter bar and custom registry settings

- TemplateService: static LSIO_CATEGORY_MAP covers ~80 well-known apps
  across Automation, Downloaders, Media, Monitoring, Networking,
  Security, Development, Productivity, Utilities, and Other. Category
  lookup is O(1) and runs once at cache-fill time. Adds source field
  ('linuxserver' | 'custom') to Template for future per-source UX.
  Portainer v2 registries pass their native categories through unchanged.
  Adds clearCache() method wired to POST /api/templates/refresh-cache.

- AppStoreView: category pill bar (All + sorted dynamic categories)
  rendered between search and grid; active pill fills on click; clicking
  a category badge on a card also sets the active filter; app count
  updates reactively; filter composed with existing search query.

- SettingsModal: new App Store section (local-node only) exposes a
  custom Portainer v2 JSON URL input with Save & Refresh (saves setting
  then busts the 24h template cache) and Reset to Default.
This commit is contained in:
SaelixCode
2026-03-20 15:19:32 -04:00
parent 18314119b0
commit 34cad76d45
5 changed files with 306 additions and 12 deletions
+46 -8
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -31,6 +31,7 @@ export interface Template {
docs_url?: string;
architectures?: string[];
stars?: number;
source?: string;
}
interface AppStoreViewProps {
@@ -48,6 +49,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
const [isDeploying, setIsDeploying] = useState(false);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<string>('All');
const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
const [portVars, setPortVars] = useState<Record<string, string>>({});
const [isDescExpanded, setIsDescExpanded] = useState(false);
@@ -180,11 +182,21 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
}
};
const filtered = templates.filter(t =>
t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
(t.categories && t.categories.join(' ').toLowerCase().includes(searchQuery.toLowerCase()))
);
const categories = useMemo(() => {
const cats = new Set<string>();
templates.forEach(t => t.categories?.forEach(c => cats.add(c)));
return ['All', ...Array.from(cats).sort()];
}, [templates]);
const filtered = useMemo(() => templates.filter(t => {
const matchesCategory = selectedCategory === 'All' || t.categories?.includes(selectedCategory);
const q = searchQuery.toLowerCase();
const matchesSearch = !q ||
t.title.toLowerCase().includes(q) ||
t.description?.toLowerCase().includes(q) ||
(t.categories && t.categories.join(' ').toLowerCase().includes(q));
return matchesCategory && matchesSearch;
}), [templates, selectedCategory, searchQuery]);
return (
<div className="flex flex-col h-full space-y-6">
@@ -196,11 +208,32 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
placeholder="Search App Store..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => { setSearchQuery(e.target.value); }}
/>
</div>
</div>
{!loading && categories.length > 1 && (
<div className="flex items-center gap-2">
<div className="flex gap-1.5 overflow-x-auto pb-1 flex-1 scrollbar-none">
{categories.map(cat => (
<Button
key={cat}
variant={selectedCategory === cat ? 'default' : 'outline'}
size="sm"
className="shrink-0 h-7 text-xs px-3 rounded-full"
onClick={() => setSelectedCategory(cat)}
>
{cat}
</Button>
))}
</div>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{filtered.length} app{filtered.length !== 1 ? 's' : ''}
</span>
</div>
)}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-48">
@@ -233,7 +266,12 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
<CardContent className="pt-0 mt-auto">
<div className="flex flex-wrap gap-1 mt-2">
{t.categories.slice(0, 3).map(c => (
<Badge variant="secondary" key={c} className="text-[10px] px-1.5 py-0 pb-0.5">
<Badge
variant={selectedCategory === c ? 'default' : 'secondary'}
key={c}
className="text-[10px] px-1.5 py-0 pb-0.5 cursor-pointer"
onClick={(e) => { e.stopPropagation(); setSelectedCategory(c); }}
>
{c}
</Badge>
))}
+91 -3
View File
@@ -12,7 +12,7 @@ import { Slider } from '@/components/ui/slider';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server } from 'lucide-react';
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw } from 'lucide-react';
import { NodeManager } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
@@ -32,11 +32,11 @@ interface SettingsModalProps {
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes'>('account');
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'>('account');
// When switching to a remote node, reset to a node-scoped section if on a global-only one
useEffect(() => {
if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes')) {
if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes' || activeSection === 'appstore')) {
setActiveSection('system');
}
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -63,6 +63,8 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
});
const [isLoading, setIsLoading] = useState(false);
const [registryUrl, setRegistryUrl] = useState('');
const [isSavingRegistry, setIsSavingRegistry] = useState(false);
useEffect(() => {
if (isOpen) {
@@ -93,12 +95,32 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
if (res.ok) {
const data = await res.json();
setSettings(prev => ({ ...prev, ...data }));
if (data.template_registry_url) {
setRegistryUrl(data.template_registry_url);
}
}
} catch (e) {
console.error('Failed to fetch settings', e);
}
};
const saveRegistrySettings = async () => {
setIsSavingRegistry(true);
try {
await apiFetch('/settings', {
method: 'POST',
body: JSON.stringify({ key: 'template_registry_url', value: registryUrl.trim() })
});
// Bust the template cache so the next App Store load uses the new URL
await apiFetch('/templates/refresh-cache', { method: 'POST' });
toast.success('Registry saved. App Store will reload from the new source.');
} catch (e) {
toast.error('Failed to save registry settings.');
} finally {
setIsSavingRegistry(false);
}
};
const handleAgentChange = (type: string, field: keyof Agent, value: any) => {
setAgents(prev => ({
...prev,
@@ -297,6 +319,16 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
Nodes
</Button>
)}
{!isRemote && (
<Button
variant={activeSection === 'appstore' ? 'secondary' : 'ghost'}
className="w-full justify-start font-medium"
onClick={() => setActiveSection('appstore')}
>
<Package className="w-4 h-4 mr-2" />
App Store
</Button>
)}
</nav>
</div>
@@ -513,6 +545,62 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<NodeManager />
)}
{activeSection === 'appstore' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold tracking-tight">App Store Registry</h3>
<p className="text-sm text-muted-foreground">Configure the template source used by the App Store.</p>
</div>
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-base">Default Registry</Label>
<p className="text-xs text-muted-foreground">
LinuxServer.io — <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
</p>
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
</div>
</div>
<div className="space-y-3 pt-4 border-t border-border">
<div className="space-y-1">
<Label className="text-base">Custom Registry URL</Label>
<p className="text-xs text-muted-foreground">
Provide a URL pointing to a <span className="font-medium">Portainer v2</span> compatible template JSON file. Overrides the default registry.
</p>
</div>
<Input
placeholder="https://example.com/templates.json"
value={registryUrl}
onChange={(e) => setRegistryUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default LinuxServer.io registry.
</p>
</div>
</div>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => setRegistryUrl('')}
disabled={isSavingRegistry || registryUrl === ''}
>
Reset to Default
</Button>
<Button onClick={saveRegistrySettings} disabled={isSavingRegistry}>
{isSavingRegistry ? (
<><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
) : (
'Save & Refresh'
)}
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>