feat: huge UI/UX overhaul, AUR improvements, and code polish

- Shortcuts Bar: Redesigned layout (Esc/Tab grouped, Space added), unified NAV styling, and implemented consistent Arch Blue branding.
- AUR Integration:
  - Added yay/paru helper toggle with keyboard shortcuts (1/2).
  - Implemented minimal visual ARCH logo indicator for AUR packages.
  - Standardized all AUR-related UI elements (checkboxes, badges) to official Arch Blue (#1793d1).
- Theme System: Refactored useTheme hook to a global Context Provider for perfect animation sync.
- Animations & UI: Enhanced drawer animations (slide-up/down), tooltips, and hover states using GSAP.
- Performance: Optimized app filtering with useMemo to prevent re-renders; fixed reconciliation issues.
- Fixes: Resolved hydration mismatches and malformed HTML tags.
- Docs: Updated README and CONTRIBUTING guidelines.
- Refactor: Cleaned up unused code.
This commit is contained in:
NIJAT
2025-12-28 23:58:49 +04:00
parent 84e212cc40
commit b0bd27341a
21 changed files with 1560 additions and 381 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);

View File

@@ -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<HTMLInputElement>(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 */}
<header ref={headerRef} className="pt-8 sm:pt-12 pb-8 sm:pb-10 px-4 sm:px-6 relative" style={{ zIndex: 1 }}>
<div className="max-w-6xl mx-auto">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
{/* Logo & Title */}
<div className="header-animate">
@@ -250,8 +284,8 @@ export default function Home() {
</header>
{/* App Grid */}
<main className="px-4 sm:px-6 pb-24 relative" style={{ zIndex: 1 }}>
<div className="max-w-6xl mx-auto">
<main className="px-4 sm:px-6 pb-40 relative" style={{ zIndex: 1 }}>
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-x-4 sm:gap-x-8">
{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 (
<div key={colIdx}>
<div key={columnKey}>
{columnCategories.map(({ category, apps: categoryApps }, catIdx) => (
<CategorySection
key={category}
key={`${category}-${categoryApps.length}`}
category={category}
categoryApps={categoryApps}
selectedApps={selectedApps}
@@ -299,6 +336,12 @@ export default function Home() {
aurAppNames={aurAppNames}
hasYayInstalled={hasYayInstalled}
setHasYayInstalled={setHasYayInstalled}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
searchInputRef={searchInputRef}
clearAll={clearAll}
selectedHelper={selectedHelper}
setSelectedHelper={setSelectedHelper}
/>
</div>
);

View File

@@ -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 (
<div
role="checkbox"
@@ -102,24 +105,34 @@ export const AppItem = memo(function AppItem({
}}
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-all duration-150
${isSelected ? 'bg-[var(--text-secondary)] border-[var(--text-secondary)]' : 'border-[var(--border-secondary)]'}
${isAur
? (isSelected ? 'bg-[#1793d1] border-[#1793d1]' : 'border-[#1793d1]/50')
: (isSelected ? 'bg-[var(--text-secondary)] border-[var(--text-secondary)]' : 'border-[var(--border-secondary)]')
}
${!isAvailable ? 'border-dashed' : ''}`}>
{isSelected && <Check className="w-2.5 h-2.5 text-[var(--bg-primary)]" strokeWidth={3} />}
</div>
<AppIcon url={app.iconUrl} name={app.name} />
<span
className={`text-sm flex-1 ${!isAvailable ? 'text-[var(--text-muted)]' : isSelected ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}
style={{
transition: 'color 0.5s',
textRendering: 'geometricPrecision',
WebkitFontSmoothing: 'antialiased',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{app.name}
</span>
<div className="flex-1 flex items-baseline gap-1.5 min-w-0 overflow-hidden">
<span
className={`text-sm truncate ${!isAvailable ? 'text-[var(--text-muted)]' : isSelected ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}
style={{
transition: 'color 0.5s',
textRendering: 'geometricPrecision',
WebkitFontSmoothing: 'antialiased'
}}
>
{app.name}
</span>
{isAur && (
<img
src="https://api.iconify.design/simple-icons/archlinux.svg?color=%231793d1"
className="ml-1.5 w-3 h-3 flex-shrink-0 opacity-80"
alt="AUR"
title="This is an AUR package"
/>
)}
</div>
{/* Exclamation mark icon for unavailable apps */}
{!isAvailable && (
<div

View File

@@ -1,6 +1,6 @@
'use client';
import { memo, useRef, useLayoutEffect } from 'react';
import { memo, useRef, useLayoutEffect, useEffect } from 'react';
import gsap from 'gsap';
import { type DistroId, type AppData, type Category } from '@/lib/data';
import { analytics } from '@/lib/analytics';
@@ -15,39 +15,6 @@ import { AppItem } from './AppItem';
* - Expandable/collapsible content
* - Keyboard navigation support
* - Analytics tracking for expand/collapse
*
* @param category - Category name
* @param categoryApps - Array of apps in this category
* @param selectedApps - Set of selected app IDs
* @param isAppAvailable - Function to check app availability
* @param selectedDistro - Currently selected distro ID
* @param toggleApp - Function to toggle app selection
* @param isExpanded - Whether the category is expanded
* @param onToggleExpanded - Callback to toggle expansion
* @param focusedId - Currently focused item ID
* @param focusedType - Type of focused item ('category' or 'app')
* @param onTooltipEnter - Callback for tooltip show
* @param onTooltipLeave - Callback for tooltip hide
* @param categoryIndex - Index for staggered animation timing
* @param onCategoryFocus - Optional callback when category receives focus
* @param onAppFocus - Optional callback when app receives focus
*
* @example
* <CategorySection
* category="Browsers"
* categoryApps={browserApps}
* selectedApps={selectedApps}
* isAppAvailable={isAppAvailable}
* selectedDistro="ubuntu"
* toggleApp={toggleApp}
* isExpanded={true}
* onToggleExpanded={() => 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<HTMLDivElement>(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 (
<div ref={sectionRef} className="mb-5 category-section">
<CategoryHeader
@@ -140,7 +119,10 @@ export const CategorySection = memo(function CategorySection({
selectedCount={selectedInCategory}
onFocus={onCategoryFocus}
/>
<div className={`overflow-hidden transition-all duration-300 ease-out ${isExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'}`}>
<div
className={`overflow-hidden transition-all duration-500 ${isExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'}`}
style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
>
{categoryApps.map((app) => (
<AppItem
key={app.id}
@@ -158,4 +140,32 @@ export const CategorySection = memo(function CategorySection({
</div>
</div>
);
}
// 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;
});

View File

@@ -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 (
<div className="mb-6 rounded-xl border border-[#1793d1]/20 bg-[#1793d1]/5 overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-[#1793d1]/10 flex items-start gap-3">
<div className="p-2 rounded-lg bg-[#1793d1]/10 text-[#1793d1] shrink-0">
<Package className="w-5 h-5" />
</div>
<div>
<h4 className="text-sm font-semibold text-[var(--text-primary)]">
AUR Packages Detected
</h4>
<p className="text-xs text-[var(--text-muted)] mt-1 leading-relaxed">
These apps require an AUR helper: <span className="text-[var(--text-primary)] opacity-80">{aurAppNames.join(', ')}</span>
</p>
</div>
</div>
{/* Controls Grid */}
<div className="p-5 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 1. Installation Mode */}
<div>
<label className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider mb-2.5 flex items-center gap-2">
<Download className="w-3.5 h-3.5" />
Installation Logic
</label>
<div className="flex bg-[var(--bg-tertiary)] p-1 rounded-lg border border-[var(--border-primary)]/50">
<button
onClick={() => setHasYayInstalled(false)}
className={`flex-1 py-2 px-3 text-xs font-medium rounded-md transition-all ${!hasYayInstalled
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm border border-[var(--border-primary)]/50'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
Install New
</button>
<button
onClick={() => setHasYayInstalled(true)}
className={`flex-1 py-2 px-3 text-xs font-medium rounded-md transition-all ${hasYayInstalled
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm border border-[var(--border-primary)]/50'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
I Have One
</button>
</div>
<p className="text-[10px] text-[var(--text-muted)] mt-2 opacity-70 px-1">
{hasYayInstalled
? "Script will use your existing helper"
: "Script will install the helper first"}
</p>
</div>
{/* 2. Helper Selection */}
<div>
<label className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider mb-2.5 flex items-center gap-2">
<Terminal className="w-3.5 h-3.5" />
Preferred Helper
</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setSelectedHelper('yay')}
className={`relative px-3 py-2 rounded-lg text-left border transition-all ${selectedHelper === 'yay'
? 'bg-[#1793d1]/10 border-[#1793d1]/30 text-[#1793d1]'
: 'bg-[var(--bg-tertiary)] border-[var(--border-primary)]/50 text-[var(--text-muted)] hover:bg-[var(--bg-hover)]'
}`}
>
<span className="block text-xs font-bold">yay</span>
<span className="block text-[10px] opacity-70 mt-0.5">Go-based</span>
{selectedHelper === 'yay' && (
<div className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full bg-[#1793d1]" />
)}
</button>
<button
onClick={() => setSelectedHelper('paru')}
className={`relative px-3 py-2 rounded-lg text-left border transition-all ${selectedHelper === 'paru'
? 'bg-[#1793d1]/10 border-[#1793d1]/30 text-[#1793d1]'
: 'bg-[var(--bg-tertiary)] border-[var(--border-primary)]/50 text-[var(--text-muted)] hover:bg-[var(--bg-hover)]'
}`}
>
<span className="block text-xs font-bold">paru</span>
<span className="block text-[10px] opacity-70 mt-0.5">Rust-based</span>
{selectedHelper === 'paru' && (
<div className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full bg-[#1793d1]" />
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<boolean | null>(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 (
<div className="fixed bottom-24 right-4 z-30">
<p
className="text-[13px] text-[var(--text-muted)]"
style={{
animation: 'cardSlideIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards'
}}
>
You can change this later in preview tab
</p>
</div>
);
}
// Hide cards while exiting
if (isExiting && helperChosen) {
return (
<div className="fixed bottom-24 right-4 z-30 flex flex-col gap-3 items-end">
<div
className="w-72 bg-[var(--bg-secondary)] backdrop-blur-xl border border-[var(--border-primary)]/60 rounded-2xl shadow-xl shadow-black/10 overflow-hidden"
style={{ animation: 'cardSlideOut 0.25s ease-out forwards' }}
>
<div className="p-4" />
</div>
{hasAnswered !== null && (
<div
className="w-72 bg-[var(--bg-secondary)] backdrop-blur-xl border border-[var(--border-primary)]/60 rounded-2xl shadow-xl shadow-black/10 overflow-hidden"
style={{ animation: 'cardSlideOut 0.2s ease-out forwards' }}
>
<div className="p-4" />
</div>
)}
</div>
);
}
return (
<div className="fixed bottom-24 right-4 z-30 flex flex-col gap-3 items-end">
{/* Card 1: Do you have an AUR helper? */}
<div
className={`
w-72 bg-[var(--bg-secondary)] backdrop-blur-xl
border border-[var(--border-primary)]/60
rounded-2xl shadow-xl shadow-black/10
overflow-hidden
transition-all duration-200
`}
style={{
animation: isExiting
? 'cardSlideOut 0.2s ease-out forwards'
: 'cardSlideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards'
}}
>
{/* Header */}
<div className="px-4 py-3 flex items-start justify-between gap-3">
<div className="flex-1">
<p className="text-[11px] text-[var(--text-muted)] tracking-wide uppercase mb-1">
{aurAppNames.length} AUR package{aurAppNames.length !== 1 ? 's' : ''}
</p>
<p className="text-[15px] text-[var(--text-primary)] font-medium leading-snug">
Do you have an AUR helper?
</p>
</div>
<button
onClick={handleDismiss}
className="text-[var(--text-muted)]/60 hover:text-[var(--text-primary)] transition-colors duration-150 p-1 -mr-1 -mt-1 rounded-lg hover:bg-[var(--bg-hover)]"
title="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Buttons */}
<div className="px-4 pb-3 flex gap-2">
<button
onClick={() => handleFirstAnswer(true)}
className={`
flex-1 py-2 px-4 rounded-xl text-sm font-medium
transition-all duration-200 ease-out
${hasAnswered === true
? 'bg-[var(--text-primary)] text-[var(--bg-primary)] shadow-sm'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
}
`}
>
Yes
</button>
<button
onClick={() => handleFirstAnswer(false)}
className={`
flex-1 py-2 px-4 rounded-xl text-sm font-medium
transition-all duration-200 ease-out
${hasAnswered === false
? 'bg-[var(--text-primary)] text-[var(--bg-primary)] shadow-sm'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
}
`}
>
No
</button>
</div>
{/* Hint */}
<div className="px-4 pb-3 -mt-1">
<p className="text-[10px] text-[var(--text-muted)]/50 leading-relaxed">
Change anytime in preview window
</p>
</div>
</div>
{/* Card 2: Which helper? (appears after first answer) */}
{hasAnswered !== null && (
<div
className={`
w-72 bg-[var(--bg-secondary)] backdrop-blur-xl
border border-[var(--border-primary)]/60
rounded-2xl shadow-xl shadow-black/10
overflow-hidden
`}
style={{
animation: isExiting
? 'cardSlideOut 0.15s ease-out forwards'
: 'cardSlideInSecond 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) 0.05s forwards',
opacity: 0
}}
>
{/* Header */}
<div className="px-4 py-3 flex items-center justify-between gap-3">
<p className="text-[15px] text-[var(--text-primary)] font-medium">
{hasAnswered
? 'Which one do you have?'
: 'Which one to install?'
}
</p>
<button
onClick={handleDismiss}
className="text-[var(--text-muted)]/60 hover:text-[var(--text-primary)] transition-colors duration-150 p-1 -mr-1 rounded-lg hover:bg-[var(--bg-hover)]"
title="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Helper selection */}
<div className="px-4 pb-4 flex gap-2">
<button
onClick={() => handleHelperSelect('yay')}
className={`
flex-1 py-2.5 px-4 rounded-xl text-sm font-medium
transition-all duration-200 ease-out
${selectedHelper === 'yay' && helperChosen
? 'bg-[var(--text-primary)] text-[var(--bg-primary)] shadow-sm'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
}
`}
>
<span className="block font-semibold">yay</span>
<span className={`block text-[10px] mt-0.5 ${selectedHelper === 'yay' && helperChosen ? 'opacity-70' : 'opacity-50'}`}>
recommended
</span>
</button>
<button
onClick={() => handleHelperSelect('paru')}
className={`
flex-1 py-2.5 px-4 rounded-xl text-sm font-medium
transition-all duration-200 ease-out
${selectedHelper === 'paru' && helperChosen
? 'bg-[var(--text-primary)] text-[var(--bg-primary)] shadow-sm'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
}
`}
>
<span className="block font-semibold">paru</span>
<span className={`block text-[10px] mt-0.5 ${selectedHelper === 'paru' && helperChosen ? 'opacity-70' : 'opacity-50'}`}>
rust-based
</span>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="relative" ref={panelRef}>
{/* Trigger Button */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`
flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium
transition-all duration-200 border
${isExpanded
? 'bg-amber-500/20 text-amber-400 border-amber-500/40'
: 'bg-amber-500/10 text-amber-500 border-amber-500/20 hover:bg-amber-500/20 hover:border-amber-500/40'
}
`}
>
<Info className="w-3.5 h-3.5" />
<span>AUR Packages ({aurAppNames.length})</span>
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{/* Expanded Panel */}
{isExpanded && (
<div
className="absolute bottom-full left-0 mb-2 w-80 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-xl shadow-2xl overflow-hidden"
style={{ animation: 'tooltipSlideUp 0.2s ease-out' }}
>
{/* Header with explanation */}
<div className="px-4 py-3 border-b border-[var(--border-primary)] bg-amber-500/5">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">
What is an AUR Helper?
</p>
<p className="text-xs text-[var(--text-muted)] mt-1 leading-relaxed">
Some packages are from the AUR (Arch User Repository).
You need an AUR helper like <strong>yay</strong> or <strong>paru</strong> to install them.
</p>
</div>
</div>
</div>
{/* Selected AUR packages */}
<div className="px-4 py-3 border-b border-[var(--border-primary)]">
<p className="text-xs text-[var(--text-muted)] mb-2">AUR packages you selected:</p>
<div className="flex flex-wrap gap-1.5">
{aurAppNames.map((name, idx) => (
<span
key={idx}
className="px-2 py-1 bg-amber-500/15 text-amber-400 rounded-md text-xs font-medium"
>
{name}
</span>
))}
</div>
</div>
{/* Toggle: Do you have AUR helper? */}
<div className="px-4 py-3 border-b border-[var(--border-primary)]">
<label className="flex items-start gap-3 cursor-pointer select-none group">
<div className="relative mt-0.5">
<input
type="checkbox"
checked={hasYayInstalled}
onChange={(e) => setHasYayInstalled(e.target.checked)}
className="sr-only"
/>
<div
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all
${hasYayInstalled
? 'bg-emerald-500 border-emerald-500'
: 'bg-[var(--bg-primary)] border-[var(--border-secondary)] group-hover:border-emerald-500'
}`}
>
{hasYayInstalled && <Check className="w-3 h-3 text-white" strokeWidth={3} />}
</div>
</div>
<div>
<span className="text-sm text-[var(--text-primary)] font-medium">
I already have an AUR helper
</span>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
{hasYayInstalled
? "We won't add installation commands"
: "We'll include commands to install the helper first"
}
</p>
</div>
</label>
</div>
{/* Helper Selection */}
<div className="px-4 py-3">
<p className="text-xs text-[var(--text-muted)] mb-2">Choose your AUR helper:</p>
<div className="flex gap-2">
<button
onClick={() => setSelectedHelper('yay')}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all border
${selectedHelper === 'yay'
? 'bg-indigo-500/20 text-indigo-400 border-indigo-500/40'
: 'bg-[var(--bg-tertiary)] text-[var(--text-muted)] border-[var(--border-primary)] hover:text-[var(--text-primary)]'
}`}
>
<span className="opacity-50 mr-1 text-xs">1</span> yay
</button>
<button
onClick={() => setSelectedHelper('paru')}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all border
${selectedHelper === 'paru'
? 'bg-indigo-500/20 text-indigo-400 border-indigo-500/40'
: 'bg-[var(--bg-tertiary)] text-[var(--text-muted)] border-[var(--border-primary)] hover:text-[var(--text-primary)]'
}`}
>
<span className="opacity-50 mr-1 text-xs">2</span> paru
</button>
</div>
<p className="text-[10px] text-[var(--text-muted)]/60 mt-2 text-center">
Press <kbd className="font-mono bg-[var(--bg-tertiary)] px-1 rounded">1</kbd> or <kbd className="font-mono bg-[var(--bg-tertiary)] px-1 rounded">2</kbd> to switch
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="relative" ref={popoverRef}>
{/* AUR Badge */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`
flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium
transition-all duration-200
${isOpen
? 'bg-amber-500/20 text-amber-400'
: 'bg-amber-500/10 text-amber-500 hover:bg-amber-500/20'
}
`}
>
<AlertTriangle className="w-3.5 h-3.5" />
<span>AUR ({aurAppNames.length})</span>
</button>
{/* Popover */}
{isOpen && (
<div
className="absolute bottom-full left-0 mb-2 w-64 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-xl shadow-2xl overflow-hidden"
style={{ animation: 'tooltipSlideUp 0.2s ease-out' }}
>
{/* Header */}
<div className="px-3 py-2 border-b border-[var(--border-primary)] bg-[var(--bg-tertiary)]">
<p className="text-xs font-medium text-[var(--text-primary)]">AUR Packages</p>
<p className="text-xs text-[var(--text-muted)]">
{hasYayInstalled ? 'Using yay' : 'Will install yay first'}
</p>
</div>
{/* Package List */}
<div className="px-3 py-2 flex flex-wrap gap-1.5">
{aurAppNames.map((name, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-amber-500/15 text-amber-400 rounded text-xs"
>
{name}
</span>
))}
</div>
{/* Yay Checkbox */}
<div className="px-3 py-2 border-t border-[var(--border-primary)]">
<label className="flex items-center gap-2 cursor-pointer select-none group">
<div className="relative">
<input
type="checkbox"
checked={hasYayInstalled}
onChange={(e) => setHasYayInstalled(e.target.checked)}
className="sr-only"
/>
<div
className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all
${hasYayInstalled
? 'bg-amber-500 border-amber-500'
: 'bg-[var(--bg-primary)] border-[var(--border-secondary)] group-hover:border-amber-500'
}`}
>
{hasYayInstalled && <Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />}
</div>
</div>
<span className="text-xs text-[var(--text-secondary)]">
I have yay installed
</span>
</label>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string>;
hasAurPackages: boolean;
aurAppNames: string[];
hasYayInstalled: boolean;
setHasYayInstalled: (value: boolean) => void;
// Search props
searchQuery: string;
onSearchChange: (query: string) => void;
searchInputRef: React.RefObject<HTMLInputElement | null>;
// 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
* <CommandFooter
* command={generatedCommand}
* selectedCount={selectedApps.size}
* selectedDistro="arch"
* selectedApps={selectedApps}
* hasAurPackages={true}
* aurAppNames={["Discord"]}
* hasYayInstalled={false}
* setHasYayInstalled={setHasYay}
* />
* 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<string>;
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 (
<div className="fixed bottom-0 left-0 right-0" style={{ zIndex: 10 }}>
{/* AUR Bar - seamlessly stacked above command bar with slide animation */}
<div
className="grid transition-all duration-300 ease-out"
style={{
gridTemplateRows: showAurBar ? '1fr' : '0fr',
transition: 'grid-template-rows 0.3s ease-out'
}}
>
<div className="overflow-hidden">
<AurBar
aurAppNames={aurAppNames}
hasYayInstalled={hasYayInstalled}
setHasYayInstalled={setHasYayInstalled}
<div className="fixed bottom-0 left-0 right-0 p-3" style={{ zIndex: 10 }}>
{/* AUR Floating Card - appears when AUR packages selected */}
<AurFloatingCard
show={showAur}
aurAppNames={aurAppNames}
hasYayInstalled={hasYayInstalled}
setHasYayInstalled={setHasYayInstalled}
selectedHelper={selectedHelper}
setSelectedHelper={setSelectedHelper}
/>
{/* Footer container with strong outer glow */}
<div className="relative w-[85%] mx-auto">
{/* Outer glow - large spread */}
<div
className="absolute -inset-12 rounded-3xl pointer-events-none"
style={{
background: 'var(--bg-primary)',
filter: 'blur(40px)',
zIndex: -1
}}
/>
{/* Middle glow layer */}
<div
className="absolute -inset-8 rounded-3xl pointer-events-none"
style={{
background: 'var(--bg-primary)',
filter: 'blur(30px)',
zIndex: -1
}}
/>
{/* Inner glow - sharp */}
<div
className="absolute -inset-4 rounded-2xl pointer-events-none"
style={{
background: 'var(--bg-primary)',
filter: 'blur(20px)',
zIndex: -1
}}
/>
{/* Bars container */}
<div className="relative flex flex-col gap-1.5">
{/* Shortcuts Bar with Search (nvim-style) */}
<ShortcutsBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
searchInputRef={searchInputRef}
selectedCount={selectedCount}
distroName={distroDisplayName}
showAur={showAur}
selectedHelper={selectedHelper}
setSelectedHelper={setSelectedHelper}
/>
</div>
</div>
{/* Command Bar - Compact */}
<div className="bg-[var(--bg-secondary)]/95 backdrop-blur-md border-t border-[var(--border-primary)]" style={{ transition: 'background-color 0.5s, border-color 0.5s' }}>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3">
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-[var(--text-muted)] whitespace-nowrap tabular-nums hidden sm:block font-medium min-w-[20px]" style={{ transition: 'color 0.5s' }}>{selectedCount}</span>
<div
className="flex-1 min-w-0 bg-[var(--bg-tertiary)] rounded-lg font-mono text-sm cursor-pointer hover:bg-[var(--bg-hover)] transition-colors group overflow-hidden"
style={{ transition: 'background-color 0.5s' }}
onClick={() => selectedCount > 0 && setDrawerOpen(true)}
>
<div className="flex items-start gap-3 px-4 pt-3 pb-1">
<div className="flex-1 min-w-0 overflow-x-auto command-scroll">
<code className={`whitespace-nowrap ${selectedCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'}`} style={{ transition: 'color 0.5s' }}>{command}</code>
</div>
{selectedCount > 0 && (
<div className="shrink-0 w-6 h-6 rounded-md bg-[var(--bg-hover)] group-hover:bg-[var(--accent)]/20 flex items-center justify-center transition-all">
<ChevronUp className="w-4 h-4 text-[var(--text-muted)] group-hover:text-[var(--text-primary)] transition-colors" />
</div>
)}
</div>
</div>
{/* Download Button with Tooltip */}
<div className="relative flex items-center"
onMouseEnter={() => selectedCount > 0 && setShowDownloadTooltip(true)}
onMouseLeave={() => setShowDownloadTooltip(false)}
>
<button onClick={handleDownload} disabled={selectedCount === 0}
className={`h-11 w-11 sm:w-auto sm:px-4 rounded-xl text-sm flex items-center justify-center gap-2 transition-all duration-200 outline-none ${selectedCount > 0 ? 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]' : 'bg-[var(--bg-tertiary)] text-[var(--text-muted)] opacity-50 cursor-not-allowed'
}`} style={{ transition: 'background-color 0.5s, color 0.5s' }}>
<Download className="w-4 h-4" />
<span className="hidden sm:inline font-medium">Download</span>
</button>
{showDownloadTooltip && (
<div className="absolute bottom-full right-0 mb-2 px-3 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-sm rounded-lg shadow-xl border border-[var(--border-secondary)] whitespace-nowrap"
style={{ animation: 'tooltipSlideUp 0.3s ease-out forwards' }}>
Download install script
<div className="absolute right-4 translate-x-1/2 w-2.5 h-2.5 bg-[var(--bg-tertiary)] border-r border-b border-[var(--border-secondary)] rotate-45" style={{ bottom: '-6px' }} />
</div>
)}
</div>
{/* Copy Button with Tooltip */}
<div className="relative flex items-center">
<button onClick={handleCopy} disabled={selectedCount === 0}
className={`h-11 px-5 rounded-xl text-sm font-medium flex items-center justify-center gap-2 transition-all duration-200 outline-none ${selectedCount > 0 ? (copied ? 'bg-emerald-600 text-white' : 'bg-[var(--text-primary)] text-[var(--bg-primary)] hover:opacity-90') : 'bg-[var(--bg-tertiary)] text-[var(--text-muted)] opacity-50 cursor-not-allowed'
}`}>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
<span className="hidden sm:inline">{copied ? 'Copied!' : 'Copy'}</span>
</button>
{showCopyTooltip && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-4 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded-lg shadow-xl whitespace-nowrap"
style={{ animation: 'tooltipSlideUp 0.3s ease-out forwards' }}>
Paste this in your terminal!
<div className="absolute left-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-emerald-600 rotate-45" style={{ bottom: '-5px' }} />
</div>
)}
</div>
</div>
</div>
</div>
{/* Slide-up Drawer */}
{drawerOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40"
onClick={closeDrawer}
aria-hidden="true"
style={{ animation: drawerClosing ? 'fadeOut 0.25s ease-out forwards' : 'fadeIn 0.2s ease-out' }}
/>
{/* Drawer - Mobile: bottom sheet, Desktop: centered modal */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border-primary)] shadow-2xl
bottom-0 left-0 right-0 rounded-t-2xl
md:bottom-auto md:top-1/2 md:left-1/2 md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-2xl md:max-w-2xl md:w-[90vw]"
style={{
animation: drawerClosing ? 'slideDown 0.25s ease-in forwards' : 'slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
maxHeight: '80vh'
}}
>
{/* Drawer Handle - mobile only */}
<div className="flex justify-center pt-3 pb-2 md:hidden">
{/* Command Bar - Bufferline style tabs */}
<div className="bg-[var(--bg-tertiary)] font-mono text-xs rounded-lg overflow-hidden border border-[var(--border-primary)]/40 shadow-2xl">
<div className="flex items-stretch">
{/* Tab: Expand/Preview (opens drawer) */}
<button
className="w-12 h-1.5 bg-[var(--text-muted)]/40 rounded-full cursor-pointer hover:bg-[var(--text-muted)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--bg-secondary)]"
onClick={closeDrawer}
aria-label="Close drawer"
/>
</div>
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 sm:px-6 pb-3 md:pt-4 border-b border-[var(--border-primary)]">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
<span className="text-emerald-500 font-bold text-sm">$</span>
</div>
<div>
<h3 id="drawer-title" className="text-sm font-semibold text-[var(--text-primary)]">Terminal Command</h3>
<p className="text-xs text-[var(--text-muted)]">{selectedCount} app{selectedCount !== 1 ? 's' : ''} Press Esc to close</p>
</div>
</div>
<button
onClick={closeDrawer}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--bg-hover)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
aria-label="Close drawer"
onClick={() => selectedCount > 0 && setDrawerOpen(true)}
disabled={selectedCount === 0}
className={`flex items-center gap-2 px-4 py-3 border-r border-[var(--border-primary)]/30 transition-all shrink-0 bg-indigo-500/10 text-indigo-400 hover:bg-indigo-500/20 hover:text-indigo-300 ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
title="Toggle Preview (Tab)"
>
<X className="w-5 h-5" />
<ChevronUp className="w-3.5 h-3.5" />
<span className="font-bold">PREVIEW</span>
{selectedCount > 0 && (
<span className="text-[10px] opacity-60 ml-0.5">[{selectedCount}]</span>
)}
</button>
</div>
{/* Command Content - Terminal style */}
<div className="p-4 sm:p-6 overflow-y-auto" style={{ maxHeight: 'calc(80vh - 200px)' }}>
<div className="bg-[#1a1a1a] rounded-xl overflow-hidden border border-[var(--border-primary)]">
{/* Terminal header with action buttons on desktop */}
<div className="flex items-center justify-between px-4 py-2 bg-[#252525] border-b border-[var(--border-primary)]">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/80" />
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
<div className="w-3 h-3 rounded-full bg-green-500/80" />
<span className="ml-2 text-xs text-[var(--text-muted)]">bash</span>
</div>
{/* Desktop inline actions */}
<div className="hidden md:flex items-center gap-2">
<button
onClick={handleDownload}
className="h-7 px-3 flex items-center gap-1.5 rounded-md bg-[var(--bg-tertiary)]/50 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors text-xs font-medium"
>
<Download className="w-3.5 h-3.5" />
Download
</button>
<button
onClick={() => { handleCopy(); setTimeout(closeDrawer, 3000); }}
className={`h-7 px-3 flex items-center gap-1.5 rounded-md text-xs font-medium transition-all ${copied
? 'bg-emerald-600 text-white'
: 'bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600 hover:text-white'
}`}
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
{/* Terminal content */}
<div className="p-4 font-mono text-sm overflow-x-auto">
<div className="flex gap-2">
<span className="text-emerald-400 select-none shrink-0">$</span>
<code className="text-gray-300 break-all whitespace-pre-wrap" style={{ lineHeight: '1.6' }}>
{command}
</code>
</div>
</div>
{/* Command text - fills available space, centered both ways */}
<div
className="flex-1 min-w-0 flex items-center justify-center px-4 py-4 overflow-hidden bg-[var(--bg-secondary)] cursor-pointer hover:bg-[var(--bg-hover)] transition-colors group"
onClick={() => selectedCount > 0 && setDrawerOpen(true)}
>
<code className={`whitespace-nowrap overflow-x-auto command-scroll leading-none ${selectedCount > 0 ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'}`}>
{command}
</code>
</div>
</div>
{/* Drawer Actions - mobile only (stacked) */}
<div className="md:hidden flex flex-col items-stretch gap-3 px-4 py-4 border-t border-[var(--border-primary)]">
{/* Tab: Download */}
<button
onClick={handleDownload}
className="flex-1 h-14 flex items-center justify-center gap-2 rounded-xl bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] transition-colors font-medium text-base focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
aria-label="Download install script"
>
<Download className="w-5 h-5" />
Download Script
</button>
<button
onClick={() => { handleCopy(); setTimeout(closeDrawer, 3000); }}
className={`flex-1 h-14 flex items-center justify-center gap-2 rounded-xl font-medium text-base transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 ${copied
? 'bg-emerald-600 text-white focus:ring-emerald-500'
: 'bg-[var(--text-primary)] text-[var(--bg-primary)] hover:opacity-90 focus:ring-[var(--accent)]'
disabled={selectedCount === 0}
onMouseEnter={() => selectedCount > 0 && setShowDownloadTooltip(true)}
onMouseLeave={() => setShowDownloadTooltip(false)}
className={`flex items-center gap-1.5 px-3 py-3 border-l border-[var(--border-primary)]/30 transition-colors ${selectedCount > 0
? 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] hover:text-[var(--text-primary)]'
: 'text-[var(--text-muted)] opacity-50 cursor-not-allowed'
}`}
aria-label={copied ? 'Command copied to clipboard' : 'Copy command to clipboard'}
title="Download Script (d)"
>
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
{copied ? 'Copied!' : 'Copy Command'}
<Download className="w-3 h-3" />
<span className="hidden sm:inline">Download</span>
</button>
{/* Tab: Copy (highlighted) */}
<button
onClick={handleCopy}
disabled={selectedCount === 0}
className={`flex items-center gap-1.5 px-3 py-3 border-l border-[var(--border-primary)]/30 transition-colors ${selectedCount > 0
? (copied
? 'bg-emerald-600 text-white'
: 'bg-[var(--text-primary)] text-[var(--bg-primary)] hover:opacity-90')
: 'text-[var(--text-muted)] opacity-50 cursor-not-allowed'
}`}
title="Copy Command (y)"
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span className="hidden sm:inline">{copied ? 'Copied!' : 'Copy'}</span>
</button>
</div>
</div>
</>
)}
{/* Slide-up Drawer */}
{drawerOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40"
onClick={closeDrawer}
aria-hidden="true"
style={{ animation: drawerClosing ? 'fadeOut 0.3s ease-out forwards' : 'fadeIn 0.3s ease-out' }}
/>
{/* Drawer */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border-primary)] shadow-2xl
bottom-0 left-0 right-0 rounded-t-2xl
md:bottom-auto md:top-1/2 md:left-1/2 md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-2xl md:max-w-2xl md:w-[90vw]"
style={{
animation: drawerClosing
? 'slideDown 0.3s cubic-bezier(0.32, 0, 0.67, 0) forwards'
: 'slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
maxHeight: '80vh'
}}
>
{/* Drawer Handle - mobile only */}
<div className="flex justify-center pt-3 pb-2 md:hidden">
<button
className="w-12 h-1.5 bg-[var(--text-muted)]/40 rounded-full cursor-pointer hover:bg-[var(--text-muted)] transition-colors"
onClick={closeDrawer}
aria-label="Close drawer"
/>
</div>
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 sm:px-6 pb-3 md:pt-4 border-b border-[var(--border-primary)]">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
<span className="text-emerald-500 font-bold text-sm">$</span>
</div>
<div>
<h3 id="drawer-title" className="text-sm font-semibold text-[var(--text-primary)]">Terminal Command</h3>
<p className="text-xs text-[var(--text-muted)]">{selectedCount} app{selectedCount !== 1 ? 's' : ''} Press Esc to close</p>
</div>
</div>
<button
onClick={closeDrawer}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--bg-hover)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close drawer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Command Content */}
<div className="p-4 sm:p-6 overflow-y-auto" style={{ maxHeight: 'calc(80vh - 200px)' }}>
{/* AUR Settings (if AUR packages selected) */}
{showAur && (
<AurDrawerSettings
aurAppNames={aurAppNames}
hasYayInstalled={hasYayInstalled}
setHasYayInstalled={setHasYayInstalled}
selectedHelper={selectedHelper}
setSelectedHelper={setSelectedHelper}
/>
)}
<div className="bg-[#1a1a1a] rounded-xl overflow-hidden border border-[var(--border-primary)]">
<div className="flex items-center justify-between px-4 py-2 bg-[#252525] border-b border-[var(--border-primary)]">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/80" />
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
<div className="w-3 h-3 rounded-full bg-green-500/80" />
<span className="ml-2 text-xs text-[var(--text-muted)]">bash</span>
</div>
<div className="hidden md:flex items-center gap-2">
<button
onClick={handleDownload}
className="h-7 px-3 flex items-center gap-1.5 rounded-md bg-[var(--bg-tertiary)]/50 text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors text-xs font-medium"
>
<Download className="w-3.5 h-3.5" />
Download
</button>
<button
onClick={() => { handleCopy(); setTimeout(closeDrawer, 3000); }}
className={`h-7 px-3 flex items-center gap-1.5 rounded-md text-xs font-medium transition-all ${copied
? 'bg-emerald-600 text-white'
: 'bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600 hover:text-white'
}`}
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<div className="p-4 font-mono text-sm overflow-x-auto">
<div className="flex gap-2">
<span className="text-emerald-400 select-none shrink-0">$</span>
<code className="text-gray-300 break-all whitespace-pre-wrap" style={{ lineHeight: '1.6' }}>
{command}
</code>
</div>
</div>
</div>
</div>
{/* Mobile Actions */}
<div className="md:hidden flex flex-col items-stretch gap-3 px-4 py-4 border-t border-[var(--border-primary)]">
<button
onClick={handleDownload}
className="flex-1 h-14 flex items-center justify-center gap-2 rounded-xl bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] transition-colors font-medium text-base"
>
<Download className="w-5 h-5" />
Download Script
</button>
<button
onClick={() => { handleCopy(); setTimeout(closeDrawer, 3000); }}
className={`flex-1 h-14 flex items-center justify-center gap-2 rounded-xl font-medium text-base transition-all ${copied
? 'bg-emerald-600 text-white'
: 'bg-[var(--text-primary)] text-[var(--bg-primary)] hover:opacity-90'
}`}
>
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
{copied ? 'Copied!' : 'Copy Command'}
</button>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
'use client';
import { forwardRef } from 'react';
import { X } from 'lucide-react';
interface ShortcutsBarProps {
searchQuery: string;
onSearchChange: (query: string) => void;
searchInputRef: React.RefObject<HTMLInputElement | null>;
selectedCount: number;
distroName: string;
showAur: boolean;
selectedHelper: 'yay' | 'paru';
setSelectedHelper: (helper: 'yay' | 'paru') => void;
}
/**
* ShortcutsBar - Neovim-style statusline with search on left, shortcuts on right
* Uses theme-aware colors for dark/light mode compatibility
*/
export const ShortcutsBar = forwardRef<HTMLInputElement, ShortcutsBarProps>(
function ShortcutsBar({
searchQuery,
onSearchChange,
searchInputRef,
selectedCount,
distroName,
showAur,
selectedHelper,
setSelectedHelper,
}, ref) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape' || e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
};
const helperShortcuts = showAur ? [
{ key: '1', label: 'yay' },
{ key: '2', label: 'paru' },
] : [];
return (
<div className="bg-[var(--bg-tertiary)] border border-[var(--border-primary)] font-mono text-xs rounded-lg overflow-hidden">
<div className="flex items-stretch justify-between">
{/* LEFT SECTION */}
<div className="flex items-stretch">
{/* Mode Badge - like nvim NORMAL/INSERT */}
<div className="bg-[var(--text-primary)] text-[var(--bg-primary)] px-3 py-1 font-bold flex items-center">
{distroName.toUpperCase()}
</div>
{/* Search Section */}
<div className="flex items-center gap-1.5 px-3 py-1 bg-[var(--bg-secondary)] border-r border-[var(--border-primary)]/30">
<span className="text-[var(--text-muted)]">/</span>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="search..."
className="
w-20 sm:w-28
bg-transparent
text-[var(--text-primary)]
placeholder:text-[var(--text-muted)]/50
outline-none
"
/>
{searchQuery && (
<button
onClick={() => onSearchChange('')}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{/* App count */}
{selectedCount > 0 && (
<div className="flex items-center px-3 py-1 text-[var(--text-muted)] border-r border-[var(--border-primary)]/30">
[{selectedCount} app{selectedCount !== 1 ? 's' : ''}]
</div>
)}
{/* AUR Helper Switch */}
{showAur && (
<div className="flex items-stretch border-r border-[var(--border-primary)]/30">
<button
onClick={() => setSelectedHelper('yay')}
className={`px-3 flex items-center gap-2 text-[10px] font-medium transition-colors border-r border-[var(--border-primary)]/30 ${selectedHelper === 'yay' ? 'bg-[var(--text-primary)] text-[var(--bg-primary)] font-bold' : 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}`}
>
<span className="font-mono opacity-70">1</span>
yay
</button>
<button
onClick={() => setSelectedHelper('paru')}
className={`px-3 flex items-center gap-2 text-[10px] font-medium transition-colors ${selectedHelper === 'paru' ? 'bg-[var(--text-primary)] text-[var(--bg-primary)] font-bold' : 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}`}
>
<span className="font-mono opacity-70">2</span>
paru
</button>
</div>
)}
</div>
{/* RIGHT SECTION - Shortcuts */}
<div className="flex items-stretch">
<div className="hidden sm:flex items-center gap-4 px-4 py-1 text-[var(--text-muted)] text-[11px] font-medium border-l border-[var(--border-primary)]/30">
{/* Navigation Group */}
<div className="hidden lg:flex items-center gap-1.5 transition-opacity hover:opacity-100">
<span className="font-mono text-[10px] tracking-widest text-[var(--text-muted)]">NAV</span>
<div className="flex items-center gap-1 font-mono text-[var(--text-primary)]">
<span></span>
<span className="opacity-50">/</span>
<span>hjkl</span>
</div>
</div>
{/* Separator */}
<div className="w-px h-3 bg-[var(--border-primary)]/40 hidden lg:block"></div>
{/* Actions Group */}
<div className="flex items-center gap-4">
{[...helperShortcuts,
{ key: '/', label: 'Search' },
{ key: 'Space', label: 'Toggle' },
{ key: 'y', label: 'Copy' },
{ key: 'd', label: 'Download' },
{ key: 'c', label: 'Clear' },
{ key: 't', label: 'Theme' }
].map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5 group cursor-help transition-colors hover:text-[var(--text-primary)]">
<span className={`font-mono font-bold transition-colors ${showAur && (key === '1' || key === '2') ? 'text-[#1793d1]' : 'text-[var(--text-primary)] group-hover:text-[#1793d1]'}`}>
{key}
</span>
<span className="opacity-60 group-hover:opacity-100 transition-opacity">{label}</span>
</div>
))}
<div className="flex items-center gap-4 border-l border-[var(--border-primary)]/40 pl-4">
<div className="flex items-center gap-1.5 transition-colors hover:text-[var(--text-primary)]">
<span className="font-mono font-bold text-[var(--text-primary)]">Esc</span>
<span className="opacity-60">Back</span>
</div>
<div className="flex items-center gap-1.5 transition-colors hover:text-[var(--text-primary)]">
<span className="font-mono font-bold text-[var(--text-primary)]">Tab</span>
<span className="opacity-60">Preview</span>
</div>
</div>
</div>
</div>
{/* End badge - like nvim line:col */}
<div className="bg-[var(--text-primary)] text-[var(--bg-primary)] px-3 py-1 flex items-center font-bold text-xs tracking-wider">
TUX
</div>
</div>
</div>
</div>
);
}
);

View File

@@ -2,9 +2,11 @@
* Command Components
*
* Components related to command generation and display:
* - AurBar: AUR packages info for Arch Linux
* - CommandFooter: Fixed bottom bar with command output
* - AurPopover: AUR packages popover badge
* - ShortcutsBar: Vim-style bar with search and keyboard shortcuts
*/
export { AurBar } from './AurBar';
export { CommandFooter } from './CommandFooter';
export { AurPopover } from './AurPopover';
export { ShortcutsBar } from './ShortcutsBar';

View File

@@ -0,0 +1,54 @@
'use client';
import { forwardRef } from 'react';
import { Search, X } from 'lucide-react';
interface SearchBoxProps {
query: string;
onChange: (query: string) => void;
onClear: () => void;
}
export const SearchBox = forwardRef<HTMLInputElement, SearchBoxProps>(
function SearchBox({ query, onChange, onClear }, ref) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === 'Escape') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
};
return (
<div className="relative flex items-center">
<Search className="absolute left-2.5 w-3.5 h-3.5 text-[var(--text-muted)] pointer-events-none" />
<input
ref={ref}
type="text"
value={query}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Press / to search..."
className="
w-36 sm:w-44 pl-8 pr-7 py-1.5
bg-[var(--bg-secondary)]
border border-[var(--border-primary)]
rounded-lg text-xs
text-[var(--text-primary)]
placeholder:text-[var(--text-muted)]
outline-none
focus:border-[var(--accent-primary)]
transition-all duration-200
"
/>
{query && (
<button
onClick={onClear}
className="absolute right-2 p-0.5 hover:bg-[var(--bg-hover)] rounded transition-colors"
>
<X className="w-3 h-3 text-[var(--text-muted)]" />
</button>
)}
</div>
);
}
);

View File

@@ -0,0 +1 @@
export { SearchBox } from './SearchOverlay';

View File

@@ -8,6 +8,8 @@ import { isAurPackage } from '@/lib/aur';
export { isAurPackage, AUR_PATTERNS, KNOWN_AUR_PACKAGES } from '@/lib/aur';
// ... (previous imports)
export interface UseLinuxInitReturn {
selectedDistro: DistroId;
selectedApps: Set<string>;
@@ -23,6 +25,8 @@ export interface UseLinuxInitReturn {
// Arch/AUR specific
hasYayInstalled: boolean;
setHasYayInstalled: (value: boolean) => void;
selectedHelper: 'yay' | 'paru';
setSelectedHelper: (helper: 'yay' | 'paru') => void;
hasAurPackages: boolean;
aurPackageNames: string[];
aurAppNames: string[];
@@ -33,11 +37,13 @@ export interface UseLinuxInitReturn {
const STORAGE_KEY_DISTRO = 'linuxinit_distro';
const STORAGE_KEY_APPS = 'linuxinit_apps';
const STORAGE_KEY_YAY = 'linuxinit_yay_installed';
const STORAGE_KEY_HELPER = 'linuxinit_selected_helper'; // New storage key
export function useLinuxInit(): UseLinuxInitReturn {
const [selectedDistro, setSelectedDistroState] = useState<DistroId>('ubuntu');
const [selectedApps, setSelectedApps] = useState<Set<string>>(new Set());
const [hasYayInstalled, setHasYayInstalled] = useState(false);
const [selectedHelper, setSelectedHelper] = useState<'yay' | 'paru'>('yay'); // New state
const [hydrated, setHydrated] = useState(false);
// Hydrate from localStorage on mount
@@ -46,6 +52,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
const savedDistro = localStorage.getItem(STORAGE_KEY_DISTRO) as DistroId | null;
const savedApps = localStorage.getItem(STORAGE_KEY_APPS);
const savedYay = localStorage.getItem(STORAGE_KEY_YAY);
const savedHelper = localStorage.getItem(STORAGE_KEY_HELPER) as 'yay' | 'paru' | null;
if (savedDistro && distros.some(d => d.id === savedDistro)) {
setSelectedDistroState(savedDistro);
@@ -66,6 +73,10 @@ export function useLinuxInit(): UseLinuxInitReturn {
if (savedYay === 'true') {
setHasYayInstalled(true);
}
if (savedHelper === 'paru') {
setSelectedHelper('paru');
}
} catch (e) {
// Ignore localStorage errors
}
@@ -79,10 +90,11 @@ export function useLinuxInit(): UseLinuxInitReturn {
localStorage.setItem(STORAGE_KEY_DISTRO, selectedDistro);
localStorage.setItem(STORAGE_KEY_APPS, JSON.stringify([...selectedApps]));
localStorage.setItem(STORAGE_KEY_YAY, hasYayInstalled.toString());
localStorage.setItem(STORAGE_KEY_HELPER, selectedHelper);
} catch (e) {
// Ignore localStorage errors
}
}, [selectedDistro, selectedApps, hasYayInstalled, hydrated]);
}, [selectedDistro, selectedApps, hasYayInstalled, selectedHelper, hydrated]);
// Compute AUR package info for Arch
const aurPackageInfo = useMemo(() => {
@@ -205,24 +217,33 @@ export function useLinuxInit(): UseLinuxInitReturn {
if (packageNames.length === 1) {
return `${distro.installPrefix} ${packageNames[0]}`;
}
// For multiple snap packages, we chain them with &&
// Note: snap doesn't support installing multiple packages in one command like apt
return packageNames.map(p => `sudo snap install ${p}`).join(' && ');
}
// Handle Arch Linux with AUR packages
if (selectedDistro === 'arch' && aurPackageInfo.hasAur) {
if (!hasYayInstalled) {
// User doesn't have yay installed - prepend yay installation
const yayInstallCmd = 'sudo pacman -S --needed git base-devel && git clone https://aur.archlinux.org/yay.git /tmp/yay && cd /tmp/yay && makepkg -si --noconfirm && cd - && rm -rf /tmp/yay';
const installCmd = `yay -S --needed --noconfirm ${packageNames.join(' ')}`;
return `${yayInstallCmd} && ${installCmd}`;
// User doesn't have current helper installed - prepend installation
const helperName = selectedHelper; // yay or paru
// Common setup: sudo pacman -S --needed git base-devel
// Then clone, make, install
const installHelperCmd = `sudo pacman -S --needed git base-devel && git clone https://aur.archlinux.org/${helperName}.git /tmp/${helperName} && cd /tmp/${helperName} && makepkg -si --noconfirm && cd - && rm -rf /tmp/${helperName}`;
// Install packages using the helper
const installCmd = `${helperName} -S --needed --noconfirm ${packageNames.join(' ')}`;
return `${installHelperCmd} && ${installCmd}`;
} else {
// User has yay installed - use yay for ALL packages (both official and AUR)
return `yay -S --needed --noconfirm ${packageNames.join(' ')}`;
// User has helper installed - use it for ALL packages
return `${selectedHelper} -S --needed --noconfirm ${packageNames.join(' ')}`;
}
}
return `${distro.installPrefix} ${packageNames.join(' ')}`;
}, [selectedDistro, selectedApps, aurPackageInfo.hasAur, hasYayInstalled]);
}, [selectedDistro, selectedApps, aurPackageInfo.hasAur, hasYayInstalled, selectedHelper]);
return {
selectedDistro,
@@ -239,6 +260,8 @@ export function useLinuxInit(): UseLinuxInitReturn {
// Arch/AUR specific
hasYayInstalled,
setHasYayInstalled,
selectedHelper,
setSelectedHelper,
hasAurPackages: aurPackageInfo.hasAur,
aurPackageNames: aurPackageInfo.packages,
aurAppNames: aurPackageInfo.appNames,

View File

@@ -1,36 +0,0 @@
"use client"
import { useState, useEffect, useCallback } from "react"
export function useTheme() {
// Initial state reads from DOM to match what the inline script set
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
if (typeof window !== 'undefined') {
return document.documentElement.classList.contains('light') ? 'light' : 'dark';
}
return 'light'; // SSR default
});
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
// On mount, sync with localStorage (which should match DOM already)
const saved = localStorage.getItem('theme') as 'dark' | 'light' | null;
if (saved) {
setTheme(saved);
document.documentElement.classList.toggle('light', saved === 'light');
}
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
localStorage.setItem('theme', theme);
document.documentElement.classList.toggle('light', theme === 'light');
}, [theme, hydrated]);
const toggle = useCallback(() => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
}, []);
return { theme, toggle };
}

58
src/hooks/useTheme.tsx Normal file
View File

@@ -0,0 +1,58 @@
"use client"
import React, { createContext, useContext, useEffect, useState, useCallback } from "react"
type Theme = 'dark' | 'light'
interface ThemeContextType {
theme: Theme
toggle: () => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Initial state reads from DOM to match what the inline script set
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
return document.documentElement.classList.contains('light') ? 'light' : 'dark'
}
return 'light' // SSR default
})
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
// On mount, sync with localStorage (which should match DOM already)
const saved = localStorage.getItem('theme') as Theme | null
if (saved) {
setTheme(saved)
document.documentElement.classList.toggle('light', saved === 'light')
}
setHydrated(true)
}, [])
useEffect(() => {
if (!hydrated) return
localStorage.setItem('theme', theme)
document.documentElement.classList.toggle('light', theme === 'light')
}, [theme, hydrated])
const toggle = useCallback(() => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
}, [])
return (
<ThemeContext.Provider value= {{ theme, toggle }
}>
{ children }
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}

View File

@@ -23,13 +23,14 @@ import {
interface ScriptOptions {
distroId: DistroId;
selectedAppIds: Set<string>;
helper?: 'yay' | 'paru';
}
/**
* Generate a full installation script with progress bars, error handling, and retries
*/
export function generateInstallScript(options: ScriptOptions): string {
const { distroId, selectedAppIds } = options;
const { distroId, selectedAppIds, helper = 'yay' } = options;
const distro = distros.find(d => d.id === distroId);
if (!distro) return '#!/bin/bash\necho "Error: Unknown distribution"\nexit 1';
@@ -40,7 +41,7 @@ export function generateInstallScript(options: ScriptOptions): string {
switch (distroId) {
case 'ubuntu': return generateUbuntuScript(packages);
case 'debian': return generateDebianScript(packages);
case 'arch': return generateArchScript(packages);
case 'arch': return generateArchScript(packages, helper);
case 'fedora': return generateFedoraScript(packages);
case 'opensuse': return generateOpenSUSEScript(packages);
case 'nix': return generateNixScript(packages);

View File

@@ -5,7 +5,7 @@
import { generateAsciiHeader, generateSharedUtils, escapeShellString, type PackageInfo } from './shared';
import { isAurPackage } from '../aur';
export function generateArchScript(packages: PackageInfo[]): string {
export function generateArchScript(packages: PackageInfo[], helper: 'yay' | 'paru' = 'yay'): string {
const aurPackages = packages.filter(p => isAurPackage(p.pkg));
const officialPackages = packages.filter(p => !isAurPackage(p.pkg));
@@ -57,7 +57,7 @@ install_aur() {
local start=$(date +%s)
local output
if output=$(with_retry yay -S --needed --noconfirm "$pkg"); then
if output=$(with_retry ${helper} -S --needed --noconfirm "$pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
@@ -85,14 +85,14 @@ info "Syncing databases..."
with_retry sudo pacman -Sy --noconfirm >/dev/null && success "Synced" || warn "Sync failed, continuing..."
${aurPackages.length > 0 ? `
if ! command -v yay &>/dev/null; then
warn "Installing yay for AUR packages..."
if ! command -v ${helper} &>/dev/null; then
warn "Installing ${helper} for AUR packages..."
sudo pacman -S --needed --noconfirm git base-devel >/dev/null 2>&1
tmp=$(mktemp -d)
git clone https://aur.archlinux.org/yay.git "$tmp/yay" >/dev/null 2>&1
(cd "$tmp/yay" && makepkg -si --noconfirm >/dev/null 2>&1)
git clone https://aur.archlinux.org/${helper}.git "$tmp/${helper}" >/dev/null 2>&1
(cd "$tmp/${helper}" && makepkg -si --noconfirm >/dev/null 2>&1)
rm -rf "$tmp"
command -v yay &>/dev/null && success "yay installed" || warn "yay install failed"
command -v ${helper} &>/dev/null && success "${helper} installed" || warn "${helper} install failed"
fi
` : ''}
@@ -102,7 +102,7 @@ echo
${officialPackages.map(({ app, pkg }) => `install_pacman "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
${aurPackages.length > 0 ? `
if command -v yay &>/dev/null; then
if command -v ${helper} &>/dev/null; then
${aurPackages.map(({ app, pkg }) => ` install_aur "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
fi
` : ''}