Add filter Buttons

This commit is contained in:
Michel Roegl-Brunner
2025-11-13 15:26:49 +01:00
parent 955d0e72d7
commit 5cb7bc95fa
419 changed files with 1548 additions and 2115 deletions

View File

@@ -29,6 +29,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
searchQuery: '',
showUpdatable: null,
selectedTypes: [],
selectedRepositories: [],
sortBy: 'name',
sortOrder: 'asc',
});
@@ -283,6 +284,22 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
});
}
// Filter by repositories
if (filters.selectedRepositories.length > 0) {
scripts = scripts.filter(script => {
if (!script) return false;
const repoUrl = script.repository_url;
// If script has no repository_url, exclude it when filtering by repositories
if (!repoUrl) {
return false;
}
// Only include scripts from selected repositories
return filters.selectedRepositories.includes(repoUrl);
});
}
// Apply sorting
scripts.sort((a, b) => {
if (!a || !b) return 0;

View File

@@ -3,12 +3,14 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react";
import { api } from "~/trpc/react";
export interface FilterState {
searchQuery: string;
showUpdatable: boolean | null; // null = all, true = only updatable, false = only non-updatable
selectedTypes: string[]; // Array of selected types: 'lxc', 'vm', 'addon', 'pve'
selectedRepositories: string[]; // Array of selected repository URLs
sortBy: "name" | "created"; // Sort criteria (removed 'updated')
sortOrder: "asc" | "desc"; // Sort direction
}
@@ -43,6 +45,23 @@ export function FilterBar({
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
// Fetch enabled repositories
const { data: enabledReposData } = api.repositories.getEnabled.useQuery();
const enabledRepos = enabledReposData?.repositories ?? [];
// Helper function to extract repository name from URL
const getRepoName = (url: string): string => {
try {
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) {
return `${match[1]}/${match[2]}`;
}
return url;
} catch {
return url;
}
};
const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates });
};
@@ -52,6 +71,7 @@ export function FilterBar({
searchQuery: "",
showUpdatable: null,
selectedTypes: [],
selectedRepositories: [],
sortBy: "name",
sortOrder: "asc",
});
@@ -61,6 +81,7 @@ export function FilterBar({
filters.searchQuery ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0 ||
filters.selectedRepositories.length > 0 ||
filters.sortBy !== "name" ||
filters.sortOrder !== "asc";
@@ -290,6 +311,40 @@ export function FilterBar({
)}
</div>
{/* Repository Filter Buttons - Only show if more than one enabled repo */}
{enabledRepos.length > 1 && enabledRepos.map((repo) => {
const isSelected = filters.selectedRepositories.includes(repo.url);
return (
<Button
key={repo.id}
onClick={() => {
const currentSelected = filters.selectedRepositories;
if (isSelected) {
// Remove repository from selection
updateFilters({
selectedRepositories: currentSelected.filter(url => url !== repo.url)
});
} else {
// Add repository to selection
updateFilters({
selectedRepositories: [...currentSelected, repo.url]
});
}
}}
variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
isSelected
? "border border-primary/20 bg-primary/10 text-primary"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<GitBranch className="h-4 w-4" />
<span>{getRepoName(repo.url)}</span>
</Button>
);
})}
{/* Sort By Dropdown */}
<div className="relative w-full sm:w-auto">
<Button

View File

@@ -29,6 +29,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
searchQuery: '',
showUpdatable: null,
selectedTypes: [],
selectedRepositories: [],
sortBy: 'name',
sortOrder: 'asc',
});
@@ -239,6 +240,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
filters.searchQuery?.trim() !== '' ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0 ||
filters.selectedRepositories.length > 0 ||
filters.sortBy !== 'name' ||
filters.sortOrder !== 'asc' ||
selectedCategory !== null
@@ -312,6 +314,22 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
}
// Filter by repositories
if (filters.selectedRepositories.length > 0) {
scripts = scripts.filter(script => {
if (!script) return false;
const repoUrl = script.repository_url;
// If script has no repository_url, exclude it when filtering by repositories
if (!repoUrl) {
return false;
}
// Only include scripts from selected repositories
return filters.selectedRepositories.includes(repoUrl);
});
}
// Exclude newest scripts from main grid when no filters are active (they'll be shown in carousel)
if (!hasActiveFilters) {
const newestScriptSlugs = new Set(newestScripts.map(script => script.slug).filter(Boolean));

View File

@@ -5,6 +5,7 @@ import { githubJsonService } from "~/server/services/githubJsonService";
import { localScriptsService } from "~/server/services/localScripts";
import { scriptDownloaderService } from "~/server/services/scriptDownloader.js";
import { AutoSyncService } from "~/server/services/autoSyncService";
import { repositoryService } from "~/server/services/repositoryService";
import type { ScriptCard } from "~/types/script";
export const scriptsRouter = createTRPCRouter({
@@ -166,14 +167,18 @@ export const scriptsRouter = createTRPCRouter({
getScriptCardsWithCategories: publicProcedure
.query(async () => {
try {
const [cards, metadata] = await Promise.all([
const [cards, metadata, enabledRepos] = await Promise.all([
localScriptsService.getScriptCards(),
localScriptsService.getMetadata()
localScriptsService.getMetadata(),
repositoryService.getEnabledRepositories()
]);
// Get all scripts to access their categories
const scripts = await localScriptsService.getAllScripts();
// Create a set of enabled repository URLs for fast lookup
const enabledRepoUrls = new Set(enabledRepos.map(repo => repo.url));
// Create category ID to name mapping
const categoryMap: Record<number, string> = {};
if (metadata?.categories) {
@@ -218,7 +223,21 @@ export const scriptsRouter = createTRPCRouter({
} as ScriptCard;
});
return { success: true, cards: cardsWithCategories, metadata };
// Filter cards to only include scripts from enabled repositories
// For backward compatibility, include scripts without repository_url
const filteredCards = cardsWithCategories.filter(card => {
const repoUrl = card.repository_url;
// If script has no repository_url, include it for backward compatibility
if (!repoUrl) {
return true;
}
// Only include scripts from enabled repositories
return enabledRepoUrls.has(repoUrl);
});
return { success: true, cards: filteredCards, metadata };
} catch (error) {
console.error('Error in getScriptCardsWithCategories:', error);
return {

View File

@@ -80,7 +80,47 @@ export class LocalScriptsService {
try {
const content = await readFile(filePath, 'utf-8');
return JSON.parse(content) as Script;
const script = JSON.parse(content) as Script;
// Ensure repository_url is set (backward compatibility)
// If missing, try to determine which repo it came from by checking all enabled repos
// Note: This is a fallback for scripts synced before repository_url was added
if (!script.repository_url) {
const { repositoryService } = await import('./repositoryService');
const enabledRepos = await repositoryService.getEnabledRepositories();
// Check each repo in priority order to see which one has this script
// We check in priority order so that if a script exists in multiple repos,
// we use the highest priority repo (same as sync logic)
let foundRepo: string | null = null;
for (const repo of enabledRepos) {
try {
const { githubJsonService } = await import('./githubJsonService.js');
const repoScript = await githubJsonService.getScriptBySlug(slug, repo.url);
if (repoScript) {
foundRepo = repo.url;
// Don't break - continue to check higher priority repos first
// Actually, repos are already sorted by priority, so first match is highest priority
break;
}
} catch {
// Continue checking other repos
}
}
// Set repository_url to found repo or default to main repo
const { env } = await import('~/env.js');
script.repository_url = foundRepo ?? env.REPO_URL ?? 'https://github.com/community-scripts/ProxmoxVE';
// Update the JSON file with the repository_url for future loads
try {
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
} catch {
// If we can't write, that's okay - at least we have it in memory
}
}
return script;
} catch {
// If file doesn't exist, return null instead of throwing
return null;

View File

@@ -16,13 +16,58 @@ export class ScriptDownloaderService {
}
}
/**
* Validates that a directory path doesn't contain nested directories with the same name
* (e.g., prevents ct/ct or install/install)
*/
validateDirectoryPath(dirPath) {
const normalizedPath = dirPath.replace(/\\/g, '/');
const parts = normalizedPath.split('/');
// Check for consecutive duplicate directory names
for (let i = 0; i < parts.length - 1; i++) {
if (parts[i] === parts[i + 1] && parts[i] !== '') {
throw new Error(`Invalid directory path: nested directory detected (${parts[i]}/${parts[i + 1]}) in path: ${dirPath}`);
}
}
return true;
}
/**
* Validates that finalTargetDir doesn't contain nested directory names like ct/ct or install/install
*/
validateTargetDir(targetDir, finalTargetDir) {
// Check if finalTargetDir contains nested directory names
const normalized = finalTargetDir.replace(/\\/g, '/');
const parts = normalized.split('/');
// Check for consecutive duplicate directory names
for (let i = 0; i < parts.length - 1; i++) {
if (parts[i] === parts[i + 1]) {
console.warn(`[Path Validation] Detected nested directory pattern "${parts[i]}/${parts[i + 1]}" in finalTargetDir: ${finalTargetDir}. Using base directory "${targetDir}" instead.`);
return targetDir; // Return the base directory instead
}
}
return finalTargetDir;
}
async ensureDirectoryExists(dirPath) {
// Validate the directory path to prevent nested directories with the same name
this.validateDirectoryPath(dirPath);
try {
console.log(`[Directory Creation] Ensuring directory exists: ${dirPath}`);
await mkdir(dirPath, { recursive: true });
console.log(`[Directory Creation] Directory created/verified: ${dirPath}`);
} catch (error) {
if (error.code !== 'EEXIST') {
console.error(`[Directory Creation] Error creating directory ${dirPath}:`, error.message);
throw error;
}
// Directory already exists, which is fine
console.log(`[Directory Creation] Directory already exists: ${dirPath}`);
}
}
@@ -112,6 +157,8 @@ export class ScriptDownloaderService {
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Modify the content for CT scripts
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
@@ -122,6 +169,8 @@ export class ScriptDownloaderService {
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
@@ -132,6 +181,8 @@ export class ScriptDownloaderService {
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
@@ -140,6 +191,8 @@ export class ScriptDownloaderService {
// Handle other script types (fallback to ct directory)
targetDir = 'ct';
finalTargetDir = targetDir;
// Validate and sanitize finalTargetDir
finalTargetDir = this.validateTargetDir(targetDir, finalTargetDir);
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
@@ -701,3 +754,4 @@ export class ScriptDownloaderService {
}
export const scriptDownloaderService = new ScriptDownloaderService();