mirror of
https://github.com/abusoww/tuxmate.git
synced 2026-04-17 19:53:11 +02:00
fix: service worker network-first strategy + lint fixes
This commit is contained in:
@@ -1,164 +0,0 @@
|
||||
# AccessGuide.io - Accessibility Guidelines Reference
|
||||
|
||||
*Compiled from [Access Guide](https://www.accessguide.io/) - a friendly introduction to digital accessibility based on WCAG 2.1*
|
||||
|
||||
---
|
||||
|
||||
## 1. Hover and Focus Best Practices
|
||||
|
||||
> WCAG criterion [1.4.13 Content on Hover or Focus](https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html) (Level AA)
|
||||
|
||||
### Why This is Important
|
||||
|
||||
If content appears and disappears on hover or focus, this can feel frustrating, unpredictable, and disruptive. Use best practices to make hover and focus more predictable and less likely to cause errors.
|
||||
|
||||
This is especially accessible for:
|
||||
- People with **physical disabilities** (unpredictable or specific movement)
|
||||
- People with **visual disabilities** (screen reader users)
|
||||
- People with **cognitive disabilities**
|
||||
|
||||
### Implementation Guidelines
|
||||
|
||||
#### Dismissible
|
||||
There should be a way to dismiss new content **without moving hover or changing focus**. This prevents disrupting users in the middle of tasks.
|
||||
|
||||
**Best practice:** Use the "Escape" key to dismiss content.
|
||||
|
||||
#### Hoverable
|
||||
New content should **remain visible if the user hovers over it**.
|
||||
|
||||
Sometimes content appears when hovering the trigger element but disappears when hovering over the new content. This is frustrating. The content must remain visible whether hover is on the trigger OR the content itself.
|
||||
|
||||
#### Persistent
|
||||
Content must remain visible until:
|
||||
- The user dismisses it
|
||||
- The user moves the mouse off of it or the trigger
|
||||
- The content no longer contains important information
|
||||
|
||||
---
|
||||
|
||||
## 2. Keyboard Accessibility
|
||||
|
||||
> WCAG criterion [2.1.1 Keyboard](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html) (Level A)
|
||||
|
||||
### Why This is Important
|
||||
|
||||
Full keyboard functionality is essential for:
|
||||
- People who rely on keyboards
|
||||
- Blind/visually impaired people using screen readers
|
||||
- People with motor disabilities who can't use a mouse
|
||||
|
||||
**All functionality available to mouse users should be available to keyboard users.**
|
||||
|
||||
### Basic Keyboard Access
|
||||
|
||||
Keyboard accessibility is enabled by default in browsers. **Don't remove or deactivate these defaults** - enhance them instead.
|
||||
|
||||
Keyboard accessibility includes:
|
||||
- ✅ Visible focus indicator
|
||||
- ✅ Intuitive focus order
|
||||
- ✅ Skip links to bypass repeating content
|
||||
- ✅ Way to turn off character key shortcuts
|
||||
- ✅ No keyboard traps
|
||||
|
||||
**Common keyboard-accessible interactions:**
|
||||
- Browsing navigation
|
||||
- Filling out forms
|
||||
- Accessing buttons and links
|
||||
|
||||
### Advanced Keyboard Access
|
||||
|
||||
For complex interactions (drawing programs, drag-and-drop):
|
||||
- Translate gestures into keyboard commands
|
||||
- Use **Tab, Enter, Space, and Arrow keys** (most common)
|
||||
|
||||
**Examples from Salesforce:**
|
||||
- Interact with a canvas (move/resize objects)
|
||||
- Move between lists
|
||||
- Sort lists
|
||||
|
||||
---
|
||||
|
||||
## 3. Focus Indicator Visibility
|
||||
|
||||
> WCAG criterion [2.4.7 Focus Visible](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html) (Level AA)
|
||||
|
||||
### Why This is Important
|
||||
|
||||
A visible focus indicator shows keyboard users what element they're currently interacting with.
|
||||
|
||||
Benefits:
|
||||
- Shows what element is ready for user input
|
||||
- Helps people with executive dysfunction
|
||||
- Reduces cognitive load by focusing attention
|
||||
|
||||
### Implementation Guidelines
|
||||
|
||||
#### Never Remove Default Focus Indicator
|
||||
```css
|
||||
/* ❌ NEVER DO THIS unless replacing with custom styling */
|
||||
outline: none;
|
||||
```
|
||||
|
||||
#### Design Accessible Hover and Focus States
|
||||
|
||||
**All interactive elements need hover AND focus states:**
|
||||
- Buttons, links
|
||||
- Text fields
|
||||
- Navigation elements
|
||||
- Radio buttons, checkboxes
|
||||
|
||||
**Difference between hover and focus:**
|
||||
| State | Purpose |
|
||||
|-------|---------|
|
||||
| Hover | "You could interact with this" |
|
||||
| Focus | "You ARE interacting with this right now" |
|
||||
|
||||
**Other important states:**
|
||||
- Error state
|
||||
- Loading state
|
||||
- Inactive/disabled state
|
||||
|
||||
#### Focus Indicator Contrast
|
||||
|
||||
The focus indicator must be visible against **both**:
|
||||
- The element itself
|
||||
- The background behind it
|
||||
|
||||
**WCAG requirement:** 3:1 contrast ratio for UI components
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Principles Summary
|
||||
|
||||
### For TuxMate Application
|
||||
|
||||
| Principle | Application |
|
||||
|-----------|-------------|
|
||||
| **Dismissible** | Tooltips close with Escape key |
|
||||
| **Hoverable** | Tooltips stay visible when hovering content |
|
||||
| **Persistent** | Content stays until user dismisses |
|
||||
| **Keyboard** | All features work with Tab/Arrow/Enter/Space |
|
||||
| **Focus Visible** | Clear visual indicator on focused elements |
|
||||
| **Contrast** | 3:1 minimum for UI components |
|
||||
|
||||
### Recommended Key Bindings
|
||||
|
||||
| Key | Common Action |
|
||||
|-----|---------------|
|
||||
| `Tab` | Move to next focusable element |
|
||||
| `Shift+Tab` | Move to previous element |
|
||||
| `Enter/Space` | Activate/select |
|
||||
| `Arrow keys` | Navigate within components |
|
||||
| `Escape` | Dismiss/close |
|
||||
| `?` | Show help |
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [MDN Web Docs - Keyboard](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_WCAG/Keyboard)
|
||||
- [18F - Keyboard Access](https://accessibility.18f.gov/keyboard/)
|
||||
- [Style hover, focus, and active states differently](https://zellwk.com/blog/style-hover-focus-active-states/)
|
||||
- [Accessible Custom Focus Indicators](https://uxdesign.cc/accessible-custom-focus-indicators-da4768d1fb7b)
|
||||
52
public/sw.js
52
public/sw.js
@@ -1,16 +1,14 @@
|
||||
// TuxMate Service Worker
|
||||
// caches the entire static app for offline use - perfect for those fresh installs with spotty wifi
|
||||
// caches the app for offline use - network-first so you always get fresh styles
|
||||
|
||||
const CACHE_NAME = 'tuxmate-v1';
|
||||
const CACHE_NAME = 'tuxmate-v2';
|
||||
|
||||
// install: we'll cache on-demand instead of precaching
|
||||
// Next.js dev mode doesn't have static files where we expect them
|
||||
// install: skip waiting so updates apply immediately
|
||||
self.addEventListener('install', () => {
|
||||
// skipWaiting so updates apply immediately
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// activate: clean up old caches when we update
|
||||
// activate: clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
@@ -20,48 +18,34 @@ self.addEventListener('activate', (event) => {
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
}).then(() => {
|
||||
// take control of all clients immediately
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// fetch: cache-first with network fallback
|
||||
// cache everything we successfully fetch for offline use
|
||||
// fetch: network-first with cache fallback (for offline support)
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// only handle GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// skip chrome-extension, analytics, and other non-http requests
|
||||
const url = new URL(event.request.url);
|
||||
if (!url.protocol.startsWith('http')) return;
|
||||
|
||||
// skip external requests (analytics, fonts CDN, etc.) - only cache our stuff
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
// got it cached, serve it up
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// not cached, fetch and cache it for next time
|
||||
return fetch(event.request).then((response) => {
|
||||
// don't cache non-ok responses
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response;
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// got fresh response, cache it for offline
|
||||
if (response && response.status === 200 && response.type === 'basic') {
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
}
|
||||
|
||||
// clone because response can only be consumed once
|
||||
const responseToCache = response.clone();
|
||||
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// network failed, try cache (offline mode)
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -37,7 +37,11 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
// Script to run before React hydrates to prevent theme flash
|
||||
/**
|
||||
* Inline script that runs before React hydrates.
|
||||
* Sets the theme immediately to prevent that jarring flash when
|
||||
* you visit the page at 2am and get blinded by light mode.
|
||||
*/
|
||||
const themeScript = `
|
||||
(function() {
|
||||
try {
|
||||
|
||||
@@ -100,8 +100,7 @@ export default function Home() {
|
||||
return cols;
|
||||
}, [allCategoriesWithApps]);
|
||||
|
||||
// ========================================================================
|
||||
// Category Expansion State
|
||||
// Category expansion - all open by default because hiding stuff is annoying
|
||||
// ========================================================================
|
||||
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(() => new Set(categories));
|
||||
@@ -141,8 +140,7 @@ export default function Home() {
|
||||
toggleApp
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// Header Animation
|
||||
// Header animation - makes the logo look fancy on first load
|
||||
// ========================================================================
|
||||
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
@@ -189,8 +187,7 @@ export default function Home() {
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Render
|
||||
// Finally, the actual page
|
||||
// ========================================================================
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
import { memo } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { distros, type DistroId, type AppData } from '@/lib/data';
|
||||
import { analytics } from '@/lib/analytics';
|
||||
import { isAurPackage } from '@/lib/aur';
|
||||
import { AppIcon } from './AppIcon';
|
||||
|
||||
// Each app row in the list. Memoized because there are a LOT of these.
|
||||
/**
|
||||
* Individual app row in the category list.
|
||||
* Memoized because we render hundreds of these and React was having a moment.
|
||||
* Handles selection state, availability indicators, AUR badges, and tooltips.
|
||||
*/
|
||||
|
||||
// Basic Tailwind-ish color palette for mapping
|
||||
// Tailwind colors as hex - because CSS variables don't work in inline styles
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
'orange': '#f97316',
|
||||
'blue': '#3b82f6',
|
||||
@@ -62,8 +65,7 @@ export const AppItem = memo(function AppItem({
|
||||
// Special styling for AUR packages (Arch users love their badges)
|
||||
const isAur = selectedDistro === 'arch' && app.targets?.arch && isAurPackage(app.targets.arch);
|
||||
|
||||
// Determine effective color (AUR overrides category color for the checkbox/badge, but maybe not the row border)
|
||||
// Actually, let's keep the row border as the category color for consistency
|
||||
// AUR gets its special Arch blue, everything else uses category color
|
||||
const hexColor = COLOR_MAP[color] || COLOR_MAP['gray'];
|
||||
const checkboxColor = isAur ? '#1793d1' : hexColor;
|
||||
|
||||
@@ -90,7 +92,6 @@ export const AppItem = memo(function AppItem({
|
||||
e.stopPropagation();
|
||||
onFocus?.();
|
||||
if (isAvailable) {
|
||||
const willBeSelected = !isSelected;
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Network, Lock, Share2, Cpu, type LucideIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
// Map category names to their icons. If you add a category, add an icon here.
|
||||
const CATEGORY_ICONS: Record<string, LucideIcon> = {
|
||||
'Web Browsers': Globe,
|
||||
'Communication': MessageCircle,
|
||||
@@ -24,7 +25,7 @@ const CATEGORY_ICONS: Record<string, LucideIcon> = {
|
||||
'System': Cpu,
|
||||
};
|
||||
|
||||
// Basic Tailwind-ish color palette for mapping
|
||||
// Tailwind colors as hex
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
'orange': '#f97316',
|
||||
'blue': '#3b82f6',
|
||||
@@ -43,7 +44,10 @@ const COLOR_MAP: Record<string, string> = {
|
||||
'gray': '#6b7280',
|
||||
};
|
||||
|
||||
// Clickable category header with chevron and selection count
|
||||
/**
|
||||
* Collapsible category header with icon, chevron, and selection badge.
|
||||
* Uses color-mix for dynamic tinting because we're fancy like that.
|
||||
*/
|
||||
export function CategoryHeader({
|
||||
category,
|
||||
isExpanded,
|
||||
|
||||
@@ -7,7 +7,11 @@ import { analytics } from '@/lib/analytics';
|
||||
import { CategoryHeader } from './CategoryHeader';
|
||||
import { AppItem } from './AppItem';
|
||||
|
||||
// A category with its apps - handles animations and expand/collapse
|
||||
/**
|
||||
* A collapsible category section containing a list of apps.
|
||||
* Handles its own GSAP animations because CSS transitions just weren't cutting it.
|
||||
* Memoized to hell and back because React was re-rendering everything.
|
||||
*/
|
||||
interface CategorySectionProps {
|
||||
category: Category;
|
||||
categoryApps: AppData[];
|
||||
@@ -26,7 +30,10 @@ interface CategorySectionProps {
|
||||
onAppFocus?: (appId: string) => void;
|
||||
}
|
||||
|
||||
// Category color mapping
|
||||
/**
|
||||
* Color palette for categories. Vibrant ones go to user-facing stuff,
|
||||
* boring grays go to developer tools because we're used to suffering.
|
||||
*/
|
||||
const categoryColors: Record<Category, string> = {
|
||||
'Web Browsers': 'orange',
|
||||
'Communication': 'blue',
|
||||
@@ -162,7 +169,10 @@ function CategorySectionComponent({
|
||||
);
|
||||
}
|
||||
|
||||
// Custom memo comparison to ensure proper re-renders when categoryApps changes
|
||||
/**
|
||||
* Custom memo comparison because React's shallow compare was killing perf.
|
||||
* This is the kind of thing that makes you question your career choices.
|
||||
*/
|
||||
export const CategorySection = memo(CategorySectionComponent, (prevProps, nextProps) => {
|
||||
// Always re-render if app count changes
|
||||
if (prevProps.categoryApps.length !== nextProps.categoryApps.length) return false;
|
||||
|
||||
@@ -8,7 +8,12 @@ interface AurDrawerSettingsProps {
|
||||
setSelectedHelper: (helper: 'yay' | 'paru') => void;
|
||||
}
|
||||
|
||||
// AUR settings configuration panel
|
||||
/**
|
||||
* AUR package settings panel for Arch users.
|
||||
* Lets you pick between yay and paru, and whether to install the helper.
|
||||
* The naming of hasYayInstalled is a bit misleading - it actually means
|
||||
* "user already has an AUR helper" regardless of which one. Tech debt, I know.
|
||||
*/
|
||||
export function AurDrawerSettings({
|
||||
aurAppNames,
|
||||
hasYayInstalled,
|
||||
|
||||
@@ -12,7 +12,11 @@ interface AurFloatingCardProps {
|
||||
setSelectedHelper: (helper: 'yay' | 'paru') => void;
|
||||
}
|
||||
|
||||
// Floating cards that ask Arch users about their AUR helper (yay vs paru drama)
|
||||
/**
|
||||
* Floating card wizard for Arch users with AUR packages.
|
||||
* Asks whether they have an AUR helper, then which one.
|
||||
* The yay vs paru debate is the real holy war of our time.
|
||||
*/
|
||||
export function AurFloatingCard({
|
||||
show,
|
||||
aurAppNames,
|
||||
@@ -27,7 +31,7 @@ export function AurFloatingCard({
|
||||
const [hasAnswered, setHasAnswered] = useState<boolean | null>(null);
|
||||
// Track if user has selected a helper (completed flow)
|
||||
const [helperChosen, setHelperChosen] = useState(false);
|
||||
// Track if user has interacted (dismissed or selected) to prevent nagging - use ref to persist
|
||||
// Tracks if user has answered - use ref to survive re-renders
|
||||
const userInteractedRef = useRef(false);
|
||||
|
||||
// Reset when new AUR packages appear, BUT ONLY if user hasn't interacted yet
|
||||
|
||||
@@ -23,7 +23,11 @@ interface CommandDrawerProps {
|
||||
distroColor: string;
|
||||
}
|
||||
|
||||
// Bottom sheet for mobile, centered modal for desktop
|
||||
/**
|
||||
* Command drawer that shows the generated install command.
|
||||
* Acts as a bottom sheet on mobile (swipe to dismiss) and a centered modal on desktop.
|
||||
* If you're reading this, yes, I did spend way too much time on the animations.
|
||||
*/
|
||||
export function CommandDrawer({
|
||||
isOpen,
|
||||
isClosing,
|
||||
@@ -41,11 +45,11 @@ export function CommandDrawer({
|
||||
setSelectedHelper,
|
||||
distroColor,
|
||||
}: CommandDrawerProps) {
|
||||
// Swipe-to-dismiss state
|
||||
// Swipe-to-dismiss for mobile users who hate tapping tiny X buttons
|
||||
const [dragOffset, setDragOffset] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartY = useRef(0);
|
||||
const DISMISS_THRESHOLD = 100; // px to drag before closing
|
||||
const DISMISS_THRESHOLD = 100; // Feels right™
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
dragStartY.current = e.touches[0].clientY;
|
||||
@@ -69,6 +73,7 @@ export function CommandDrawer({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Copy command and auto-close after a celebratory 3 seconds
|
||||
const handleCopyAndClose = () => {
|
||||
onCopy();
|
||||
setTimeout(onClose, 3000);
|
||||
@@ -149,8 +154,7 @@ export function CommandDrawer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Terminal window */}
|
||||
{/* Terminal window */}
|
||||
{/* Terminal preview - where the magic gets displayed */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] overflow-hidden shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)]">
|
||||
<span className="text-xs font-mono text-[var(--text-muted)]">bash</span>
|
||||
|
||||
@@ -27,7 +27,11 @@ interface CommandFooterProps {
|
||||
setSelectedHelper: (helper: 'yay' | 'paru') => void;
|
||||
}
|
||||
|
||||
// The sticky footer with command preview and copy/download buttons
|
||||
/**
|
||||
* The sticky footer that shows the generated command and action buttons.
|
||||
* Contains more state than I'd like, but hey, it works.
|
||||
* Keyboard shortcuts are vim-style because we're not savages.
|
||||
*/
|
||||
export function CommandFooter({
|
||||
command,
|
||||
selectedCount,
|
||||
@@ -52,9 +56,11 @@ export function CommandFooter({
|
||||
|
||||
const { toggle: toggleTheme } = useTheme();
|
||||
|
||||
// Track if selection has changed from initial state (user interaction)
|
||||
// Track if user has actually interacted - we hide the bar until then.
|
||||
// Otherwise it just sits there looking sad with "No apps selected".
|
||||
useEffect(() => {
|
||||
if (selectedCount !== initialCountRef.current && !hasEverHadSelection) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setHasEverHadSelection(true);
|
||||
}
|
||||
}, [selectedCount, hasEverHadSelection]);
|
||||
|
||||
@@ -14,7 +14,11 @@ interface ShortcutsBarProps {
|
||||
setSelectedHelper: (helper: 'yay' | 'paru') => void;
|
||||
}
|
||||
|
||||
// Neovim-style statusline - looks cool and shows all the keyboard shortcuts
|
||||
/**
|
||||
* Neovim-style statusline at the bottom.
|
||||
* Shows search, app count, AUR helper toggle, and keyboard shortcuts.
|
||||
* If you use vim, you'll feel right at home.
|
||||
*/
|
||||
export function ShortcutsBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
|
||||
@@ -10,6 +10,11 @@ interface TooltipProps {
|
||||
setRef?: (el: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow-cursor tooltip that appears on hover.
|
||||
* Desktop only - mobile users don't have a cursor to follow.
|
||||
* Supports markdown-ish formatting: **bold**, `code`, and [links](url).
|
||||
*/
|
||||
export function Tooltip({ tooltip, onMouseEnter, onMouseLeave, setRef }: TooltipProps) {
|
||||
const [current, setCurrent] = useState<TooltipState | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
@@ -4,10 +4,12 @@ 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';
|
||||
|
||||
// Dropdown to pick your Linux flavor
|
||||
/**
|
||||
* Distro picker dropdown. Uses portal rendering so the dropdown isn't
|
||||
* clipped by parent overflow. Learned that lesson the hard way.
|
||||
*/
|
||||
export function DistroSelector({
|
||||
selectedDistro,
|
||||
onSelect
|
||||
@@ -40,7 +42,8 @@ export function DistroSelector({
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
// Dropdown rendered via portal to body
|
||||
// Portal the dropdown to body so it's not affected by parent styles.
|
||||
// The positioning math looks scary but it's just "anchor to button bottom-right".
|
||||
const dropdown = isOpen && mounted ? (
|
||||
<>
|
||||
{/* Backdrop with subtle blur */}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { createPortal } from 'react-dom';
|
||||
import { HelpCircle, X } from 'lucide-react';
|
||||
import { analytics } from '@/lib/analytics';
|
||||
|
||||
// The "?" help modal - shows keyboard shortcuts and how to use the app
|
||||
/**
|
||||
* Help modal with keyboard shortcuts and getting started guide.
|
||||
* Opens with "?" key - because that's what you'd naturally press.
|
||||
*/
|
||||
export function HowItWorks() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
@@ -14,7 +14,11 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||
const { theme, toggle } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Prevent hydration mismatch by only rendering after mount
|
||||
/**
|
||||
* Classic hydration mismatch avoidance. Server has no idea what
|
||||
* localStorage says, so we render a placeholder first.
|
||||
* Yes, there's probably a better way. No, I don't want to hear about it.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true)
|
||||
|
||||
@@ -17,7 +17,11 @@ export interface FocusPosition {
|
||||
}
|
||||
|
||||
|
||||
// Vim-style keyboard navigation. Because real devs don't use mice.
|
||||
/**
|
||||
* Vim-style keyboard navigation for the app grid.
|
||||
* Because hjkl is objectively superior to arrow keys.
|
||||
* Also supports arrow keys for the normies.
|
||||
*/
|
||||
export function useKeyboardNavigation(
|
||||
navItems: NavItem[][],
|
||||
onToggleCategory: (id: string) => void,
|
||||
|
||||
@@ -7,7 +7,11 @@ import { isAurPackage } from '@/lib/aur';
|
||||
// Re-export for backwards compatibility
|
||||
export { isAurPackage, AUR_PATTERNS, KNOWN_AUR_PACKAGES } from '@/lib/aur';
|
||||
|
||||
// Everything the app needs to work
|
||||
/**
|
||||
* The big hook that runs the whole show.
|
||||
* Manages distro selection, app selection, localStorage persistence,
|
||||
* and command generation. If this breaks, everything breaks.
|
||||
*/
|
||||
|
||||
export interface UseLinuxInitReturn {
|
||||
selectedDistro: DistroId;
|
||||
@@ -83,7 +87,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
// Persist to localStorage when state changes
|
||||
// Save to localStorage whenever state changes (but not on first render)
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
try {
|
||||
@@ -223,7 +227,7 @@ export function useLinuxInit(): UseLinuxInitReturn {
|
||||
return packageNames.map(p => `sudo snap install ${p}`).join(' && ');
|
||||
}
|
||||
|
||||
// Handle Arch Linux with AUR packages
|
||||
// Arch with AUR packages - this is where it gets fun
|
||||
if (selectedDistro === 'arch' && aurPackageInfo.hasAur) {
|
||||
if (!hasYayInstalled) {
|
||||
// User doesn't have current helper installed - prepend installation
|
||||
|
||||
@@ -11,6 +11,10 @@ interface ThemeContextType {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* Theme provider that syncs with localStorage and system preferences.
|
||||
* Also handles the initial hydration dance to avoid theme flash.
|
||||
*/
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
// Initial state reads from DOM to match what the inline script set
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// AUR package detection - figures out if a package is from AUR or official repos
|
||||
/**
|
||||
* AUR package detection for Arch users.
|
||||
* Figures out if a package comes from AUR or official repos.
|
||||
* Necessary because yay/paru handle AUR packages differently.
|
||||
*/
|
||||
|
||||
/** Patterns that indicate an AUR package (suffixes) */
|
||||
/** Suffixes that scream "I'm from the AUR" */
|
||||
export const AUR_PATTERNS = ['-bin', '-git', '-appimage'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,10 @@ interface ScriptOptions {
|
||||
helper?: 'yay' | 'paru';
|
||||
}
|
||||
|
||||
// The full fancy script with progress bars and all that jazz
|
||||
/**
|
||||
* Generate a full install script with progress bars, error handling,
|
||||
* and all the fancy stuff. This is what gets downloaded as .sh file.
|
||||
*/
|
||||
export function generateInstallScript(options: ScriptOptions): string {
|
||||
const { distroId, selectedAppIds, helper = 'yay' } = options;
|
||||
const distro = distros.find(d => d.id === distroId);
|
||||
@@ -45,7 +48,10 @@ export function generateInstallScript(options: ScriptOptions): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Quick one-liner for copy-paste warriors
|
||||
/**
|
||||
* Quick one-liner for the clipboard. No frills, just the command.
|
||||
* For users who know what they're doing and just want to paste.
|
||||
*/
|
||||
export function generateSimpleCommand(selectedAppIds: Set<string>, distroId: DistroId): string {
|
||||
const packages = getSelectedPackages(selectedAppIds, distroId);
|
||||
if (packages.length === 0) return '# No packages selected';
|
||||
|
||||
Reference in New Issue
Block a user