mirror of
https://github.com/abusoww/tuxmate.git
synced 2026-04-17 15:53:24 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
23
README.md
23
README.md
@@ -54,9 +54,9 @@ Shows which apps are available for your selected distro, with instructions for u
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
116
src/components/command/AurDrawerSettings.tsx
Normal file
116
src/components/command/AurDrawerSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
src/components/command/AurFloatingCard.tsx
Normal file
271
src/components/command/AurFloatingCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/components/command/AurPanel.tsx
Normal file
171
src/components/command/AurPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/components/command/AurPopover.tsx
Normal file
105
src/components/command/AurPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
169
src/components/command/ShortcutsBar.tsx
Normal file
169
src/components/command/ShortcutsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
54
src/components/search/SearchOverlay.tsx
Normal file
54
src/components/search/SearchOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
src/components/search/index.ts
Normal file
1
src/components/search/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SearchBox } from './SearchOverlay';
|
||||
@@ -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,
|
||||
|
||||
@@ -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
58
src/hooks/useTheme.tsx
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
` : ''}
|
||||
|
||||
Reference in New Issue
Block a user