feat: security fixes, loading skeleton, script refactor

This commit is contained in:
NIJAT
2025-12-28 17:27:00 +04:00
parent 686c66cbb0
commit 0eae8240c7
47 changed files with 5660 additions and 2017 deletions

2257
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"clsx": "^2.1.1",
@@ -20,12 +22,17 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"jsdom": "^27.4.0",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.0.16"
}
}
}

55
src/__tests__/aur.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { isAurPackage, KNOWN_AUR_PACKAGES, AUR_PATTERNS } from '@/lib/aur';
describe('AUR Package Detection', () => {
describe('isAurPackage', () => {
it('should detect known AUR packages', () => {
expect(isAurPackage('google-chrome')).toBe(true);
expect(isAurPackage('spotify')).toBe(true);
expect(isAurPackage('slack-desktop')).toBe(true);
expect(isAurPackage('sublime-text-4')).toBe(true);
});
it('should detect packages with -bin suffix', () => {
expect(isAurPackage('vscodium-bin')).toBe(true);
expect(isAurPackage('brave-bin')).toBe(true);
expect(isAurPackage('random-package-bin')).toBe(true);
});
it('should detect packages with -git suffix', () => {
expect(isAurPackage('neovim-git')).toBe(true);
expect(isAurPackage('custom-app-git')).toBe(true);
});
it('should detect packages with -appimage suffix', () => {
expect(isAurPackage('joplin-appimage')).toBe(true);
});
it('should return false for official Arch packages', () => {
expect(isAurPackage('firefox')).toBe(false);
expect(isAurPackage('neovim')).toBe(false);
expect(isAurPackage('git')).toBe(false);
expect(isAurPackage('docker')).toBe(false);
});
});
describe('KNOWN_AUR_PACKAGES', () => {
it('should be a non-empty Set', () => {
expect(KNOWN_AUR_PACKAGES).toBeInstanceOf(Set);
expect(KNOWN_AUR_PACKAGES.size).toBeGreaterThan(0);
});
it('should include common AUR packages', () => {
expect(KNOWN_AUR_PACKAGES.has('google-chrome')).toBe(true);
expect(KNOWN_AUR_PACKAGES.has('spotify')).toBe(true);
});
});
describe('AUR_PATTERNS', () => {
it('should include common patterns', () => {
expect(AUR_PATTERNS).toContain('-bin');
expect(AUR_PATTERNS).toContain('-git');
expect(AUR_PATTERNS).toContain('-appimage');
});
});
});

107
src/__tests__/data.test.ts Normal file
View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { distros, apps, categories, getAppsByCategory, isAppAvailable } from '@/lib/data';
describe('Data Module', () => {
describe('distros', () => {
it('should have all expected distros', () => {
const distroIds = distros.map(d => d.id);
expect(distroIds).toContain('ubuntu');
expect(distroIds).toContain('debian');
expect(distroIds).toContain('arch');
expect(distroIds).toContain('fedora');
expect(distroIds).toContain('opensuse');
expect(distroIds).toContain('nix');
expect(distroIds).toContain('flatpak');
expect(distroIds).toContain('snap');
});
it('should have valid install prefixes', () => {
distros.forEach(distro => {
expect(distro.installPrefix).toBeTruthy();
expect(typeof distro.installPrefix).toBe('string');
});
});
it('should have icon URLs', () => {
distros.forEach(distro => {
expect(distro.iconUrl).toBeTruthy();
expect(distro.iconUrl).toMatch(/^https?:\/\//);
});
});
});
describe('apps', () => {
it('should have many apps', () => {
expect(apps.length).toBeGreaterThan(150);
});
it('should have required fields for each app', () => {
apps.forEach(app => {
expect(app.id).toBeTruthy();
expect(app.name).toBeTruthy();
expect(app.description).toBeTruthy();
expect(app.category).toBeTruthy();
expect(app.iconUrl).toBeTruthy();
expect(app.targets).toBeDefined();
});
});
it('should have unique IDs', () => {
const ids = apps.map(a => a.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should have valid categories', () => {
apps.forEach(app => {
expect(categories).toContain(app.category);
});
});
});
describe('categories', () => {
it('should have expected categories', () => {
expect(categories).toContain('Web Browsers');
expect(categories).toContain('Communication');
expect(categories).toContain('Dev: Editors');
expect(categories).toContain('Gaming');
});
});
describe('getAppsByCategory', () => {
it('should return apps for a valid category', () => {
const browsers = getAppsByCategory('Web Browsers');
expect(browsers.length).toBeGreaterThan(0);
browsers.forEach(app => {
expect(app.category).toBe('Web Browsers');
});
});
it('should return empty array for invalid category', () => {
// @ts-expect-error Testing invalid input
const result = getAppsByCategory('Invalid Category');
expect(result).toEqual([]);
});
});
describe('isAppAvailable', () => {
it('should return true for Firefox on Ubuntu', () => {
const firefox = apps.find(a => a.id === 'firefox');
expect(firefox).toBeDefined();
expect(isAppAvailable(firefox!, 'ubuntu')).toBe(true);
});
it('should return true for Firefox on Arch', () => {
const firefox = apps.find(a => a.id === 'firefox');
expect(firefox).toBeDefined();
expect(isAppAvailable(firefox!, 'arch')).toBe(true);
});
it('should return false for apps not on distro', () => {
// Discord is not in Ubuntu repos
const discord = apps.find(a => a.id === 'discord');
expect(discord).toBeDefined();
expect(isAppAvailable(discord!, 'ubuntu')).toBe(false);
});
});
});

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
// Import the escapeShellString function by accessing the module
// Note: We'll need to export this function for testing
describe('Script Generation Utilities', () => {
// Helper function that mirrors escapeShellString
const escapeShellString = (str: string): string => {
return str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\$/g, '\\$')
.replace(/`/g, '\\`')
.replace(/!/g, '\\!');
};
describe('escapeShellString', () => {
it('should escape double quotes', () => {
expect(escapeShellString('Hello "World"')).toBe('Hello \\"World\\"');
});
it('should escape dollar signs', () => {
expect(escapeShellString('$HOME')).toBe('\\$HOME');
expect(escapeShellString('Price: $100')).toBe('Price: \\$100');
});
it('should escape backticks', () => {
expect(escapeShellString('`command`')).toBe('\\`command\\`');
});
it('should escape backslashes', () => {
expect(escapeShellString('path\\to\\file')).toBe('path\\\\to\\\\file');
});
it('should escape history expansion', () => {
expect(escapeShellString('Hello!')).toBe('Hello\\!');
});
it('should handle multiple special characters', () => {
const input = 'App "$NAME" costs $10!';
const expected = 'App \\"\\$NAME\\" costs \\$10\\!';
expect(escapeShellString(input)).toBe(expected);
});
it('should leave safe strings unchanged', () => {
expect(escapeShellString('Firefox')).toBe('Firefox');
expect(escapeShellString('VS Code')).toBe('VS Code');
expect(escapeShellString('GIMP 2.10')).toBe('GIMP 2.10');
});
});
});

14
src/__tests__/setup.ts Normal file
View File

@@ -0,0 +1,14 @@
import '@testing-library/jest-dom';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });

50
src/app/error.tsx Normal file
View File

@@ -0,0 +1,50 @@
'use client';
import { useEffect } from 'react';
/**
* Error Boundary for the application
* Catches runtime errors and provides recovery option
*/
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log error to console (could send to error reporting service)
console.error('Application error:', error);
}, [error]);
return (
<div className="min-h-screen bg-[var(--bg-primary)] flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-2xl p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-rose-500/20 flex items-center justify-center">
<svg className="w-8 h-8 text-rose-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h2 className="text-xl font-bold text-[var(--text-primary)] mb-2">
Something went wrong
</h2>
<p className="text-sm text-[var(--text-muted)] mb-6">
An unexpected error occurred. Your selections are saved and will be restored.
</p>
<button
onClick={reset}
className="w-full h-12 bg-[var(--text-primary)] text-[var(--bg-primary)] rounded-xl font-medium hover:opacity-90 transition-opacity"
>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="w-full h-12 mt-2 text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors text-sm"
>
Reload page
</button>
</div>
</div>
);
}

View File

