Add filter Buttons
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user