mirror of
https://github.com/abusoww/tuxmate.git
synced 2026-04-17 21:53:12 +02:00
refined UI/UX (new fonts, focus states, tooltips)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -41,4 +41,4 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# local notes
|
||||
# notes.md
|
||||
notes.md
|
||||
|
||||
370
.notes.md
370
.notes.md
@@ -1,370 +0,0 @@
|
||||
# Code Style & Patterns
|
||||
|
||||
Personal notes on code style for this project.
|
||||
|
||||
---
|
||||
|
||||
## Comments
|
||||
|
||||
### ✅ Do
|
||||
```typescript
|
||||
// Debounce rapid keypresses - nobody needs 60fps navigation
|
||||
// AUR gets special amber styling because it's ✨special✨
|
||||
// Skip if already installed - no point reinstalling
|
||||
// The vim keybindings that make us feel like hackers
|
||||
```
|
||||
|
||||
### ❌ Don't
|
||||
```typescript
|
||||
/**
|
||||
* Debounces rapid keypresses to prevent performance issues
|
||||
* @param delay - The delay in milliseconds
|
||||
* @returns void
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Section Header
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
### Rules
|
||||
1. Single-line `//` comments, not JSDoc blocks
|
||||
2. No decorative section dividers
|
||||
3. Explain "why", not "what"
|
||||
4. Occasional humor when it fits
|
||||
5. Short comments - if you need a paragraph, simplify the code
|
||||
|
||||
---
|
||||
|
||||
## Component Structure
|
||||
|
||||
### Size Limits
|
||||
- **Functions**: ~30 lines
|
||||
- **Components**: ~150 lines
|
||||
- **Files**: ~300 lines
|
||||
|
||||
### Organization
|
||||
- Logic in hooks, rendering in components
|
||||
- One component per file
|
||||
- Barrel files get one-liner comments
|
||||
|
||||
```typescript
|
||||
// Header area components
|
||||
export { HowItWorks } from './HowItWorks';
|
||||
```
|
||||
|
||||
### File Naming
|
||||
- `PascalCase.tsx` for components
|
||||
- `camelCase.ts` for hooks/utilities
|
||||
- `kebab-case` for directories
|
||||
|
||||
---
|
||||
|
||||
## TypeScript
|
||||
|
||||
```typescript
|
||||
// Let inference work
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
// Destructure props
|
||||
function AppItem({ app, isSelected }: AppItemProps) { ... }
|
||||
|
||||
// Union types over enums
|
||||
type DistroId = 'ubuntu' | 'debian' | 'arch';
|
||||
|
||||
// Avoid any
|
||||
const data: any = fetchData(); // ❌
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React Patterns
|
||||
|
||||
### Memoization
|
||||
```typescript
|
||||
// Memo for expensive children with frequent parent re-renders
|
||||
const AppItem = memo(function AppItem({ ... }) { ... });
|
||||
|
||||
// useMemo for expensive calculations
|
||||
const filtered = useMemo(() => apps.filter(...), [apps, query]);
|
||||
|
||||
// useCallback for handlers to memoized children
|
||||
const handleToggle = useCallback((id) => { ... }, [deps]);
|
||||
```
|
||||
|
||||
### State
|
||||
```typescript
|
||||
// Derive state, don't store computed values
|
||||
const count = selected.size; // ✅ derived
|
||||
const [count, setCount] = ... // ❌ redundant state
|
||||
|
||||
// Keep state minimal and colocated
|
||||
```
|
||||
|
||||
### Early Returns
|
||||
```typescript
|
||||
if (!apps.length) return null;
|
||||
if (isLoading) return <Skeleton />;
|
||||
// Then the happy path
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Keyboard Navigation
|
||||
```typescript
|
||||
// All interactive elements need keyboard support
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') handleClick();
|
||||
}}
|
||||
|
||||
// Focus management after modals/overlays
|
||||
useEffect(() => { inputRef.current?.focus(); }, [isOpen]);
|
||||
```
|
||||
|
||||
### Semantic HTML
|
||||
```typescript
|
||||
// Use correct elements
|
||||
<button> not <div onClick>
|
||||
<nav>, <main>, <aside>, <article>
|
||||
role="button" tabIndex={0} if custom element needed
|
||||
```
|
||||
|
||||
### Focus Visibility
|
||||
```css
|
||||
/* Always visible focus states */
|
||||
:focus-visible { outline: 2px solid var(--accent); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### Bundle Size
|
||||
- Question every new dependency
|
||||
- Use dynamic imports for heavy components
|
||||
- Tree-shake: import `{ Check }` not `import * as Icons`
|
||||
|
||||
### Rendering
|
||||
```typescript
|
||||
// Avoid layout thrashing
|
||||
const rect = element.getBoundingClientRect(); // batch reads
|
||||
element.style.top = rect.top + 'px'; // then writes
|
||||
|
||||
// Debounce expensive handlers
|
||||
const debouncedSearch = useMemo(
|
||||
() => debounce(handleSearch, 300),
|
||||
[]
|
||||
);
|
||||
```
|
||||
|
||||
### Loading States
|
||||
- Skeleton loaders for structure
|
||||
- Avoid layout shift (reserve space)
|
||||
- Show progress for long operations
|
||||
|
||||
---
|
||||
|
||||
## Shell Script Security
|
||||
|
||||
```typescript
|
||||
// ALWAYS escape user input
|
||||
`install_pkg "${escapeShellString(app.name)}" "${pkg}"`
|
||||
|
||||
// Scripts start safe
|
||||
set -euo pipefail
|
||||
|
||||
// No eval. Ever.
|
||||
// No backticks for command substitution
|
||||
```
|
||||
|
||||
### Error Messages
|
||||
```bash
|
||||
# Casual but informative
|
||||
error "Run as regular user, not root."
|
||||
warn "Update failed, continuing..."
|
||||
|
||||
# Not robotic
|
||||
error "ERROR: Elevated privileges detected." # ❌
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Pattern | Example |
|
||||
|------|---------|---------|
|
||||
| Boolean | `is`, `has`, `should`, `can` | `isAur`, `hasYay` |
|
||||
| Handler | `handle` prefix | `handleClick` |
|
||||
| Setter | `set` prefix | `setTheme` |
|
||||
| Ref | `Ref` suffix | `inputRef` |
|
||||
|
||||
**Concise**: `isAur` not `isArchUserRepositoryPackage`
|
||||
|
||||
---
|
||||
|
||||
## CSS & Theming
|
||||
|
||||
### Variables
|
||||
```css
|
||||
--bg-primary, --bg-secondary, --bg-tertiary
|
||||
--text-primary, --text-secondary, --text-muted
|
||||
--accent, --border-primary
|
||||
```
|
||||
|
||||
### Rules
|
||||
- Use variables, never hardcode colors
|
||||
- `transition-colors` not `transition-all`
|
||||
- Mobile-first breakpoints: `sm:`, `md:`, `lg:`
|
||||
|
||||
---
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Touch Targets
|
||||
- Minimum 44x44px for touch
|
||||
- Adequate spacing between targets
|
||||
|
||||
### Breakpoints
|
||||
```css
|
||||
/* Mobile first */
|
||||
.element { /* base mobile */ }
|
||||
@media (min-width: 640px) { /* sm */ }
|
||||
@media (min-width: 768px) { /* md */ }
|
||||
@media (min-width: 1024px) { /* lg */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
// User-facing: friendly
|
||||
toast.error("Couldn't copy. Try again?")
|
||||
|
||||
// Console: detailed
|
||||
console.error('Clipboard API failed:', error);
|
||||
|
||||
// Never swallow
|
||||
try { ... } catch { } // ❌
|
||||
try { ... } catch (e) { console.error(e); } // ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Before Adding
|
||||
1. Can we do it in <20 lines ourselves?
|
||||
2. What's the bundle size impact?
|
||||
3. Is it maintained?
|
||||
4. Do we need the whole package or just one function?
|
||||
|
||||
### Existing Stack
|
||||
- `framer-motion` - animations
|
||||
- `lucide-react` - icons
|
||||
- `gsap` - complex animations (use sparingly)
|
||||
- `clsx` + `tailwind-merge` - className utilities
|
||||
|
||||
---
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### New App
|
||||
1. Add to `data.ts` in correct category
|
||||
2. Include all available distro targets
|
||||
3. Add `unavailableReason` with helpful links
|
||||
4. Test generated scripts
|
||||
|
||||
### New Distro
|
||||
1. Add to `DistroId` type
|
||||
2. Add to `distros` array
|
||||
3. Create script generator in `lib/scripts/`
|
||||
4. Update `generateInstallScript.ts`
|
||||
|
||||
### New Component
|
||||
1. Create in appropriate directory
|
||||
2. Add to barrel `index.ts`
|
||||
3. Keep under 150 lines
|
||||
4. Extract hooks if logic-heavy
|
||||
|
||||
---
|
||||
|
||||
## Git Commits
|
||||
|
||||
```bash
|
||||
feat: add keyboard navigation
|
||||
fix: prevent hydration mismatch
|
||||
refactor: extract AUR logic to hook
|
||||
style: adjust tooltip positioning
|
||||
docs: update README shortcuts
|
||||
chore: bump dependencies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Console Patterns
|
||||
```typescript
|
||||
// Conditional logging
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Debug:', data);
|
||||
}
|
||||
|
||||
// Structured logging
|
||||
console.group('useLinuxInit');
|
||||
console.log('distro:', distro);
|
||||
console.log('selected:', selected.size);
|
||||
console.groupEnd();
|
||||
```
|
||||
|
||||
### React DevTools
|
||||
- Check for unnecessary re-renders
|
||||
- Verify memo is working
|
||||
- Profile expensive components
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't | Do Instead |
|
||||
|-------|------------|
|
||||
| `any` type | Proper types or `unknown` |
|
||||
| Prop drilling 5+ | Context or composition |
|
||||
| `useEffect` for derived state | Compute during render |
|
||||
| Giant switch | Object lookup |
|
||||
| `// TODO: fix later` | Fix now or create issue |
|
||||
| `transition-all` | `transition-colors` |
|
||||
| Hardcoded colors | CSS variables |
|
||||
| Store computed state | Derive it |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|------|---------|
|
||||
| `// brief comment` | `/** Long JSDoc */` |
|
||||
| Early return | Nested ifs |
|
||||
| `isAur` | `isArchUserRepositoryPackage` |
|
||||
| Extract hooks | 500-line components |
|
||||
| CSS variables | Hardcoded colors |
|
||||
| `escapeShellString()` | String concatenation |
|
||||
| Union types | Enums |
|
||||
| Derive state | Store computed |
|
||||
| `<button>` | `<div onClick>` |
|
||||
| 44px touch targets | Tiny buttons |
|
||||
|
||||
---
|
||||
|
||||
## Philosophy
|
||||
|
||||
Code should feel like it was written by someone who:
|
||||
- Uses Arch btw
|
||||
- Has vim keybindings muscle memory
|
||||
- Ships fast, iterates faster
|
||||
- Writes for humans first
|
||||
|
||||
Not like enterprise software following a 200-page style guide.
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Geist, Geist_Mono, JetBrains_Mono, Open_Sans } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/hooks/useTheme";
|
||||
|
||||
@@ -13,6 +13,17 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-jetbrains-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const openSans = Open_Sans({
|
||||
variable: "--font-open-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "TuxMate - LINUX BULK APP INSTALLER",
|
||||
description: "TuxMate helps you generate terminal commands to install your favorite apps on any Linux distribution. Select your distro, pick your apps, and get your install command.",
|
||||
@@ -67,7 +78,7 @@ export default function RootLayout({
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${jetbrainsMono.variable} ${openSans.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
|
||||
@@ -5,7 +5,7 @@ import gsap from 'gsap';
|
||||
|
||||
// Hooks
|
||||
import { useLinuxInit } from '@/hooks/useLinuxInit';
|
||||
import { useDelayedTooltip } from '@/hooks/useDelayedTooltip';
|
||||
import { useTooltip } from '@/hooks/useTooltip';
|
||||
import { useKeyboardNavigation, type NavItem } from '@/hooks/useKeyboardNavigation';
|
||||
|
||||
// Data
|
||||
@@ -24,7 +24,7 @@ import { Tooltip, GlobalStyles, LoadingSkeleton } from '@/components/common';
|
||||
export default function Home() {
|
||||
// All the state we need to make this thing work
|
||||
|
||||
const { tooltip, show: showTooltip, hide: hideTooltip, onTooltipEnter, onTooltipLeave } = useDelayedTooltip(600);
|
||||
const { tooltip, show: showTooltip, hide: hideTooltip, tooltipMouseEnter, tooltipMouseLeave } = useTooltip();
|
||||
|
||||
const {
|
||||
selectedDistro,
|
||||
@@ -89,15 +89,17 @@ export default function Home() {
|
||||
// 5 columns looks good on most screens
|
||||
const COLUMN_COUNT = 5;
|
||||
|
||||
// Tetris-style packing: shortest column gets the next category
|
||||
// Pack categories into shortest column while preserving order
|
||||
const columns = useMemo(() => {
|
||||
const cols: Array<typeof allCategoriesWithApps> = Array.from({ length: COLUMN_COUNT }, () => []);
|
||||
const heights = Array(COLUMN_COUNT).fill(0);
|
||||
|
||||
allCategoriesWithApps.forEach(catData => {
|
||||
const minIdx = heights.indexOf(Math.min(...heights));
|
||||
cols[minIdx].push(catData);
|
||||
heights[minIdx] += catData.apps.length + 2;
|
||||
});
|
||||
|
||||
return cols;
|
||||
}, [allCategoriesWithApps]);
|
||||
|
||||
@@ -110,7 +112,11 @@ export default function Home() {
|
||||
const toggleCategoryExpanded = useCallback((cat: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(cat) ? next.delete(cat) : next.add(cat);
|
||||
if (next.has(cat)) {
|
||||
next.delete(cat);
|
||||
} else {
|
||||
next.add(cat);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -132,7 +138,7 @@ export default function Home() {
|
||||
return items;
|
||||
}, [columns, expandedCategories]);
|
||||
|
||||
const { focusedItem, clearFocus, setFocusByItem } = useKeyboardNavigation(
|
||||
const { focusedItem, clearFocus, setFocusByItem, isKeyboardNavigating } = useKeyboardNavigation(
|
||||
navItems,
|
||||
toggleCategoryExpanded,
|
||||
toggleApp
|
||||
@@ -197,7 +203,7 @@ export default function Home() {
|
||||
onClick={clearFocus}
|
||||
>
|
||||
<GlobalStyles />
|
||||
<Tooltip tooltip={tooltip} onEnter={onTooltipEnter} onLeave={onTooltipLeave} />
|
||||
<Tooltip tooltip={tooltip} onMouseEnter={tooltipMouseEnter} onMouseLeave={tooltipMouseLeave} />
|
||||
|
||||
{/* Header */}
|
||||
<header ref={headerRef} className="pt-8 sm:pt-12 pb-8 sm:pb-10 px-4 sm:px-6 relative" style={{ zIndex: 1 }}>
|
||||
@@ -206,6 +212,7 @@ export default function Home() {
|
||||
{/* Logo & Title */}
|
||||
<div className="header-animate">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/tuxmate.png"
|
||||
alt="TuxMate Logo"
|
||||
@@ -256,7 +263,7 @@ export default function Home() {
|
||||
{/* Mobile: 2-column grid with balanced distribution */}
|
||||
<div className="grid grid-cols-2 gap-x-4 md:hidden items-start">
|
||||
{(() => {
|
||||
// Tetris packing for 2 columns on mobile
|
||||
// Pack into 2 columns on mobile
|
||||
const mobileColumns: Array<typeof allCategoriesWithApps> = [[], []];
|
||||
const heights = [0, 0];
|
||||
allCategoriesWithApps.forEach(catData => {
|
||||
@@ -277,8 +284,8 @@ export default function Home() {
|
||||
toggleApp={toggleApp}
|
||||
isExpanded={expandedCategories.has(category)}
|
||||
onToggleExpanded={() => toggleCategoryExpanded(category)}
|
||||
focusedId={focusedItem?.id}
|
||||
focusedType={focusedItem?.type}
|
||||
focusedId={isKeyboardNavigating ? focusedItem?.id : undefined}
|
||||
focusedType={isKeyboardNavigating ? focusedItem?.type : undefined}
|
||||
onTooltipEnter={showTooltip}
|
||||
onTooltipLeave={hideTooltip}
|
||||
categoryIndex={catIdx}
|
||||
@@ -316,8 +323,8 @@ export default function Home() {
|
||||
toggleApp={toggleApp}
|
||||
isExpanded={expandedCategories.has(category)}
|
||||
onToggleExpanded={() => toggleCategoryExpanded(category)}
|
||||
focusedId={focusedItem?.id}
|
||||
focusedType={focusedItem?.type}
|
||||
focusedId={isKeyboardNavigating ? focusedItem?.id : undefined}
|
||||
focusedType={isKeyboardNavigating ? focusedItem?.type : undefined}
|
||||
onTooltipEnter={showTooltip}
|
||||
onTooltipLeave={hideTooltip}
|
||||
categoryIndex={globalIdx + catIdx}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function AppIcon({ url, name }: { url: string; name: string }) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-4 h-4 rounded bg-[var(--accent)] flex items-center justify-center text-[10px] font-bold">
|
||||
<div className="w-[18px] h-[18px] rounded bg-[var(--accent)] flex items-center justify-center text-[10px] font-bold">
|
||||
{name[0]}
|
||||
</div>
|
||||
);
|
||||
@@ -22,7 +22,7 @@ export function AppIcon({ url, name }: { url: string; name: string }) {
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
className="w-4 h-4 object-contain opacity-75"
|
||||
className="w-[18px] h-[18px] object-contain opacity-75"
|
||||
onError={() => setError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
@@ -66,13 +66,6 @@ export const AppItem = memo(function AppItem({
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
// Show description tooltip for all apps (available and unavailable)
|
||||
onTooltipEnter(app.description, e);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onTooltipLeave();
|
||||
}}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors duration-150
|
||||
${isAur
|
||||
@@ -85,12 +78,22 @@ export const AppItem = memo(function AppItem({
|
||||
<AppIcon url={app.iconUrl} name={app.name} />
|
||||
<div className="flex-1 flex items-baseline gap-1.5 min-w-0 overflow-hidden">
|
||||
<span
|
||||
className={`text-sm truncate ${!isAvailable ? 'text-[var(--text-muted)]' : isSelected ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}
|
||||
className={`truncate cursor-help ${!isAvailable ? 'text-[var(--text-muted)]' : isSelected ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}
|
||||
style={{
|
||||
fontFamily: 'var(--font-open-sans), sans-serif',
|
||||
fontSize: '16px',
|
||||
transition: 'color 0.5s',
|
||||
textRendering: 'geometricPrecision',
|
||||
WebkitFontSmoothing: 'antialiased'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.stopPropagation();
|
||||
onTooltipEnter(app.description, e);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.stopPropagation();
|
||||
onTooltipLeave();
|
||||
}}
|
||||
>
|
||||
{app.name}
|
||||
</span>
|
||||
|
||||
@@ -48,9 +48,9 @@ export function CategoryHeader({
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${category} category, ${selectedCount} apps selected`}
|
||||
// Soft Pill design: rounded tags with accent color
|
||||
className={`category-header group w-full flex items-center gap-2 text-xs font-bold text-[var(--accent)]
|
||||
className={`category-header group w-full h-7 flex items-center gap-2 text-xs font-bold text-[var(--accent)]
|
||||
bg-[var(--accent)]/10 hover:bg-[var(--accent)]/20
|
||||
uppercase tracking-wider py-1 px-3 rounded-lg mb-3
|
||||
uppercase tracking-wider px-3 rounded-lg mb-3
|
||||
transition-all duration-200 outline-none
|
||||
${isFocused ? 'bg-[var(--accent)]/25 shadow-sm' : ''}`}
|
||||
>
|
||||
@@ -62,10 +62,10 @@ export function CategoryHeader({
|
||||
<span className="flex-1 text-left">{category}</span>
|
||||
{selectedCount > 0 && (
|
||||
<span
|
||||
className="text-[10px] bg-[var(--accent)]/10 text-[var(--accent)] w-5 h-5 rounded-full flex items-center justify-center font-bold"
|
||||
style={{ transition: 'background-color 0.5s, color 0.5s' }}
|
||||
className="text-xs text-[var(--accent)] font-bold ml-1.5 opacity-100"
|
||||
style={{ transition: 'color 0.5s' }}
|
||||
>
|
||||
{selectedCount}
|
||||
[{selectedCount}]
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -181,7 +181,13 @@ export function CommandDrawer({
|
||||
<div className="p-4 font-mono text-sm overflow-x-auto">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-400 select-none shrink-0">$</span>
|
||||
<code className="text-gray-300 break-all whitespace-pre-wrap" style={{ lineHeight: '1.6' }}>
|
||||
<code
|
||||
className="text-gray-300 break-all whitespace-pre-wrap"
|
||||
style={{
|
||||
lineHeight: '1.6',
|
||||
fontFamily: 'var(--font-jetbrains-mono), monospace'
|
||||
}}
|
||||
>
|
||||
{command}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ShortcutsBar({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="search..."
|
||||
className="
|
||||
w-20 sm:w-28
|
||||
w-28 sm:w-40
|
||||
bg-transparent
|
||||
text-[var(--text-primary)]
|
||||
placeholder:text-[var(--text-muted)]/50
|
||||
|
||||
@@ -1,62 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface TooltipData {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
key: number;
|
||||
}
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { type TooltipState } from '@/hooks/useTooltip';
|
||||
|
||||
interface TooltipProps {
|
||||
tooltip: TooltipData | null;
|
||||
onEnter?: () => void;
|
||||
onLeave?: () => void;
|
||||
tooltip: TooltipState | null;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
// Floating tooltip with markdown rendering - follows the cursor around
|
||||
export function Tooltip({ tooltip, onEnter, onLeave }: TooltipProps) {
|
||||
if (!tooltip) return null;
|
||||
export function Tooltip({ tooltip, onMouseEnter, onMouseLeave }: TooltipProps) {
|
||||
const [current, setCurrent] = useState<TooltipState | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Center horizontally relative to the element
|
||||
const left = tooltip.x;
|
||||
const top = tooltip.y;
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (tooltip) {
|
||||
// eslint-disable-next-line
|
||||
setCurrent(tooltip);
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
} else {
|
||||
setVisible(false);
|
||||
timeoutRef.current = setTimeout(() => setCurrent(null), 60);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [tooltip]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
// Helper to render markdown content
|
||||
const renderContent = (text: string) => {
|
||||
// Split by **bold**, `code`, or [link](url)
|
||||
return text.split(/(\*\*.*?\*\*|`.*?`|\[.*?\]\(.*?\))/g).map((part, i) => {
|
||||
// Bold
|
||||
return text.split(/(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g).map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return (
|
||||
<strong key={i} className="font-bold text-[var(--accent)]">
|
||||
{part.slice(2, -2)}
|
||||
</strong>
|
||||
);
|
||||
return <strong key={i} className="font-medium text-[var(--text-primary)]">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
// Code
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return (
|
||||
<code key={i} className="bg-[var(--bg-secondary)] px-1 rounded font-mono text-[var(--accent)] text-[10px]">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
return <code key={i} className="px-1 py-0.5 rounded bg-black/20 font-mono text-[var(--accent)] text-[11px]">{part.slice(1, -1)}</code>;
|
||||
}
|
||||
// Link
|
||||
if (part.startsWith('[') && part.includes('](') && part.endsWith(')')) {
|
||||
const match = part.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (match) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--accent)] underline decoration-[var(--accent)]/50 hover:decoration-[var(--accent)] font-semibold transition-colors hover:text-emerald-500"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent triggering parent clicks
|
||||
>
|
||||
<a key={i} href={match[2]} target="_blank" rel="noopener noreferrer"
|
||||
className="text-[var(--accent)] underline underline-offset-2 hover:brightness-125 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
@@ -66,30 +60,45 @@ export function Tooltip({ tooltip, onEnter, onLeave }: TooltipProps) {
|
||||
});
|
||||
};
|
||||
|
||||
// Hide tooltips on mobile - they don't work with touch
|
||||
return (
|
||||
<div
|
||||
role="tooltip"
|
||||
className="hidden md:block fixed z-50 pointer-events-auto"
|
||||
style={{
|
||||
left: left,
|
||||
top: top,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
// Using the specific key ensures fresh animation on new tooltip
|
||||
animation: 'tooltipSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||
maxWidth: '400px', // Limit width
|
||||
width: 'max-content'
|
||||
}}
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onLeave}
|
||||
className="fixed hidden md:block pointer-events-auto z-[9999]"
|
||||
style={{ left: current.x, top: current.y - 10 }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<div className="relative mb-2 px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-primary)] text-xs font-medium shadow-xl border border-[var(--border-primary)]/40 backdrop-blur-sm whitespace-normal break-words leading-relaxed">
|
||||
{renderContent(tooltip.text)}
|
||||
|
||||
{/* Arrow pointer */}
|
||||
<div className={`
|
||||
absolute left-1/2 bottom-0 -translate-x-1/2
|
||||
transition-opacity duration-75
|
||||
${visible ? 'opacity-100' : 'opacity-0 pointer-events-none'}
|
||||
`}>
|
||||
{/* Clean tooltip bubble */}
|
||||
<div
|
||||
className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-[var(--bg-tertiary)] border-b border-r border-[var(--border-primary)]/40 rotate-45"
|
||||
/>
|
||||
className="px-3.5 py-2.5 rounded-lg shadow-lg overflow-hidden"
|
||||
style={{
|
||||
minWidth: '300px',
|
||||
maxWidth: '300px',
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<p className="text-[13px] leading-[1.55] text-[var(--text-secondary)] break-words" style={{ wordBreak: 'break-word' }}>
|
||||
{renderContent(current.content)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 -bottom-[5px]">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rotate-45"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderRight: '1px solid var(--border-primary)',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Shared components - tooltip, animations, skeleton
|
||||
|
||||
export { Tooltip, type TooltipData } from './Tooltip';
|
||||
export { Tooltip } from './Tooltip';
|
||||
export { GlobalStyles } from './GlobalStyles';
|
||||
export { LoadingSkeleton } from './LoadingSkeleton';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { type Category } from '@/lib/data';
|
||||
|
||||
// What we're navigating to
|
||||
@@ -25,6 +25,12 @@ export function useKeyboardNavigation(
|
||||
) {
|
||||
const [focusPos, setFocusPos] = useState<FocusPosition | null>(null);
|
||||
|
||||
// Track if focus was set via keyboard (to enable scroll) vs mouse (no scroll)
|
||||
const fromKeyboard = useRef(false);
|
||||
|
||||
// Track if focus mode is keyboard (for UI highlighting)
|
||||
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false);
|
||||
|
||||
/** Clear focus (e.g., when clicking outside) */
|
||||
const clearFocus = useCallback(() => setFocusPos(null), []);
|
||||
|
||||
@@ -34,12 +40,14 @@ export function useKeyboardNavigation(
|
||||
return navItems[focusPos.col]?.[focusPos.row] || null;
|
||||
}, [navItems, focusPos]);
|
||||
|
||||
/** Set focus position by item type and id */
|
||||
/** Set focus position by item type and id (from mouse - no scroll) */
|
||||
const setFocusByItem = useCallback((type: 'category' | 'app', id: string) => {
|
||||
for (let col = 0; col < navItems.length; col++) {
|
||||
const colItems = navItems[col];
|
||||
for (let row = 0; row < colItems.length; row++) {
|
||||
if (colItems[row].type === type && colItems[row].id === id) {
|
||||
fromKeyboard.current = false; // Mouse selection - don't scroll
|
||||
setIsKeyboardNavigating(false); // Disable focus ring
|
||||
setFocusPos({ col, row });
|
||||
return;
|
||||
}
|
||||
@@ -79,6 +87,10 @@ export function useKeyboardNavigation(
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as keyboard navigation - will trigger scroll and focus ring
|
||||
fromKeyboard.current = true;
|
||||
setIsKeyboardNavigating(true);
|
||||
|
||||
// Navigate
|
||||
setFocusPos(prev => {
|
||||
if (!prev) return { col: 0, row: 0 };
|
||||
@@ -117,16 +129,18 @@ export function useKeyboardNavigation(
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [navItems, focusPos, onToggleCategory, onToggleApp]);
|
||||
|
||||
/* Scroll focused item into view instantly */
|
||||
/* Scroll focused item into view - only when navigating via keyboard */
|
||||
useEffect(() => {
|
||||
if (!focusPos) return;
|
||||
if (!focusPos || !fromKeyboard.current) return;
|
||||
|
||||
const item = navItems[focusPos.col]?.[focusPos.row];
|
||||
if (!item) return;
|
||||
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
// Find visible element among duplicates (mobile/desktop layouts both render same data-nav-id)
|
||||
const elements = document.querySelectorAll<HTMLElement>(
|
||||
`[data-nav-id="${item.type}:${item.id}"]`
|
||||
);
|
||||
const el = Array.from(elements).find(e => e.offsetWidth > 0 && e.offsetHeight > 0);
|
||||
|
||||
if (!el) return;
|
||||
|
||||
@@ -142,5 +156,6 @@ export function useKeyboardNavigation(
|
||||
focusedItem,
|
||||
clearFocus,
|
||||
setFocusByItem,
|
||||
isKeyboardNavigating,
|
||||
};
|
||||
}
|
||||
|
||||
101
src/hooks/useTooltip.ts
Normal file
101
src/hooks/useTooltip.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
export interface TooltipState {
|
||||
content: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip that stays open while hovering trigger or tooltip.
|
||||
* - 450ms delay before showing
|
||||
* - Stays open once shown (until mouse leaves both trigger and tooltip)
|
||||
* - Dismiss on click/scroll/escape
|
||||
*/
|
||||
export function useTooltip() {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const showTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const hideTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const isOverTrigger = useRef(false);
|
||||
const isOverTooltip = useRef(false);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (showTimeout.current) {
|
||||
clearTimeout(showTimeout.current);
|
||||
showTimeout.current = null;
|
||||
}
|
||||
if (hideTimeout.current) {
|
||||
clearTimeout(hideTimeout.current);
|
||||
hideTimeout.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tryHide = useCallback(() => {
|
||||
cancel();
|
||||
// Only hide if mouse is not over trigger or tooltip
|
||||
hideTimeout.current = setTimeout(() => {
|
||||
if (!isOverTrigger.current && !isOverTooltip.current) {
|
||||
setTooltip(null);
|
||||
}
|
||||
}, 100);
|
||||
}, [cancel]);
|
||||
|
||||
const show = useCallback((content: string, e: React.MouseEvent) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
isOverTrigger.current = true;
|
||||
cancel();
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
showTimeout.current = setTimeout(() => {
|
||||
setTooltip({
|
||||
content,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
});
|
||||
}, 450);
|
||||
}, [cancel]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
isOverTrigger.current = false;
|
||||
tryHide();
|
||||
}, [tryHide]);
|
||||
|
||||
const tooltipMouseEnter = useCallback(() => {
|
||||
isOverTooltip.current = true;
|
||||
cancel();
|
||||
}, [cancel]);
|
||||
|
||||
const tooltipMouseLeave = useCallback(() => {
|
||||
isOverTooltip.current = false;
|
||||
tryHide();
|
||||
}, [tryHide]);
|
||||
|
||||
useEffect(() => {
|
||||
const dismiss = () => {
|
||||
cancel();
|
||||
isOverTrigger.current = false;
|
||||
isOverTooltip.current = false;
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') dismiss();
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', dismiss, true);
|
||||
window.addEventListener('scroll', dismiss, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
cancel();
|
||||
window.removeEventListener('mousedown', dismiss, true);
|
||||
window.removeEventListener('scroll', dismiss, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [cancel]);
|
||||
|
||||
return { tooltip, show, hide, tooltipMouseEnter, tooltipMouseLeave };
|
||||
}
|
||||
@@ -255,11 +255,27 @@ export const apps: AppData[] = [
|
||||
{ id: 'conky', name: 'Conky', description: 'Highly configurable desktop system monitor', category: 'System', iconUrl: mdi('monitor-dashboard', '#FFFFFF'), targets: { ubuntu: 'conky-all', debian: 'conky-all', arch: 'conky', fedora: 'conky', opensuse: 'conky', nix: 'conky' }, unavailableReason: 'Conky is a system tool and not available via Flatpak or Snap.' },
|
||||
];
|
||||
|
||||
// Categories in display order
|
||||
// Categories in display order - beginner-friendly (popular first), balanced heights
|
||||
// Popular/Consumer first, then Developer/Power-user categories
|
||||
export const categories: Category[] = [
|
||||
'Web Browsers', 'Communication', 'Dev: Languages', 'Dev: Editors', 'Dev: Tools',
|
||||
'Terminal', 'CLI Tools', 'Media', 'Creative', 'Gaming', 'Office',
|
||||
'VPN & Network', 'Security', 'File Sharing', 'System'
|
||||
// Row 1: Most popular consumer categories
|
||||
'Web Browsers', // 9 - everyone needs this first
|
||||
'Communication', // 8 - Discord, Telegram, etc.
|
||||
'Media', // 14 - VLC, Spotify, etc.
|
||||
'Gaming', // 10 - Steam, etc.
|
||||
'Office', // 11 - LibreOffice, etc.
|
||||
// Row 2: Creative & System
|
||||
'Creative', // 10 - Blender, GIMP, etc.
|
||||
'System', // 13 - Utilities
|
||||
'File Sharing', // 9 - Syncthing, torrents
|
||||
'Security', // 6 - Passwords, VPN
|
||||
'VPN & Network', // 7 - ProtonVPN, etc.
|
||||
// Row 3: Developer categories (larger, go last to fill columns)
|
||||
'Dev: Editors', // 9 - VS Code, etc.
|
||||
'Dev: Languages', // 10 - Python, Node, etc.
|
||||
'Dev: Tools', // 18 - Docker, Git, etc.
|
||||
'Terminal', // 9 - Alacritty, etc.
|
||||
'CLI Tools', // 21 - btop, fzf, etc.
|
||||
];
|
||||
|
||||
// Get apps by category
|
||||
|
||||
Reference in New Issue
Block a user