diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1755aea..8011a15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,7 @@ All applications are defined in [`src/lib/data.ts`](src/lib/data.ts). 2. If found → use `arch` field 3. If NOT found → search [aur.archlinux.org](https://aur.archlinux.org/) → use `arch` field with AUR package name 4. Prefer `-bin` suffix packages in AUR (pre-built, faster install) +5. **IMPORTANT**: If your AUR package name does **NOT** end in `-bin`, `-git`, or `-appimage`, you **MUST** add it to `KNOWN_AUR_PACKAGES` in [`src/lib/aur.ts`](src/lib/aur.ts) so the app knows it's from AUR. #### Ubuntu/Debian: Official Repos Only diff --git a/README.md b/README.md index e3fdf24..3d85bc2 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ Shows which apps are available for your selected distro, with instructions for u ## 📸 Screenshots -![Main interface with app selection](src/screenshots/1.png) -![Category browsing and filtering](src/screenshots/2.png) -![Generated install script](src/screenshots/3.png) +![1](src/screenshots/1.png) +![2](src/screenshots/2.png) +![3](src/screenshots/3.png) @@ -216,18 +216,21 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. - [x] Nix, Flatpak & Snap universal package support - [x] 150+ applications across 15 categories - [x] Smart script generation with error handling -- [x] AUR helper integration (yay) for Arch -- [x] Keyboard navigation (Vim keys + Arrows) -- [x] Dark / Light theme toggle +- [x] Dark / Light theme toggle with smooth animations - [x] Copy command & Download script -- [x] Package availability indicators - [x] Custom domain -- [x] Docker support for containerized deployment -- [x] CI/CD workflow for automated Docker builds +- [x] Docker support +- [x] CI/CD shortcuts & workflow +- [x] Search & filter applications (Real-time) +- [x] AUR Helper selection (yay/paru) + Auto-detection +- [x] Keyboard navigation (Vim keys, Arrows, Space, Esc, Enter) +- [x] Package availability indicators (including AUR badges) + + + ### Planned -- [ ] Search & filter applications - [ ] Winget support (Windows) - [ ] Homebrew support (macOS) - [ ] Save custom presets / profiles diff --git a/src/app/globals.css b/src/app/globals.css index f4a1c4a..fd1152d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -356,7 +356,7 @@ html { } .stagger-item { - animation: staggerIn 0.15s ease-out forwards; + animation: staggerIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; } /* Tooltip fade */ @@ -373,7 +373,7 @@ html { } .tooltip-animate { - animation: tooltipIn 0.15s ease-out forwards; + animation: tooltipIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards; } /* Dropdown entrance */ @@ -390,7 +390,7 @@ html { } .dropdown-animate { - animation: dropIn 0.15s ease-out forwards; + animation: dropIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; } /* Button press */ @@ -478,24 +478,24 @@ html { @keyframes slideUp { 0% { opacity: 0; - transform: translateY(100%); + transform: translateY(20px) scale(0.98); } 100% { opacity: 1; - transform: translateY(0); + transform: translateY(0) scale(1); } } @keyframes slideDown { 0% { opacity: 1; - transform: translateY(0); + transform: translateY(0) scale(1); } 100% { opacity: 0; - transform: translateY(100%); + transform: translateY(20px) scale(0.98); } } @@ -509,6 +509,51 @@ html { } } +/* Smooth spring-like animations for floating cards */ +@keyframes cardSlideIn { + 0% { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + + 60% { + transform: translateY(-3px) scale(1.01); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes cardSlideOut { + 0% { + opacity: 1; + transform: translateY(0) scale(1); + } + + 100% { + opacity: 0; + transform: translateY(10px) scale(0.95); + } +} + +@keyframes cardSlideInSecond { + 0% { + opacity: 0; + transform: translateY(15px) scale(0.97); + } + + 60% { + transform: translateY(-2px) scale(1.005); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + @keyframes tooltipSlideUp { 0% { opacity: 0; @@ -524,25 +569,53 @@ html { /* ===== COMMAND BAR SCROLLBAR ===== */ .command-scroll { - scrollbar-width: thin; - scrollbar-color: var(--text-muted) var(--bg-hover); - padding-bottom: 10px; + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE/Edge */ } .command-scroll::-webkit-scrollbar { - height: 6px; + display: none; + /* Chrome/Safari/Opera */ } -.command-scroll::-webkit-scrollbar-track { - background: var(--bg-hover); - border-radius: 6px; +/* ===== SEARCH POPUP ANIMATION ===== */ + +@keyframes searchPopIn { + 0% { + opacity: 0; + transform: translateY(10px) scale(0.95); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } } -.command-scroll::-webkit-scrollbar-thumb { - background: var(--text-muted); - border-radius: 6px; +/* ===== THEME FLASH ANIMATION ===== */ + +@keyframes themeFlash { + 0% { + opacity: 0; + } + + 50% { + opacity: 0.15; + } + + 100% { + opacity: 0; + } } -.command-scroll::-webkit-scrollbar-thumb:hover { +body.theme-flash::after { + content: ""; + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; background: var(--text-primary); + animation: themeFlash 0.15s ease-out forwards; } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 641de7a..887bc67 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { ThemeProvider } from "@/hooks/useTheme"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -62,7 +63,9 @@ export default function RootLayout({ - {children} + + {children} + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 44dae69..6896975 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react'; +import { useState, useMemo, useCallback, useRef, useLayoutEffect, useEffect } from 'react'; import { X } from 'lucide-react'; import gsap from 'gsap'; @@ -20,6 +20,7 @@ import { CategorySection } from '@/components/app'; import { CommandFooter } from '@/components/command'; import { Tooltip, GlobalStyles, LoadingSkeleton } from '@/components/common'; + // ============================================================================ // Main Page Component // ============================================================================ @@ -55,19 +56,52 @@ export default function Home() { setHasYayInstalled, hasAurPackages, aurAppNames, - isHydrated + isHydrated, + selectedHelper, + setSelectedHelper } = useLinuxInit(); + // Search state + const [searchQuery, setSearchQuery] = useState(''); + const searchInputRef = useRef(null); + + // Handle "/" key to focus search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Skip if already in input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + if (e.key === '/') { + e.preventDefault(); + searchInputRef.current?.focus(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + // ======================================================================== // Category & Column Layout // ======================================================================== - /** All categories with their apps */ - const allCategoriesWithApps = useMemo(() => - categories - .map(cat => ({ category: cat, apps: getAppsByCategory(cat) })) - .filter(c => c.apps.length > 0), - []); + /** All categories with their apps (filtered by search) */ + const allCategoriesWithApps = useMemo(() => { + const query = searchQuery.toLowerCase().trim(); + return categories + .map(cat => { + const categoryApps = getAppsByCategory(cat); + // Filter apps if there's a search query (match name or id only) + const filteredApps = query + ? categoryApps.filter(app => + app.name.toLowerCase().includes(query) || + app.id.toLowerCase().includes(query) + ) + : categoryApps; + return { category: cat, apps: filteredApps }; + }) + .filter(c => c.apps.length > 0); + }, [searchQuery]); /** Number of columns for the app grid layout */ const COLUMN_COUNT = 5; @@ -188,7 +222,7 @@ export default function Home() { {/* Header */}
-
+
{/* Logo & Title */}
@@ -250,8 +284,8 @@ export default function Home() {
{/* App Grid */} -
-
+
+
{columns.map((columnCategories, colIdx) => { // Calculate starting index for this column (for staggered animation) @@ -260,11 +294,14 @@ export default function Home() { globalIdx += columns[c].length; } + // Generate stable key based on column content to ensure proper reconciliation + const columnKey = `col-${colIdx}-${columnCategories.map(c => c.category).join('-')}`; + return ( -
+
{columnCategories.map(({ category, apps: categoryApps }, catIdx) => (
); diff --git a/src/components/app/AppItem.tsx b/src/components/app/AppItem.tsx index 9365738..2615640 100644 --- a/src/components/app/AppItem.tsx +++ b/src/components/app/AppItem.tsx @@ -1,9 +1,10 @@ 'use client'; import { memo } from 'react'; -import { Check } from 'lucide-react'; +import { Check, Package } from 'lucide-react'; import { distros, type DistroId, type AppData } from '@/lib/data'; import { analytics } from '@/lib/analytics'; +import { isAurPackage } from '@/lib/aur'; import { AppIcon } from './AppIcon'; /** @@ -70,6 +71,8 @@ export const AppItem = memo(function AppItem({ return `Not available in ${distroName} repos`; }; + const isAur = selectedDistro === 'arch' && app.targets?.arch && isAurPackage(app.targets.arch); + return (
{isSelected && }
- - {app.name} - +
+ + {app.name} + + {isAur && ( + AUR + )} +
{/* Exclamation mark icon for unavailable apps */} {!isAvailable && (
toggleCategory("Browsers")} - * focusedId={focusedItem?.id} - * focusedType={focusedItem?.type} - * onTooltipEnter={showTooltip} - * onTooltipLeave={hideTooltip} - * categoryIndex={0} - * /> */ interface CategorySectionProps { @@ -68,7 +35,7 @@ interface CategorySectionProps { onAppFocus?: (appId: string) => void; } -export const CategorySection = memo(function CategorySection({ +function CategorySectionComponent({ category, categoryApps, selectedApps, @@ -89,7 +56,9 @@ export const CategorySection = memo(function CategorySection({ const isCategoryFocused = focusedType === 'category' && focusedId === category; const sectionRef = useRef(null); const hasAnimated = useRef(false); + const prevAppCount = useRef(categoryApps.length); + // Initial entrance animation useLayoutEffect(() => { if (!sectionRef.current || hasAnimated.current) return; hasAnimated.current = true; @@ -107,21 +76,31 @@ export const CategorySection = memo(function CategorySection({ gsap.to(header, { clipPath: 'inset(0 0% 0 0)', - duration: 0.6, - ease: 'power2.out', - delay: delay + 0.2 + duration: 0.9, + ease: 'power3.out', + delay: delay + 0.1 }); gsap.to(items, { y: 0, opacity: 1, - duration: 0.5, - stagger: 0.03, - ease: 'power2.out', - delay: delay + 0.4 + duration: 0.8, + stagger: 0.04, + ease: 'expo.out', + delay: delay + 0.2 }); }, [categoryIndex]); + // When app count changes (after search clears), ensure all items are visible + useEffect(() => { + if (categoryApps.length !== prevAppCount.current && sectionRef.current) { + const items = sectionRef.current.querySelectorAll('.app-item'); + // Reset any hidden items to visible + gsap.set(items, { y: 0, opacity: 1, clearProps: 'all' }); + } + prevAppCount.current = categoryApps.length; + }, [categoryApps.length]); + return (
-
+
{categoryApps.map((app) => (
); +} + +// Custom memo comparison to ensure proper re-renders when categoryApps changes +export const CategorySection = memo(CategorySectionComponent, (prevProps, nextProps) => { + // Always re-render if app count changes + if (prevProps.categoryApps.length !== nextProps.categoryApps.length) return false; + + // Check if app IDs are the same + const prevIds = prevProps.categoryApps.map(a => a.id).join(','); + const nextIds = nextProps.categoryApps.map(a => a.id).join(','); + if (prevIds !== nextIds) return false; + + // Check other important props + if (prevProps.category !== nextProps.category) return false; + if (prevProps.isExpanded !== nextProps.isExpanded) return false; + if (prevProps.selectedDistro !== nextProps.selectedDistro) return false; + if (prevProps.focusedId !== nextProps.focusedId) return false; + if (prevProps.focusedType !== nextProps.focusedType) return false; + if (prevProps.categoryIndex !== nextProps.categoryIndex) return false; + + // Check if selection state changed for any app in this category + for (const app of nextProps.categoryApps) { + if (prevProps.selectedApps.has(app.id) !== nextProps.selectedApps.has(app.id)) { + return false; + } + } + + return true; }); diff --git a/src/components/command/AurDrawerSettings.tsx b/src/components/command/AurDrawerSettings.tsx new file mode 100644 index 0000000..66adc7d --- /dev/null +++ b/src/components/command/AurDrawerSettings.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { Package, Download, Terminal } from 'lucide-react'; + +interface AurDrawerSettingsProps { + aurAppNames: string[]; + hasYayInstalled: boolean; + setHasYayInstalled: (value: boolean) => void; + selectedHelper: 'yay' | 'paru'; + setSelectedHelper: (helper: 'yay' | 'paru') => void; +} + +/** + * AurDrawerSettings - Settings panel for AUR configuration inside the drawer + */ +export function AurDrawerSettings({ + aurAppNames, + hasYayInstalled, + setHasYayInstalled, + selectedHelper, + setSelectedHelper, +}: AurDrawerSettingsProps) { + return ( +
+ {/* Header */} +
+
+ +
+
+

+ AUR Packages Detected +

+

+ These apps require an AUR helper: {aurAppNames.join(', ')} +

+
+
+ + {/* Controls Grid */} +
+ + {/* 1. Installation Mode */} +
+ +
+ + +
+

+ {hasYayInstalled + ? "Script will use your existing helper" + : "Script will install the helper first"} +

+
+ + {/* 2. Helper Selection */} +
+ +
+ + + +
+
+ +
+
+ ); +} diff --git a/src/components/command/AurFloatingCard.tsx b/src/components/command/AurFloatingCard.tsx new file mode 100644 index 0000000..7774063 --- /dev/null +++ b/src/components/command/AurFloatingCard.tsx @@ -0,0 +1,271 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { X } from 'lucide-react'; + +interface AurFloatingCardProps { + show: boolean; + aurAppNames: string[]; + hasYayInstalled: boolean; + setHasYayInstalled: (value: boolean) => void; + selectedHelper: 'yay' | 'paru'; + setSelectedHelper: (helper: 'yay' | 'paru') => void; +} + +/** + * AurFloatingCard - Elegant floating notification cards for AUR helper configuration + * Smooth spring animations, auto-dismisses after selection + */ +export function AurFloatingCard({ + show, + aurAppNames, + hasYayInstalled, + setHasYayInstalled, + selectedHelper, + setSelectedHelper, +}: AurFloatingCardProps) { + const [dismissed, setDismissed] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const [showConfirmation, setShowConfirmation] = useState(false); + // Track if user has answered the first question + const [hasAnswered, setHasAnswered] = useState(null); + // Track if user has selected a helper (completed flow) + const [helperChosen, setHelperChosen] = useState(false); + // Track if user has interacted (dismissed or selected) to prevent nagging + const [userInteracted, setUserInteracted] = useState(false); + + // Reset when new AUR packages appear, BUT ONLY if user hasn't interacted yet + useEffect(() => { + if (show && aurAppNames.length > 0 && !userInteracted) { + setDismissed(false); + setIsExiting(false); + setShowConfirmation(false); + setHelperChosen(false); + setHasAnswered(null); + } + }, [aurAppNames.length, show, userInteracted]); + + if (!show || dismissed) return null; + + const handleFirstAnswer = (hasHelper: boolean) => { + setHasYayInstalled(hasHelper); + setHasAnswered(hasHelper); + }; + + const handleHelperSelect = (helper: 'yay' | 'paru') => { + setSelectedHelper(helper); + setHelperChosen(true); + setUserInteracted(true); // Don't ask again + + // Start exit animation after a brief moment + setTimeout(() => { + setIsExiting(true); + setTimeout(() => { + setShowConfirmation(true); + }, 250); + }, 400); + }; + + const handleDismiss = () => { + setUserInteracted(true); // Don't ask again + setIsExiting(true); + setTimeout(() => { + setDismissed(true); + setIsExiting(false); + }, 200); + }; + + const handleConfirmationDismiss = () => { + setDismissed(true); + }; + + // Show confirmation message after selecting helper, auto-dismiss after 3s + if (showConfirmation) { + // Auto dismiss after 3 seconds + setTimeout(() => { + setDismissed(true); + }, 3000); + + return ( +
+

+ You can change this later in preview tab +

+
+ ); + } + + // Hide cards while exiting + if (isExiting && helperChosen) { + return ( +
+
+
+
+ {hasAnswered !== null && ( +
+
+
+ )} +
+ ); + } + + return ( +
+ {/* Card 1: Do you have an AUR helper? */} +
+ {/* Header */} +
+
+

+ {aurAppNames.length} AUR package{aurAppNames.length !== 1 ? 's' : ''} +

+

+ Do you have an AUR helper? +

+
+ +
+ + {/* Buttons */} +
+ + +
+ + {/* Hint */} +
+

+ Change anytime in preview window +

+
+
+ + {/* Card 2: Which helper? (appears after first answer) */} + {hasAnswered !== null && ( +
+ {/* Header */} +
+

+ {hasAnswered + ? 'Which one do you have?' + : 'Which one to install?' + } +

+ +
+ + {/* Helper selection */} +
+ + +
+
+ )} +
+ ); +} diff --git a/src/components/command/AurPanel.tsx b/src/components/command/AurPanel.tsx new file mode 100644 index 0000000..b15c63f --- /dev/null +++ b/src/components/command/AurPanel.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { Check, Info, ChevronDown, ChevronUp } from 'lucide-react'; + +interface AurPanelProps { + aurAppNames: string[]; + hasYayInstalled: boolean; + setHasYayInstalled: (value: boolean) => void; +} + +/** + * AurPanel - Beginner-friendly AUR configuration panel + * Shows when AUR packages are selected, with clear explanations + */ +export function AurPanel({ + aurAppNames, + hasYayInstalled, + setHasYayInstalled, +}: AurPanelProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [selectedHelper, setSelectedHelper] = useState<'yay' | 'paru'>('yay'); + const panelRef = useRef(null); + + // Close on click outside + useEffect(() => { + if (!isExpanded) return; + const handleClickOutside = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setIsExpanded(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isExpanded]); + + // Keyboard shortcuts for helper selection + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) return; + if (e.key === '1') setSelectedHelper('yay'); + if (e.key === '2') setSelectedHelper('paru'); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + return ( +
+ {/* Trigger Button */} + + + {/* Expanded Panel */} + {isExpanded && ( +
+ {/* Header with explanation */} +
+
+ +
+

+ What is an AUR Helper? +

+

+ Some packages are from the AUR (Arch User Repository). + You need an AUR helper like yay or paru to install them. +

+
+
+
+ + {/* Selected AUR packages */} +
+

AUR packages you selected:

+
+ {aurAppNames.map((name, idx) => ( + + {name} + + ))} +
+
+ + {/* Toggle: Do you have AUR helper? */} +
+ +
+ + {/* Helper Selection */} +
+

Choose your AUR helper:

+
+ + +
+

+ Press 1 or 2 to switch +

+
+
+ )} +
+ ); +} diff --git a/src/components/command/AurPopover.tsx b/src/components/command/AurPopover.tsx new file mode 100644 index 0000000..4e5d652 --- /dev/null +++ b/src/components/command/AurPopover.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { Check, AlertTriangle } from 'lucide-react'; + +interface AurPopoverProps { + aurAppNames: string[]; + hasYayInstalled: boolean; + setHasYayInstalled: (value: boolean) => void; +} + +export function AurPopover({ + aurAppNames, + hasYayInstalled, + setHasYayInstalled, +}: AurPopoverProps) { + const [isOpen, setIsOpen] = useState(false); + const popoverRef = useRef(null); + + // Close on click outside + useEffect(() => { + if (!isOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + return ( +
+ {/* AUR Badge */} + + + {/* Popover */} + {isOpen && ( +
+ {/* Header */} +
+

AUR Packages

+

+ {hasYayInstalled ? 'Using yay' : 'Will install yay first'} +

+
+ + {/* Package List */} +
+ {aurAppNames.map((name, idx) => ( + + {name} + + ))} +
+ + {/* Yay Checkbox */} +
+ +
+
+ )} +
+ ); +} diff --git a/src/components/command/CommandFooter.tsx b/src/components/command/CommandFooter.tsx index ee1323f..fc00460 100644 --- a/src/components/command/CommandFooter.tsx +++ b/src/components/command/CommandFooter.tsx @@ -5,38 +5,38 @@ import { Check, Copy, ChevronUp, X, Download } from 'lucide-react'; import { distros, type DistroId } from '@/lib/data'; import { generateInstallScript } from '@/lib/generateInstallScript'; import { analytics } from '@/lib/analytics'; -import { AurBar } from './AurBar'; +import { useTheme } from '@/hooks/useTheme'; +import { ShortcutsBar } from './ShortcutsBar'; +import { AurFloatingCard } from './AurFloatingCard'; +import { AurDrawerSettings } from './AurDrawerSettings'; + +interface CommandFooterProps { + command: string; + selectedCount: number; + selectedDistro: DistroId; + selectedApps: Set; + hasAurPackages: boolean; + aurAppNames: string[]; + hasYayInstalled: boolean; + setHasYayInstalled: (value: boolean) => void; + // Search props + searchQuery: string; + onSearchChange: (query: string) => void; + searchInputRef: React.RefObject; + // Clear selections + clearAll: () => void; + // AUR Helper + selectedHelper: 'yay' | 'paru'; + setSelectedHelper: (helper: 'yay' | 'paru') => void; +} /** * CommandFooter - Fixed bottom bar with command output * - * Features: - * - Command preview with copy button - * - Download button for shell script - * - Slide-up drawer for full command view - * - AUR bar for Arch with yay status - * - Mobile responsive (bottom sheet) and desktop (centered modal) + * Features shortcuts bar at top, command preview, AUR badge, Download/Copy buttons. + * Search is now a separate floating popup component. * - * @param command - Generated install command - * @param selectedCount - Number of selected apps - * @param selectedDistro - Currently selected distro ID - * @param selectedApps - Set of selected app IDs - * @param hasAurPackages - Whether any AUR packages are selected - * @param aurAppNames - Array of AUR app names - * @param hasYayInstalled - Whether yay is installed - * @param setHasYayInstalled - Callback to update yay status - * - * @example - * + * Update: Added distinct drawer expansion button and global hotkeys. */ export function CommandFooter({ command, @@ -46,23 +46,22 @@ export function CommandFooter({ hasAurPackages, aurAppNames, hasYayInstalled, - setHasYayInstalled -}: { - command: string; - selectedCount: number; - selectedDistro: DistroId; - selectedApps: Set; - hasAurPackages: boolean; - aurAppNames: string[]; - hasYayInstalled: boolean; - setHasYayInstalled: (value: boolean) => void; -}) { + setHasYayInstalled, + searchQuery, + onSearchChange, + searchInputRef, + clearAll, + selectedHelper, + setSelectedHelper, +}: CommandFooterProps) { const [copied, setCopied] = useState(false); const [showCopyTooltip, setShowCopyTooltip] = useState(false); const [showDownloadTooltip, setShowDownloadTooltip] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerClosing, setDrawerClosing] = useState(false); + const { toggle: toggleTheme } = useTheme(); + const closeDrawer = useCallback(() => { setDrawerClosing(true); setTimeout(() => { @@ -81,6 +80,60 @@ export function CommandFooter({ return () => document.removeEventListener('keydown', handleEscape); }, [drawerOpen, closeDrawer]); + const showAur = selectedDistro === 'arch' && hasAurPackages; + const distroDisplayName = distros.find(d => d.id === selectedDistro)?.name || selectedDistro; + + // Global keyboard shortcuts (vim-like) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if in naturally interactive elements + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement + ) { + return; + } + + // These shortcuts always work + const alwaysEnabled = ['t', 'c']; + if (selectedCount === 0 && !alwaysEnabled.includes(e.key)) return; + + switch (e.key) { + case 'y': + handleCopy(); + break; + case 'd': + handleDownload(); + break; + case 't': + // Flash effect for theme toggle + document.body.classList.add('theme-flash'); + setTimeout(() => document.body.classList.remove('theme-flash'), 150); + toggleTheme(); + break; + case 'c': + clearAll(); + break; + case '1': + if (showAur) setSelectedHelper('yay'); + break; + case '2': + if (showAur) setSelectedHelper('paru'); + break; + case 'Tab': + e.preventDefault(); + if (selectedCount > 0) { + setDrawerOpen(prev => !prev); + } + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedCount, toggleTheme, clearAll, showAur, setHasYayInstalled]); + const handleCopy = async () => { if (selectedCount === 0) return; await navigator.clipboard.writeText(command); @@ -99,6 +152,7 @@ export function CommandFooter({ const script = generateInstallScript({ distroId: selectedDistro, selectedAppIds: selectedApps, + helper: selectedHelper, }); const blob = new Blob([script], { type: 'text/x-shellscript' }); const url = URL.createObjectURL(blob); @@ -106,214 +160,258 @@ export function CommandFooter({ a.href = url; a.download = `tuxmate-${selectedDistro}.sh`; a.click(); - // Delay revoke to ensure download starts (click is async) setTimeout(() => URL.revokeObjectURL(url), 1000); const distroName = distros.find(d => d.id === selectedDistro)?.name || selectedDistro; analytics.scriptDownloaded(distroName, selectedCount); }; - const showAurBar = selectedDistro === 'arch' && hasAurPackages; - return ( -
- {/* AUR Bar - seamlessly stacked above command bar with slide animation */} -
-
- + {/* AUR Floating Card - appears when AUR packages selected */} + + + {/* Footer container with strong outer glow */} +
+ {/* Outer glow - large spread */} +
+ {/* Middle glow layer */} +
+ {/* Inner glow - sharp */} +
+ + {/* Bars container */} +
+ {/* Shortcuts Bar with Search (nvim-style) */} + -
-
- {/* Command Bar - Compact */} -
-
-
- {selectedCount} -
selectedCount > 0 && setDrawerOpen(true)} - > -
-
- 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'}`} style={{ transition: 'color 0.5s' }}>{command} -
- {selectedCount > 0 && ( -
- -
- )} -
-
- {/* Download Button with Tooltip */} -
selectedCount > 0 && setShowDownloadTooltip(true)} - onMouseLeave={() => setShowDownloadTooltip(false)} - > - - {showDownloadTooltip && ( -
- Download install script -
-
- )} -
- {/* Copy Button with Tooltip */} -
- - {showCopyTooltip && ( -
- Paste this in your terminal! -
-
- )} -
-
-
-
- - {/* Slide-up Drawer */} - {drawerOpen && ( - <> - {/* Backdrop */} -