mirror of
https://github.com/abusoww/tuxmate.git
synced 2026-04-17 15:53:24 +02:00
feat: security fixes, loading skeleton, script refactor
This commit is contained in:
2257
package-lock.json
generated
2257
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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
55
src/__tests__/aur.test.ts
Normal 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
107
src/__tests__/data.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
src/__tests__/escapeShell.test.ts
Normal file
50
src/__tests__/escapeShell.test.ts
Normal 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
14
src/__tests__/setup.ts
Normal 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
50
src/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`}
|
||||
|
||||
1379
src/app/page.tsx
1379
src/app/page.tsx
File diff suppressed because it is too large
Load Diff
40
src/components/app/AppIcon.tsx
Normal file
40
src/components/app/AppIcon.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
142
src/components/app/AppItem.tsx
Normal file
142
src/components/app/AppItem.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
68
src/components/app/CategoryHeader.tsx
Normal file
68
src/components/app/CategoryHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
src/components/app/CategorySection.tsx
Normal file
161
src/components/app/CategorySection.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
14
src/components/app/index.ts
Normal file
14
src/components/app/index.ts
Normal 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';
|
||||
87
src/components/command/AurBar.tsx
Normal file
87
src/components/command/AurBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
319
src/components/command/CommandFooter.tsx
Normal file
319
src/components/command/CommandFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/components/command/index.ts
Normal file
10
src/components/command/index.ts
Normal 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';
|
||||
133
src/components/common/GlobalStyles.tsx
Normal file
133
src/components/common/GlobalStyles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/common/LoadingSkeleton.tsx
Normal file
81
src/components/common/LoadingSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/components/common/Tooltip.tsx
Normal file
114
src/components/common/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/common/index.ts
Normal file
12
src/components/common/index.ts
Normal 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';
|
||||
45
src/components/distro/DistroIcon.tsx
Normal file
45
src/components/distro/DistroIcon.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
149
src/components/distro/DistroSelector.tsx
Normal file
149
src/components/distro/DistroSelector.tsx
Normal 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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
src/components/distro/index.ts
Normal file
10
src/components/distro/index.ts
Normal 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';
|
||||
36
src/components/header/ContributeLink.tsx
Normal file
36
src/components/header/ContributeLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/header/GitHubLink.tsx
Normal file
37
src/components/header/GitHubLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
src/components/header/HowItWorks.tsx
Normal file
239
src/components/header/HowItWorks.tsx
Normal 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 & 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't in your distro's repos. Here'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'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 & 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't have yay, check "I have yay installed" 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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/components/header/index.ts
Normal file
12
src/components/header/index.ts
Normal 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';
|
||||
@@ -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 {
|
||||
|
||||
84
src/hooks/useDelayedTooltip.ts
Normal file
84
src/hooks/useDelayedTooltip.ts
Normal 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 };
|
||||
}
|
||||
147
src/hooks/useKeyboardNavigation.ts
Normal file
147
src/hooks/useKeyboardNavigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
82
src/lib/aur.ts
Normal 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));
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
112
src/lib/scripts/arch.ts
Normal 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
77
src/lib/scripts/debian.ts
Normal 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
66
src/lib/scripts/fedora.ts
Normal 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
115
src/lib/scripts/flatpak.ts
Normal 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
13
src/lib/scripts/index.ts
Normal 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
57
src/lib/scripts/nix.ts
Normal 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."
|
||||
`;
|
||||
}
|
||||
58
src/lib/scripts/opensuse.ts
Normal file
58
src/lib/scripts/opensuse.ts
Normal 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
190
src/lib/scripts/shared.ts
Normal 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
67
src/lib/scripts/snap.ts
Normal 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
86
src/lib/scripts/ubuntu.ts
Normal 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
18
vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user