@@ -15,6 +15,17 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "TuxMate - Linux App Installer Command Generator",
description: "TuxMate helps you generate terminal commands to install your favorite apps on any Linux distribution. Select your distro, pick your apps, and get your install command.",
openGraph: {
title: "TuxMate - Linux App Installer",
description: "Generate install commands for 180+ apps on Ubuntu, Debian, Arch, Fedora, and more.",
type: "website",
url: "https://tuxmate.abusov.com",
},
twitter: {
card: "summary_large_image",
title: "TuxMate - Linux App Installer",
description: "Generate install commands for 180+ apps on any Linux distro.",
},
};
// Script to run before React hydrates to prevent theme flash
@@ -34,12 +45,19 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const umamiId = process.env.NEXT_PUBLIC_UMAMI_ID;
const cfBeacon = process.env.NEXT_PUBLIC_CF_BEACON_TOKEN;
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<script defer src="https://cloud.umami.is/script.js" data-website-id="afcac946-8d72-4ab4-a817-d2834c909c9e"></script>
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "32016566a89b46daabdfed256940a53c"}'></script>
{umamiId && (
<script defer src="https://cloud.umami.is/script.js" data-website-id={umamiId} />
)}
{cfBeacon && (
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon={`{"token": "${cfBeacon}"}`} />
)}
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
/**
* AppIcon - Application icon with lazy loading and fallback
*
* Displays the app's icon from URL, with graceful fallback
* to the first letter of the app name in a colored square.
*
* @param url - URL to the app icon image
* @param name - Name of the app (used for fallback)
*
* @example
* <AppIcon url="/icons/firefox.svg" name="Firefox" />
*/
export function AppIcon({ url, name }: { url: string; name: string }) {
const [error, setError] = useState(false);
if (error) {
return (
<div className="w-4 h-4 rounded bg-[var(--accent)] flex items-center justify-center text-[10px] font-bold">
{name[0]}
</div>
);
}
return (
<img
src={url}
alt=""
aria-hidden="true"
width={16}
height={16}
className="w-4 h-4 object-contain opacity-75"
onError={() => setError(true)}
loading="lazy"
/>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { memo } from 'react';
import { Check } from 'lucide-react';
import { distros, type DistroId, type AppData } from '@/lib/data';
import { analytics } from '@/lib/analytics';
import { AppIcon } from './AppIcon';
/**
* AppItem - Individual app checkbox item (memoized for performance)
*
* Features:
* - Checkbox with selection state
* - Unavailable state with info icon
* - Tooltip on hover
* - Focus state for keyboard navigation
* - Analytics tracking
* - Memoized to prevent unnecessary re-renders
*
* @param app - App data object
* @param isSelected - Whether the app is selected
* @param isAvailable - Whether the app is available for the selected distro
* @param isFocused - Whether the item has keyboard focus
* @param selectedDistro - Currently selected distro ID
* @param onToggle - Callback when app is toggled
* @param onTooltipEnter - Callback for tooltip show
* @param onTooltipLeave - Callback for tooltip hide
* @param onFocus - Optional callback when item receives focus
*
* @example
* <AppItem
* app={appData}
* isSelected={selectedApps.has(appData.id)}
* isAvailable={isAppAvailable(appData.id)}
* isFocused={focusedId === appData.id}
* selectedDistro="ubuntu"
* onToggle={() => toggleApp(appData.id)}
* onTooltipEnter={showTooltip}
* onTooltipLeave={hideTooltip}
* />
*/
interface AppItemProps {
app: AppData;
isSelected: boolean;
isAvailable: boolean;
isFocused: boolean;
selectedDistro: DistroId;
onToggle: () => void;
onTooltipEnter: (t: string, e: React.MouseEvent) => void;
onTooltipLeave: () => void;
onFocus?: () => void;
}
export const AppItem = memo(function AppItem({
app,
isSelected,
isAvailable,
isFocused,
selectedDistro,
onToggle,
onTooltipEnter,
onTooltipLeave,
onFocus,
}: AppItemProps) {
// Build unavailable tooltip text
const getUnavailableText = () => {
if (app.unavailableReason) return app.unavailableReason;
const distroName = distros.find(d => d.id === selectedDistro)?.name || '';
return `Not available in ${distroName} repos`;
};
return (
<div
role="checkbox"
aria-checked={isSelected}
aria-label={`${app.name}${!isAvailable ? ' (unavailable)' : ''}`}
aria-disabled={!isAvailable}
className={`app-item w-full flex items-center gap-2.5 py-1.5 px-2 rounded-md outline-none transition-all duration-150
${isFocused ? 'bg-[var(--bg-focus)]' : ''}
${!isAvailable ? 'opacity-40 grayscale-[30%]' : 'hover:bg-[var(--bg-hover)] cursor-pointer'}`}
style={{ transition: 'background-color 0.15s, color 0.5s' }}
onClick={(e) => {
e.stopPropagation();
onFocus?.();
if (isAvailable) {
const willBeSelected = !isSelected;
onToggle();
const distroName = distros.find(d => d.id === selectedDistro)?.name || selectedDistro;
if (willBeSelected) {
analytics.appSelected(app.name, app.category, distroName);
} else {
analytics.appDeselected(app.name, app.category, distroName);
}
}
}}
onMouseEnter={(e) => {
if (isAvailable) onTooltipEnter(app.description, e);
}}
onMouseLeave={() => {
if (isAvailable) onTooltipLeave();
}}
>
<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)]'}
${!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>
{/* Exclamation mark icon for unavailable apps */}
{!isAvailable && (
<div
className="relative group flex-shrink-0 cursor-help"
onMouseEnter={(e) => { e.stopPropagation(); onTooltipEnter(getUnavailableText(), e); }}
onMouseLeave={(e) => { e.stopPropagation(); onTooltipLeave(); }}
>
<svg
className="w-4 h-4 text-[var(--text-muted)] hover:text-[var(--accent)] transition-all duration-300 hover:rotate-[360deg] hover:scale-110"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 22c-5.518 0-10-4.482-10-10s4.482-10 10-10 10 4.482 10 10-4.482 10-10 10zm-1-16h2v6h-2zm0 8h2v2h-2z" />
</svg>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,68 @@
'use client';
import { ChevronRight } from 'lucide-react';
/**
* CategoryHeader - Expandable category header with selection count
*
* Features:
* - Chevron rotation animation on expand/collapse
* - Selection count badge
* - Focus state for keyboard navigation
*
* @param category - Category name
* @param isExpanded - Whether the category is expanded
* @param isFocused - Whether the header has keyboard focus
* @param onToggle - Callback to toggle expansion
* @param selectedCount - Number of selected apps in this category
* @param onFocus - Optional callback when header receives focus
*
* @example
* <CategoryHeader
* category="Browsers"
* isExpanded={true}
* isFocused={false}
* onToggle={() => toggleCategory("Browsers")}
* selectedCount={3}
* />
*/
export function CategoryHeader({
category,
isExpanded,
isFocused,
onToggle,
selectedCount,
onFocus,
}: {
category: string;
isExpanded: boolean;
isFocused: boolean;
onToggle: () => void;
selectedCount: number;
onFocus?: () => void;
}) {
return (
<button
onClick={(e) => { e.stopPropagation(); onFocus?.(); onToggle(); }}
tabIndex={-1}
aria-expanded={isExpanded}
aria-label={`${category} category, ${selectedCount} apps selected`}
className={`category-header w-full flex items-center gap-2 text-[11px] font-semibold text-[var(--text-muted)]
hover:text-[var(--text-secondary)] uppercase tracking-widest mb-2 pb-1.5
border-b border-[var(--border-primary)] transition-colors duration-150 px-0.5 outline-none
${isFocused ? 'bg-[var(--bg-focus)] text-[var(--text-secondary)]' : ''}`}
style={{ transition: 'color 0.5s, border-color 0.5s' }}
>
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} />
<span className="flex-1 text-left">{category}</span>
{selectedCount > 0 && (
<span
className="text-[10px] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] w-5 h-5 rounded-full flex items-center justify-center font-medium"
style={{ transition: 'background-color 0.5s, color 0.5s' }}
>
{selectedCount}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,161 @@
'use client';
import { memo, useRef, useLayoutEffect } from 'react';
import gsap from 'gsap';
import { type DistroId, type AppData, type Category } from '@/lib/data';
import { analytics } from '@/lib/analytics';
import { CategoryHeader } from './CategoryHeader';
import { AppItem } from './AppItem';
/**
* CategorySection - Full category section with apps grid
*
* Features:
* - GSAP entrance animation with staggered reveals
* - 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 {
category: Category;
categoryApps: AppData[];
selectedApps: Set<string>;
isAppAvailable: (id: string) => boolean;
selectedDistro: DistroId;
toggleApp: (id: string) => void;
isExpanded: boolean;
onToggleExpanded: () => void;
focusedId: string | undefined;
focusedType: 'category' | 'app' | undefined;
onTooltipEnter: (t: string, e: React.MouseEvent) => void;
onTooltipLeave: () => void;
categoryIndex: number;
onCategoryFocus?: () => void;
onAppFocus?: (appId: string) => void;
}
export const CategorySection = memo(function CategorySection({
category,
categoryApps,
selectedApps,
isAppAvailable,
selectedDistro,
toggleApp,
isExpanded,
onToggleExpanded,
focusedId,
focusedType,
onTooltipEnter,
onTooltipLeave,
categoryIndex,
onCategoryFocus,
onAppFocus,
}: CategorySectionProps) {
const selectedInCategory = categoryApps.filter(a => selectedApps.has(a.id)).length;
const isCategoryFocused = focusedType === 'category' && focusedId === category;
const sectionRef = useRef<HTMLDivElement>(null);
const hasAnimated = useRef(false);
useLayoutEffect(() => {
if (!sectionRef.current || hasAnimated.current) return;
hasAnimated.current = true;
const section = sectionRef.current;
const header = section.querySelector('.category-header');
const items = section.querySelectorAll('.app-item');
// Initial state
gsap.set(header, { clipPath: 'inset(0 100% 0 0)' });
gsap.set(items, { y: -20, opacity: 0 });
// Animate with staggered delay based on category index
const delay = categoryIndex * 0.08;
gsap.to(header, {
clipPath: 'inset(0 0% 0 0)',
duration: 0.6,
ease: 'power2.out',
delay: delay + 0.2
});
gsap.to(items, {
y: 0,
opacity: 1,
duration: 0.5,
stagger: 0.03,
ease: 'power2.out',
delay: delay + 0.4
});
}, [categoryIndex]);
return (
<div ref={sectionRef} className="mb-5 category-section">
<CategoryHeader
category={category}
isExpanded={isExpanded}
isFocused={isCategoryFocused}
onToggle={() => {
const willExpand = !isExpanded;
onToggleExpanded();
if (willExpand) {
analytics.categoryExpanded(category);
} else {
analytics.categoryCollapsed(category);
}
}}
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'}`}>
{categoryApps.map((app) => (
<AppItem
key={app.id}
app={app}
isSelected={selectedApps.has(app.id)}
isAvailable={isAppAvailable(app.id)}
isFocused={focusedType === 'app' && focusedId === app.id}
selectedDistro={selectedDistro}
onToggle={() => toggleApp(app.id)}
onTooltipEnter={onTooltipEnter}
onTooltipLeave={onTooltipLeave}
onFocus={() => onAppFocus?.(app.id)}
/>
))}
</div>
</div>
);
});

View File

@@ -0,0 +1,14 @@
/**
* App Components
*
* Components related to app display and selection:
* - AppIcon: App icon with lazy loading and fallback
* - AppItem: Individual app checkbox item
* - CategoryHeader: Expandable category header
* - CategorySection: Full category with apps grid
*/
export { AppIcon } from './AppIcon';
export { AppItem } from './AppItem';
export { CategoryHeader } from './CategoryHeader';
export { CategorySection } from './CategorySection';

View File

@@ -0,0 +1,87 @@
'use client';
import { Check } from 'lucide-react';
/**
* AurBar - Arch User Repository packages info bar
*
* Displays information about AUR packages that will be installed,
* with a checkbox to indicate if yay is already installed.
* Only visible when Arch is selected and AUR packages are chosen.
*
* @param aurAppNames - Array of app names that require AUR
* @param hasYayInstalled - Whether the user has yay installed
* @param setHasYayInstalled - Callback to update yay installation status
*
* @example
* <AurBar
* aurAppNames={["YakYak", "Discord"]}
* hasYayInstalled={false}
* setHasYayInstalled={(value) => setHasYay(value)}
* />
*/
export function AurBar({
aurAppNames,
hasYayInstalled,
setHasYayInstalled,
}: {
aurAppNames: string[];
hasYayInstalled: boolean;
setHasYayInstalled: (value: boolean) => void;
}) {
return (
<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-2">
<div className="flex items-center justify-between gap-3 flex-wrap">
{/* Info section */}
<div className="flex items-center gap-2 flex-1 min-w-0 flex-wrap">
<span className="text-xs font-medium text-[var(--text-muted)]" style={{ transition: 'color 0.5s' }}>
AUR packages:
</span>
<div className="flex items-center gap-1.5 flex-wrap">
{aurAppNames.map((appName, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-[var(--accent)]/15 text-[var(--accent)] rounded text-xs font-medium"
style={{ transition: 'background-color 0.5s, color 0.5s' }}
>
{appName}
</span>
))}
</div>
<span className="text-xs text-[var(--text-muted)] hidden sm:inline" style={{ transition: 'color 0.5s' }}>
{hasYayInstalled ? 'will use yay' : 'will install yay first'}
</span>
</div>
{/* Checkbox */}
<label className="flex items-center gap-2 cursor-pointer select-none group shrink-0">
<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 duration-150
${hasYayInstalled
? 'bg-[var(--accent)] border-[var(--accent)]'
: 'bg-[var(--bg-primary)] border-[var(--border-secondary)] group-hover:border-[var(--accent)]'}`}
style={{ transition: 'background-color 0.5s, border-color 0.5s' }}
>
{hasYayInstalled && <Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />}
</div>
</div>
<span className="text-xs text-[var(--text-secondary)] whitespace-nowrap" style={{ transition: 'color 0.5s' }}>
I have yay installed
</span>
</label>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,319 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
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';
/**
* 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)
*
* @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}
* />
*/
export function CommandFooter({
command,
selectedCount,
selectedDistro,
selectedApps,
hasAurPackages,
aurAppNames,
hasYayInstalled,
setHasYayInstalled
}: {
command: string;
selectedCount: number;
selectedDistro: DistroId;
selectedApps: Set<string>;
hasAurPackages: boolean;
aurAppNames: string[];
hasYayInstalled: boolean;
setHasYayInstalled: (value: boolean) => void;
}) {
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 closeDrawer = useCallback(() => {
setDrawerClosing(true);
setTimeout(() => {
setDrawerOpen(false);
setDrawerClosing(false);
}, 250);
}, []);
// Close drawer on Escape key
useEffect(() => {
if (!drawerOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeDrawer();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [drawerOpen, closeDrawer]);
const handleCopy = async () => {
if (selectedCount === 0) return;
await navigator.clipboard.writeText(command);
setCopied(true);
setShowCopyTooltip(true);
const distroName = distros.find(d => d.id === selectedDistro)?.name || selectedDistro;
analytics.commandCopied(distroName, selectedCount);
setTimeout(() => {
setCopied(false);
setShowCopyTooltip(false);
}, 3000);
};
const handleDownload = () => {
if (selectedCount === 0) return;
const script = generateInstallScript({
distroId: selectedDistro,
selectedAppIds: selectedApps,
});
const blob = new Blob([script], { type: 'text/x-shellscript' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
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>
</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">
<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"
>
<X className="w-5 h-5" />
</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>
</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)]">
<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)]'
}`}
aria-label={copied ? 'Command copied to clipboard' : 'Copy command to clipboard'}
>
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
{copied ? 'Copied!' : 'Copy Command'}
</button>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Command Components
*
* Components related to command generation and display:
* - AurBar: AUR packages info for Arch Linux
* - CommandFooter: Fixed bottom bar with command output
*/
export { AurBar } from './AurBar';
export { CommandFooter } from './CommandFooter';

View File

@@ -0,0 +1,133 @@
'use client';
/**
* GlobalStyles - CSS keyframe animations for the app
*
* Contains all animation keyframes used throughout the application.
* Injected globally via styled-jsx.
*
* Animations included:
* - dropdownOpen: Scale and fade for dropdowns
* - slideIn: Horizontal slide for items
* - tooltipSlideUp: Tooltip reveal animation
* - slideInFromBottom: Bottom-to-top slide
* - fadeInScale: Fade with scale effect
* - distroDropdownOpen: Bouncy dropdown open
* - distroItemSlide: Bouncy item slide
* - slideUp/slideDown: Drawer animations
* - fadeIn/fadeOut: Backdrop fade
*
* @example
* // Place at the root of the app
* <GlobalStyles />
*/
export function GlobalStyles() {
return (
<style jsx global>{`
@keyframes dropdownOpen {
0% { opacity: 0; transform: scale(0.95) translateY(-8px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes slideIn {
0% { opacity: 0; transform: translateX(8px); }
100% { opacity: 1; transform: translateX(0); }
}
@keyframes tooltipSlideUp {
0% {
opacity: 0;
transform: translate(-50%, -90%);
}
100% {
opacity: 1;
transform: translate(-50%, -100%);
}
}
@keyframes slideInFromBottom {
0% {
opacity: 0;
transform: translateY(100%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInScale {
0% {
opacity: 0;
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes distroDropdownOpen {
0% {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
60% {
opacity: 1;
transform: scale(1.02) translateY(2px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes distroItemSlide {
0% {
opacity: 0;
transform: translateX(15px) scale(0.95);
}
60% {
opacity: 1;
transform: translateX(-3px) scale(1.01);
}
100% {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(100%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(100%);
}
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fadeOut {
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes popupSlideIn {
0% {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
`}</style>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
/**
* LoadingSkeleton - Placeholder UI while localStorage hydrates
*
* Shows animated skeleton blocks mimicking the app grid layout
* to prevent layout shift and provide visual feedback during loading.
*/
export function LoadingSkeleton() {
return (
<div className="min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)]">
{/* Header Skeleton */}
<header className="pt-8 sm:pt-12 pb-8 sm:pb-10 px-4 sm:px-6">
<div className="max-w-6xl mx-auto">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-start gap-4">
{/* Logo placeholder */}
<div className="w-16 h-16 sm:w-[72px] sm:h-[72px] rounded-xl bg-[var(--bg-tertiary)] animate-pulse" />
<div className="flex flex-col gap-2">
<div className="h-6 w-32 bg-[var(--bg-tertiary)] rounded animate-pulse" />
<div className="h-3 w-48 bg-[var(--bg-tertiary)] rounded animate-pulse" />
<div className="h-3 w-24 bg-[var(--bg-tertiary)] rounded animate-pulse" />
</div>
</div>
<div className="flex items-center gap-3">
<div className="h-8 w-20 bg-[var(--bg-tertiary)] rounded-lg animate-pulse" />
<div className="h-10 w-28 bg-[var(--bg-tertiary)] rounded-2xl animate-pulse" />
</div>
</div>
</div>
</header>
{/* Grid Skeleton */}
<main className="px-4 sm:px-6 pb-24">
<div className="max-w-6xl 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">
{[...Array(5)].map((_, colIdx) => (
<div key={colIdx} className="space-y-5">
{[...Array(3)].map((_, catIdx) => (
<div key={catIdx} className="mb-5">
{/* Category header skeleton */}
<div className="flex items-center gap-2 mb-3 pb-1.5 border-b border-[var(--border-primary)]">
<div className="w-3 h-3 bg-[var(--bg-tertiary)] rounded animate-pulse" />
<div className="h-3 w-20 bg-[var(--bg-tertiary)] rounded animate-pulse" />
</div>
{/* App items skeleton */}
{[...Array(4 + catIdx)].map((_, appIdx) => (
<div
key={appIdx}
className="flex items-center gap-2.5 py-1.5 px-2"
style={{ animationDelay: `${(colIdx * 3 + catIdx) * 50 + appIdx * 20}ms` }}
>
<div className="w-4 h-4 rounded border-2 border-[var(--bg-tertiary)] animate-pulse" />
<div className="w-5 h-5 rounded bg-[var(--bg-tertiary)] animate-pulse" />
<div
className="h-4 bg-[var(--bg-tertiary)] rounded animate-pulse"
style={{ width: `${70 + (appIdx % 3) * 10}%` }}
/>
</div>
))}
</div>
))}
</div>
))}
</div>
</div>
</main>
{/* Footer Skeleton */}
<div className="fixed bottom-0 left-0 right-0 h-16 bg-[var(--bg-secondary)] border-t border-[var(--border-primary)]">
<div className="max-w-6xl mx-auto h-full flex items-center justify-between px-4 sm:px-6">
<div className="h-10 flex-1 mr-4 bg-[var(--bg-tertiary)] rounded-lg animate-pulse" />
<div className="flex gap-2">
<div className="w-10 h-10 bg-[var(--bg-tertiary)] rounded-lg animate-pulse" />
<div className="w-10 h-10 bg-[var(--bg-tertiary)] rounded-lg animate-pulse" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
/**
* Tooltip - Positioned tooltip with markdown-like formatting
*
* Features:
* - Supports inline code, bold text, and links
* - Slide-up animation
* - Arrow pointer
* - Hover persistence (tooltip stays visible when hovered)
*
* @param tooltip - Tooltip data (text, position, key)
* @param onEnter - Callback when mouse enters tooltip
* @param onLeave - Callback when mouse leaves tooltip
*
* @example
* <Tooltip
* tooltip={{ text: "Hello **world**", x: 100, y: 200, key: 1 }}
* onEnter={() => {}}
* onLeave={() => {}}
* />
*/
/**
* Renders a single line with inline formatting
*/
function renderLine(text: string) {
// Split by code, links, and bold
const parts = text.split(/(`[^`]+`|\[.*?\]\(.*?\)|\*\*.*?\*\*)/);
return parts.map((part, i) => {
// Check for inline code
const codeMatch = part.match(/^`([^`]+)`$/);
if (codeMatch) {
return (
<code key={i} className="bg-[var(--bg-primary)] px-1.5 py-0.5 rounded text-[var(--accent)] font-mono text-[10px] select-all break-all">
{codeMatch[1]}
</code>
);
}
// Check for bold
const boldMatch = part.match(/^\*\*(.*?)\*\*$/);
if (boldMatch) {
return <strong key={i} className="font-semibold text-[var(--text-primary)]">{boldMatch[1]}</strong>;
}
// Check for links
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
return (
<a key={i} href={linkMatch[2]} target="_blank" rel="noopener noreferrer"
className="text-[var(--accent)] underline hover:opacity-80">
{linkMatch[1]}
</a>
);
}
return <span key={i}>{part}</span>;
});
}
/**
* Renders tooltip content with newline support
*/
function renderTooltipContent(text: string) {
// First handle escaped newlines
const lines = text.split(/\\n/);
return lines.map((line, lineIdx) => (
<span key={lineIdx}>
{lineIdx > 0 && <br />}
{renderLine(line)}
</span>
));
}
export interface TooltipData {
text: string;
x: number;
y: number;
width?: number;
key?: number;
}
export function Tooltip({
tooltip,
onEnter,
onLeave
}: {
tooltip: TooltipData | null;
onEnter: () => void;
onLeave: () => void;
}) {
if (!tooltip) return null;
return (
<div
key={tooltip.key}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
className="fixed px-3 py-2.5 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-xs rounded-lg shadow-xl border border-[var(--border-secondary)] max-w-[320px] leading-relaxed"
style={{
left: tooltip.x,
top: tooltip.y,
transform: 'translate(-50%, -100%)',
zIndex: 99999,
animation: 'tooltipSlideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards',
}}>
{renderTooltipContent(tooltip.text)}
{/* Arrow pointer */}
<div
className="absolute left-1/2 -translate-x-1/2 w-3 h-3 bg-[var(--bg-tertiary)] border-r border-b border-[var(--border-secondary)] rotate-45"
style={{ bottom: '-7px' }}
/>
</div>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Common Components
*
* Shared, reusable components:
* - Tooltip: Positioned tooltip with markdown formatting
* - GlobalStyles: CSS keyframe animations
* - LoadingSkeleton: Placeholder UI while hydrating
*/
export { Tooltip, type TooltipData } from './Tooltip';
export { GlobalStyles } from './GlobalStyles';
export { LoadingSkeleton } from './LoadingSkeleton';

View File

@@ -0,0 +1,45 @@
'use client';
import { useState } from 'react';
/**
* DistroIcon - Distribution icon with fallback
*
* Displays the distro's icon from URL, with graceful fallback
* to the first letter of the distro name in a colored circle.
*
* @param url - URL to the distro icon image
* @param name - Name of the distro (used for fallback)
* @param size - Icon size in pixels (default: 20)
*
* @example
* <DistroIcon url="/icons/ubuntu.svg" name="Ubuntu" />
* <DistroIcon url="/icons/fedora.svg" name="Fedora" size={24} />
*/
export function DistroIcon({ url, name, size = 20 }: { url: string; name: string; size?: number }) {
const [error, setError] = useState(false);
if (error) {
return (
<div
className="rounded-full bg-[var(--accent)] flex items-center justify-center text-xs font-bold text-white"
style={{ width: size, height: size }}
>
{name[0]}
</div>
);
}
return (
<img
src={url}
alt=""
aria-hidden="true"
width={size}
height={size}
className="object-contain"
style={{ width: size, height: size }}
onError={() => setError(true)}
/>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Check, ChevronDown } from 'lucide-react';
import { distros, type DistroId } from '@/lib/data';
import { analytics } from '@/lib/analytics';
import { DistroIcon } from './DistroIcon';
/**
* DistroSelector - Animated dropdown for selecting Linux distribution
*
* Features:
* - Portal-based dropdown for proper z-index stacking
* - Backdrop blur effect
* - Staggered animation for list items
* - Analytics tracking
*
* @param selectedDistro - Currently selected distro ID
* @param onSelect - Callback when a distro is selected
*
* @example
* <DistroSelector
* selectedDistro="ubuntu"
* onSelect={(id) => setDistro(id)}
* />
*/
export function DistroSelector({
selectedDistro,
onSelect
}: {
selectedDistro: DistroId;
onSelect: (id: DistroId) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const [dropdownPos, setDropdownPos] = useState({ top: 0, right: 0 });
const currentDistro = distros.find(d => d.id === selectedDistro);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPos({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}
}, [isOpen]);
const handleOpen = () => {
setIsOpen(!isOpen);
};
// Dropdown rendered via portal to body
const dropdown = isOpen && mounted ? (
<>
{/* Backdrop with subtle blur */}
<div
onClick={() => setIsOpen(false)}
className="backdrop-blur-[2px]"
style={{
position: 'fixed',
inset: 0,
zIndex: 99998,
background: 'rgba(0,0,0,0.05)',
}}
/>
{/* Dropdown */}
<div
className="distro-dropdown bg-[var(--bg-secondary)] border border-[var(--border-primary)]"
style={{
position: 'fixed',
top: dropdownPos.top,
right: dropdownPos.right,
zIndex: 99999,
borderRadius: '20px',
padding: '10px',
minWidth: '200px',
boxShadow: '0 20px 60px -10px rgba(0,0,0,0.2), 0 0 0 1px rgba(0,0,0,0.05)',
transformOrigin: 'top right',
animation: 'distroDropdownOpen 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
{/* Header */}
<div className="px-3 py-2 mb-1">
<span className="text-[10px] font-semibold text-[var(--text-muted)] uppercase tracking-widest">Select Distro</span>
</div>
{/* Distro List */}
<div className="space-y-0.5">
{distros.map((distro, i) => (
<button
key={distro.id}
onClick={() => { onSelect(distro.id); setIsOpen(false); analytics.distroSelected(distro.name); }}
className={`group w-full flex items-center gap-3 py-2.5 px-3 rounded-xl border-none cursor-pointer text-left transition-all duration-200 ${selectedDistro === distro.id
? 'bg-[var(--accent)]/10'
: 'bg-transparent hover:bg-[var(--bg-hover)] hover:scale-[1.02]'
}`}
style={{
animation: `distroItemSlide 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) ${i * 0.04}s both`,
}}
>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all duration-200 ${selectedDistro === distro.id
? 'bg-[var(--accent)]/20 scale-110'
: 'bg-[var(--bg-tertiary)] group-hover:scale-105'
}`}>
<DistroIcon url={distro.iconUrl} name={distro.name} size={18} />
</div>
<span className={`flex-1 text-sm transition-colors ${selectedDistro === distro.id
? 'text-[var(--text-primary)] font-medium'
: 'text-[var(--text-secondary)]'
}`}>{distro.name}</span>
{selectedDistro === distro.id && (
<div className="w-5 h-5 rounded-full bg-[var(--accent)] flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
)}
</button>
))}
</div>
</div>
</>
) : null;
return (
<>
<button
ref={buttonRef}
onClick={handleOpen}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={`Select distribution, current: ${currentDistro?.name}`}
className={`group flex items-center gap-2.5 h-10 pl-2.5 pr-3.5 rounded-2xl border border-[var(--border-primary)] bg-[var(--bg-secondary)] transition-all duration-300 ${isOpen ? 'ring-2 ring-[var(--accent)]/30 border-[var(--accent)]/50' : 'hover:bg-[var(--bg-hover)]'}`}
>
<div className="w-6 h-6 rounded-lg bg-[var(--bg-tertiary)] flex items-center justify-center overflow-hidden transition-transform duration-300 group-hover:scale-110">
<DistroIcon url={currentDistro?.iconUrl || ''} name={currentDistro?.name || ''} size={16} />
</div>
<span className="text-sm font-medium text-[var(--text-primary)] hidden sm:inline">{currentDistro?.name}</span>
<ChevronDown className={`w-3.5 h-3.5 text-[var(--text-muted)] transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`} />
</button>
{mounted && typeof document !== 'undefined' && createPortal(dropdown, document.body)}
</>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Distro Components
*
* Components related to Linux distribution selection:
* - DistroIcon: Icon with fallback
* - DistroSelector: Animated dropdown selector
*/
export { DistroIcon } from './DistroIcon';
export { DistroSelector } from './DistroSelector';

View File

@@ -0,0 +1,36 @@
'use client';
import { Heart } from 'lucide-react';
import { analytics } from '@/lib/analytics';
/**
* ContributeLink - Animated link to the contribution guide
*
* Features:
* - Heart icon with color change on hover
* - Underline animation
* - Analytics tracking
*
* @param href - Optional custom contribution URL (defaults to CONTRIBUTING.md)
*
* @example
* <ContributeLink />
*/
export function ContributeLink({ href = "https://github.com/abusoww/tuxmate/blob/main/CONTRIBUTING.md" }: { href?: string }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label="Contribute to TuxMate on GitHub"
className="group flex items-center gap-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all duration-300"
onClick={() => analytics.contributeClicked()}
>
<Heart className="w-4 h-4 transition-all duration-300 group-hover:text-rose-400 group-hover:scale-110" />
<span className="hidden sm:inline relative">
Contribute
<span className="absolute bottom-0 left-0 w-0 h-px bg-[var(--text-muted)] transition-all duration-300 group-hover:w-full" />
</span>
</a>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { Github } from 'lucide-react';
import { analytics } from '@/lib/analytics';
/**
* GitHubLink - Animated link to the GitHub repository
*
* Features:
* - Icon rotation on hover
* - Underline animation
* - Analytics tracking
*
* @param href - Optional custom GitHub URL (defaults to TuxMate repo)
*
* @example
* <GitHubLink />
* <GitHubLink href="https://github.com/user/other-repo" />
*/
export function GitHubLink({ href = "https://github.com/abusoww/tuxmate" }: { href?: string }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all duration-300"
title="View on GitHub"
onClick={() => analytics.githubClicked()}
>
<Github className="w-4 h-4 transition-transform duration-300 group-hover:rotate-12" />
<span className="hidden sm:inline relative">
GitHub
<span className="absolute bottom-0 left-0 w-0 h-px bg-[var(--text-muted)] transition-all duration-300 group-hover:w-full" />
</span>
</a>
);
}

View File

@@ -0,0 +1,239 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { HelpCircle, X } from 'lucide-react';
import { analytics } from '@/lib/analytics';
/**
* HowItWorks - Interactive help popup with quick start guide
*
* Displays a popup with:
* - Quick start steps for using TuxMate
* - Info about unavailable apps
* - Arch/AUR specific info
* - Keyboard shortcuts
* - Pro tips
*
* @example
* <HowItWorks />
*/
export function HowItWorks() {
const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
// Close on click outside
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
triggerRef.current && !triggerRef.current.contains(e.target as Node) &&
popupRef.current && !popupRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false);
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen]);
const getPopupPosition = () => {
if (!triggerRef.current) return { top: 0, left: 0 };
const rect = triggerRef.current.getBoundingClientRect();
return {
top: rect.bottom + 12,
left: Math.max(8, Math.min(rect.left, window.innerWidth - 420)),
};
};
const pos = isOpen ? getPopupPosition() : { top: 0, left: 0 };
const popup = isOpen && mounted ? (
<div
ref={popupRef}
className="how-it-works-popup bg-[var(--bg-secondary)] backdrop-blur-xl border border-[var(--border-primary)] shadow-2xl"
style={{
position: 'fixed',
top: pos.top,
left: pos.left,
zIndex: 99999,
borderRadius: '16px',
width: '400px',
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(70vh, 600px)',
display: 'flex',
flexDirection: 'column',
animation: 'popupSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
overflow: 'hidden',
}}
>
{/* Header - fixed */}
<div className="flex items-center justify-between gap-2 p-4 pb-3 border-b border-[var(--border-primary)] shrink-0">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-[var(--accent)]/20 flex items-center justify-center">
<HelpCircle className="w-4 h-4 text-[var(--accent)]" />
</div>
<div>
<h3 className="text-base font-semibold text-[var(--text-primary)]">How TuxMate Works</h3>
<p className="text-xs text-[var(--text-muted)]">Quick guide &amp; tips</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-[var(--bg-hover)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-4 space-y-5" style={{ scrollbarGutter: 'stable' }}>
{/* Quick Start Steps */}
<div>
<h4 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-3">Quick Start</h4>
<div className="space-y-3">
<div className="flex gap-3">
<div className="w-5 h-5 rounded-full bg-[var(--accent)]/20 flex items-center justify-center text-[10px] font-bold text-[var(--accent)] shrink-0">1</div>
<p className="text-sm text-[var(--text-secondary)]">Select your distro from the dropdown</p>
</div>
<div className="flex gap-3">
<div className="w-5 h-5 rounded-full bg-[var(--accent)]/20 flex items-center justify-center text-[10px] font-bold text-[var(--accent)] shrink-0">2</div>
<p className="text-sm text-[var(--text-secondary)]">Check the apps you want to install</p>
</div>
<div className="flex gap-3">
<div className="w-5 h-5 rounded-full bg-[var(--accent)]/20 flex items-center justify-center text-[10px] font-bold text-[var(--accent)] shrink-0">3</div>
<p className="text-sm text-[var(--text-secondary)]">Copy the command or download the script</p>
</div>
<div className="flex gap-3">
<div className="w-5 h-5 rounded-full bg-[var(--accent)]/20 flex items-center justify-center text-[10px] font-bold text-[var(--accent)] shrink-0">4</div>
<p className="text-sm text-[var(--text-secondary)]">Paste in terminal (<code className="text-xs bg-[var(--bg-tertiary)] px-1 py-0.5 rounded">Ctrl+Shift+V</code>) and run</p>
</div>
</div>
</div>
{/* Unavailable Apps */}
<div className="pt-3 border-t border-[var(--border-primary)]">
<h4 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-3">App Not Available?</h4>
<div className="space-y-2.5 text-xs text-[var(--text-muted)] leading-relaxed">
<p>Greyed-out apps aren&apos;t in your distro&apos;s repos. Here&apos;s what you can do:</p>
<ul className="space-y-2 ml-2">
<li className="flex gap-2">
<span className="text-[var(--accent)]"></span>
<span><strong className="text-[var(--text-secondary)]">Use Flatpak/Snap:</strong> Switch to Flatpak or Snap in the distro selector for universal packages</span>
</li>
<li className="flex gap-2">
<span className="text-[var(--accent)]"></span>
<span><strong className="text-[var(--text-secondary)]">Download from website:</strong> Visit the app&apos;s official site and grab the <code className="bg-[var(--bg-tertiary)] px-1 rounded">.deb</code>, <code className="bg-[var(--bg-tertiary)] px-1 rounded">.rpm</code>, or <code className="bg-[var(--bg-tertiary)] px-1 rounded">.AppImage</code></span>
</li>
<li className="flex gap-2">
<span className="text-[var(--accent)]"></span>
<span><strong className="text-[var(--text-secondary)]">Check GitHub Releases:</strong> Many apps publish packages on their GitHub releases page</span>
</li>
<li className="flex gap-2">
<span className="text-[var(--accent)]"></span>
<span><strong className="text-[var(--text-secondary)]">Hover the icon:</strong> Some unavailable apps show links to alternative download methods</span>
</li>
</ul>
</div>
</div>
{/* Arch & AUR */}
<div className="pt-3 border-t border-[var(--border-primary)]">
<h4 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-3">Arch Linux &amp; AUR</h4>
<p className="text-xs text-[var(--text-muted)] leading-relaxed">
Some Arch packages are in the <strong className="text-[var(--text-secondary)]">AUR</strong> (Arch User Repository).
TuxMate uses <code className="bg-[var(--bg-tertiary)] px-1 rounded">yay</code> to install these.
If you don&apos;t have yay, check &quot;I have yay installed&quot; to skip auto-installation, or leave it unchecked to install yay first.
</p>
</div>
{/* Keyboard Shortcuts */}
<div className="pt-3 border-t border-[var(--border-primary)]">
<h4 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-3">Keyboard Shortcuts</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-2">
<kbd className="px-1.5 py-0.5 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded text-[10px] font-mono"></kbd>
<span className="text-[var(--text-muted)]">Navigate apps</span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-1.5 py-0.5 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded text-[10px] font-mono">Space</kbd>
<span className="text-[var(--text-muted)]">Toggle selection</span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-1.5 py-0.5 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded text-[10px] font-mono">Enter</kbd>
<span className="text-[var(--text-muted)]">Expand/collapse</span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-1.5 py-0.5 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded text-[10px] font-mono">Esc</kbd>
<span className="text-[var(--text-muted)]">Close popups</span>
</div>
</div>
</div>
{/* Pro Tips */}
<div className="pt-3 border-t border-[var(--border-primary)]">
<h4 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-3">Pro Tips</h4>
<ul className="space-y-2 text-xs text-[var(--text-muted)] leading-relaxed">
<li className="flex gap-2">
<span className="text-emerald-500">💡</span>
<span>The <strong className="text-[var(--text-secondary)]">download button</strong> gives you a full shell script with progress tracking, error handling, and a summary</span>
</li>
<li className="flex gap-2">
<span className="text-emerald-500">💡</span>
<span>Your selections are <strong className="text-[var(--text-secondary)]">saved automatically</strong> come back anytime to modify your setup</span>
</li>
<li className="flex gap-2">
<span className="text-emerald-500">💡</span>
<span>Running <code className="bg-[var(--bg-tertiary)] px-1 rounded">.deb</code> files: <code className="bg-[var(--bg-tertiary)] px-1 rounded">sudo dpkg -i file.deb</code> or double-click in your file manager</span>
</li>
<li className="flex gap-2">
<span className="text-emerald-500">💡</span>
<span>Running <code className="bg-[var(--bg-tertiary)] px-1 rounded">.rpm</code> files: <code className="bg-[var(--bg-tertiary)] px-1 rounded">sudo dnf install ./file.rpm</code> or <code className="bg-[var(--bg-tertiary)] px-1 rounded">sudo zypper install ./file.rpm</code></span>
</li>
</ul>
</div>
</div>
{/* Arrow pointer */}
<div
className="absolute w-3 h-3 bg-[var(--bg-secondary)] border-l border-t border-[var(--border-primary)] rotate-45"
style={{ top: '-7px', left: '24px' }}
/>
</div>
) : null;
return (
<>
<button
ref={triggerRef}
onClick={() => {
const wasOpen = isOpen;
setIsOpen(!isOpen);
if (!wasOpen) analytics.helpOpened();
else analytics.helpClosed();
}}
className={`flex items-center gap-1.5 text-sm transition-all duration-200 hover:scale-105 ${isOpen ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
>
<HelpCircle className="w-4 h-4" />
<span className="hidden sm:inline whitespace-nowrap">How it works?</span>
</button>
{mounted && typeof document !== 'undefined' && createPortal(popup, document.body)}
</>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Header Components
*
* Components used in the main header area:
* - HowItWorks: Help popup with quick start guide
* - GitHubLink: Link to GitHub repository
* - ContributeLink: Link to contribution guide
*/
export { HowItWorks } from './HowItWorks';
export { GitHubLink } from './GitHubLink';
export { ContributeLink } from './ContributeLink';

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from "react"
import { Moon, Sun } from "lucide-react"
import { cn } from "@/lib/utils"
import { useTheme } from "@/hooks/use-theme"
import { useTheme } from "@/hooks/useTheme"
import { analytics } from "@/lib/analytics"
interface ThemeToggleProps {

View File

@@ -0,0 +1,84 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
/**
* useDelayedTooltip - Hook for managing tooltip visibility with delay
*
* Features:
* - Configurable delay before showing tooltip
* - Hover persistence (tooltip stays visible when hovered)
* - Unique key generation for fresh animations
* - Cleanup on unmount to prevent memory leaks
*
* @param delay - Delay in milliseconds before showing tooltip (default: 600)
* @returns Object with tooltip state and control functions
*
* @example
* const { tooltip, show, hide, onTooltipEnter, onTooltipLeave } = useDelayedTooltip(600);
*
* // In component:
* <div
* onMouseEnter={(e) => show("Hello world", e)}
* onMouseLeave={hide}
* />
* <Tooltip
* tooltip={tooltip}
* onEnter={onTooltipEnter}
* onLeave={onTooltipLeave}
* />
*/
export function useDelayedTooltip(delay = 600) {
const [tooltip, setTooltip] = useState<{ text: string; x: number; y: number; width: number; key: number } | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const isHoveringTooltip = useRef(false);
const keyRef = useRef(0);
// Cleanup timer on unmount to prevent memory leaks
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
const show = useCallback((text: string, e: React.MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
// Position centered above the element
keyRef.current += 1; // Increment key for fresh animation
const newTooltip = {
text,
x: rect.left + rect.width / 2,
y: rect.top - 12,
width: rect.width,
key: keyRef.current
};
if (timerRef.current) clearTimeout(timerRef.current);
// Clear existing tooltip first to reset animation
setTooltip(null);
timerRef.current = setTimeout(() => setTooltip(newTooltip), delay);
}, [delay]);
const hide = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
// Delay hide to allow moving to tooltip
timerRef.current = setTimeout(() => {
if (!isHoveringTooltip.current) {
setTooltip(null);
}
}, 100);
}, []);
const onTooltipEnter = useCallback(() => {
isHoveringTooltip.current = true;
if (timerRef.current) clearTimeout(timerRef.current);
}, []);
const onTooltipLeave = useCallback(() => {
isHoveringTooltip.current = false;
setTooltip(null);
}, []);
return { tooltip, show, hide, onTooltipEnter, onTooltipLeave };
}

View File

@@ -0,0 +1,147 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { type Category } from '@/lib/data';
/**
* Navigation item for grid-based keyboard navigation
*/
export interface NavItem {
type: 'category' | 'app';
id: string;
category: Category;
}
/**
* Focus position in the navigation grid
*/
export interface FocusPosition {
col: number;
row: number;
}
/**
* useKeyboardNavigation - Grid-based keyboard navigation for app selection
*
* Features:
* - Arrow keys (↑↓←→) and vim keys (hjkl) for navigation
* - Space to toggle selection
* - Escape to clear focus
* - Skips input fields
*
* @param navItems - 2D array of navigation items (columns × rows)
* @param onToggleCategory - Callback when space is pressed on a category
* @param onToggleApp - Callback when space is pressed on an app
*
* @returns Object with focus state and control functions
*
* @example
* const { focusedItem, clearFocus, setFocusByItem } = useKeyboardNavigation(
* navItems,
* (id) => toggleCategoryExpanded(id),
* (id) => toggleApp(id)
* );
*/
export function useKeyboardNavigation(
navItems: NavItem[][],
onToggleCategory: (id: string) => void,
onToggleApp: (id: string) => void
) {
const [focusPos, setFocusPos] = useState<FocusPosition | null>(null);
/** Clear focus (e.g., when clicking outside) */
const clearFocus = useCallback(() => setFocusPos(null), []);
/** Get the currently focused item */
const focusedItem = useMemo(() => {
if (!focusPos) return null;
return navItems[focusPos.col]?.[focusPos.row] || null;
}, [navItems, focusPos]);
/** Set focus position by item type and id */
const setFocusByItem = useCallback((type: 'category' | 'app', id: string) => {
for (let col = 0; col < navItems.length; col++) {
const colItems = navItems[col];
for (let row = 0; row < colItems.length; row++) {
if (colItems[row].type === type && colItems[row].id === id) {
setFocusPos({ col, row });
return;
}
}
}
}, [navItems]);
/** Keyboard event handler */
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if typing in input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const key = e.key;
// Space to toggle
if (key === ' ') {
e.preventDefault();
if (focusPos) {
const item = navItems[focusPos.col]?.[focusPos.row];
if (item?.type === 'category') onToggleCategory(item.id);
else if (item?.type === 'app') onToggleApp(item.id);
}
return;
}
// Navigation keys (arrow keys + vim keys)
if (!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'j', 'k', 'h', 'l', 'Escape'].includes(key)) return;
e.preventDefault();
// Escape clears focus
if (key === 'Escape') {
setFocusPos(null);
return;
}
// Navigate
setFocusPos(prev => {
if (!prev) return { col: 0, row: 0 };
let { col, row } = prev;
const currentCol = navItems[col] || [];
// Down / j
if (key === 'ArrowDown' || key === 'j') {
row = Math.min(row + 1, currentCol.length - 1);
}
// Up / k
else if (key === 'ArrowUp' || key === 'k') {
row = Math.max(row - 1, 0);
}
// Right / l
else if (key === 'ArrowRight' || key === 'l') {
if (col < navItems.length - 1) {
col++;
row = Math.min(row, (navItems[col]?.length || 1) - 1);
}
}
// Left / h
else if (key === 'ArrowLeft' || key === 'h') {
if (col > 0) {
col--;
row = Math.min(row, (navItems[col]?.length || 1) - 1);
}
}
return { col, row };
});
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [navItems, focusPos, onToggleCategory, onToggleApp]);
return {
focusPos,
focusedItem,
clearFocus,
setFocusByItem,
};
}

View File

@@ -2,19 +2,11 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { distros, apps, type DistroId } from '@/lib/data';
import { isAurPackage } from '@/lib/aur';
// AUR package detection patterns
export const AUR_PATTERNS = ['-bin', '-git', '-appimage'];
// Re-export for backwards compatibility
export { isAurPackage, AUR_PATTERNS, KNOWN_AUR_PACKAGES } from '@/lib/aur';
// Known AUR packages that don't follow the suffix naming convention
export const KNOWN_AUR_PACKAGES = new Set([
'google-chrome', 'sublime-text-4', 'spotify', 'stremio', 'dropbox',
'slack-desktop', 'zoom', 'proton-vpn-gtk-app', 'bitwarden', 'discord'
]);
export function isAurPackage(packageName: string): boolean {
return AUR_PATTERNS.some(pattern => packageName.endsWith(pattern)) || KNOWN_AUR_PACKAGES.has(packageName);
}
export interface UseLinuxInitReturn {
selectedDistro: DistroId;
@@ -34,6 +26,8 @@ export interface UseLinuxInitReturn {
hasAurPackages: boolean;
aurPackageNames: string[];
aurAppNames: string[];
// Hydration state
isHydrated: boolean;
}
const STORAGE_KEY_DISTRO = 'linuxinit_distro';
@@ -143,7 +137,12 @@ export function useLinuxInit(): UseLinuxInitReturn {
}, []);
const toggleApp = useCallback((appId: string) => {
if (!isAppAvailable(appId)) return;
// Check availability inline to avoid stale closure
const app = apps.find(a => a.id === appId);
if (!app) return;
const pkg = app.targets[selectedDistro];
if (pkg === undefined || pkg === null) return;
setSelectedApps(prev => {
const newSet = new Set(prev);
if (newSet.has(appId)) {
@@ -153,7 +152,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
}
return newSet;
});
}, [isAppAvailable]);
}, [selectedDistro]);
const selectAll = useCallback(() => {
const allAvailable = apps
@@ -243,6 +242,8 @@ export function useLinuxInit(): UseLinuxInitReturn {
hasAurPackages: aurPackageInfo.hasAur,
aurPackageNames: aurPackageInfo.packages,
aurAppNames: aurPackageInfo.appNames,
// Hydration state
isHydrated: hydrated,
};
}

82
src/lib/aur.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* AUR Package Detection
*
* Centralized source of truth for identifying Arch User Repository packages.
* Used by both useLinuxInit (for UI indicators) and generateInstallScript (for script generation).
*/
/** Patterns that indicate an AUR package (suffixes) */
export const AUR_PATTERNS = ['-bin', '-git', '-appimage'];
/**
* Known AUR packages that don't follow the suffix naming convention.
* These are packages that exist only in AUR, not in official Arch repos.
*/
export const KNOWN_AUR_PACKAGES = new Set([
// Browsers
'google-chrome',
'zen-browser-bin',
'helium-browser-bin',
// Communication
'slack-desktop',
'zoom',
'vesktop-bin',
// Dev Editors
'sublime-text-4',
'vscodium-bin',
'cursor-bin',
// Dev Tools
'postman-bin',
'bruno-bin',
'hoppscotch-bin',
// Dev Languages
'bun-bin',
// Media
'spotify',
'stremio',
// Gaming
'heroic-games-launcher-bin',
'protonup-qt-bin',
// Office
'onlyoffice-bin',
'logseq-desktop-bin',
'joplin-appimage',
// VPN
'proton-vpn-gtk-app',
'mullvad-vpn-bin',
// File Sharing
'localsend-bin',
'dropbox',
'ab-download-manager-bin',
// Security
'bitwarden',
// Creative
'orcaslicer-bin',
// Browsers (additional)
'brave-bin',
'librewolf-bin',
]);
/**
* Check if a package name is an AUR package
* @param packageName - The Arch package name to check
* @returns true if the package is from AUR
*/
export function isAurPackage(packageName: string): boolean {
if (KNOWN_AUR_PACKAGES.has(packageName)) {
return true;
}
return AUR_PATTERNS.some(pattern => packageName.endsWith(pattern));
}

View File

@@ -266,16 +266,4 @@ export const isAppAvailable = (app: AppData, distro: DistroId): boolean => {
return distro in app.targets;
};
// Generate install command
export const generateCommand = (selectedAppIds: Set<string>, distro: DistroId): string => {
const distroData = distros.find(d => d.id === distro);
if (!distroData) return '';
const packages = Array.from(selectedAppIds)
.map(id => apps.find(a => a.id === id))
.filter(app => app && app.targets[distro])
.map(app => app!.targets[distro]);
if (packages.length === 0) return 'Select apps to generate command';
return `${distroData.installPrefix} ${packages.join(' ')}`;
};
// Note: For command generation, use useLinuxInit().generatedCommand or generateInstallScript()

View File

@@ -1,788 +1,33 @@
import { apps, distros, type DistroId, type AppData } from './data';
/**
* Generate install scripts for Linux distributions
*
* This module provides the main entry point for generating
* distribution-specific installation scripts.
*
* Each distro has its own module in ./scripts/ for easier maintenance.
*/
import { distros, type DistroId } from './data';
import {
getSelectedPackages,
generateUbuntuScript,
generateDebianScript,
generateArchScript,
generateFedoraScript,
generateOpenSUSEScript,
generateNixScript,
generateFlatpakScript,
generateSnapScript,
} from './scripts';
interface ScriptOptions {
distroId: DistroId;
selectedAppIds: Set<string>;
}
function getSelectedPackages(selectedAppIds: Set<string>, distroId: DistroId): { app: AppData; pkg: string }[] {
return Array.from(selectedAppIds)
.map(id => apps.find(a => a.id === id))
.filter((app): app is AppData => !!app && !!app.targets[distroId])
.map(app => ({ app, pkg: app.targets[distroId]! }));
}
function generateAsciiHeader(distroName: string, pkgCount: number): string {
const date = new Date().toISOString().split('T')[0];
return `#!/bin/bash
#
# ████████╗██╗ ██╗██╗ ██╗███╗ ███╗ █████╗ ████████╗███████╗
# ╚══██╔══╝██║ ██║╚██╗██╔╝████╗ ████║██╔══██╗╚══██╔══╝██╔════╝
# ██║ ██║ ██║ ╚███╔╝ ██╔████╔██║███████║ ██║ █████╗
# ██║ ██║ ██║ ██╔██╗ ██║╚██╔╝██║██╔══██║ ██║ ██╔══╝
# ██║ ╚██████╔╝██╔╝ ██╗██║ ╚═╝ ██║██║ ██║ ██║ ███████╗
# ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝
#
# Linux App Installer
# https://github.com/abusoww/tuxmate
#
# Distribution: ${distroName}
# Packages: ${pkgCount}
# Generated: ${date}
#
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
`;
}
// Shared utilities for all scripts
function generateSharedUtils(total: number): string {
return `# ─────────────────────────────────────────────────────────────────────────────
# Colors & Utilities
# ─────────────────────────────────────────────────────────────────────────────
if [ -t 1 ]; then
RED='\\033[0;31m' GREEN='\\033[0;32m' YELLOW='\\033[1;33m'
BLUE='\\033[0;34m' CYAN='\\033[0;36m' BOLD='\\033[1m' DIM='\\033[2m' NC='\\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' NC=''
fi
info() { echo -e "\${BLUE}::\${NC} $1"; }
success() { echo -e "\${GREEN}\${NC} $1"; }
warn() { echo -e "\${YELLOW}!\${NC} $1"; }
error() { echo -e "\${RED}\${NC} $1" >&2; }
skip() { echo -e "\${DIM}\${NC} $1 \${DIM}(already installed)\${NC}"; }
timing() { echo -e "\${GREEN}\${NC} $1 \${DIM}($2s)\${NC}"; }
TOTAL=${total}
CURRENT=0
FAILED=()
SUCCEEDED=()
SKIPPED=()
INSTALL_TIMES=()
START_TIME=$(date +%s)
AVG_TIME=8 # Initial estimate: 8 seconds per package
show_progress() {
local current=$1 total=$2 name=$3
local percent=$((current * 100 / total))
local filled=$((percent / 5))
local empty=$((20 - filled))
# Calculate ETA
local remaining=$((total - current))
local eta=$((remaining * AVG_TIME))
local eta_str=""
if [ $eta -ge 60 ]; then
eta_str="~$((eta / 60))m"
else
eta_str="~\${eta}s"
fi
printf "\\r\\033[K[\${CYAN}"
printf "%\${filled}s" | tr ' ' '█'
printf "\${NC}"
printf "%\${empty}s" | tr ' ' '░'
printf "] %3d%% (%d/%d) \${BOLD}%s\${NC} \${DIM}%s left\${NC}" "$percent" "$current" "$total" "$name" "$eta_str"
}
# Update average install time
update_avg_time() {
local new_time=$1
if [ \${#INSTALL_TIMES[@]} -eq 0 ]; then
AVG_TIME=$new_time
else
local sum=$new_time
for t in "\${INSTALL_TIMES[@]}"; do
sum=$((sum + t))
done
AVG_TIME=$((sum / (\${#INSTALL_TIMES[@]} + 1)))
fi
INSTALL_TIMES+=($new_time)
}
# Network retry wrapper
with_retry() {
local max_attempts=3
local attempt=1
local delay=5
local cmd="$1"
while [ $attempt -le $max_attempts ]; do
if output=$(eval "$cmd" 2>&1); then
echo "$output"
return 0
fi
# Check for network errors
if echo "$output" | grep -qiE "network|connection|timeout|unreachable|resolve"; then
if [ $attempt -lt $max_attempts ]; then
warn "Network error, retrying in \${delay}s... (attempt $attempt/$max_attempts)"
sleep $delay
delay=$((delay * 2))
attempt=$((attempt + 1))
continue
fi
fi
echo "$output"
return 1
done
return 1
}
print_summary() {
local end_time=$(date +%s)
local duration=$((end_time - START_TIME))
local mins=$((duration / 60))
local secs=$((duration % 60))
echo
echo "─────────────────────────────────────────────────────────────────────────────"
local installed=\${#SUCCEEDED[@]}
local skipped_count=\${#SKIPPED[@]}
local failed_count=\${#FAILED[@]}
if [ $failed_count -eq 0 ]; then
if [ $skipped_count -gt 0 ]; then
echo -e "\${GREEN}\${NC} Done! $installed installed, $skipped_count already installed \${DIM}(\${mins}m \${secs}s)\${NC}"
else
echo -e "\${GREEN}\${NC} All $TOTAL packages installed! \${DIM}(\${mins}m \${secs}s)\${NC}"
fi
else
echo -e "\${YELLOW}!\${NC} $installed installed, $skipped_count skipped, $failed_count failed \${DIM}(\${mins}m \${secs}s)\${NC}"
echo
echo -e "\${RED}Failed:\${NC}"
for pkg in "\${FAILED[@]}"; do
echo " • $pkg"
done
fi
echo "─────────────────────────────────────────────────────────────────────────────"
}
`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// UBUNTU SCRIPT
// ═══════════════════════════════════════════════════════════════════════════════
function generateUbuntuScript(packages: { app: AppData; pkg: string }[]): string {
return generateAsciiHeader('Ubuntu', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { dpkg -l "$1" 2>/dev/null | grep -q "^ii"; }
# Auto-fix broken dependencies
fix_deps() {
if sudo apt-get --fix-broken install -y >/dev/null 2>&1; then
success "Dependencies fixed"
return 0
fi
return 1
}
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry "sudo apt-get install -y $pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "Unable to locate"; then
echo -e " \${DIM}Package not found\${NC}"
elif echo "$output" | grep -q "unmet dependencies"; then
echo -e " \${DIM}Fixing dependencies...\${NC}"
if fix_deps; then
# Retry once after fixing deps
if sudo apt-get install -y "$pkg" >/dev/null 2>&1; then
timing "$name" "$(($(date +%s) - start))"
SUCCEEDED+=("$name")
return 0
fi
fi
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# Pre-flight
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
# Wait for apt lock
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
warn "Waiting for package manager..."
sleep 2
done
info "Updating package lists..."
with_retry "sudo apt-get update -qq" >/dev/null && success "Updated" || warn "Update failed, continuing..."
# ─────────────────────────────────────────────────────────────────────────────
# Installation
# ─────────────────────────────────────────────────────────────────────────────
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${app.name}" "${pkg}"`).join('\n')}
print_summary
`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// DEBIAN SCRIPT
// ═══════════════════════════════════════════════════════════════════════════════
function generateDebianScript(packages: { app: AppData; pkg: string }[]): string {
return generateAsciiHeader('Debian', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { dpkg -l "$1" 2>/dev/null | grep -q "^ii"; }
fix_deps() {
if sudo apt-get --fix-broken install -y >/dev/null 2>&1; then
success "Dependencies fixed"
return 0
fi
return 1
}
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry "sudo apt-get install -y $pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "Unable to locate"; then
echo -e " \${DIM}Package not found\${NC}"
elif echo "$output" | grep -q "unmet dependencies"; then
echo -e " \${DIM}Fixing dependencies...\${NC}"
if fix_deps; then
if sudo apt-get install -y "$pkg" >/dev/null 2>&1; then
timing "$name" "$(($(date +%s) - start))"
SUCCEEDED+=("$name")
return 0
fi
fi
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
warn "Waiting for package manager..."
sleep 2
done
info "Updating package lists..."
with_retry "sudo apt-get update -qq" >/dev/null && success "Updated" || warn "Update failed, continuing..."
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${app.name}" "${pkg}"`).join('\n')}
print_summary
`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// ARCH SCRIPT
// ═══════════════════════════════════════════════════════════════════════════════
function generateArchScript(packages: { app: AppData; pkg: string }[]): string {
// Known AUR packages based on data.ts (not in official Arch repos)
const knownAurPackages = new Set([
// -bin, -git, -appimage suffixes
'brave-bin', 'librewolf-bin', 'vesktop-bin', 'vscodium-bin', 'bun-bin',
'postman-bin', 'heroic-games-launcher-bin', 'protonup-qt-bin', 'onlyoffice-bin',
'logseq-desktop-bin', 'joplin-appimage', 'localsend-bin', 'zen-browser-bin',
'helium-browser-bin', 'cursor-bin', 'ab-download-manager-bin', 'mullvad-vpn-bin',
'orcaslicer-bin', 'bruno-bin', 'hoppscotch-bin',
// Known AUR packages without suffix
'google-chrome', 'sublime-text-4', 'spotify', 'stremio', 'dropbox',
'slack-desktop', 'zoom', 'proton-vpn-gtk-app', 'bitwarden', 'obsidian'
]);
const aurPackages = packages.filter(p => knownAurPackages.has(p.pkg));
const officialPackages = packages.filter(p => !knownAurPackages.has(p.pkg));
return generateAsciiHeader('Arch Linux', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { pacman -Qi "$1" &>/dev/null; }
install_pkg() {
local name=$1 cmd=$2 pkg=$3
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry "$cmd"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "target not found"; then
echo -e " \${DIM}Package not found\${NC}"
elif echo "$output" | grep -q "signature"; then
echo -e " \${DIM}GPG issue - try: sudo pacman-key --refresh-keys\${NC}"
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
while [ -f /var/lib/pacman/db.lck ]; do
warn "Waiting for pacman lock..."
sleep 2
done
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..."
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)
rm -rf "$tmp"
command -v yay &>/dev/null && success "yay installed" || warn "yay install failed"
fi
` : ''}
echo
info "Installing $TOTAL packages"
echo
${officialPackages.map(({ app, pkg }) => `install_pkg "${app.name}" "sudo pacman -S --needed --noconfirm ${pkg}" "${pkg}"`).join('\n')}
${aurPackages.length > 0 ? `
if command -v yay &>/dev/null; then
${aurPackages.map(({ app, pkg }) => ` install_pkg "${app.name}" "yay -S --needed --noconfirm ${pkg}" "${pkg}"`).join('\n')}
fi
` : ''}
print_summary
`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// FEDORA SCRIPT
// ═══════════════════════════════════════════════════════════════════════════════
function generateFedoraScript(packages: { app: AppData; pkg: string }[]): string {
const rpmFusionPkgs = ['steam', 'vlc', 'ffmpeg', 'obs-studio'];
const needsRpmFusion = packages.some(p => rpmFusionPkgs.includes(p.pkg));
return generateAsciiHeader('Fedora', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { rpm -q "$1" &>/dev/null; }
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry "sudo dnf install -y $pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "No match"; then
echo -e " \${DIM}Package not found\${NC}"
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
command -v dnf &>/dev/null || { error "dnf not found"; exit 1; }
${needsRpmFusion ? `
if ! dnf repolist 2>/dev/null | grep -q rpmfusion; then
info "Enabling RPM Fusion..."
sudo dnf install -y \\
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \\
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \\
>/dev/null 2>&1 && success "RPM Fusion enabled"
fi
` : ''}
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${app.name}" "${pkg}"`).join('\n')}
print_summary
`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// OPENSUSE SCRIPT
// ═══════════════════════════════════════════════════════════════════════════════
function generateOpenSUSEScript(packages: { app: AppData; pkg: string }[]): string {
return generateAsciiHeader('openSUSE', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { rpm -q "$1" &>/dev/null; }
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry "sudo zypper --non-interactive install --auto-agree-with-licenses $pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
command -v zypper &>/dev/null || { error "zypper not found"; exit 1; }
while [ -f /var/run/zypp.pid ]; do
warn "Waiting for zypper..."
sleep 2
done
info "Refreshing repos..."
with_retry "sudo zypper --non-interactive refresh" >/dev/null && success "Refreshed" || warn "Refresh failed"
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${app.name}" "${pkg}"`).join('\n')}
print_summary
`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// NIX SCRIPT
// ═══════════════════════════════════════════════════════════════════════════════
function generateNixScript(packages: { app: AppData; pkg: string }[]): string {
return generateAsciiHeader('Nix', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { nix-env -q 2>/dev/null | grep -q "$1"; }
install_pkg() {
local name=$1 attr=$2
CURRENT=$((CURRENT + 1))
if is_installed "$attr"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry "nix-env -iA nixpkgs.$attr"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "attribute.*not found"; then
echo -e " \${DIM}Attribute not found\${NC}"
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
command -v nix-env &>/dev/null || { error "nix-env not found"; exit 1; }
info "Updating channels..."
with_retry "nix-channel --update" >/dev/null && success "Updated" || warn "Update failed"
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${app.name}" "${pkg}"`).join('\n')}
print_summary
echo
info "Restart your shell for new commands."
`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// FLATPAK SCRIPT - WITH PARALLEL INSTALL
// ═══════════════════════════════════════════════════════════════════════════════
function generateFlatpakScript(packages: { app: AppData; pkg: string }[]): string {
const parallel = packages.length >= 3;
return generateAsciiHeader('Flatpak', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { flatpak list --app 2>/dev/null | grep -q "$1"; }
${parallel ? `
# Parallel install for Flatpak
install_parallel() {
local pids=()
local names=()
local start=$(date +%s)
for pair in "$@"; do
local name="\${pair%%|*}"
local appid="\${pair##*|}"
if is_installed "$appid"; then
skip "$name"
SKIPPED+=("$name")
continue
fi
(flatpak install flathub -y "$appid" >/dev/null 2>&1) &
pids+=($!)
names+=("$name")
done
local total=\${#pids[@]}
local done_count=0
if [ $total -eq 0 ]; then
return
fi
info "Installing $total apps in parallel..."
for i in "\${!pids[@]}"; do
wait \${pids[$i]}
local status=$?
done_count=$((done_count + 1))
if [ $status -eq 0 ]; then
SUCCEEDED+=("\${names[$i]}")
success "\${names[$i]}"
else
FAILED+=("\${names[$i]}")
error "\${names[$i]} failed"
fi
done
local elapsed=$(($(date +%s) - start))
echo -e "\${DIM}Parallel install took \${elapsed}s\${NC}"
}
` : `
install_pkg() {
local name=$1 appid=$2
CURRENT=$((CURRENT + 1))
if is_installed "$appid"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
if with_retry "flatpak install flathub -y $appid" >/dev/null; then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
FAILED+=("$name")
fi
}
`}
# ─────────────────────────────────────────────────────────────────────────────
command -v flatpak &>/dev/null || {
error "Flatpak not installed"
info "Install: sudo apt/dnf/pacman install flatpak"
exit 1
}
if ! flatpak remotes 2>/dev/null | grep -q flathub; then
info "Adding Flathub..."
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
success "Flathub added"
fi
echo
info "Installing $TOTAL packages"
echo
${parallel
? `install_parallel ${packages.map(({ app, pkg }) => `"${app.name}|${pkg}"`).join(' ')}`
: packages.map(({ app, pkg }) => `install_pkg "${app.name}" "${pkg}"`).join('\n')
}
print_summary
echo
info "Restart session for apps in menu."
`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// SNAP SCRIPT
// ═══════════════════════════════════════════════════════════════════════════════
function generateSnapScript(packages: { app: AppData; pkg: string }[]): string {
return generateAsciiHeader('Snap', packages.length) + generateSharedUtils(packages.length) + `
is_installed() {
local snap_name=$(echo "$1" | awk '{print $1}')
snap list 2>/dev/null | grep -q "^$snap_name "
}
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry "sudo snap install $pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "not found"; then
echo -e " \${DIM}Snap not found\${NC}"
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
command -v snap &>/dev/null || {
error "Snap not installed"
info "Install: sudo apt/dnf/pacman install snapd"
exit 1
}
if command -v systemctl &>/dev/null && ! systemctl is-active --quiet snapd; then
info "Starting snapd..."
sudo systemctl enable --now snapd.socket
sudo systemctl start snapd
sleep 2
success "snapd started"
fi
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${app.name}" "${pkg}"`).join('\n')}
print_summary
`;
}
// ═══════════════════════════════════════════════════════════════════════════════
// MAIN EXPORT
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Generate a full installation script with progress bars, error handling, and retries
*/
export function generateInstallScript(options: ScriptOptions): string {
const { distroId, selectedAppIds } = options;
const distro = distros.find(d => d.id === distroId);
@@ -805,6 +50,9 @@ export function generateInstallScript(options: ScriptOptions): string {
}
}
/**
* Generate a simple one-liner command for quick copy-paste
*/
export function generateSimpleCommand(selectedAppIds: Set<string>, distroId: DistroId): string {
const packages = getSelectedPackages(selectedAppIds, distroId);
if (packages.length === 0) return '# No packages selected';

112
src/lib/scripts/arch.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Arch Linux script generator with AUR support
*/
import { generateAsciiHeader, generateSharedUtils, escapeShellString, type PackageInfo } from './shared';
import { isAurPackage } from '../aur';
export function generateArchScript(packages: PackageInfo[]): string {
const aurPackages = packages.filter(p => isAurPackage(p.pkg));
const officialPackages = packages.filter(p => !isAurPackage(p.pkg));
return generateAsciiHeader('Arch Linux', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { pacman -Qi "$1" &>/dev/null; }
install_pacman() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry sudo pacman -S --needed --noconfirm "$pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "target not found"; then
echo -e " \${DIM}Package not found\${NC}"
elif echo "$output" | grep -q "signature"; then
echo -e " \${DIM}GPG issue - try: sudo pacman-key --refresh-keys\${NC}"
fi
FAILED+=("$name")
fi
}
install_aur() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry yay -S --needed --noconfirm "$pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "target not found"; then
echo -e " \${DIM}Package not found in AUR\${NC}"
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
while [ -f /var/lib/pacman/db.lck ]; do
warn "Waiting for pacman lock..."
sleep 2
done
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..."
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)
rm -rf "$tmp"
command -v yay &>/dev/null && success "yay installed" || warn "yay install failed"
fi
` : ''}
echo
info "Installing $TOTAL packages"
echo
${officialPackages.map(({ app, pkg }) => `install_pacman "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
${aurPackages.length > 0 ? `
if command -v yay &>/dev/null; then
${aurPackages.map(({ app, pkg }) => ` install_aur "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
fi
` : ''}
print_summary
`;
}

77
src/lib/scripts/debian.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* Debian script generator
*/
import { generateAsciiHeader, generateSharedUtils, escapeShellString, type PackageInfo } from './shared';
export function generateDebianScript(packages: PackageInfo[]): string {
return generateAsciiHeader('Debian', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { dpkg -l "$1" 2>/dev/null | grep -q "^ii"; }
fix_deps() {
if sudo apt-get --fix-broken install -y >/dev/null 2>&1; then
success "Dependencies fixed"
return 0
fi
return 1
}
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry sudo apt-get install -y "$pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "Unable to locate"; then
echo -e " \${DIM}Package not found\${NC}"
elif echo "$output" | grep -q "unmet dependencies"; then
echo -e " \${DIM}Fixing dependencies...\${NC}"
if fix_deps; then
if sudo apt-get install -y "$pkg" >/dev/null 2>&1; then
timing "$name" "$(($(date +%s) - start))"
SUCCEEDED+=("$name")
return 0
fi
fi
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
warn "Waiting for package manager..."
sleep 2
done
info "Updating package lists..."
with_retry sudo apt-get update -qq >/dev/null && success "Updated" || warn "Update failed, continuing..."
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
print_summary
`;
}

66
src/lib/scripts/fedora.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Fedora script generator with RPM Fusion support
*/
import { generateAsciiHeader, generateSharedUtils, escapeShellString, type PackageInfo } from './shared';
export function generateFedoraScript(packages: PackageInfo[]): string {
const rpmFusionPkgs = ['steam', 'vlc', 'ffmpeg', 'obs-studio'];
const needsRpmFusion = packages.some(p => rpmFusionPkgs.includes(p.pkg));
return generateAsciiHeader('Fedora', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { rpm -q "$1" &>/dev/null; }
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry sudo dnf install -y "$pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "No match"; then
echo -e " \${DIM}Package not found\${NC}"
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
command -v dnf &>/dev/null || { error "dnf not found"; exit 1; }
${needsRpmFusion ? `
if ! dnf repolist 2>/dev/null | grep -q rpmfusion; then
info "Enabling RPM Fusion..."
sudo dnf install -y \\
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \\
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \\
>/dev/null 2>&1 && success "RPM Fusion enabled"
fi
` : ''}
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
print_summary
`;
}

115
src/lib/scripts/flatpak.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Flatpak script generator with parallel install
*/
import { generateAsciiHeader, generateSharedUtils, escapeShellString, type PackageInfo } from './shared';
export function generateFlatpakScript(packages: PackageInfo[]): string {
const parallel = packages.length >= 3;
return generateAsciiHeader('Flatpak', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { flatpak list --app 2>/dev/null | grep -q "$1"; }
${parallel ? `
# Parallel install for Flatpak
install_parallel() {
local pids=()
local names=()
local start=$(date +%s)
for pair in "$@"; do
local name="\${pair%%|*}"
local appid="\${pair##*|}"
if is_installed "$appid"; then
skip "$name"
SKIPPED+=("$name")
continue
fi
(flatpak install flathub -y "$appid" >/dev/null 2>&1) &
pids+=($!)
names+=("$name")
done
local total=\${#pids[@]}
local done_count=0
if [ $total -eq 0 ]; then
return
fi
info "Installing $total apps in parallel..."
for i in "\${!pids[@]}"; do
wait \${pids[$i]}
local status=$?
done_count=$((done_count + 1))
if [ $status -eq 0 ]; then
SUCCEEDED+=("\${names[$i]}")
success "\${names[$i]}"
else
FAILED+=("\${names[$i]}")
error "\${names[$i]} failed"
fi
done
local elapsed=$(($(date +%s) - start))
echo -e "\${DIM}Parallel install took \${elapsed}s\${NC}"
}
` : `
install_pkg() {
local name=$1 appid=$2
CURRENT=$((CURRENT + 1))
if is_installed "$appid"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
if with_retry flatpak install flathub -y "$appid" >/dev/null; then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
FAILED+=("$name")
fi
}
`}
# ─────────────────────────────────────────────────────────────────────────────
command -v flatpak &>/dev/null || {
error "Flatpak not installed"
info "Install: sudo apt/dnf/pacman install flatpak"
exit 1
}
if ! flatpak remotes 2>/dev/null | grep -q flathub; then
info "Adding Flathub..."
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
success "Flathub added"
fi
echo
info "Installing $TOTAL packages"
echo
${parallel
? `install_parallel ${packages.map(({ app, pkg }) => `"${escapeShellString(app.name)}|${pkg}"`).join(' ')}`
: packages.map(({ app, pkg }) => `install_pkg "${escapeShellString(app.name)}" "${pkg}"`).join('\n')
}
print_summary
echo
info "Restart session for apps in menu."
`;
}

13
src/lib/scripts/index.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Script generator index - re-exports all distro generators
*/
export { escapeShellString, getSelectedPackages, type PackageInfo } from './shared';
export { generateUbuntuScript } from './ubuntu';
export { generateDebianScript } from './debian';
export { generateArchScript } from './arch';
export { generateFedoraScript } from './fedora';
export { generateOpenSUSEScript } from './opensuse';
export { generateNixScript } from './nix';
export { generateFlatpakScript } from './flatpak';
export { generateSnapScript } from './snap';

57
src/lib/scripts/nix.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Nix script generator
*/
import { generateAsciiHeader, generateSharedUtils, escapeShellString, type PackageInfo } from './shared';
export function generateNixScript(packages: PackageInfo[]): string {
return generateAsciiHeader('Nix', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { nix-env -q 2>/dev/null | grep -q "$1"; }
install_pkg() {
local name=$1 attr=$2
CURRENT=$((CURRENT + 1))
if is_installed "$attr"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry nix-env -iA "nixpkgs.$attr"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "attribute.*not found"; then
echo -e " \${DIM}Attribute not found\${NC}"
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
command -v nix-env &>/dev/null || { error "nix-env not found"; exit 1; }
info "Updating channels..."
with_retry nix-channel --update >/dev/null && success "Updated" || warn "Update failed"
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
print_summary
echo
info "Restart your shell for new commands."
`;
}

View File

@@ -0,0 +1,58 @@
/**
* openSUSE script generator
*/
import { generateAsciiHeader, generateSharedUtils, escapeShellString, type PackageInfo } from './shared';
export function generateOpenSUSEScript(packages: PackageInfo[]): string {
return generateAsciiHeader('openSUSE', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { rpm -q "$1" &>/dev/null; }
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry sudo zypper --non-interactive install --auto-agree-with-licenses "$pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
command -v zypper &>/dev/null || { error "zypper not found"; exit 1; }
while [ -f /var/run/zypp.pid ]; do
warn "Waiting for zypper..."
sleep 2
done
info "Refreshing repos..."
with_retry sudo zypper --non-interactive refresh >/dev/null && success "Refreshed" || warn "Refresh failed"
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
print_summary
`;
}

190
src/lib/scripts/shared.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* Shared utilities and types for script generation
*/
import { apps, type DistroId, type AppData } from '../data';
export interface PackageInfo {
app: AppData;
pkg: string;
}
/**
* Escape special characters for safe use in shell scripts
* Prevents injection attacks from malicious app/package names
*/
export function escapeShellString(str: string): string {
return str
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/"/g, '\\"') // Escape double quotes
.replace(/\$/g, '\\$') // Escape dollar signs
.replace(/`/g, '\\`') // Escape backticks
.replace(/!/g, '\\!'); // Escape history expansion
}
export function getSelectedPackages(selectedAppIds: Set<string>, distroId: DistroId): PackageInfo[] {
return Array.from(selectedAppIds)
.map(id => apps.find(a => a.id === id))
.filter((app): app is AppData => !!app && !!app.targets[distroId])
.map(app => ({ app, pkg: app.targets[distroId]! }));
}
export function generateAsciiHeader(distroName: string, pkgCount: number): string {
const date = new Date().toISOString().split('T')[0];
return `#!/bin/bash
#
# ████████╗██╗ ██╗██╗ ██╗███╗ ███╗ █████╗ ████████╗███████╗
# ╚══██╔══╝██║ ██║╚██╗██╔╝████╗ ████║██╔══██╗╚══██╔══╝██╔════╝
# ██║ ██║ ██║ ╚███╔╝ ██╔████╔██║███████║ ██║ █████╗
# ██║ ██║ ██║ ██╔██╗ ██║╚██╔╝██║██╔══██║ ██║ ██╔══╝
# ██║ ╚██████╔╝██╔╝ ██╗██║ ╚═╝ ██║██║ ██║ ██║ ███████╗
# ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝
#
# Linux App Installer
# https://github.com/abusoww/tuxmate
#
# Distribution: ${distroName}
# Packages: ${pkgCount}
# Generated: ${date}
#
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
`;
}
export function generateSharedUtils(total: number): string {
return `# ─────────────────────────────────────────────────────────────────────────────
# Colors & Utilities
# ─────────────────────────────────────────────────────────────────────────────
if [ -t 1 ]; then
RED='\\033[0;31m' GREEN='\\033[0;32m' YELLOW='\\033[1;33m'
BLUE='\\033[0;34m' CYAN='\\033[0;36m' BOLD='\\033[1m' DIM='\\033[2m' NC='\\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' NC=''
fi
info() { echo -e "\${BLUE}::\${NC} $1"; }
success() { echo -e "\${GREEN}\${NC} $1"; }
warn() { echo -e "\${YELLOW}!\${NC} $1"; }
error() { echo -e "\${RED}\${NC} $1" >&2; }
skip() { echo -e "\${DIM}\${NC} $1 \${DIM}(already installed)\${NC}"; }
timing() { echo -e "\${GREEN}\${NC} $1 \${DIM}($2s)\${NC}"; }
# Graceful exit on Ctrl+C
trap 'printf "\\n"; warn "Installation cancelled by user"; print_summary; exit 130' INT
TOTAL=${total}
CURRENT=0
FAILED=()
SUCCEEDED=()
SKIPPED=()
INSTALL_TIMES=()
START_TIME=$(date +%s)
AVG_TIME=8 # Initial estimate: 8 seconds per package
show_progress() {
local current=$1 total=$2 name=$3
local percent=$((current * 100 / total))
local filled=$((percent / 5))
local empty=$((20 - filled))
# Calculate ETA
local remaining=$((total - current))
local eta=$((remaining * AVG_TIME))
local eta_str=""
if [ $eta -ge 60 ]; then
eta_str="~$((eta / 60))m"
else
eta_str="~\${eta}s"
fi
printf "\\r\\033[K[\${CYAN}"
printf "%\${filled}s" | tr ' ' '█'
printf "\${NC}"
printf "%\${empty}s" | tr ' ' '░'
printf "] %3d%% (%d/%d) \${BOLD}%s\${NC} \${DIM}%s left\${NC}" "$percent" "$current" "$total" "$name" "$eta_str"
}
# Update average install time
update_avg_time() {
local new_time=$1
if [ \${#INSTALL_TIMES[@]} -eq 0 ]; then
AVG_TIME=$new_time
else
local sum=$new_time
for t in "\${INSTALL_TIMES[@]}"; do
sum=$((sum + t))
done
AVG_TIME=$((sum / (\${#INSTALL_TIMES[@]} + 1)))
fi
INSTALL_TIMES+=($new_time)
}
# Safe command executor (no eval)
run_cmd() {
"$@" 2>&1
}
# Network retry wrapper - uses run_cmd for safety
with_retry() {
local max_attempts=3
local attempt=1
local delay=5
while [ $attempt -le $max_attempts ]; do
if output=$(run_cmd "$@"); then
echo "$output"
return 0
fi
# Check for network errors
if echo "$output" | grep -qiE "network|connection|timeout|unreachable|resolve"; then
if [ $attempt -lt $max_attempts ]; then
warn "Network error, retrying in \${delay}s... (attempt $attempt/$max_attempts)"
sleep $delay
delay=$((delay * 2))
attempt=$((attempt + 1))
continue
fi
fi
echo "$output"
return 1
done
return 1
}
print_summary() {
local end_time=$(date +%s)
local duration=$((end_time - START_TIME))
local mins=$((duration / 60))
local secs=$((duration % 60))
echo
echo "─────────────────────────────────────────────────────────────────────────────"
local installed=\${#SUCCEEDED[@]}
local skipped_count=\${#SKIPPED[@]}
local failed_count=\${#FAILED[@]}
if [ $failed_count -eq 0 ]; then
if [ $skipped_count -gt 0 ]; then
echo -e "\${GREEN}\${NC} Done! $installed installed, $skipped_count already installed \${DIM}(\${mins}m \${secs}s)\${NC}"
else
echo -e "\${GREEN}\${NC} All $TOTAL packages installed! \${DIM}(\${mins}m \${secs}s)\${NC}"
fi
else
echo -e "\${YELLOW}!\${NC} $installed installed, $skipped_count skipped, $failed_count failed \${DIM}(\${mins}m \${secs}s)\${NC}"
echo
echo -e "\${RED}Failed:\${NC}"
for pkg in "\${FAILED[@]}"; do
echo " • $pkg"
done
fi
echo "─────────────────────────────────────────────────────────────────────────────"
}
`;
}

67
src/lib/scripts/snap.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Snap script generator
*/
import { generateAsciiHeader, generateSharedUtils, escapeShellString, type PackageInfo } from './shared';
export function generateSnapScript(packages: PackageInfo[]): string {
return generateAsciiHeader('Snap', packages.length) + generateSharedUtils(packages.length) + `
is_installed() {
local snap_name=$(echo "$1" | awk '{print $1}')
snap list 2>/dev/null | grep -q "^$snap_name "
}
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry sudo snap install $pkg); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "not found"; then
echo -e " \${DIM}Snap not found\${NC}"
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
command -v snap &>/dev/null || {
error "Snap not installed"
info "Install: sudo apt/dnf/pacman install snapd"
exit 1
}
if command -v systemctl &>/dev/null && ! systemctl is-active --quiet snapd; then
info "Starting snapd..."
sudo systemctl enable --now snapd.socket
sudo systemctl start snapd
sleep 2
success "snapd started"
fi
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
print_summary
`;
}

86
src/lib/scripts/ubuntu.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* Ubuntu script generator
*/
import { generateAsciiHeader, generateSharedUtils, escapeShellString, type PackageInfo } from './shared';
export function generateUbuntuScript(packages: PackageInfo[]): string {
return generateAsciiHeader('Ubuntu', packages.length) + generateSharedUtils(packages.length) + `
is_installed() { dpkg -l "$1" 2>/dev/null | grep -q "^ii"; }
# Auto-fix broken dependencies
fix_deps() {
if sudo apt-get --fix-broken install -y >/dev/null 2>&1; then
success "Dependencies fixed"
return 0
fi
return 1
}
install_pkg() {
local name=$1 pkg=$2
CURRENT=$((CURRENT + 1))
if is_installed "$pkg"; then
skip "$name"
SKIPPED+=("$name")
return 0
fi
show_progress $CURRENT $TOTAL "$name"
local start=$(date +%s)
local output
if output=$(with_retry sudo apt-get install -y "$pkg"); then
local elapsed=$(($(date +%s) - start))
update_avg_time $elapsed
printf "\\r\\033[K"
timing "$name" "$elapsed"
SUCCEEDED+=("$name")
else
printf "\\r\\033[K\${RED}\${NC} %s\\n" "$name"
if echo "$output" | grep -q "Unable to locate"; then
echo -e " \${DIM}Package not found\${NC}"
elif echo "$output" | grep -q "unmet dependencies"; then
echo -e " \${DIM}Fixing dependencies...\${NC}"
if fix_deps; then
# Retry once after fixing deps
if sudo apt-get install -y "$pkg" >/dev/null 2>&1; then
timing "$name" "$(($(date +%s) - start))"
SUCCEEDED+=("$name")
return 0
fi
fi
fi
FAILED+=("$name")
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# Pre-flight
# ─────────────────────────────────────────────────────────────────────────────
[ "$EUID" -eq 0 ] && { error "Run as regular user, not root."; exit 1; }
# Wait for apt lock
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
warn "Waiting for package manager..."
sleep 2
done
info "Updating package lists..."
with_retry sudo apt-get update -qq >/dev/null && success "Updated" || warn "Update failed, continuing..."
# ─────────────────────────────────────────────────────────────────────────────
# Installation
# ─────────────────────────────────────────────────────────────────────────────
echo
info "Installing $TOTAL packages"
echo
${packages.map(({ app, pkg }) => `install_pkg "${escapeShellString(app.name)}" "${pkg}"`).join('\n')}
print_summary
`;
}

18
vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/__tests__/setup.ts'],
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});