diff --git a/scripts/ct/2fauth.sh b/scripts/ct/2fauth.sh deleted file mode 100644 index b301cf2..0000000 --- a/scripts/ct/2fauth.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -SCRIPT_DIR="$(dirname "$0")" -source "$SCRIPT_DIR/../core/build.func" -# Copyright (c) 2021-2025 community-scripts ORG -# Author: jkrgr0 -# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE -# Source: https://docs.2fauth.app/ - -APP="2FAuth" -var_tags="${var_tags:-2fa;authenticator}" -var_cpu="${var_cpu:-1}" -var_ram="${var_ram:-512}" -var_disk="${var_disk:-2}" -var_os="${var_os:-debian}" -var_version="${var_version:-13}" -var_unprivileged="${var_unprivileged:-1}" - -header_info "$APP" -variables -color -catch_errors - -function update_script() { - header_info - check_container_storage - check_container_resources - - if [[ ! -d "/opt/2fauth" ]]; then - msg_error "No ${APP} Installation Found!" - exit - fi - if check_for_gh_release "2fauth" "Bubka/2FAuth"; then - $STD apt update - $STD apt -y upgrade - - msg_info "Creating Backup" - mv "/opt/2fauth" "/opt/2fauth-backup" - if ! dpkg -l | grep -q 'php8.3'; then - cp /etc/nginx/conf.d/2fauth.conf /etc/nginx/conf.d/2fauth.conf.bak - fi - msg_ok "Backup Created" - - if ! dpkg -l | grep -q 'php8.3'; then - $STD apt-get install -y \ - lsb-release \ - gnupg2 - PHP_VERSION="8.3" PHP_MODULE="common,ctype,fileinfo,mysql,cli" PHP_FPM="YES" setup_php - sed -i 's/php8.2/php8.3/g' /etc/nginx/conf.d/2fauth.conf - fi - fetch_and_deploy_gh_release "2fauth" "Bubka/2FAuth" - setup_composer - mv "/opt/2fauth-backup/.env" "/opt/2fauth/.env" - mv "/opt/2fauth-backup/storage" "/opt/2fauth/storage" - cd "/opt/2fauth" || return - chown -R www-data: "/opt/2fauth" - chmod -R 755 "/opt/2fauth" - export COMPOSER_ALLOW_SUPERUSER=1 - $STD composer install --no-dev --prefer-source - php artisan 2fauth:install - $STD systemctl restart nginx - - msg_info "Cleaning Up" - if dpkg -l | grep -q 'php8.2'; then - $STD apt remove --purge -y php8.2* - fi - $STD apt -y autoremove - $STD apt -y autoclean - $STD apt -y clean - msg_ok "Cleanup Completed" - msg_ok "Updated Successfully" - fi - exit -} - -start -build_container -description - -msg_ok "Completed Successfully!\n" -echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}" -echo -e "${INFO}${YW} Access it using the following URL:${CL}" -echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:80${CL}" diff --git a/scripts/ct/debian.sh b/scripts/ct/debian.sh deleted file mode 100644 index 54b1748..0000000 --- a/scripts/ct/debian.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -SCRIPT_DIR="$(dirname "$0")" -source "$SCRIPT_DIR/../core/build.func" -# Copyright (c) 2021-2025 tteck -# Author: tteck (tteckster) -# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE -# Source: https://www.debian.org/ - -APP="Debian" -var_tags="${var_tags:-os}" -var_cpu="${var_cpu:-1}" -var_ram="${var_ram:-512}" -var_disk="${var_disk:-2}" -var_os="${var_os:-debian}" -var_version="${var_version:-13}" -var_unprivileged="${var_unprivileged:-1}" - -header_info "$APP" -variables -color -catch_errors - -function update_script() { - header_info - check_container_storage - check_container_resources - if [[ ! -d /var ]]; then - msg_error "No ${APP} Installation Found!" - exit - fi - msg_info "Updating $APP LXC" - $STD apt update - $STD apt -y upgrade - msg_ok "Updated $APP LXC" - exit -} - -start -build_container -description - -msg_ok "Completed Successfully!\n" -echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}" diff --git a/scripts/install/2fauth-install.sh b/scripts/install/2fauth-install.sh deleted file mode 100644 index 2ef85c8..0000000 --- a/scripts/install/2fauth-install.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env bash - -# Copyright (c) 2021-2025 community-scripts ORG -# Author: jkrgr0 -# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE -# Source: https://docs.2fauth.app/ - -source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" -color -verb_ip6 -catch_errors -setting_up_container -network_check -update_os - -msg_info "Installing Dependencies" -$STD apt install -y \ - lsb-release \ - nginx -msg_ok "Installed Dependencies" - -PHP_VERSION="8.3" PHP_MODULE="common,ctype,fileinfo,mysql,cli" PHP_FPM="YES" setup_php -setup_composer -setup_mariadb - -msg_info "Setting up Database" -DB_NAME=2fauth_db -DB_USER=2fauth -DB_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c13) -$STD mariadb -u root -e "CREATE DATABASE $DB_NAME;" -$STD mariadb -u root -e "CREATE USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';" -$STD mariadb -u root -e "GRANT ALL ON $DB_NAME.* TO '$DB_USER'@'localhost'; FLUSH PRIVILEGES;" -{ - echo "2FAuth Credentials" - echo "Database User: $DB_USER" - echo "Database Password: $DB_PASS" - echo "Database Name: $DB_NAME" -} >>~/2FAuth.creds -msg_ok "Set up Database" - -fetch_and_deploy_gh_release "2fauth" "Bubka/2FAuth" - -msg_info "Setup 2FAuth" -cd /opt/2fauth || exit -cp .env.example .env -IPADDRESS=$(hostname -I | awk '{print $1}') -sed -i -e "s|^APP_URL=.*|APP_URL=http://$IPADDRESS|" \ - -e "s|^DB_CONNECTION=$|DB_CONNECTION=mysql|" \ - -e "s|^DB_DATABASE=$|DB_DATABASE=$DB_NAME|" \ - -e "s|^DB_HOST=$|DB_HOST=127.0.0.1|" \ - -e "s|^DB_PORT=$|DB_PORT=3306|" \ - -e "s|^DB_USERNAME=$|DB_USERNAME=$DB_USER|" \ - -e "s|^DB_PASSWORD=$|DB_PASSWORD=$DB_PASS|" .env -export COMPOSER_ALLOW_SUPERUSER=1 -$STD composer update --no-plugins --no-scripts -$STD composer install --no-dev --prefer-source --no-plugins --no-scripts -$STD php artisan key:generate --force -$STD php artisan migrate:refresh -$STD php artisan passport:install -q -n -$STD php artisan storage:link -$STD php artisan config:cache -chown -R www-data: /opt/2fauth -chmod -R 755 /opt/2fauth -msg_ok "Setup 2fauth" - -msg_info "Configure Service" -cat </etc/nginx/conf.d/2fauth.conf -server { - listen 80; - root /opt/2fauth/public; - server_name $IPADDRESS; - index index.php; - charset utf-8; - - location / { - try_files \$uri \$uri/ /index.php?\$query_string; - } - - location = /favicon.ico { access_log off; log_not_found off; } - location = /robots.txt { access_log off; log_not_found off; } - - error_page 404 /index.php; - - location ~ \.php\$ { - fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; - fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name; - include fastcgi_params; - } - - location ~ /\.(?!well-known).* { - deny all; - } -} -EOF -systemctl reload nginx -msg_ok "Configured Service" - -motd_ssh -customize - -msg_info "Cleaning up" -$STD apt -y autoremove -$STD apt -y autoclean -$STD apt -y clean -msg_ok "Cleaned" diff --git a/scripts/install/debian-install.sh b/scripts/install/debian-install.sh deleted file mode 100644 index 7b00eca..0000000 --- a/scripts/install/debian-install.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -# Copyright (c) 2021-2025 tteck -# Author: tteck (tteckster) -# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE -# Source: https://www.debian.org/ - -source /dev/stdin <<<"$FUNCTIONS_FILE_PATH" -color -verb_ip6 -catch_errors -setting_up_container -network_check -update_os - -motd_ssh -customize - -msg_info "Cleaning up" -$STD apt -y autoremove -$STD apt -y autoclean -$STD apt -y clean -msg_ok "Cleaned" - diff --git a/src/app/_components/DownloadedScriptsTab.tsx b/src/app/_components/DownloadedScriptsTab.tsx new file mode 100644 index 0000000..0ae5f68 --- /dev/null +++ b/src/app/_components/DownloadedScriptsTab.tsx @@ -0,0 +1,413 @@ +'use client'; + +import React, { useState, useRef, useEffect } from 'react'; +import { api } from '~/trpc/react'; +import { ScriptCard } from './ScriptCard'; +import { ScriptDetailModal } from './ScriptDetailModal'; +import { CategorySidebar } from './CategorySidebar'; +import { FilterBar, type FilterState } from './FilterBar'; +import { Button } from './ui/button'; +import type { ScriptCard as ScriptCardType } from '~/types/script'; + +export function DownloadedScriptsTab() { + const [selectedSlug, setSelectedSlug] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedCategory, setSelectedCategory] = useState(null); + const [filters, setFilters] = useState({ + searchQuery: '', + showUpdatable: null, + selectedTypes: [], + sortBy: 'name', + sortOrder: 'asc', + }); + const gridRef = useRef(null); + + const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery(); + const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery(); + const { data: scriptData } = api.scripts.getScriptBySlug.useQuery( + { slug: selectedSlug ?? '' }, + { enabled: !!selectedSlug } + ); + + // Extract categories from metadata + const categories = React.useMemo((): string[] => { + if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return []; + + return (scriptCardsData.metadata.categories as any[]) + .filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list + .sort((a, b) => a.sort_order - b.sort_order) + .map((cat) => cat.name as string) + .filter((name): name is string => typeof name === 'string'); + }, [scriptCardsData]); + + // Get GitHub scripts with download status (deduplicated) + const combinedScripts = React.useMemo((): ScriptCardType[] => { + if (!scriptCardsData?.success) return []; + + // Use Map to deduplicate by slug/name + const scriptMap = new Map(); + + scriptCardsData.cards?.forEach(script => { + if (script?.name && script?.slug) { + // Use slug as unique identifier, only keep first occurrence + if (!scriptMap.has(script.slug)) { + scriptMap.set(script.slug, { + ...script, + source: 'github' as const, + isDownloaded: false, // Will be updated by status check + isUpToDate: false, // Will be updated by status check + }); + } + } + }); + + return Array.from(scriptMap.values()); + }, [scriptCardsData]); + + // Update scripts with download status and filter to only downloaded scripts + const downloadedScripts = React.useMemo((): ScriptCardType[] => { + return combinedScripts + .map(script => { + if (!script?.name) { + return script; // Return as-is if invalid + } + + // Check if there's a corresponding local script + const hasLocalVersion = localScriptsData?.scripts?.some(local => { + if (!local?.name) return false; + const localName = local.name.replace(/\.sh$/, ''); + return localName.toLowerCase() === script.name.toLowerCase() || + localName.toLowerCase() === (script.slug ?? '').toLowerCase(); + }) ?? false; + + return { + ...script, + isDownloaded: hasLocalVersion, + }; + }) + .filter(script => script.isDownloaded); // Only show downloaded scripts + }, [combinedScripts, localScriptsData]); + + // Count scripts per category (using downloaded scripts only) + const categoryCounts = React.useMemo((): Record => { + if (!scriptCardsData?.success) return {}; + + const counts: Record = {}; + + // Initialize all categories with 0 + categories.forEach((categoryName: string) => { + counts[categoryName] = 0; + }); + + // Count each unique downloaded script only once per category + downloadedScripts.forEach(script => { + if (script.categoryNames && script.slug) { + const countedCategories = new Set(); + script.categoryNames.forEach((categoryName: unknown) => { + if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) { + countedCategories.add(categoryName); + counts[categoryName]++; + } + }); + } + }); + + return counts; + }, [categories, downloadedScripts, scriptCardsData?.success]); + + // Filter scripts based on all filters and category + const filteredScripts = React.useMemo((): ScriptCardType[] => { + let scripts = downloadedScripts; + + // Filter by search query + if (filters.searchQuery?.trim()) { + const query = filters.searchQuery.toLowerCase().trim(); + + if (query.length >= 1) { + scripts = scripts.filter(script => { + if (!script || typeof script !== 'object') { + return false; + } + + const name = (script.name ?? '').toLowerCase(); + const slug = (script.slug ?? '').toLowerCase(); + + return name.includes(query) ?? slug.includes(query); + }); + } + } + + // Filter by category using real category data from downloaded scripts + if (selectedCategory) { + scripts = scripts.filter(script => { + if (!script) return false; + + // Check if the downloaded script has categoryNames that include the selected category + return script.categoryNames?.includes(selectedCategory) ?? false; + }); + } + + // Filter by updateable status + if (filters.showUpdatable !== null) { + scripts = scripts.filter(script => { + if (!script) return false; + const isUpdatable = script.updateable ?? false; + return filters.showUpdatable ? isUpdatable : !isUpdatable; + }); + } + + // Filter by script types + if (filters.selectedTypes.length > 0) { + scripts = scripts.filter(script => { + if (!script) return false; + const scriptType = (script.type ?? '').toLowerCase(); + return filters.selectedTypes.some(type => type.toLowerCase() === scriptType); + }); + } + + // Apply sorting + scripts.sort((a, b) => { + if (!a || !b) return 0; + + let compareValue = 0; + + switch (filters.sortBy) { + case 'name': + compareValue = (a.name ?? '').localeCompare(b.name ?? ''); + break; + case 'created': + // Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD") + const aCreated = a?.date_created ?? ''; + const bCreated = b?.date_created ?? ''; + + // If both have dates, compare them directly + if (aCreated && bCreated) { + // For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020) + compareValue = aCreated.localeCompare(bCreated); + } else if (aCreated && !bCreated) { + // Scripts with dates come before scripts without dates + compareValue = -1; + } else if (!aCreated && bCreated) { + // Scripts without dates come after scripts with dates + compareValue = 1; + } else { + // Both have no dates, fallback to name comparison + compareValue = (a.name ?? '').localeCompare(b.name ?? ''); + } + break; + default: + compareValue = (a.name ?? '').localeCompare(b.name ?? ''); + } + + // Apply sort order + return filters.sortOrder === 'asc' ? compareValue : -compareValue; + }); + + return scripts; + }, [downloadedScripts, filters, selectedCategory]); + + // Calculate filter counts for FilterBar + const filterCounts = React.useMemo(() => { + const updatableCount = downloadedScripts.filter(script => script?.updateable).length; + + return { installedCount: downloadedScripts.length, updatableCount }; + }, [downloadedScripts]); + + // Handle filter changes + const handleFiltersChange = (newFilters: FilterState) => { + setFilters(newFilters); + }; + + // Handle category selection with auto-scroll + const handleCategorySelect = (category: string | null) => { + setSelectedCategory(category); + }; + + // Auto-scroll effect when category changes + useEffect(() => { + if (selectedCategory && gridRef.current) { + const timeoutId = setTimeout(() => { + gridRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }); + }, 100); + + return () => clearTimeout(timeoutId); + } + }, [selectedCategory]); + + const handleCardClick = (scriptCard: { slug: string }) => { + // All scripts are GitHub scripts, open modal + setSelectedSlug(scriptCard.slug); + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + setSelectedSlug(null); + }; + + if (githubLoading || localLoading) { + return ( +
+
+ Loading downloaded scripts... +
+ ); + } + + if (githubError || localError) { + return ( +
+
+ + + +

Failed to load downloaded scripts

+

+ {githubError?.message ?? localError?.message ?? 'Unknown error occurred'} +

+
+ +
+ ); + } + + if (!downloadedScripts || downloadedScripts.length === 0) { + return ( +
+
+ + + +

No downloaded scripts found

+

+ You haven't downloaded any scripts yet. Visit the Available Scripts tab to download some scripts. +

+
+
+ ); + } + + return ( +
+ {/* Header with Stats */} +
+

Downloaded Scripts

+ +
+
+
{downloadedScripts.length}
+
Total Downloaded
+
+
+
{filterCounts.updatableCount}
+
Updatable
+
+
+
{filteredScripts.length}
+
Filtered Results
+
+
+
+ +
+ {/* Category Sidebar */} +
+ +
+ + {/* Main Content */} +
+ {/* Enhanced Filter Bar */} + + + {/* Scripts Grid */} + {filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? ( +
+
+ + + +

No matching downloaded scripts found

+

+ Try different filter settings or clear all filters. +

+
+ {filters.searchQuery && ( + + )} + {selectedCategory && ( + + )} +
+
+
+ ) : ( +
+ {filteredScripts.map((script, index) => { + // Add validation to ensure script has required properties + if (!script || typeof script !== 'object') { + return null; + } + + // Create a unique key by combining slug, name, and index to handle duplicates + const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`; + + return ( + + ); + })} +
+ )} + + { + // Downloaded scripts don't need installation + }} + /> +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 174f34c..dbd8a17 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { ScriptsGrid } from './_components/ScriptsGrid'; +import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab'; import { InstalledScriptsTab } from './_components/InstalledScriptsTab'; import { ResyncButton } from './_components/ResyncButton'; import { Terminal } from './_components/Terminal'; @@ -11,7 +12,7 @@ import { Button } from './_components/ui/button'; export default function Home() { const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null); - const [activeTab, setActiveTab] = useState<'scripts' | 'installed'>('scripts'); + const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts'); const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => { setRunningScript({ path: scriptPath, name: scriptName, mode, server }); @@ -61,6 +62,17 @@ export default function Home() { }`}> 📦 Available Scripts +