diff --git a/next.config.ts b/next.config.ts index e9ffa30..cb651cd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,5 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = { - /* config options here */ -}; +const nextConfig: NextConfig = {}; export default nextConfig; diff --git a/src/app/api/script-explain/route.ts b/src/app/api/script-explain/route.ts new file mode 100644 index 0000000..8273308 --- /dev/null +++ b/src/app/api/script-explain/route.ts @@ -0,0 +1,173 @@ +import { NextRequest } from 'next/server'; +import Groq from 'groq-sdk'; +import { checkRateLimit, isValidGroqApiKey } from '@/lib/security'; + +let groq: Groq | null = null; + +// Rate limit: 10 requests per minute per IP +const RATE_LIMIT_MAX = 10; +const RATE_LIMIT_WINDOW_MS = 60 * 1000; + +function getGroqClient() { + if (!groq) { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) { + throw new Error('GROQ_API_KEY is not defined'); + } + // SECURITY: Validate API key format to catch misconfigurations early + if (!isValidGroqApiKey(apiKey)) { + console.error('[Security] Invalid GROQ_API_KEY format detected'); + throw new Error('Invalid GROQ_API_KEY format'); + } + groq = new Groq({ apiKey }); + } + return groq; +} + +function getClientIP(request: NextRequest): string { + // Get IP from various headers, fallback to 'unknown' + const forwarded = request.headers.get('x-forwarded-for'); + const realIP = request.headers.get('x-real-ip'); + return forwarded?.split(',')[0]?.trim() || realIP || 'unknown'; +} + +export async function POST(request: NextRequest) { + try { + // SECURITY: Rate limiting check + const clientIP = getClientIP(request); + const rateLimit = checkRateLimit(`script-explain:${clientIP}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS); + + if (!rateLimit.allowed) { + return new Response( + JSON.stringify({ + error: 'Rate limit exceeded. Please try again later.', + retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) + }), + { + status: 429, + headers: { + 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), + } + } + ); + } + + const { script, packages, os, shell } = await request.json(); + + if (!script || typeof script !== 'string') { + return new Response(JSON.stringify({ error: 'Invalid script' }), { status: 400 }); + } + + if (!packages || !Array.isArray(packages)) { + return new Response(JSON.stringify({ error: 'Invalid packages' }), { status: 400 }); + } + + // Build the AI prompt + const packageList = packages.map((p: { name: string; description: string; category: string; version: string }) => + `- ${p.name} (${p.category}): ${p.description}${p.version ? ` [v${p.version}]` : ''}` + ).join('\n'); + + const systemPrompt = { + role: 'system' as const, + content: `You are "Root", an expert DevOps assistant for the SudoStart application. + +Your task is to explain installation scripts in plain, friendly English. Help users understand what will be installed and why. + +Guidelines: +- Be concise but informative (2-4 paragraphs) +- Use markdown formatting for readability +- Highlight the purpose of the setup +- Mention any notable tools or configurations +- Keep a helpful, professional tone +- Focus on what the user will get after running the script`, + }; + + const userPrompt = { + role: 'user' as const, + content: `Explain this installation script in plain English: + +**Target System:** +- OS: ${os} +- Shell: ${shell} + +**Tools to Install (${packages.length} total):** +${packageList} + +**Script Preview (first 50 lines):** +\`\`\`bash +${script.split('\n').slice(0, 50).join('\n')} +\`\`\` + +Provide a brief, helpful summary of what this script does and what the user will have after running it.`, + }; + + const client = getGroqClient(); + const stream = await client.chat.completions.create({ + messages: [systemPrompt, userPrompt], + model: 'llama-3.3-70b-versatile', + temperature: 0.7, + max_tokens: 1024, + stream: true, + }); + + // Stream the response as Server-Sent Events + const encoder = new TextEncoder(); + const readable = new ReadableStream({ + async start(controller) { + try { + let fullContent = ''; + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta?.content ?? ''; + if (delta) { + fullContent += delta; + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ delta, done: false })} + +`) + ); + } + } + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ delta: '', done: true, full: fullContent })} + +`) + ); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); + + return new Response(readable, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), + }, + }); + } catch (error) { + // SECURITY: Never leak the API key in error messages + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const safeErrorMessage = errorMessage.includes('gsk_') + ? 'Internal server error' + : errorMessage; + + // Log with masked key if present + if (errorMessage.includes('gsk_')) { + console.error('Script explain API error: [REDACTED - API key detected in error]'); + } else { + console.error('Script explain API error:', error); + } + + return new Response( + JSON.stringify({ error: safeErrorMessage }), + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index ef00625..f4748cf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -195,17 +195,17 @@ } [data-theme="dark"] .terminal-text { - text-shadow: 0 0 5px var(--terminal-green); + text-shadow: 0 0 3px color-mix(in oklch, var(--terminal-green) 50%, transparent); } .terminal-glow { - box-shadow: 0 0 10px color-mix(in oklch, var(--primary) 40%, transparent), - 0 0 20px color-mix(in oklch, var(--primary) 20%, transparent); + box-shadow: 0 0 8px color-mix(in oklch, var(--primary) 25%, transparent), + 0 0 16px color-mix(in oklch, var(--primary) 12%, transparent); } [data-theme="light"] .terminal-glow { box-shadow: 0 0 0 1px var(--primary), - 0 2px 8px color-mix(in oklch, var(--primary) 25%, transparent); + 0 2px 6px color-mix(in oklch, var(--primary) 15%, transparent); } .cursor-blink { @@ -239,14 +239,14 @@ .terminal-card { background: var(--terminal-bg); border: 1px solid var(--border); - box-shadow: 0 0 20px color-mix(in oklch, var(--primary) 12%, transparent), - inset 0 0 20px color-mix(in oklch, var(--primary) 4%, transparent); + box-shadow: 0 0 12px color-mix(in oklch, var(--primary) 8%, transparent), + inset 0 0 12px color-mix(in oklch, var(--primary) 3%, transparent); } [data-theme="light"] .terminal-card { background: var(--card); border: 1px solid var(--border); - box-shadow: 0 1px 4px color-mix(in oklch, var(--primary) 10%, transparent); + box-shadow: 0 1px 3px color-mix(in oklch, var(--primary) 8%, transparent); } /* Theme transition for all interactive elements */ @@ -293,6 +293,104 @@ margin-bottom: 0; } +/* ── Focus Visible Styles ─────────────────────────────── */ +@layer base { + /* Default focus-visible styles for all interactive elements */ + button:focus-visible, + a:focus-visible, + input:focus-visible, + select:focus-visible, + textarea:focus-visible, + [tabindex]:not([tabindex="-1"]):focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + ring: 2px; + ring-color: var(--ring); + } + + /* Remove default focus styles and use focus-visible instead */ + *:focus { + outline: none; + } + + /* Ensure focus is visible on keyboard navigation */ + *:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } +} + +/* Skip link for accessibility */ +.skip-link { + position: absolute; + top: -100%; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + padding: 0.75rem 1.5rem; + background: var(--primary); + color: var(--primary-foreground); + border-radius: 0.375rem; + font-weight: 500; + transition: top 0.2s ease; +} + +.skip-link:focus { + top: 1rem; + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +/* High contrast focus indicators */ +.focus-indicator { + position: relative; +} + +.focus-indicator::after { + content: ''; + position: absolute; + inset: -2px; + border: 2px solid transparent; + border-radius: inherit; + pointer-events: none; + transition: border-color 0.2s ease; +} + +.focus-indicator:focus-visible::after { + border-color: var(--ring); +} + +/* Keyboard-only focus styles */ +.keyboard-focus:focus { + outline: none; +} + +.keyboard-focus:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + box-shadow: 0 0 0 4px color-mix(in oklch, var(--ring) 20%, transparent); +} + +/* Focus trap indicator for modals */ +.focus-trap-active { + position: relative; +} + +.focus-trap-active::before { + content: ''; + position: absolute; + inset: 0; + border: 2px solid var(--ring); + border-radius: inherit; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +.focus-trap-active:focus-within::before { + opacity: 1; +} + /* ── Version Note Styles ─────────────────────────────── */ .version-note-input { background: var(--note-bg); @@ -343,4 +441,46 @@ .delay-300 { animation-delay: 300ms; +} + +/* ── Empty State Animations ─────────────────────────────── */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes bounce-slow { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-5px); + } +} + +@keyframes cursor-blink { + 0%, 50% { + opacity: 1; + } + 51%, 100% { + opacity: 0.3; + } +} + +.animate-fade-in { + animation: fade-in 0.4s ease-out forwards; +} + +.animate-bounce-slow { + animation: bounce-slow 2s ease-in-out infinite; +} + +.animate-cursor-blink { + animation: cursor-blink 1.5s ease-in-out infinite; } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c3d1998..9677546 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/lib/theme-context"; +import { ToastProvider } from "@/lib/toast-context"; +import { Toaster } from "@/components/toaster"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -28,8 +30,20 @@ export default function RootLayout({ + {/* Skip to main content link for accessibility */} + + Skip to main content + - {children} + +
+ {children} +
+ +
diff --git a/src/components/boot-screen.tsx b/src/components/boot-screen.tsx index 73fb5bb..ce3b466 100644 --- a/src/components/boot-screen.tsx +++ b/src/components/boot-screen.tsx @@ -1,7 +1,7 @@ 'use client'; import { useStore } from '@/lib/store'; -import { OS, Shell } from '@/types'; +import { OS } from '@/types'; import { Terminal } from 'lucide-react'; import { useState } from 'react'; diff --git a/src/components/bucket-modal.tsx b/src/components/bucket-modal.tsx index 81c280e..bf0bc95 100644 --- a/src/components/bucket-modal.tsx +++ b/src/components/bucket-modal.tsx @@ -3,37 +3,150 @@ import { useStore } from '@/lib/store'; import { X, Trash2, Package } from 'lucide-react'; import { VersionNote } from './version-note'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useToast } from '@/hooks/use-toast'; +import { useFocusTrap, useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; +import { EmptyBucketState } from './empty-state'; +import { appCatalog } from '@/lib/apps'; +import { PresetsModal } from './presets-modal'; interface BucketModalProps { onClose: () => void; } export function BucketModal({ onClose }: BucketModalProps) { - const { bucket, removeFromBucket, clearBucket, setCurrentStep, updatePackageNote } = useStore(); + const { bucket, removeFromBucket, clearBucket, setCurrentStep, updatePackageNote, addDefaultAppsToBucket, addToBucket } = useStore(); + const { toast } = useToast(); const modalRef = useRef(null); + const [focusedIndex, setFocusedIndex] = useState(-1); + const itemRefs = useRef<(HTMLLIElement | null)[]>([]); + const [showPresets, setShowPresets] = useState(false); + + // Popular packages for quick-add + const popularPackages = useMemo(() => { + const popularIds = ['git', 'nodejs', 'docker', 'vscode', 'zsh']; + return popularIds + .map(id => appCatalog.find(p => p.id === id)) + .filter((p): p is NonNullable => p !== undefined) + .slice(0, 3); + }, []); + + const handleAddDefaults = useCallback(() => { + addDefaultAppsToBucket(); + toast.success('⚡ Added recommended tools'); + }, [addDefaultAppsToBucket, toast]); + + const handleBrowseCatalog = useCallback(() => { + onClose(); + setCurrentStep('catalog'); + }, [onClose, setCurrentStep]); + + const handleOpenPresets = useCallback(() => { + setShowPresets(true); + }, []); + + // Focus trap for modal + useFocusTrap(modalRef, true); + + const handleRemoveFromBucket = (pkgId: string, pkgName: string) => { + removeFromBucket(pkgId); + toast.success(`🗑️ Removed ${pkgName}`); + }; + + const handleClearBucket = () => { + clearBucket(); + toast.info('🧹 Bucket cleared'); + }; const handleGenerateScript = () => { + if (bucket.length === 0) { + toast.info('Add packages to bucket first'); + return; + } onClose(); setCurrentStep('output'); + toast.success('🚀 Script generated'); }; - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; + // Navigate bucket items with arrow keys + const navigateItems = useCallback((direction: 'up' | 'down') => { + if (bucket.length === 0) return; + setFocusedIndex((prev) => { + let next: number; + if (direction === 'down') { + next = prev < bucket.length - 1 ? prev + 1 : 0; + } else { + next = prev > 0 ? prev - 1 : bucket.length - 1; + } + // Focus the item + setTimeout(() => { + itemRefs.current[next]?.focus(); + }, 0); + return next; + }); + }, [bucket.length]); + + // Remove focused item + const removeFocusedItem = useCallback(() => { + if (focusedIndex >= 0 && focusedIndex < bucket.length) { + const pkg = bucket[focusedIndex]; + removeFromBucket(pkg.id); + toast.success(`🗑️ Removed ${pkg.name}`); + // Adjust focus index + if (focusedIndex >= bucket.length - 1) { + setFocusedIndex(Math.max(0, bucket.length - 2)); + } + } + }, [focusedIndex, bucket, removeFromBucket, toast]); + + // Keyboard shortcuts for bucket + const bucketShortcuts = [ + { + key: 'ArrowDown', + description: 'Navigate down in bucket', + action: () => navigateItems('down'), + }, + { + key: 'ArrowUp', + description: 'Navigate up in bucket', + action: () => navigateItems('up'), + }, + { + key: 'Delete', + description: 'Remove focused item', + action: removeFocusedItem, + }, + { + key: 'Backspace', + description: 'Remove focused item', + action: removeFocusedItem, + }, + { + key: 'Enter', + description: 'Generate script', + action: handleGenerateScript, + }, + { + key: 'Escape', + description: 'Close bucket modal', + action: onClose, + preventDefault: false, + }, + ]; + + useKeyboardShortcuts(bucketShortcuts, true); + + useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (modalRef.current && !modalRef.current.contains(e.target as Node)) { onClose(); } }; - window.addEventListener('keydown', handleKey); document.addEventListener('mousedown', handleClickOutside); return () => { - window.removeEventListener('keydown', handleKey); document.removeEventListener('mousedown', handleClickOutside); }; }, [onClose]); @@ -55,9 +168,12 @@ export function BucketModal({ onClose }: BucketModalProps) { {bucket.length} - @@ -66,20 +182,52 @@ export function BucketModal({ onClose }: BucketModalProps) { {/* Content */}
{bucket.length === 0 ? ( -
- -

Your bucket is empty

-

Add some tools to get started!

-
+ ({ + id: pkg.id, + name: pkg.name, + onAdd: () => { + addToBucket({ ...pkg, selectedVersion: pkg.defaultVersion }); + toast.success(`✅ Added ${pkg.name}`); + }, + }))} + /> ) : ( -
    - {bucket.map((pkg) => { +
      + {bucket.map((pkg, index) => { const v = pkg.selectedVersion || pkg.defaultVersion; const isGeneric = ['stable', 'latest'].includes(v); const versionLabel = isGeneric ? 'Stable' : v.startsWith('v') ? v : 'v' + v; + const isFocused = focusedIndex === index; return ( -
    • +
    • { itemRefs.current[index] = el; }} + tabIndex={isFocused ? 0 : -1} + className={`p-3 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none ${ + isFocused ? 'bg-accent/50 ring-2 ring-ring' : 'hover:bg-accent/30' + }`} + onFocus={() => setFocusedIndex(index)} + onKeyDown={(e) => { + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + handleRemoveFromBucket(pkg.id, pkg.name); + // Adjust focus + if (index >= bucket.length - 1) { + setFocusedIndex(Math.max(0, bucket.length - 2)); + } + } else if (e.key === 'Enter') { + e.preventDefault(); + handleGenerateScript(); + } + }} + role="listitem" + aria-label={`${pkg.name} version ${versionLabel}`} + >

      {pkg.name}

      @@ -96,9 +244,10 @@ export function BucketModal({ onClose }: BucketModalProps) { />
      @@ -117,14 +266,15 @@ export function BucketModal({ onClose }: BucketModalProps) { @@ -132,6 +282,11 @@ export function BucketModal({ onClose }: BucketModalProps) { )}
+ + {/* Presets Modal */} + {showPresets && ( + setShowPresets(false)} /> + )} ); } \ No newline at end of file diff --git a/src/components/chat-window.tsx b/src/components/chat-window.tsx index b8617fb..61f82c7 100644 --- a/src/components/chat-window.tsx +++ b/src/components/chat-window.tsx @@ -8,9 +8,12 @@ import { Send, X, Minimize2, Maximize2, Bot } from 'lucide-react'; import { useState, useRef, useEffect, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { useToast } from '@/hooks/use-toast'; +import { useFocusTrap, useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; export function ChatWindow() { const { isChatOpen, toggleChat, addToBucket, removeFromBucket, bucket, updatePackageVersion } = useStore(); + const { toast } = useToast(); const [messages, setMessages] = useState([ { role: 'assistant', @@ -20,10 +23,28 @@ export function ChatWindow() { const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [streamingContent, setStreamingContent] = useState(''); - const [isMinimized, setIsMinimized] = useState(false); + const [isMinimized, setIsMinimized] = useState(true); const textareaRef = useRef(null); const messagesEndRef = useRef(null); + const chatWindowRef = useRef(null); const abortRef = useRef(null); + const hasShownMinimizeToast = useRef(false); + + // Focus trap when chat is open and not minimized + useFocusTrap(chatWindowRef, isChatOpen && !isMinimized); + + const handleMinimize = () => { + setIsMinimized(true); + if (!hasShownMinimizeToast.current) { + toast.info('💬 Chat minimized'); + hasShownMinimizeToast.current = true; + } + }; + + const handleMaximize = () => { + setIsMinimized(false); + hasShownMinimizeToast.current = false; + }; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -172,13 +193,34 @@ export function ChatWindow() { return raw; }; + // Chat keyboard shortcuts + const chatShortcuts = [ + { + key: 'Escape', + description: 'Close chat', + action: () => { + // Only close if textarea is not focused or input is empty + if (document.activeElement !== textareaRef.current || !input.trim()) { + toggleChat(); + } + }, + preventDefault: false, + }, + ]; + + useKeyboardShortcuts(chatShortcuts, isChatOpen && !isMinimized); + if (!isChatOpen) return null; return (
{/* Header */}
@@ -192,10 +234,21 @@ export function ChatWindow() { )}
- -
@@ -277,11 +330,14 @@ export function ChatWindow() { placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-none overflow-y-auto font-mono text-sm" disabled={isLoading} /> - diff --git a/src/components/dependency-panel.tsx b/src/components/dependency-panel.tsx index cad5720..63b06ce 100644 --- a/src/components/dependency-panel.tsx +++ b/src/components/dependency-panel.tsx @@ -4,8 +4,8 @@ import { Package } from '@/types'; import { dependencyWarnings, pairSuggestions } from '@/lib/suggestions'; import { appCatalog } from '@/lib/apps'; import { useStore } from '@/lib/store'; -import { AlertTriangle, Lightbulb, Plus } from 'lucide-react'; -import { useMemo } from 'react'; +import { AlertTriangle, Lightbulb, Plus, ChevronDown, ChevronUp } from 'lucide-react'; +import { useMemo, useState } from 'react'; interface DependencyPanelProps { bucket: Package[]; @@ -14,7 +14,8 @@ interface DependencyPanelProps { export function DependencyPanel({ bucket, os }: DependencyPanelProps) { const { addToBucket } = useStore(); - const bucketIds = new Set(bucket.map((p) => p.id)); + const [isExpanded, setIsExpanded] = useState(false); + const bucketIds = useMemo(() => new Set(bucket.map((p) => p.id)), [bucket]); const warnings = useMemo(() => { return dependencyWarnings.filter( @@ -45,64 +46,86 @@ export function DependencyPanel({ bucket, os }: DependencyPanelProps) { if (warnings.length === 0 && suggestions.length === 0) return null; return ( -
- {/* Warnings */} - {warnings.length > 0 && ( -
-
- - Dependency Warnings -
-
    - {warnings.map((w, i) => { - const needsPkg = appCatalog.find((p) => p.id === w.needs); - return ( -
  • - {w.message} - {needsPkg && ( +
    + {/* Compact header - always visible */} + + + {/* Expanded content */} + {isExpanded && ( +
    + {/* Warnings */} + {warnings.length > 0 && ( +
    + {warnings.map((w, i) => { + const needsPkg = appCatalog.find((p) => p.id === w.needs); + return ( +
    + + + {w.message} + + {needsPkg && ( + + )} +
    + ); + })} +
    + )} + + {/* Suggestions */} + {suggestions.length > 0 && ( +
    0 ? 'pt-2 border-t border-border/30' : 'pt-2'}`}> +
    + {suggestions.map(({ triggeredBy, suggestedId }) => { + const pkg = appCatalog.find((p) => p.id === suggestedId); + if (!pkg) return null; + return ( - )} -
  • - ); - })} -
-
- )} - - {/* Suggestions */} - {suggestions.length > 0 && ( -
-
- - Pairs Well With -
-
- {suggestions.map(({ triggeredBy, suggestedId }) => { - const pkg = appCatalog.find((p) => p.id === suggestedId); - if (!pkg) return null; - return ( - - ); - })} -
+ ); + })} +
+
+ )}
)} ); -} \ No newline at end of file +} diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx new file mode 100644 index 0000000..2d26349 --- /dev/null +++ b/src/components/empty-state.tsx @@ -0,0 +1,369 @@ +'use client'; + +import { ReactNode } from 'react'; +import { Package, Search, Terminal, FolderOpen, Sparkles, ArrowRight } from 'lucide-react'; + +export type EmptyStateIllustration = 'bucket' | 'search' | 'terminal' | 'package' | 'category'; + +export interface EmptyStateAction { + label: string; + onClick: () => void; + variant?: 'primary' | 'secondary'; + icon?: ReactNode; +} + +export interface EmptyStateSuggestion { + label: string; + onClick?: () => void; +} + +export interface EmptyStateProps { + illustration: EmptyStateIllustration; + title: string; + description: string; + actions?: EmptyStateAction[]; + suggestions?: EmptyStateSuggestion[]; + suggestionTitle?: string; + children?: ReactNode; + className?: string; +} + +const illustrationConfig: Record = { + bucket: { icon: Package, color: 'text-primary', animation: 'animate-bounce-slow' }, + search: { icon: Search, color: 'text-muted-foreground', animation: 'animate-pulse' }, + terminal: { icon: Terminal, color: 'text-primary', animation: 'animate-cursor-blink' }, + package: { icon: FolderOpen, color: 'text-muted-foreground', animation: '' }, + category: { icon: FolderOpen, color: 'text-muted-foreground', animation: '' }, +}; + +export function EmptyState({ + illustration, + title, + description, + actions, + suggestions, + suggestionTitle = 'Try these instead:', + children, + className = '', +}: EmptyStateProps) { + const config = illustrationConfig[illustration]; + const Icon = config.icon; + + return ( +
+ {/* Illustration */} + + + {/* Title */} +

+ {title} +

+ + {/* Description */} +

+ {description} +

+ + {/* Actions */} + {actions && actions.length > 0 && ( +
+ {actions.map((action, index) => ( + + ))} +
+ )} + + {/* Suggestions */} + {suggestions && suggestions.length > 0 && ( +
+

+ + {suggestionTitle} +

+
+ {suggestions.map((suggestion, index) => ( + + ))} +
+
+ )} + + {/* Custom content */} + {children} +
+ ); +} + +// Specialized empty state components for common use cases + +export function EmptyBucketState({ + onAddDefaults, + onBrowseCatalog, + onOpenPresets, + popularPackages, +}: { + onAddDefaults: () => void; + onBrowseCatalog: () => void; + onOpenPresets: () => void; + popularPackages?: { id: string; name: string; onAdd: () => void }[]; +}) { + return ( + , + }, + { + label: 'Browse Popular', + onClick: onBrowseCatalog, + variant: 'secondary', + }, + { + label: 'Load Preset', + onClick: onOpenPresets, + variant: 'secondary', + }, + ]} + suggestions={ + popularPackages?.map((pkg) => ({ + label: pkg.name, + onClick: pkg.onAdd, + })) + } + suggestionTitle="Popular tools:" + /> + ); +} + +export function NoSearchResultsState({ + searchTerm, + onBrowseAll, + onRequestPackage, + suggestions, + didYouMean, +}: { + searchTerm: string; + onBrowseAll: () => void; + onRequestPackage?: () => void; + suggestions?: string[]; + didYouMean?: { label: string; onClick: () => void }; +}) { + return ( + + {/* Did you mean */} + {didYouMean && ( +
+

+ Did you mean:{" "} + + ? +

+
+ )} + + {/* Search tips */} + {suggestions && suggestions.length > 0 && ( +
+

Search tips:

+
    + {suggestions.map((tip, index) => ( +
  • + + {tip} +
  • + ))} +
+
+ )} +
+ ); +} + +export function EmptyCategoryState({ + category, + os, + alternativeCategories, + onSwitchCategory, + onSwitchOS, +}: { + category: string; + os: string; + alternativeCategories?: { id: string; name: string }[]; + onSwitchCategory?: (categoryId: string) => void; + onSwitchOS?: () => void; +}) { + const categoryName = category.replace('-', ' '); + + return ( + + {/* Alternative categories */} + {alternativeCategories && alternativeCategories.length > 0 && onSwitchCategory && ( +
+

+ Try these categories instead: +

+
+ {alternativeCategories.map((cat) => ( + + ))} +
+
+ )} + + {/* Switch OS option */} + {onSwitchOS && ( +
+ +
+ )} +
+ ); +} + +export function NoScriptGeneratedState({ + os, + hasPackages, + onAddTools, +}: { + os: string | null; + hasPackages: boolean; + onAddTools: () => void; +}) { + return ( + +
+
+
+
+ +
+ + OS selected: {os || 'Not selected'} + +
+
+
+ + {hasPackages ? '✓' : '○'} + +
+ + {hasPackages + ? 'Tools added to bucket' + : 'Add at least one tool to bucket'} + +
+
+ + {!hasPackages && ( + + )} +
+
+ ); +} diff --git a/src/components/keyboard-help-modal.tsx b/src/components/keyboard-help-modal.tsx new file mode 100644 index 0000000..a048d91 --- /dev/null +++ b/src/components/keyboard-help-modal.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { X, Command, CornerDownLeft, ArrowUp, ArrowDown, Delete, Keyboard } from 'lucide-react'; +import { useEffect, useRef } from 'react'; +import { useFocusTrap, getModifierSymbol, isMac } from '@/hooks/use-keyboard-shortcuts'; + +interface KeyboardHelpModalProps { + onClose: () => void; +} + +interface ShortcutGroup { + title: string; + shortcuts: { + keys: string[]; + description: string; + }[]; +} + +export function KeyboardHelpModal({ onClose }: KeyboardHelpModalProps) { + const modalRef = useRef(null); + const isMacOS = isMac(); + const mod = getModifierSymbol(); + + useFocusTrap(modalRef, true); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const shortcutGroups: ShortcutGroup[] = [ + { + title: 'Global Shortcuts', + shortcuts: [ + { keys: [`${mod}K`], description: 'Open search' }, + { keys: [`${mod}B`], description: 'Toggle bucket modal' }, + { keys: [`${mod}↵`], description: 'Generate script (when bucket has items)' }, + { keys: [`${mod}⇧C`], description: 'Copy current script' }, + { keys: ['?'], description: 'Show this help modal' }, + { keys: ['Esc'], description: 'Close current modal/panel' }, + ], + }, + { + title: 'Navigation Shortcuts', + shortcuts: [ + { keys: ['Tab'], description: 'Navigate between focusable elements' }, + { keys: ['⇧Tab'], description: 'Navigate backwards' }, + { keys: ['↑', '↓'], description: 'Navigate packages in search results' }, + { keys: ['↵'], description: 'Select focused package / Add to bucket' }, + { keys: ['1-9'], description: 'Quick select category (1=All, 2=IDEs, etc.)' }, + ], + }, + { + title: 'Bucket Shortcuts', + shortcuts: [ + { keys: ['Del', '⌫'], description: 'Remove focused item from bucket' }, + { keys: ['↑', '↓'], description: 'Navigate items in bucket' }, + { keys: ['↵'], description: 'Generate script from bucket' }, + ], + }, + { + title: 'Chat Shortcuts', + shortcuts: [ + { keys: [`${mod}⇧A`], description: 'Toggle AI chat' }, + { keys: ['Esc'], description: 'Close chat (when focused)' }, + { keys: ['↵'], description: 'Send message' }, + { keys: ['⇧↵'], description: 'New line in message' }, + ], + }, + ]; + + const renderKey = (key: string) => { + // Handle special keys + if (key === 'Esc') return Esc; + if (key === 'Del') return ; + if (key === '⌫') return ; + if (key === '↵') return ; + if (key === '⇧') return ; + if (key === '↑') return ; + if (key === '↓') return ; + if (key === mod) return ; + if (key === '1-9') return 1-9; + if (key === '?') return ?; + if (key === 'Tab') return Tab; + + return {key}; + }; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+
+ +
+
+

+ Keyboard Shortcuts +

+

+ Master the keyboard to work faster +

+
+
+ +
+ + {/* Content */} +
+ {shortcutGroups.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut, idx) => ( +
+ {shortcut.description} +
+ {shortcut.keys.map((key, keyIdx) => ( + + + {renderKey(key)} + + {keyIdx < shortcut.keys.length - 1 && ( + + + )} + + ))} +
+
+ ))} +
+
+ ))} + + {/* Platform note */} +
+

+ Tip: Shortcuts use{' '} + + {isMacOS ? '⌘' : 'Ctrl'} + {' '} + as the primary modifier key on your platform. +

+
+ + {/* Accessibility note */} +
+

+ Accessibility: All interactive elements + are keyboard accessible. Use{' '} + Tab{' '} + to navigate and{' '} + {' '} + to activate. +

+
+
+ + {/* Footer */} +
+ Press Esc to close + SudoStart Keyboard Navigation +
+
+
+ ); +} diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 4587d28..14381eb 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -2,20 +2,27 @@ import { useStore } from '@/lib/store'; import { useTheme } from '@/lib/theme-context'; -import { MessageSquare, ShoppingCart, Terminal, Search, Layers, Upload, Download as DownloadIcon, Sun, Moon } from 'lucide-react'; -import { useState, useRef } from 'react'; +import { MessageSquare, ShoppingCart, Terminal, Search, Layers, Upload, Download as DownloadIcon, Sun, Moon, MoreHorizontal } from 'lucide-react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { BucketModal } from './bucket-modal'; import { SearchBar } from './search-bar'; import { PresetsModal } from './presets-modal'; +import { useToast } from '@/hooks/use-toast'; +import { useKeyboardShortcuts, getModifierSymbol } from '@/hooks/use-keyboard-shortcuts'; +import { copyToClipboard } from '@/lib/utils'; export function Navbar() { - const { os, bucket, toggleChat, exportBucket, importBucket } = useStore(); + const { os, bucket, toggleChat, exportBucket, importBucket, setCurrentStep, generatedScript } = useStore(); const { theme, toggleTheme } = useTheme(); + const { toast } = useToast(); const [isBucketOpen, setIsBucketOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isPresetsOpen, setIsPresetsOpen] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); const [importError, setImportError] = useState(false); const fileInputRef = useRef(null); + const menuRef = useRef(null); + const modSymbol = getModifierSymbol(); const handleImport = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -23,28 +30,111 @@ export function Navbar() { const reader = new FileReader(); reader.onload = (ev) => { const success = importBucket(ev.target?.result as string); - if (!success) { + if (success) { + toast.success('📂 Configuration imported successfully'); + setImportError(false); + } else { setImportError(true); + toast.error('❌ Failed to import configuration'); setTimeout(() => setImportError(false), 3000); } }; + reader.onerror = () => { + toast.error('❌ Failed to read file'); + }; reader.readAsText(file); e.target.value = ''; }; - // Keyboard shortcut: Cmd+K / Ctrl+K - useState(() => { - const handler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - setIsSearchOpen(true); + const handleExport = () => { + exportBucket(); + toast.success('💾 Configuration exported'); + }; + + // Copy script to clipboard + const handleCopyScript = useCallback(async () => { + if (!generatedScript) { + toast.error('No script to copy'); + return; + } + const success = await copyToClipboard(generatedScript); + if (success) { + toast.success('📋 Script copied to clipboard'); + } else { + toast.error('❌ Failed to copy script'); + } + }, [generatedScript, toast]); + + // Generate script from bucket + const handleGenerateScript = useCallback(() => { + if (bucket.length === 0) { + toast.info('Add packages to bucket first'); + return; + } + setIsBucketOpen(false); + setCurrentStep('output'); + toast.success('🚀 Script generated'); + }, [bucket.length, setCurrentStep, toast]); + + // Define keyboard shortcuts + const shortcuts = [ + // Global shortcuts + { + key: 'k', + modifiers: { meta: true }, + description: 'Open search', + action: () => setIsSearchOpen(true), + }, + { + key: 'b', + modifiers: { meta: true }, + description: 'Toggle bucket', + action: () => setIsBucketOpen(prev => !prev), + }, + { + key: 'Enter', + modifiers: { meta: true }, + description: 'Generate script', + action: handleGenerateScript, + }, + { + key: 'c', + modifiers: { meta: true, shift: true }, + description: 'Copy script', + action: handleCopyScript, + }, + { + key: 'a', + modifiers: { meta: true, shift: true }, + description: 'Toggle AI chat', + action: toggleChat, + }, + { + key: 'Escape', + description: 'Close modals', + action: () => { + setIsSearchOpen(false); + setIsBucketOpen(false); + setIsPresetsOpen(false); + setIsMenuOpen(false); + }, + preventDefault: false, + }, + ]; + + // Apply keyboard shortcuts + useKeyboardShortcuts(shortcuts, true); + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setIsMenuOpen(false); } }; - if (typeof window !== 'undefined') { - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - } - }); + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); return ( <> @@ -80,13 +170,12 @@ export function Navbar() { {/* Spacer */} @@ -100,7 +189,7 @@ export function Navbar() { title="Search packages" aria-label="Search packages" onClick={() => setIsSearchOpen(true)} - className="md:hidden p-2 rounded-lg border border-border hover:border-primary/50 transition-all" + className="md:hidden p-2 rounded-lg border border-border hover:border-primary/50 transition-all focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" > @@ -110,47 +199,64 @@ export function Navbar() { type="button" onClick={() => setIsPresetsOpen(true)} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border - hover:border-primary/50 transition-all text-sm" + hover:border-primary/50 transition-all text-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" title="Starter presets" > Presets - {/* Import */} - - - {/* Export */} - + {/* More Menu (Import/Export) */} +
+ + + {isMenuOpen && ( +
+ + +
+ )} +
{/* Theme Toggle */} {/* Bucket */} @@ -170,7 +273,9 @@ export function Navbar() { type="button" onClick={() => setIsBucketOpen(!isBucketOpen)} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border-2 border-border - hover:border-primary/50 transition-all terminal-card" + hover:border-primary/50 transition-all terminal-card focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" + aria-keyshortcuts="Meta+B" + title={`Bucket (${modSymbol}B)`} > Bucket @@ -189,7 +294,9 @@ export function Navbar() { type="button" onClick={toggleChat} className="flex items-center gap-1.5 px-3 py-2 rounded-lg border-2 border-primary - terminal-text hover:terminal-glow transition-all text-sm" + terminal-text hover:terminal-glow transition-all text-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" + aria-keyshortcuts="Meta+Shift+A" + title={`Root AI (${modSymbol}⇧A)`} > Root AI diff --git a/src/components/package-manager.tsx b/src/components/package-manager.tsx index 69e9982..45db89d 100644 --- a/src/components/package-manager.tsx +++ b/src/components/package-manager.tsx @@ -3,12 +3,15 @@ import { useStore } from '@/lib/store'; import { appCatalog, getAppsForOS } from '@/lib/apps'; import { Package } from '@/types'; -import { Plus, Check, ChevronDown, AlertCircle, Wand2, Copy, Clock, HardDrive } from 'lucide-react'; -import { useState, useMemo, useEffect } from 'react'; +import { Plus, Check, ChevronDown, AlertCircle, Wand2, Copy, Clock, HardDrive, Terminal } from 'lucide-react'; +import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { Navbar } from './navbar'; import { DependencyPanel } from './dependency-panel'; import { VersionNote } from './version-note'; import { estimateInstallTime, estimateDiskSpace } from '@/lib/script-generator'; +import { useToast } from '@/hooks/use-toast'; +import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; +import { copyToClipboard } from '@/lib/utils'; const categoryIcons: Record = { all: '🗂️', ide: '📝', browser: '🌐', tool: '🔧', runtime: '⚙️', @@ -20,31 +23,78 @@ const categoryIcons: Record = { }; export function PackageManager() { - const { os, bucket, addToBucket, updatePackageVersion, updatePackageNote, addDefaultAppsToBucket } = useStore(); + const { os, bucket, addToBucket, updatePackageVersion, updatePackageNote, addDefaultAppsToBucket, setCurrentStep } = useStore(); + const { toast } = useToast(); const [selectedCategory, setSelectedCategory] = useState('all'); + const [focusedPackageIndex, setFocusedPackageIndex] = useState(-1); + const packageGridRef = useRef(null); const availableApps = useMemo(() => { if (!os) return appCatalog; return getAppsForOS(os); }, [os]); - const filteredPackages = - selectedCategory === 'all' + const filteredPackages = useMemo(() => { + return selectedCategory === 'all' ? availableApps : availableApps.filter((p) => p.category === selectedCategory); + }, [selectedCategory, availableApps]); - const isInBucket = (pkg: Package) => bucket.some((p) => p.id === pkg.id); - const getBucketPkg = (pkg: Package) => bucket.find((p) => p.id === pkg.id); + const isInBucket = useCallback((pkg: Package) => bucket.some((p) => p.id === pkg.id), [bucket]); + const getBucketPkg = useCallback((pkg: Package) => bucket.find((p) => p.id === pkg.id), [bucket]); - const handleAddToBucket = (pkg: Package, versionId: string) => { + const handleAddToBucket = useCallback((pkg: Package, versionId: string) => { if (isInBucket(pkg)) { updatePackageVersion(pkg.id, versionId); + toast.success(`🔄 ${pkg.name} version updated`); } else { addToBucket({ ...pkg, selectedVersion: versionId }); + toast.success(`✅ Added ${pkg.name} to bucket`); } - }; + }, [isInBucket, updatePackageVersion, addToBucket, toast]); + + const categories = useMemo(() => ['all', ...Array.from(new Set(availableApps.map((p) => p.category)))], [availableApps]); + + // Category keyboard shortcuts (1-9) + const categoryShortcuts = useMemo(() => { + const shortcuts = []; + for (let i = 0; i < Math.min(categories.length, 9); i++) { + const category = categories[i]; + const key = (i + 1).toString(); + shortcuts.push({ + key, + description: `Select category: ${category}`, + action: () => { + setSelectedCategory(category); + setFocusedPackageIndex(-1); + }, + }); + } + return shortcuts; + }, [categories]); + + // Apply category shortcuts + useKeyboardShortcuts(categoryShortcuts, true); - const categories = ['all', ...Array.from(new Set(availableApps.map((p) => p.category)))]; + // Generate script shortcut + const handleGenerateScript = useCallback(() => { + if (bucket.length === 0) { + toast.info('Add packages to bucket first'); + return; + } + setCurrentStep('output'); + toast.success('🚀 Script generated'); + }, [bucket.length, setCurrentStep, toast]); + + // Add generate script shortcut + useKeyboardShortcuts([ + { + key: 'Enter', + modifiers: { meta: true }, + description: 'Generate script', + action: handleGenerateScript, + }, + ], true); // Stats const estTime = estimateInstallTime(bucket); @@ -93,27 +143,42 @@ export function PackageManager() { {/* Dependency panel */} - {/* Category filters */} -
- {categories.map((cat) => ( - - ))} + {/* Category filters - compact pills */} +
+ {categories.map((cat, index) => { + const shortcutNumber = index < 9 ? index + 1 : null; + const isSelected = selectedCategory === cat; + return ( + + ); + })}
{/* Package Grid */} -
- {filteredPackages.map((pkg) => { +
+ {filteredPackages.map((pkg, index) => { const bucketPkg = getBucketPkg(pkg); return ( setFocusedPackageIndex(index)} + tabIndex={focusedPackageIndex === index ? 0 : -1} /> ); })} @@ -135,6 +203,24 @@ export function PackageManager() {
)}
+ + {/* Floating Action Button - Generate Script */} + {bucket.length > 0 && ( + + )}
); } @@ -146,6 +232,9 @@ function PackageCard({ bucketNote, onAddToBucket, onUpdateNote, + isFocused, + onFocus, + tabIndex, }: { pkg: Package; os: 'macos' | 'linux' | null; @@ -153,12 +242,17 @@ function PackageCard({ bucketNote: string; onAddToBucket: (pkg: Package, versionId: string) => void; onUpdateNote: (pkgId: string, note: string) => void; + isFocused?: boolean; + onFocus?: () => void; + tabIndex?: number; }) { + const { toast } = useToast(); const [selectedVersion, setSelectedVersion] = useState(pkg.defaultVersion); const [dynamicVersions, setDynamicVersions] = useState([]); const [isLoadingVersions, setIsLoadingVersions] = useState(false); const [hasFetchedVersions, setHasFetchedVersions] = useState(false); const [copied, setCopied] = useState(false); + const [showDetails, setShowDetails] = useState(false); const dynamicVersionTools = [ // Runtimes @@ -207,7 +301,6 @@ function PackageCard({ const data = await res.json(); if (data.versions?.length > 0) { setDynamicVersions(data.versions); - // Don't auto-change selected version, let user choose } } catch { // silently fall back to static versions @@ -247,57 +340,74 @@ function PackageCard({ const handleCopyCommand = async () => { const cmd = getPreviewCommand(); if (!cmd) return; - await navigator.clipboard.writeText(cmd); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const success = await copyToClipboard(cmd); + if (success) { + setCopied(true); + toast.success('📋 Command copied'); + setTimeout(() => setCopied(false), 2000); + } else { + toast.error('❌ Failed to copy'); + } }; + const cardRef = useRef(null); + + // Focus the card when isFocused changes + useEffect(() => { + if (isFocused && cardRef.current) { + cardRef.current.focus(); + } + }, [isFocused]); + + // Show details when in bucket + useEffect(() => { + setShowDetails(isInBucket); + }, [isInBucket]); + return (
setShowDetails(true)} + onMouseLeave={() => !isInBucket && setShowDetails(false)} + onKeyDown={(e) => { + if (e.key === 'Enter' && isAvailable && !isInBucket) { + e.preventDefault(); + onAddToBucket(pkg, selectedVersion); + } + }} + className={`terminal-card rounded-lg p-4 transition-all flex flex-col focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none group ${ + isAvailable ? 'hover:border-primary/40' : 'opacity-60' + } ${isFocused ? 'ring-2 ring-ring' : ''} ${isInBucket ? 'border-primary/30 bg-primary/5' : ''}`} + role="article" + aria-label={`${pkg.name} - ${pkg.description}`} > -
-
-
-
-

{pkg.name}

- {!isAvailable && ( - - - {os === 'linux' ? 'Mac Only' : 'Linux Only'} - - )} -
-

{pkg.description}

- - {categoryIcons[pkg.category] || '📁'} {pkg.category.replace('-', ' ')} - + {/* Header */} +
+
+
+

{pkg.name}

+ {!isAvailable && ( + + {os === 'linux' ? 'Mac' : 'Linux'} + + )}
- - {/* Version note button — only shown when package is in bucket */} - {isInBucket && ( - - )} +

{pkg.description}

+ + {/* Category icon */} + + {categoryIcons[pkg.category] || '📁'} + +
+ {/* Expanded details - shown on hover or when in bucket */} +
{/* Version selector */} {(versionsToShow.length > 1 || supportsDynamic) && ( -
- +
- + + {isLoadingVersions && ( + + loading... + + )}
)} - {/* Show pinned note inline on card if present */} + {/* Note badge */} {isInBucket && bucketNote && ( -
+
📌 {bucketNote}
)} -
- {/* Actions */} -
- + + {/* Copy command - only show when details visible */} + {isAvailable && os && ( + )} - - {/* Copy install command */} - {isAvailable && os && ( + {/* Version note button */} + {isInBucket && ( + + )} +
+
+ + {/* Compact action button - shown when details hidden */} + {!showDetails && ( +
- )} -
+
+ )}
); } \ No newline at end of file diff --git a/src/components/presets-modal.tsx b/src/components/presets-modal.tsx index b96aa5d..ebaab67 100644 --- a/src/components/presets-modal.tsx +++ b/src/components/presets-modal.tsx @@ -7,6 +7,7 @@ import { X, Clock, Layers, Check } from 'lucide-react'; import { useState, useEffect, useRef } from 'react'; import { estimateInstallTime } from '@/lib/script-generator'; import { Package } from '@/types'; +import { useToast } from '@/hooks/use-toast'; interface PresetsModalProps { onClose: () => void; @@ -14,6 +15,7 @@ interface PresetsModalProps { export function PresetsModal({ onClose }: PresetsModalProps) { const { loadPreset, bucket, os } = useStore(); + const { toast } = useToast(); const [applied, setApplied] = useState(null); const modalRef = useRef(null); @@ -37,9 +39,10 @@ export function PresetsModal({ onClose }: PresetsModalProps) { }; }, [onClose]); - const handleApply = (presetId: string, packageIds: string[]) => { + const handleApply = (presetId: string, packageIds: string[], presetName: string) => { loadPreset(packageIds); setApplied(presetId); + toast.success(`⚡ ${presetName} preset applied`); setTimeout(() => { onClose(); }, 800); @@ -87,7 +90,7 @@ export function PresetsModal({ onClose }: PresetsModalProps) { + )} + + {/* Explanation Panel */} + {isOpen && ( +
+ {/* Header */} +
+
+ + Script Explanation + {cachedExplanation && !isLoading && ( + + cached + + )} +
+
+ + +
+
+ +
+ {/* AI Generated Explanation */} + {(isLoading || streamingContent || explanation) && ( +
+
+ + AI Summary +
+ + {isLoading && !streamingContent && ( +
+ + Root is analyzing your script... +
+ )} + + {(streamingContent || explanation) && ( +
+ + {streamingContent || explanation} + + {streamingContent && ( + + )} +
+ )} + + {isLoading && ( + + )} +
+ )} + + {/* Overview Section */} +
+ + + {expandedSections.overview && ( +
+
+
+

{packages.length}

+

Tools

+
+
+

{os || '?'}

+

OS

+
+
+
+ +

~{estTime}m

+
+

Install time

+
+
+
+ +

~{diskLabel}

+
+

Disk space

+
+
+ +
+ + Target shell: {shell || 'auto-detect'} +
+
+ )} +
+ + {/* Tools Section */} +
+ + + {expandedSections.tools && ( +
+ {tools.map((tool) => ( +
+
+ + {tool.name.charAt(0).toUpperCase()} + +
+
+
+ {tool.name} + {tool.versionLabel && ( + + {tool.versionLabel} + + )} + + {tool.installMethod} + +
+

+ {tool.description} +

+
+
+ ))} +
+ )} +
+ + {/* Dependencies Section */} + {dependencies.length > 0 && ( +
+ + + {expandedSections.dependencies && ( +
+ {dependencies.map((dep, idx) => ( +
+ +
+ {dep.tool} + depends on + {dep.dependsOn} +

{dep.note}

+
+
+ ))} +
+ )} +
+ )} + + {/* Post-Install Section */} + {postInstallNotes.length > 0 && ( +
+ + + {expandedSections.postInstall && ( +
+ {postInstallNotes.map((note, idx) => ( +
+
+ {idx + 1} +
+
+ {note.tool} +

{note.action}

+
+
+ ))} +
+ )} +
+ )} + + {/* Security Section */} +
+ + + {expandedSections.security && ( +
+
+ + All commands use official package managers (Homebrew, apt, etc.) +
+ + {hasCurlPipe && ( +
+ +
+ Warning: + This script contains commands that pipe curl output to bash. Always review scripts before running them. +
+
+ )} + +
+ Recommendation: + Review the script before executing. You can download and inspect it first with the Download button above. +
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/components/script-output.tsx b/src/components/script-output.tsx index 7dc8c80..bd77938 100644 --- a/src/components/script-output.tsx +++ b/src/components/script-output.tsx @@ -10,16 +10,20 @@ import { } from '@/lib/script-generator'; import { Download, Copy, Check, ChevronLeft, Link2, Terminal, - RefreshCw, Clock, HardDrive, Package, FileText, StickyNote, + RefreshCw, Clock, HardDrive, FileText, StickyNote, } from 'lucide-react'; import { useState, useEffect } from 'react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { useToast } from '@/hooks/use-toast'; +import { ScriptExplanation } from './script-explanation'; +import { copyToClipboard } from '@/lib/utils'; type Tab = 'script' | 'brewfile' | 'curl'; export function ScriptOutput() { const { os, shell, bucket, setCurrentStep, clearBucket } = useStore(); + const { toast } = useToast(); const [script, setScript] = useState(''); const [brewfile, setBrewfile] = useState(''); const [activeTab, setActiveTab] = useState('script'); @@ -42,9 +46,14 @@ export function ScriptOutput() { const pinnedPackages = bucket.filter((p) => p.versionNote?.trim()); const handleCopy = async (text: string) => { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const success = await copyToClipboard(text); + if (success) { + setCopied(true); + toast.success('📋 Script copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } else { + toast.error('❌ Failed to copy to clipboard'); + } }; const handleGenerateCurlUrl = async () => { @@ -56,12 +65,22 @@ export function ScriptOutput() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ script, os, packages: bucket.map((p) => p.name) }), }); - if (!res.ok) throw new Error(); + if (!res.ok) { + if (res.status === 429) { + toast.error('⏳ Rate limit reached, please wait'); + throw new Error('Rate limit'); + } + throw new Error(); + } const { id } = await res.json(); setCurlUrl(`${window.location.origin}/api/script-share?id=${id}`); setActiveTab('curl'); - } catch { - setCurlError('Failed to generate URL. Please try again.'); + toast.success('🔗 Shareable URL created (expires in 24h)'); + } catch (error) { + if ((error as Error).message !== 'Rate limit') { + setCurlError('Failed to generate URL. Please try again.'); + toast.error('🌐 Connection error, please try again'); + } } finally { setCurlLoading(false); } @@ -69,9 +88,14 @@ export function ScriptOutput() { const handleCopyCurl = async () => { if (!curlUrl) return; - await navigator.clipboard.writeText(`bash <(curl -fsSL "${curlUrl}")`); - setCurlCopied(true); - setTimeout(() => setCurlCopied(false), 2000); + const success = await copyToClipboard(`bash <(curl -fsSL "${curlUrl}")`); + if (success) { + setCurlCopied(true); + toast.success('📋 Curl command copied to clipboard'); + setTimeout(() => setCurlCopied(false), 2000); + } else { + toast.error('❌ Failed to copy to clipboard'); + } }; const tabs: { id: Tab; label: string; icon: React.ReactNode; macOnly?: boolean }[] = [ @@ -178,6 +202,16 @@ export function ScriptOutput() { )}
)} + + {/* Script Explanation */} +
+ +
{/* Tabs */} @@ -293,8 +327,9 @@ export function ScriptOutput() { {!curlUrl ? (
-
- $ bash <(curl -fsSL "https://…/api/script-share?id=xxxxxxxx") +
+
$ bash <(curl -fsSL "https://…/api/script-share?id=xxxxxxxx")
+
# Add --verbose for detailed logs

⚠️ Security reminder

@@ -338,7 +373,14 @@ export function ScriptOutput() { className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-all terminal-glow text-sm font-mono"> {curlCopied ? <> Copied! : <> Copy One-liner} - @@ -349,19 +391,45 @@ export function ScriptOutput() { {[ { label: 'wget', cmd: `bash <(wget -qO- "${curlUrl}")` }, { label: 'pipe to bash', cmd: `curl -fsSL "${curlUrl}" | bash` }, - { label: 'download only', cmd: `curl -fsSL "${curlUrl}" -o setup.sh` }, + { label: 'download only', cmd: `curl -fsSL "${curlUrl}" -o setup.sh && chmod +x setup.sh` }, ].map(({ label, cmd }) => (
{cmd} -
))} +
+ + curl -fsSL "{curlUrl}" | bash -s -- --verbose + + with logs + +
)} @@ -369,14 +437,19 @@ export function ScriptOutput() { )} {/* Footer */} -
+
-

- 💡 chmod +x sudo-start-setup.sh && ./sudo-start-setup.sh -

+
+

+ 💡 chmod +x sudo-start-setup.sh && ./sudo-start-setup.sh +

+

+ 📋 Add --verbose to see detailed installation logs +

+
diff --git a/src/components/search-bar.tsx b/src/components/search-bar.tsx index cb0cd7b..f12e242 100644 --- a/src/components/search-bar.tsx +++ b/src/components/search-bar.tsx @@ -4,7 +4,9 @@ import { useStore } from '@/lib/store'; import { appCatalog, getAppsForOS } from '@/lib/apps'; import { Package } from '@/types'; import { Search, X, Plus, Check } from 'lucide-react'; -import { useState, useRef, useEffect, useMemo } from 'react'; +import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { useFocusTrap, useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; + const categoryIcons: Record = { ide: '📝', @@ -36,8 +38,10 @@ interface SearchBarProps { export function SearchBar({ onClose }: SearchBarProps) { const { os, bucket, addToBucket, removeFromBucket } = useStore(); const [query, setQuery] = useState(''); + const [focusedIndex, setFocusedIndex] = useState(-1); const inputRef = useRef(null); const containerRef = useRef(null); + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); const availableApps = useMemo(() => { if (!os) return appCatalog; @@ -48,23 +52,8 @@ export function SearchBar({ onClose }: SearchBarProps) { inputRef.current?.focus(); }, []); - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [onClose]); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - onClose(); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [onClose]); + // Focus trap for modal + useFocusTrap(containerRef, true); const results = useMemo(() => { if (!query.trim()) return []; @@ -87,19 +76,82 @@ export function SearchBar({ onClose }: SearchBarProps) { return availableApps.filter((p) => popular.includes(p.id)).slice(0, 6); }, [query, availableApps]); - const isInBucket = (pkg: Package) => bucket.some((p) => p.id === pkg.id); + const isInBucket = useCallback((pkg: Package) => bucket.some((p) => p.id === pkg.id), [bucket]); - const handleToggle = (pkg: Package) => { + const handleToggle = useCallback((pkg: Package) => { if (isInBucket(pkg)) { removeFromBucket(pkg.id); } else { addToBucket({ ...pkg, selectedVersion: pkg.defaultVersion }); } - }; + }, [isInBucket, removeFromBucket, addToBucket]); const displayItems = query.trim() ? results : suggestions; const showingSuggestions = !query.trim(); + // Navigate results with arrow keys + const navigateResults = useCallback((direction: 'up' | 'down') => { + if (displayItems.length === 0) return; + + setFocusedIndex((prev) => { + let next: number; + if (direction === 'down') { + next = prev < displayItems.length - 1 ? prev + 1 : 0; + } else { + next = prev > 0 ? prev - 1 : displayItems.length - 1; + } + // Focus the item + setTimeout(() => { + itemRefs.current[next]?.focus(); + }, 0); + return next; + }); + }, [displayItems.length]); + + // Select focused item + const selectFocusedItem = useCallback(() => { + if (focusedIndex >= 0 && focusedIndex < displayItems.length) { + handleToggle(displayItems[focusedIndex]); + } + }, [focusedIndex, displayItems, handleToggle]); + + // Keyboard shortcuts for search + const searchShortcuts = [ + { + key: 'ArrowDown', + description: 'Navigate down in results', + action: () => navigateResults('down'), + }, + { + key: 'ArrowUp', + description: 'Navigate up in results', + action: () => navigateResults('up'), + }, + { + key: 'Enter', + description: 'Select focused item', + action: selectFocusedItem, + }, + { + key: 'Escape', + description: 'Close search', + action: onClose, + preventDefault: false, + }, + ]; + + useKeyboardShortcuts(searchShortcuts, true); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose]); + return (
@@ -123,8 +175,8 @@ export function SearchBar({ onClose }: SearchBarProps) { ESC + )} +
+ + + + {/* Progress bar for auto-dismiss */} +
+ + +
+ ); +} diff --git a/src/components/toaster.tsx b/src/components/toaster.tsx new file mode 100644 index 0000000..d1fc023 --- /dev/null +++ b/src/components/toaster.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useContext } from 'react'; +import { ToastContext } from '@/lib/toast-context'; +import { Toast } from './toast'; +import { cn } from '@/lib/utils'; + +interface ToasterProps { + position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center'; +} + +const positionStyles = { + 'top-left': 'top-4 left-4', + 'top-right': 'top-4 right-4', + 'bottom-left': 'bottom-4 left-4', + 'bottom-right': 'bottom-4 right-4', + 'top-center': 'top-4 left-1/2 -translate-x-1/2', + 'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2', +}; + +export function Toaster({ position = 'bottom-right' }: ToasterProps) { + const context = useContext(ToastContext); + + if (!context) { + return null; + } + + const { toasts, removeToast } = context; + + if (toasts.length === 0) { + return null; + } + + return ( +
+ {toasts.map((toast, index) => ( +
+ removeToast(toast.id)} + index={toasts.length - 1 - index} + /> +
+ ))} +
+ ); +} diff --git a/src/components/version-note.tsx b/src/components/version-note.tsx index 4e44a5b..24612d1 100644 --- a/src/components/version-note.tsx +++ b/src/components/version-note.tsx @@ -29,7 +29,7 @@ export function VersionNote({ pkgId, pkgName, version, note, onSave, variant = ' textareaRef.current.focus(); textareaRef.current.setSelectionRange(draft.length, draft.length); } - }, [isEditing]); + }, [isEditing, draft.length]); const handleSave = useCallback(() => { onSave(pkgId, draft.trim()); diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts new file mode 100644 index 0000000..b34c984 --- /dev/null +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -0,0 +1,234 @@ +'use client'; + +import { useEffect, useCallback, useRef } from 'react'; + +export interface ShortcutConfig { + [key: string]: () => void; +} + +export interface ShortcutDefinition { + key: string; + modifiers?: { + ctrl?: boolean; + meta?: boolean; + shift?: boolean; + alt?: boolean; + }; + description: string; + action: () => void; + preventDefault?: boolean; +} + +/** + * Hook for managing keyboard shortcuts + * Supports both simple key combinations and complex modifier combinations + */ +export function useKeyboardShortcuts( + shortcuts: ShortcutDefinition[], + enabled: boolean = true +) { + const shortcutsRef = useRef(shortcuts); + + // Keep ref up to date + useEffect(() => { + shortcutsRef.current = shortcuts; + }, [shortcuts]); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (!enabled) return; + + for (const shortcut of shortcutsRef.current) { + const { key, modifiers = {}, action, preventDefault = true } = shortcut; + + // Check if key matches (case insensitive for letters) + const keyMatches = e.key.toLowerCase() === key.toLowerCase(); + + // Check modifiers + const ctrlMatches = modifiers.ctrl === undefined || e.ctrlKey === modifiers.ctrl; + const metaMatches = modifiers.meta === undefined || e.metaKey === modifiers.meta; + const shiftMatches = modifiers.shift === undefined || e.shiftKey === modifiers.shift; + const altMatches = modifiers.alt === undefined || e.altKey === modifiers.alt; + + // Special handling for Cmd/Ctrl - treat them as equivalent + const isCmdOrCtrl = (e.metaKey || e.ctrlKey) && (modifiers.ctrl || modifiers.meta); + const cmdMatches = isCmdOrCtrl || (ctrlMatches && metaMatches); + + if (keyMatches && cmdMatches && shiftMatches && altMatches) { + if (preventDefault) { + e.preventDefault(); + } + action(); + break; + } + } + }, [enabled]); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); +} + +/** + * Hook for focus management within a container + * Implements focus trap for modals and restores focus on close + */ +export function useFocusTrap( + containerRef: React.RefObject, + isActive: boolean +) { + const previousFocusRef = useRef(null); + + useEffect(() => { + if (isActive) { + // Store the currently focused element + previousFocusRef.current = document.activeElement as HTMLElement; + + // Focus the first focusable element in the container + const focusableElements = getFocusableElements(containerRef.current); + if (focusableElements.length > 0) { + focusableElements[0].focus(); + } + } else if (previousFocusRef.current) { + // Restore focus when deactivated + previousFocusRef.current.focus(); + } + }, [isActive, containerRef]); + + const handleTabKey = useCallback((e: KeyboardEvent) => { + if (!isActive || !containerRef.current) return; + + const focusableElements = getFocusableElements(containerRef.current); + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.key === 'Tab') { + if (e.shiftKey) { + // Shift + Tab + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } + } else { + // Tab + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + } + } + }, [isActive, containerRef]); + + useEffect(() => { + if (isActive) { + document.addEventListener('keydown', handleTabKey); + return () => document.removeEventListener('keydown', handleTabKey); + } + }, [isActive, handleTabKey]); +} + +/** + * Get all focusable elements within a container + */ +function getFocusableElements(container: HTMLElement | null): HTMLElement[] { + if (!container) return []; + + const selectors = [ + 'button:not([disabled])', + 'a[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable]', + ].join(', '); + + return Array.from(container.querySelectorAll(selectors)).filter( + (el) => { + // Filter out hidden elements + const element = el as HTMLElement; + return element.offsetParent !== null && !element.hasAttribute('disabled'); + } + ) as HTMLElement[]; +} + +/** + * Hook for arrow key navigation in lists + */ +export function useArrowKeyNavigation( + itemCount: number, + onSelect: (index: number) => void, + enabled: boolean = true +) { + const [focusedIndex, setFocusedIndex] = useState(-1); + + useEffect(() => { + if (!enabled) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (itemCount === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex((prev) => { + const next = prev < itemCount - 1 ? prev + 1 : 0; + return next; + }); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex((prev) => { + const next = prev > 0 ? prev - 1 : itemCount - 1; + return next; + }); + break; + case 'Enter': + if (focusedIndex >= 0 && focusedIndex < itemCount) { + e.preventDefault(); + onSelect(focusedIndex); + } + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [enabled, itemCount, focusedIndex, onSelect]); + + return { focusedIndex, setFocusedIndex }; +} + +import { useState } from 'react'; + +/** + * Format shortcut for display (e.g., "⌘K" or "Ctrl+K") + */ +export function formatShortcut(key: string, isMac: boolean = false): string { + const modifier = isMac ? '⌘' : 'Ctrl'; + return `${modifier}${key}`; +} + +/** + * Check if user is on Mac + */ +export function isMac(): boolean { + if (typeof navigator === 'undefined') return false; + return navigator.platform.toUpperCase().indexOf('MAC') >= 0; +} + +/** + * Get the appropriate modifier key symbol for the current platform + */ +export function getModifierSymbol(): string { + return isMac() ? '⌘' : 'Ctrl'; +} + +/** + * Get the full modifier key name for the current platform + */ +export function getModifierKey(): string { + return isMac() ? 'Cmd' : 'Ctrl'; +} diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000..b5a2cab --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useContext, useCallback } from 'react'; +import { ToastContext } from '@/lib/toast-context'; +import { ToastType, ToastOptions } from '@/types/toast'; + +export function useToast() { + const context = useContext(ToastContext); + + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + + const { addToast, removeToast, clearAll } = context; + + const createToast = useCallback( + (type: ToastType) => + (message: string, options?: ToastOptions) => { + return addToast({ + type, + message, + duration: options?.duration, + action: options?.action, + }); + }, + [addToast] + ); + + return { + toast: { + success: createToast('success'), + error: createToast('error'), + warning: createToast('warning'), + info: createToast('info'), + }, + dismiss: removeToast, + dismissAll: clearAll, + }; +} diff --git a/src/lib/script-generator.ts b/src/lib/script-generator.ts index 89fd4bd..8956f69 100644 --- a/src/lib/script-generator.ts +++ b/src/lib/script-generator.ts @@ -104,7 +104,9 @@ export function generateScript( lines.push('GREEN="\\033[0;32m"'); lines.push('YELLOW="\\033[1;33m"'); lines.push('CYAN="\\033[0;36m"'); + lines.push('BLUE="\\033[0;34m"'); lines.push('RESET="\\033[0m"'); + lines.push('BOLD="\\033[1m"'); lines.push(''); lines.push('log() { echo -e "${CYAN}[SudoStart]${RESET} $*"; }'); lines.push('ok() { echo -e "${GREEN} ✓${RESET} $*"; }'); @@ -112,6 +114,52 @@ export function generateScript( lines.push('err() { echo -e "${RED} ✗${RESET} $*" >&2; }'); lines.push(''); + // Progress bar functions + lines.push('# ── Progress bar functions ───────────────────────────'); + lines.push('PROGRESS_WIDTH=30'); + lines.push('draw_progress_bar() {'); + lines.push(' local current=$1'); + lines.push(' local total=$2'); + lines.push(' local name="$3"'); + lines.push(' local filled=$((current * PROGRESS_WIDTH / total))'); + lines.push(' local empty=$((PROGRESS_WIDTH - filled))'); + lines.push(' local bar=""'); + lines.push(' for ((i=0; i /dev/null 2>&1; do'); + lines.push(' local temp=${spinstr#?}'); + lines.push(' printf " ${CYAN}%c${RESET}" "$spinstr"'); + lines.push(' local spinstr=$temp${spinstr%"$temp"}'); + lines.push(' sleep $delay'); + lines.push(' printf "\\b\\b "'); + lines.push(' done'); + lines.push(' printf "\\b\\b"'); + lines.push('}'); + lines.push(''); + lines.push('echo ""'); lines.push('echo -e "${CYAN}╔══════════════════════════════════════════════════╗${RESET}"'); lines.push('echo -e "${CYAN}║ SudoStart — System Setup Initialization ║${RESET}"'); @@ -125,18 +173,30 @@ export function generateScript( lines.push('# ── Bootstrap: Homebrew ───────────────────────────────'); lines.push('if ! command -v brew &>/dev/null; then'); lines.push(' log "Installing Homebrew..."'); - lines.push(' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'); + lines.push(' if [ "$VERBOSE" = true ]; then'); + lines.push(' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'); + lines.push(' else'); + lines.push(' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 2>&1 | tail -5'); + lines.push(' fi'); lines.push(' ok "Homebrew installed"'); lines.push('else'); lines.push(' log "Homebrew found — updating..."'); - lines.push(' brew update --quiet'); + lines.push(' if [ "$VERBOSE" = true ]; then'); + lines.push(' brew update'); + lines.push(' else'); + lines.push(' brew update --quiet 2>&1 | tail -3'); + lines.push(' fi'); lines.push(' ok "Homebrew up to date"'); lines.push('fi'); lines.push(''); } else { lines.push('# ── Bootstrap: apt ────────────────────────────────────'); lines.push('log "Updating package lists..."'); - lines.push('sudo apt-get update -qq'); + lines.push('if [ "$VERBOSE" = true ]; then'); + lines.push(' sudo apt-get update'); + lines.push('else'); + lines.push(' sudo apt-get update -qq 2>&1 | tail -5'); + lines.push('fi'); lines.push('ok "Package lists updated"'); lines.push(''); @@ -144,7 +204,11 @@ export function generateScript( lines.push('# ── Bootstrap: Flatpak ────────────────────────────────'); lines.push('if ! command -v flatpak &>/dev/null; then'); lines.push(' log "Installing Flatpak..."'); - lines.push(' sudo apt-get install -y flatpak'); + lines.push(' if [ "$VERBOSE" = true ]; then'); + lines.push(' sudo apt-get install -y flatpak'); + lines.push(' else'); + lines.push(' sudo apt-get install -y flatpak -qq 2>&1 | tail -3'); + lines.push(' fi'); lines.push(' sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo'); lines.push(' warn "A system restart may be required for Flatpak apps to appear"'); lines.push('else'); @@ -155,7 +219,12 @@ export function generateScript( } if (packages.length > 0) { - lines.push('log "Starting package installation..."'); + lines.push('# ── Package Installation ─────────────────────────────'); + lines.push(`TOTAL_PACKAGES=${packages.length}`); + lines.push('CURRENT_PACKAGE=0'); + lines.push(''); + lines.push('echo ""'); + lines.push(`echo -e "\${BOLD}Installing ${packages.length} package(s)...\${RESET}"`); lines.push('echo ""'); lines.push(''); @@ -164,15 +233,16 @@ export function generateScript( const isGeneric = ['stable', 'latest'].includes(versionId); const versionLabel = isGeneric ? '' : ` @ ${versionId}`; - lines.push(`# ── [${idx + 1}/${packages.length}] ${pkg.name}${versionLabel} ${'─'.repeat(Math.max(0, 44 - pkg.name.length - versionLabel.length))}`); + lines.push(`# [${idx + 1}/${packages.length}] ${pkg.name}${versionLabel}`); + lines.push(`CURRENT_PACKAGE=$((CURRENT_PACKAGE + 1))`); + lines.push(`draw_progress_bar $CURRENT_PACKAGE ${packages.length} "${pkg.name}"`); + lines.push(''); // Emit pin note as inline comment if present if (pkg.versionNote?.trim()) { lines.push(`# 📌 Pin note: ${pkg.versionNote.trim()}`); } - lines.push(`log "Installing ${pkg.name}${versionLabel}..."`); - const installCmd = resolveCommand(pkg, os); if (!installCmd || installCmd.trim().startsWith('#')) { @@ -181,21 +251,42 @@ export function generateScript( const checkCmd = getCheckCommand(pkg.id); if (checkCmd) { lines.push(`if command -v ${checkCmd} &>/dev/null; then`); - lines.push(` ok "${pkg.name} already installed — skipping"`); + lines.push(` : # Already installed`); lines.push('else'); + // Run install with suppressed output unless verbose + lines.push(' if [ "$VERBOSE" = true ]; then'); installCmd.split('\n').forEach((line) => { - lines.push(` ${line}`); + lines.push(` ${line}`); + }); + lines.push(' else'); + lines.push(' # Suppress output and show only errors'); + installCmd.split('\n').forEach((line) => { + if (line.trim()) { + lines.push(` ${line} > /dev/null 2>&1 || true`); + } }); - lines.push(` ok "${pkg.name}${versionLabel} installed"`); + lines.push(' fi'); lines.push('fi'); } else { - lines.push(installCmd); - lines.push(`ok "${pkg.name}${versionLabel} done"`); + lines.push('if [ "$VERBOSE" = true ]; then'); + installCmd.split('\n').forEach((line) => { + lines.push(` ${line}`); + }); + lines.push('else'); + installCmd.split('\n').forEach((line) => { + if (line.trim()) { + lines.push(` ${line} > /dev/null 2>&1 || true`); + } + }); + lines.push('fi'); } } lines.push(''); }); + + lines.push('clear_line'); + lines.push(''); } lines.push('echo ""'); @@ -207,6 +298,10 @@ export function generateScript( lines.push(`echo " Shell : ${shell}"`); lines.push(`echo " Packages : ${packages.length} installed"`); lines.push('echo ""'); + lines.push('if [ "$VERBOSE" = false ]; then'); + lines.push(' echo -e "${CYAN}Tip:${RESET} Run with ${BOLD}--verbose${RESET} flag to see detailed output"'); + lines.push('fi'); + lines.push('echo ""'); return lines.join('\n'); } diff --git a/src/lib/toast-context.tsx b/src/lib/toast-context.tsx new file mode 100644 index 0000000..5d1af7e --- /dev/null +++ b/src/lib/toast-context.tsx @@ -0,0 +1,54 @@ +'use client'; + +import React, { createContext, useCallback, useState, useRef } from 'react'; +import { Toast, ToastContextType } from '@/types/toast'; + +export const ToastContext = createContext(undefined); + +const MAX_TOASTS = 5; +const DEFAULT_DURATION = 4000; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const toastTimers = useRef>(new Map()); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + const timer = toastTimers.current.get(id); + if (timer) { + clearTimeout(timer); + toastTimers.current.delete(id); + } + }, []); + + const addToast = useCallback((toast: Omit) => { + const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const duration = toast.duration ?? DEFAULT_DURATION; + + setToasts((prev) => { + // Remove oldest toast if at max capacity + const newToasts = prev.length >= MAX_TOASTS ? prev.slice(1) : prev; + return [...newToasts, { ...toast, id }]; + }); + + // Auto-dismiss timer + const timer = setTimeout(() => { + removeToast(id); + }, duration); + toastTimers.current.set(id, timer); + + return id; + }, [removeToast]); + + const clearAll = useCallback(() => { + toastTimers.current.forEach((timer) => clearTimeout(timer)); + toastTimers.current.clear(); + setToasts([]); + }, []); + + return ( + + {children} + + ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..f826672 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,38 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Copy text to clipboard with fallback for non-secure contexts + * Tries multiple methods: Clipboard API -> execCommand -> manual selection + */ +export async function copyToClipboard(text: string): Promise { + // Method 1: Modern Clipboard API (requires secure context) + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to next method + } + } + + // Method 2: execCommand fallback (works in non-secure contexts) + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + textArea.style.top = '0'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + return successful; + } catch { + document.body.removeChild(textArea); + return false; + } +} diff --git a/src/types/toast.ts b/src/types/toast.ts new file mode 100644 index 0000000..a17bad8 --- /dev/null +++ b/src/types/toast.ts @@ -0,0 +1,27 @@ +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface ToastAction { + label: string; + onClick: () => void; +} + +export interface Toast { + id: string; + type: ToastType; + title?: string; + message: string; + duration?: number; + action?: ToastAction; +} + +export interface ToastOptions { + duration?: number; + action?: ToastAction; +} + +export interface ToastContextType { + toasts: Toast[]; + addToast: (toast: Omit) => string; + removeToast: (id: string) => void; + clearAll: () => void; +}