feat: added sidebar, removed verbose comments, fixed npm audits

This commit is contained in:
N1C4T
2026-02-22 04:52:21 +04:00
parent 48dad0786b
commit 813775cb50
27 changed files with 1423 additions and 663 deletions

384
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"framer-motion": "^12.23.26",
"gsap": "^3.14.2",
"lucide-react": "^0.561.0",
"next": "16.0.10",
"next": "^16.1.6",
"react": "19.2.1",
"react-dom": "19.2.1",
"tailwind-merge": "^3.4.0"
@@ -26,7 +26,7 @@
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"eslint-config-next": "^16.1.6",
"jsdom": "^27.4.0",
"tailwindcss": "^4",
"typescript": "^5",
@@ -1018,9 +1018,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1125,9 +1125,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"version": "9.39.3",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
"integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1761,15 +1761,15 @@
}
},
"node_modules/@next/env": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.10.tgz",
"integrity": "sha512-b2NlWN70bbPLmfyoLvvidPKWENBYYIe017ZGUpElvQjDytCWgxPJx7L9juxHt0xHvNVA08ZHJdOyhGzon/KJuw==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1777,9 +1777,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz",
"integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"cpu": [
"arm64"
],
@@ -1793,9 +1793,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz",
"integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"cpu": [
"x64"
],
@@ -1809,9 +1809,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz",
"integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"cpu": [
"arm64"
],
@@ -1825,9 +1825,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz",
"integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"cpu": [
"arm64"
],
@@ -1841,9 +1841,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz",
"integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"cpu": [
"x64"
],
@@ -1857,9 +1857,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz",
"integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"cpu": [
"x64"
],
@@ -1873,9 +1873,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz",
"integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"cpu": [
"arm64"
],
@@ -1889,9 +1889,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz",
"integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"cpu": [
"x64"
],
@@ -2782,20 +2782,20 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
"integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
"integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/type-utils": "8.50.0",
"@typescript-eslint/utils": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0",
"ignore": "^7.0.0",
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/type-utils": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.1.0"
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2805,8 +2805,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.50.0",
"eslint": "^8.57.0 || ^9.0.0",
"@typescript-eslint/parser": "^8.56.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
@@ -2821,17 +2821,17 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz",
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0",
"debug": "^4.3.4"
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2841,20 +2841,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz",
"integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz",
"integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.50.0",
"@typescript-eslint/types": "^8.50.0",
"debug": "^4.3.4"
"@typescript-eslint/tsconfig-utils": "^8.56.0",
"@typescript-eslint/types": "^8.56.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2868,14 +2868,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz",
"integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz",
"integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0"
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2886,9 +2886,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz",
"integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz",
"integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2903,17 +2903,17 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz",
"integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz",
"integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0",
"@typescript-eslint/utils": "8.50.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2923,14 +2923,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz",
"integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
"integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2942,21 +2942,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz",
"integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz",
"integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.50.0",
"@typescript-eslint/tsconfig-utils": "8.50.0",
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/visitor-keys": "8.50.0",
"debug": "^4.3.4",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"@typescript-eslint/project-service": "8.56.0",
"@typescript-eslint/tsconfig-utils": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/visitor-keys": "8.56.0",
"debug": "^4.4.3",
"minimatch": "^9.0.5",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.1.0"
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2969,36 +2969,10 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -3009,16 +2983,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz",
"integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz",
"integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0"
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3028,19 +3002,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz",
"integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz",
"integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.50.0",
"eslint-visitor-keys": "^4.2.1"
"@typescript-eslint/types": "8.56.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3050,6 +3024,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
@@ -3485,9 +3472,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3769,17 +3756,19 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.8.tgz",
"integrity": "sha512-Y1fOuNDowLfgKOypdc9SPABfoWXuZHBOyCS4cD52IeZBhr4Md6CLLs6atcxVrzRmQ06E7hSlm5bHHApPKR/byA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -3796,14 +3785,16 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/braces": {
@@ -3995,13 +3986,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -4558,9 +4542,9 @@
}
},
"node_modules/eslint": {
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"version": "9.39.3",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4570,7 +4554,7 @@
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.2",
"@eslint/js": "9.39.3",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -4618,13 +4602,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.10.tgz",
"integrity": "sha512-BxouZUm0I45K4yjOOIzj24nTi0H2cGo0y7xUmk+Po/PYtJXFBYVDS1BguE7t28efXjKdcN0tmiLivxQy//SsZg==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.0.10",
"@next/eslint-plugin-next": "16.1.6",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -5053,9 +5037,9 @@
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -6618,16 +6602,19 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "*"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
@@ -6704,13 +6691,14 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz",
"integrity": "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==",
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"license": "MIT",
"dependencies": {
"@next/env": "16.0.10",
"@next/env": "16.1.6",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -6722,14 +6710,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.10",
"@next/swc-darwin-x64": "16.0.10",
"@next/swc-linux-arm64-gnu": "16.0.10",
"@next/swc-linux-arm64-musl": "16.0.10",
"@next/swc-linux-x64-gnu": "16.0.10",
"@next/swc-linux-x64-musl": "16.0.10",
"@next/swc-win32-arm64-msvc": "16.0.10",
"@next/swc-win32-x64-msvc": "16.0.10",
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"sharp": "^0.34.4"
},
"peerDependencies": {
@@ -8137,9 +8125,9 @@
}
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8287,16 +8275,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.0.tgz",
"integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz",
"integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@typescript-eslint/typescript-estree": "8.50.0",
"@typescript-eslint/utils": "8.50.0"
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/typescript-estree": "8.56.0",
"@typescript-eslint/utils": "8.56.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -8306,7 +8294,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},

View File

@@ -17,7 +17,7 @@
"framer-motion": "^12.23.26",
"gsap": "^3.14.2",
"lucide-react": "^0.561.0",
"next": "16.0.10",
"next": "^16.1.6",
"react": "19.2.1",
"react-dom": "19.2.1",
"tailwind-merge": "^3.4.0"
@@ -31,10 +31,13 @@
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"eslint-config-next": "^16.1.6",
"jsdom": "^27.4.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.16"
},
"overrides": {
"minimatch": "^10.2.1"
}
}

View File

@@ -1,9 +1,9 @@
@import "tailwindcss";
/* ===== WARM PAPER AESTHETIC THEMES ===== */
:root {
/* Dark theme - lighter warm charcoal per user request */
zoom: 1.07;
--bg-primary: #262522;
--bg-secondary: #2f2e2a;
--bg-tertiary: #3a3934;
@@ -18,7 +18,6 @@
}
.light {
/* Light theme - warm paper/parchment */
--bg-primary: #f5f2ed;
--bg-secondary: #ebe8e2;
--bg-tertiary: #e0dcd4;
@@ -71,7 +70,6 @@ body::before {
mix-blend-mode: overlay;
}
/* Smooth scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -86,10 +84,8 @@ body::before {
border-radius: 3px;
}
/* Scroll margin to keep focused items visible during keyboard navigation */
[data-nav-id] {
scroll-margin-block: 100px 150px;
/* top: 100px, bottom: 150px for shortcuts bar */
}
html {
@@ -101,8 +97,6 @@ html {
background: rgba(128, 120, 100, 0.25);
}
/* ===== THEME SWITCH ===== */
.switch {
position: relative;
-webkit-tap-highlight-color: transparent;
@@ -249,7 +243,6 @@ html {
height: 1px;
}
/* Checked state (dark mode) */
.switch__input:checked {
background-color: var(--bg-tertiary);
}
@@ -312,14 +305,10 @@ html {
transform: translateX(-50%) rotate(315deg) translateY(0.625em) scale(0);
}
/* Set switch size */
.switch {
font-size: 1.5rem;
}
/* ===== ANIMATIONS ===== */
/* Checkbox pop */
@keyframes checkPop {
0% {
transform: scale(0.8);
@@ -338,7 +327,6 @@ html {
animation: checkPop 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Checkmark entrance */
@keyframes checkIn {
0% {
transform: scale(0) rotate(-45deg);
@@ -355,12 +343,10 @@ html {
animation: checkIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* Spring chevron */
.chevron-spring {
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Stagger items */
@keyframes staggerIn {
0% {
opacity: 0;
@@ -377,7 +363,6 @@ html {
animation: staggerIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Tooltip fade */
@keyframes tooltipIn {
0% {
opacity: 0;
@@ -394,7 +379,6 @@ html {
animation: tooltipIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Dropdown entrance */
@keyframes dropIn {
0% {
opacity: 0;
@@ -411,7 +395,6 @@ html {
animation: dropIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Button press */
.btn-press {
transition: transform 0.1s ease;
}
@@ -420,15 +403,11 @@ html {
transform: scale(0.95);
}
/* Focus row highlight */
.focus-row {
background: var(--bg-focus);
transition: background 0.15s ease;
}
/* ===== ENTRANCE ANIMATION INITIAL STATES ===== */
/* These prevent the flash of content before GSAP animates */
.category-header {
clip-path: inset(0 100% 0 0);
}
@@ -448,8 +427,6 @@ html {
transform: translateY(-10px);
}
/* ===== HOW IT WORKS POPUP ===== */
@keyframes popupSlideIn {
0% {
opacity: 0;
@@ -482,8 +459,6 @@ html {
border-radius: 2px;
}
/* ===== SLIDE-UP DRAWER ANIMATIONS ===== */
@keyframes fadeIn {
0% {
opacity: 0;
@@ -574,7 +549,6 @@ html {
}
}
/* Smooth spring-like animations for floating cards */
@keyframes cardSlideIn {
0% {
opacity: 0;
@@ -619,7 +593,6 @@ html {
}
}
/* Slide in from right edge */
@keyframes slideInFromRight {
0% {
opacity: 0;
@@ -676,18 +649,14 @@ html {
}
}
/* ===== COMMAND BAR SCROLLBAR ===== */
.command-scroll {
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE/Edge */
}
.command-scroll::-webkit-scrollbar {
display: none;
/* Chrome/Safari/Opera */
}
/* ===== SEARCH POPUP ANIMATION ===== */
@@ -704,8 +673,6 @@ html {
}
}
/* ===== THEME FLASH ANIMATION ===== */
@keyframes themeFlash {
0% {
opacity: 0;
@@ -730,8 +697,6 @@ body.theme-flash::after {
animation: themeFlash 0.15s ease-out forwards;
}
/* ===== SKELETON LOADING ANIMATION ===== */
@keyframes skeletonPulse {
0%,
@@ -749,13 +714,11 @@ body.theme-flash::after {
will-change: opacity;
}
/* Staggered skeleton items for wave effect */
.skeleton-item {
animation: skeletonPulse 1.5s ease-in-out infinite;
will-change: opacity;
}
/* Force GPU acceleration for animated elements */
.category-header,
.app-item,
.header-animate,
@@ -765,3 +728,87 @@ body.theme-flash::after {
-webkit-perspective: 1000px;
perspective: 1000px;
}
@keyframes sidebarSlideIn {
0% {
opacity: 0;
transform: translateX(-30px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.sidebar {
animation: sidebarSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
background: var(--bg-primary);
border-right: 1px solid color-mix(in srgb, var(--border-primary), transparent 50%);
}
.sidebar-scroll::-webkit-scrollbar {
width: 3px;
}
.sidebar-scroll::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-scroll::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text-muted), transparent 70%);
border-radius: 3px;
}
.sidebar-scroll::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--text-muted), transparent 40%);
}
.sidebar-action-btn {
transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
}
.sidebar-action-btn:hover:not(:disabled) {
background-color: var(--bg-hover);
}
.sidebar-action-btn:active:not(:disabled) {
opacity: 0.8;
}
.sidebar-search {
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.sidebar-search:focus-within {
border-color: var(--accent);
background: var(--bg-secondary);
}
.sidebar-command-preview {
transition: background-color 0.15s ease;
}
.sidebar-command-preview:hover {
background: var(--bg-hover);
}
.sidebar-distro-btn {
transition: background-color 0.15s ease;
}
.sidebar-distro-btn:hover {
filter: brightness(1.08);
}
@media (min-width: 1024px) {
.main-with-sidebar {
margin-left: 380px;
}
}
@media (min-width: 1280px) {
.main-with-sidebar {
margin-left: 420px;
}
}

View File

@@ -3,22 +3,19 @@
import { useState, useMemo, useCallback, useRef, useLayoutEffect, useEffect } from 'react';
import gsap from 'gsap';
// Hooks
import { useLinuxInit } from '@/hooks/useLinuxInit';
import { useTooltip } from '@/hooks/useTooltip';
import { useKeyboardNavigation, type NavItem } from '@/hooks/useKeyboardNavigation';
import { useVerification } from '@/hooks/useVerification';
// Data
import { categories, getAppsByCategory } from '@/lib/data';
// Components
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { HowItWorks, GitHubLink, ContributeLink } from '@/components/header';
import { DistroSelector } from '@/components/distro';
import { CategorySection } from '@/components/app';
import { CommandFooter } from '@/components/command';
import { Tooltip, GlobalStyles, LoadingSkeleton } from '@/components/common';
import { Sidebar } from '@/components/sidebar';
export default function Home() {
@@ -44,40 +41,104 @@ export default function Home() {
unfreeAppNames,
} = useLinuxInit();
// Search state
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
// Verification status for Flatpak/Snap apps
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerClosing, setDrawerClosing] = useState(false);
const closeDrawer = useCallback(() => {
setDrawerClosing(true);
setTimeout(() => {
setDrawerOpen(false);
setDrawerClosing(false);
}, 250);
}, []);
const openDrawer = useCallback(() => {
if (selectedCount > 0) setDrawerOpen(true);
}, [selectedCount]);
const [activeShortcut, setActiveShortcut] = useState<string | null>(null);
const toggleThemeWithFlash = useCallback(() => {
document.body.classList.add('theme-flash');
setTimeout(() => document.body.classList.remove('theme-flash'), 150);
const themeBtn = document.querySelector('[aria-label="Toggle theme"]') as HTMLButtonElement;
if (themeBtn) themeBtn.click();
}, []);
const { isVerified, getVerificationSource } = useVerification();
// Handle "/" key to focus search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if already in input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
// Skip if modifier keys are pressed (prevents conflicts with browser shortcuts)
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key === '/') {
e.preventDefault();
searchInputRef.current?.focus();
const inputs = document.querySelectorAll<HTMLInputElement>('input[placeholder="Search apps..."]');
const visibleInput = Array.from(inputs).find(input => input.offsetParent !== null);
if (visibleInput) visibleInput.focus();
return;
}
const alwaysEnabled = ['t', 'c', '?'];
if (selectedCount === 0 && !alwaysEnabled.includes(e.key)) return;
switch (e.key) {
case 'y':
setActiveShortcut('y');
setTimeout(() => setActiveShortcut(null), 150);
const copyBtns = document.querySelectorAll<HTMLButtonElement>('[data-action="copy"]');
const visibleCopyBtn = Array.from(copyBtns).find(b => b.offsetParent !== null);
if (visibleCopyBtn) visibleCopyBtn.click();
break;
case 'd':
setActiveShortcut('d');
setTimeout(() => setActiveShortcut(null), 150);
const dlBtns = document.querySelectorAll<HTMLButtonElement>('[data-action="download"]');
const visibleDlBtn = Array.from(dlBtns).find(b => b.offsetParent !== null);
if (visibleDlBtn) visibleDlBtn.click();
break;
case 't':
setActiveShortcut('t');
setTimeout(() => setActiveShortcut(null), 150);
toggleThemeWithFlash();
break;
case 'c':
setActiveShortcut('c');
setTimeout(() => setActiveShortcut(null), 150);
clearAll();
break;
case '1':
if (hasAurPackages) setSelectedHelper('yay');
break;
case '2':
if (hasAurPackages) setSelectedHelper('paru');
break;
case 'Tab':
e.preventDefault();
if (selectedCount > 0) {
if (drawerOpen) {
closeDrawer();
} else {
openDrawer();
}
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}, [selectedCount, clearAll, hasAurPackages, setSelectedHelper, drawerOpen, closeDrawer, openDrawer, toggleThemeWithFlash]);
// Distribute apps into a nice grid
const allCategoriesWithApps = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
return categories
.map(cat => {
const categoryApps = getAppsByCategory(cat);
// Filter apps if there's a search query (match name or id only)
const filteredApps = query
? categoryApps.filter(app =>
app.name.toLowerCase().includes(query) ||
@@ -89,10 +150,8 @@ export default function Home() {
.filter(c => c.apps.length > 0);
}, [searchQuery]);
// 5 columns looks good on most screens
const COLUMN_COUNT = 5;
const COLUMN_COUNT = 4;
// 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);
@@ -106,9 +165,6 @@ export default function Home() {
return cols;
}, [allCategoriesWithApps]);
// Category expansion - all open by default because hiding stuff is annoying
// ========================================================================
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(() => new Set(categories));
const toggleCategoryExpanded = useCallback((cat: string) => {
@@ -123,8 +179,6 @@ export default function Home() {
});
}, []);
// Build nav items for keyboard navigation
const navItems = useMemo(() => {
const items: NavItem[][] = [];
columns.forEach((colCategories) => {
@@ -146,9 +200,6 @@ export default function Home() {
toggleApp
);
// Header animation - makes the logo look fancy on first load
// ========================================================================
const headerRef = useRef<HTMLElement>(null);
useLayoutEffect(() => {
@@ -158,7 +209,6 @@ export default function Home() {
const title = header.querySelector('.header-animate');
const controls = header.querySelector('.header-controls');
// Fancy clip-path reveal for the logo
gsap.fromTo(title,
{ clipPath: 'inset(0 100% 0 0)' },
{
@@ -172,7 +222,6 @@ export default function Home() {
}
);
// Animate controls with fade-in
gsap.fromTo(controls,
{ opacity: 0, y: -10 },
{
@@ -185,17 +234,10 @@ export default function Home() {
);
}, [isHydrated]);
// Don't render until we've loaded from localStorage (avoids flash)
// Show loading skeleton until localStorage is hydrated
if (!isHydrated) {
return <LoadingSkeleton />;
}
// Finally, the actual page
// ========================================================================
return (
<div
className="min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)] relative"
@@ -205,14 +247,30 @@ export default function Home() {
<GlobalStyles />
<Tooltip tooltip={tooltip} onMouseEnter={tooltipMouseEnter} onMouseLeave={tooltipMouseLeave} setRef={setTooltipRef} />
{/* Header */}
<header ref={headerRef} className="pt-8 sm:pt-12 pb-8 sm:pb-10 px-4 sm:px-6 relative" style={{ zIndex: 1 }}>
<Sidebar
selectedDistro={selectedDistro}
onDistroSelect={setSelectedDistro}
selectedApps={selectedApps}
selectedCount={selectedCount}
clearAll={clearAll}
command={generatedCommand}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
searchInputRef={searchInputRef}
hasAurPackages={hasAurPackages}
aurAppNames={aurAppNames}
selectedHelper={selectedHelper}
setSelectedHelper={setSelectedHelper}
hasUnfreePackages={hasUnfreePackages}
unfreeAppNames={unfreeAppNames}
onOpenDrawer={openDrawer}
/>
<header ref={headerRef} className="lg:hidden pt-8 sm:pt-12 pb-8 sm:pb-10 px-4 sm:px-6 relative" style={{ zIndex: 1 }}>
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
{/* 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"
@@ -235,11 +293,9 @@ export default function Home() {
</div>
</div>
{/* Header Controls */}
<div className="header-controls flex items-center justify-between sm:justify-end gap-3 sm:gap-4">
{/* Left side on mobile: Help + Links */}
<div className="flex items-center gap-3 sm:gap-4">
{/* Help - mobile only here, desktop is in title area */}
<div className="sm:hidden">
<HowItWorks />
</div>
@@ -247,7 +303,6 @@ export default function Home() {
<ContributeLink />
</div>
{/* Right side: Theme + Distro (with separator on desktop) */}
<div className="flex items-center gap-2 pl-2 sm:pl-3 border-l border-[var(--border-primary)]">
<ThemeToggle />
<DistroSelector selectedDistro={selectedDistro} onSelect={setSelectedDistro} />
@@ -257,13 +312,10 @@ export default function Home() {
</div>
</header>
{/* App Grid */}
<main className="px-4 sm:px-6 pb-40 relative" style={{ zIndex: 1 }}>
<div className="max-w-7xl mx-auto">
{/* Mobile: 2-column grid with balanced distribution */}
<div className="grid grid-cols-2 gap-x-4 md:hidden items-start">
<main className="main-with-sidebar px-4 sm:px-6 pb-40 relative" style={{ zIndex: 1 }}>
<div className="max-w-7xl mx-auto lg:pt-8">
<div className="grid grid-cols-2 gap-x-4 lg:hidden items-start">
{(() => {
// Pack into 2 columns on mobile
const mobileColumns: Array<typeof allCategoriesWithApps> = [[], []];
const heights = [0, 0];
allCategoriesWithApps.forEach(catData => {
@@ -300,16 +352,13 @@ export default function Home() {
})()}
</div>
{/* Desktop: Grid with Tetris packing */}
<div className="hidden md:grid md:grid-cols-4 lg:grid-cols-5 gap-x-8 items-start">
<div className="hidden lg:grid lg:grid-cols-3 xl:grid-cols-4 gap-x-8 items-start">
{columns.map((columnCategories, colIdx) => {
// Calculate starting index for this column (for staggered animation)
let globalIdx = 0;
for (let c = 0; c < colIdx; c++) {
globalIdx += columns[c].length;
}
// Generate stable key based on column content to ensure proper reconciliation
const columnKey = `col-${colIdx}-${columnCategories.map(c => c.category).join('-')}`;
return (
@@ -343,7 +392,6 @@ export default function Home() {
</div>
</main>
{/* Command Footer */}
<CommandFooter
command={generatedCommand}
selectedCount={selectedCount}
@@ -361,6 +409,11 @@ export default function Home() {
setSelectedHelper={setSelectedHelper}
hasUnfreePackages={hasUnfreePackages}
unfreeAppNames={unfreeAppNames}
drawerOpen={drawerOpen}
drawerClosing={drawerClosing}
onDrawerOpen={() => setDrawerOpen(true)}
onDrawerClose={closeDrawer}
activeShortcut={activeShortcut}
/>
</div>
);

View File

@@ -8,7 +8,7 @@ export function AppIcon({ url, name }: { url: string; name: string }) {
if (error) {
return (
<div className="w-[18px] h-[18px] rounded bg-[var(--accent)] flex items-center justify-center text-[10px] font-bold">
<div className="w-[22px] h-[22px] rounded bg-[var(--accent)] flex items-center justify-center text-[11px] font-bold">
{name[0]}
</div>
);
@@ -20,9 +20,9 @@ export function AppIcon({ url, name }: { url: string; name: string }) {
src={url}
alt=""
aria-hidden="true"
width={16}
height={16}
className="w-[18px] h-[18px] object-contain opacity-75"
width={22}
height={22}
className="w-[22px] h-[22px] object-contain opacity-75"
onError={() => setError(true)}
loading="lazy"
/>

View File

@@ -5,15 +5,7 @@ import { Check } from 'lucide-react';
import { distros, type DistroId, type AppData } from '@/lib/data';
import { isAurPackage } from '@/lib/aur';
import { AppIcon } from './AppIcon';
// import { analytics } from '@/lib/analytics'; // Uncomment to enable app selection tracking
/**
* 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.
*/
// Tailwind colors as hex - because CSS variables don't work in inline styles
// Individual app item.
const COLOR_MAP: Record<string, string> = {
'orange': '#f97316',
'blue': '#3b82f6',
@@ -43,7 +35,6 @@ interface AppItemProps {
onTooltipLeave: () => void;
onFocus?: () => void;
color?: string;
// Flatpak/Snap verification status
isVerified?: boolean;
verificationSource?: 'flathub' | 'snap' | null;
}
@@ -62,16 +53,12 @@ export const AppItem = memo(function AppItem({
isVerified = false,
verificationSource = null,
}: AppItemProps) {
// Why isn't this app available? Tell the user.
const getUnavailableText = () => {
const distroName = distros.find(d => d.id === selectedDistro)?.name || '';
return app.unavailableReason || `Not available in ${distroName} repos`;
};
// Special styling for AUR packages (Arch users love their badges)
const isAur = selectedDistro === 'arch' && app.targets?.arch && isAurPackage(app.targets.arch);
// AUR gets its special Arch blue, everything else uses category color
const hexColor = COLOR_MAP[color] || COLOR_MAP['gray'];
const checkboxColor = isAur ? '#1793d1' : hexColor;
@@ -82,7 +69,7 @@ export const AppItem = memo(function AppItem({
aria-checked={isSelected}
aria-label={`${app.name}${!isAvailable ? ' (unavailable)' : ''}`}
aria-disabled={!isAvailable}
className={`app-item group w-full flex items-center gap-2.5 py-1.5 px-2 outline-none transition-all duration-150
className={`app-item group w-full flex items-center gap-3 py-[7px] px-3 outline-none transition-all duration-150
${isFocused ? 'bg-[var(--bg-secondary)] border-l-2 shadow-sm' : 'border-l-2 border-transparent'}
${!isAvailable
? 'opacity-40 grayscale-[30%]'
@@ -91,7 +78,7 @@ export const AppItem = memo(function AppItem({
style={{
transition: 'background-color 0.15s, color 0.5s',
borderColor: isFocused ? hexColor : 'transparent',
backgroundColor: isFocused ? `color-mix(in srgb, ${hexColor}, transparent 85%)` : undefined, // Stronger tint on focus (15% opacity)
backgroundColor: isFocused ? `color-mix(in srgb, ${hexColor}, transparent 85%)` : undefined,
'--item-color': hexColor,
} as React.CSSProperties}
onClick={(e) => {
@@ -99,23 +86,17 @@ export const AppItem = memo(function AppItem({
onFocus?.();
if (isAvailable) {
onToggle();
// Umami tracking disabled to save quota
// if (isSelected) {
// analytics.appDeselected(app.name, app.category || '', selectedDistro);
// } else {
// analytics.appSelected(app.name, app.category || '', selectedDistro);
// }
}
}}
>
<div
className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors duration-150 ${!isAvailable ? 'border-dashed' : ''}`}
className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors duration-150 ${!isAvailable ? 'border-dashed' : ''}`}
style={{
borderColor: isSelected || isAur ? checkboxColor : 'var(--border-secondary)',
backgroundColor: isSelected ? checkboxColor : 'transparent',
}}
>
{isSelected && <Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />}
{isSelected && <Check className="w-3.5 h-3.5 text-white" strokeWidth={3} />}
</div>
<AppIcon url={app.iconUrl} name={app.name} />
<div className="flex-1 flex items-baseline gap-1.5 min-w-0 overflow-hidden">
@@ -123,7 +104,8 @@ export const AppItem = memo(function AppItem({
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',
fontSize: '20px',
fontWeight: 500,
transition: 'color 0.5s',
textRendering: 'geometricPrecision',
WebkitFontSmoothing: 'antialiased'
@@ -162,7 +144,6 @@ export const AppItem = memo(function AppItem({
</svg>
)}
</div>
{/* Exclamation mark icon for unavailable apps */}
{!isAvailable && (
<div
className="relative group flex-shrink-0 cursor-help"
@@ -170,8 +151,8 @@ export const AppItem = memo(function AppItem({
onMouseLeave={(e) => { e.stopPropagation(); onTooltipLeave(); }}
>
<svg
className="w-4 h-4 text-[var(--text-muted)] transition-[color,transform] duration-300 hover:rotate-[360deg] hover:scale-110"
style={{ color: isFocused ? hexColor : undefined }} // Use category color on hover/focus
className="w-[18px] h-[18px] text-[var(--text-muted)] transition-[color,transform] duration-300 hover:rotate-[360deg] hover:scale-110"
style={{ color: isFocused ? hexColor : undefined }}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -6,7 +6,6 @@ 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,
@@ -25,7 +24,6 @@ const CATEGORY_ICONS: Record<string, LucideIcon> = {
'System': Cpu,
};
// Tailwind colors as hex
const COLOR_MAP: Record<string, string> = {
'orange': '#f97316',
'blue': '#3b82f6',
@@ -44,10 +42,7 @@ const COLOR_MAP: Record<string, string> = {
'gray': '#6b7280',
};
/**
* Collapsible category header with icon, chevron, and selection badge.
* Uses color-mix for dynamic tinting because we're fancy like that.
*/
// Category header.
export function CategoryHeader({
category,
isExpanded,
@@ -74,33 +69,32 @@ export function CategoryHeader({
tabIndex={-1}
aria-expanded={isExpanded}
aria-label={`${category} category, ${selectedCount} apps selected`}
// AccessGuide-style: subtle bg with colored left border accent
className={`category-header group w-full h-8 flex items-center gap-2 text-sm font-semibold
className={`category-header group w-full py-2 flex items-center gap-2.5 text-[15px] font-semibold
border-l-4
px-3 mb-3
px-3 mb-2
transition-all duration-200 outline-none
hover:bg-[color-mix(in_srgb,var(--header-color),transparent_80%)]`}
style={{
color: 'var(--text-primary)',
borderColor: hexColor,
backgroundColor: isFocused
? `color-mix(in srgb, ${hexColor}, transparent 75%)` // 25% opacity for focus
: `color-mix(in srgb, ${hexColor}, transparent 90%)`, // 10% opacity for default
? `color-mix(in srgb, ${hexColor}, transparent 75%)`
: `color-mix(in srgb, ${hexColor}, transparent 90%)`,
'--header-color': hexColor,
} as React.CSSProperties}
>
<ChevronRight
className={`w-4 h-4 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
className={`w-[18px] h-[18px] transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
style={{ color: hexColor }}
/>
{(() => {
const Icon = CATEGORY_ICONS[category] || Terminal;
return <Icon className="w-4 h-4" style={{ color: hexColor }} />;
return <Icon className="w-[18px] h-[18px]" style={{ color: hexColor }} />;
})()}
<span className="flex-1 text-left">{category}</span>
{selectedCount > 0 && (
<span
className="text-xs font-bold ml-1.5 px-1.5 py-0.5 rounded"
className="text-sm font-bold ml-1.5 px-2 py-0.5 rounded"
style={{
transition: 'color 0.5s',
color: hexColor,

View File

@@ -7,11 +7,7 @@ import { analytics } from '@/lib/analytics';
import { CategoryHeader } from './CategoryHeader';
import { AppItem } from './AppItem';
/**
* 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.
*/
// Category section.
interface CategorySectionProps {
category: Category;
categoryApps: AppData[];
@@ -28,15 +24,10 @@ interface CategorySectionProps {
categoryIndex: number;
onCategoryFocus?: () => void;
onAppFocus?: (appId: string) => void;
// Flatpak/Snap verification status
isVerified?: (distro: DistroId, packageName: string) => boolean;
getVerificationSource?: (distro: DistroId, packageName: string) => 'flathub' | 'snap' | null;
}
/**
* 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',
@@ -80,10 +71,8 @@ function CategorySectionComponent({
const hasAnimated = useRef(false);
const prevAppCount = useRef(categoryApps.length);
// Get color for this category
const color = categoryColors[category] || 'gray';
// Initial entrance animation
useLayoutEffect(() => {
if (!sectionRef.current || hasAnimated.current) return;
hasAnimated.current = true;
@@ -92,16 +81,12 @@ function CategorySectionComponent({
const header = section.querySelector('.category-header');
const items = section.querySelectorAll('.app-item');
// Use requestAnimationFrame for smoother initial setup
requestAnimationFrame(() => {
// Initial state with GPU-accelerated transforms
gsap.set(header, { clipPath: 'inset(0 100% 0 0)' });
gsap.set(items, { y: -15, opacity: 0, force3D: true });
// Staggered delay based on category index (reduced for faster feel)
const delay = categoryIndex * 0.05;
// Animate header with clip-path reveal
gsap.to(header, {
clipPath: 'inset(0 0% 0 0)',
duration: 0.6,
@@ -109,7 +94,6 @@ function CategorySectionComponent({
delay: delay + 0.05
});
// Animate items with GPU-accelerated transforms
gsap.to(items, {
y: 0,
opacity: 1,
@@ -121,11 +105,9 @@ function CategorySectionComponent({
});
}, [categoryIndex]);
// When app count changes (after search clears), ensure all items are visible
useEffect(() => {
if (categoryApps.length !== prevAppCount.current && sectionRef.current) {
const items = sectionRef.current.querySelectorAll('.app-item');
// Reset any hidden items to visible
gsap.set(items, { y: 0, opacity: 1, clearProps: 'all' });
}
prevAppCount.current = categoryApps.length;
@@ -151,7 +133,7 @@ function CategorySectionComponent({
color={color}
/>
<div
className={`overflow-hidden transition-[max-height,opacity] duration-500 ${isExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'}`}
className={`overflow-hidden transition-[max-height,opacity] duration-500 pt-0.5 ${isExpanded ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'}`}
style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
>
{categoryApps.map((app) => (
@@ -183,17 +165,13 @@ function CategorySectionComponent({
);
}
// Custom memo comparison - React's shallow compare was killing perf
export const CategorySection = memo(CategorySectionComponent, (prevProps, nextProps) => {
// Always re-render if app count changes
if (prevProps.categoryApps.length !== nextProps.categoryApps.length) return false;
// Check if app IDs are the same
const prevIds = prevProps.categoryApps.map(a => a.id).join(',');
const nextIds = nextProps.categoryApps.map(a => a.id).join(',');
if (prevIds !== nextIds) return false;
// Check other important props
if (prevProps.category !== nextProps.category) return false;
if (prevProps.isExpanded !== nextProps.isExpanded) return false;
if (prevProps.selectedDistro !== nextProps.selectedDistro) return false;
@@ -201,11 +179,9 @@ export const CategorySection = memo(CategorySectionComponent, (prevProps, nextPr
if (prevProps.focusedType !== nextProps.focusedType) return false;
if (prevProps.categoryIndex !== nextProps.categoryIndex) return false;
// Re-render when verification functions change (Flathub data loads)
if (prevProps.isVerified !== nextProps.isVerified) return false;
if (prevProps.getVerificationSource !== nextProps.getVerificationSource) return false;
// Check if selection state changed for any app in this category
for (const app of nextProps.categoryApps) {
if (prevProps.selectedApps.has(app.id) !== nextProps.selectedApps.has(app.id)) {
return false;

View File

@@ -8,12 +8,7 @@ interface AurDrawerSettingsProps {
setSelectedHelper: (helper: 'yay' | 'paru') => void;
}
/**
* 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.
*/
// AUR package settings panel.
export function AurDrawerSettings({
aurAppNames,
hasYayInstalled,
@@ -23,8 +18,8 @@ export function AurDrawerSettings({
distroColor,
}: AurDrawerSettingsProps & { distroColor: string }) {
return (
<div className="mb-4 rounded-lg border border-[var(--border-primary)] bg-[var(--bg-secondary)] overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--border-primary)]/50 flex">
<div className="mb-4 rounded-lg bg-[var(--bg-secondary)]/30 border border-[var(--border-primary)]/20 overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--border-primary)]/10 flex">
<span className="text-xs font-medium text-[var(--text-secondary)] whitespace-nowrap mr-2">AUR Packages:</span>
<span className="text-xs text-[var(--text-muted)] truncate">{aurAppNames.join(', ')}</span>
</div>
@@ -32,7 +27,7 @@ export function AurDrawerSettings({
<div className="p-4 grid grid-cols-2 gap-4 text-sm">
<div className="flex flex-col gap-2">
<span className="text-[var(--text-secondary)] font-medium">AUR Helper</span>
<div className="flex w-full bg-[var(--bg-primary)] rounded-md border border-[var(--border-primary)] p-1 h-10">
<div className="flex w-full bg-[var(--bg-tertiary)]/40 rounded-md border border-[var(--border-primary)]/30 p-1 h-10">
<button
onClick={() => setSelectedHelper('yay')}
className={`flex-1 rounded-sm font-medium transition-all ${selectedHelper === 'yay' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
@@ -52,7 +47,7 @@ export function AurDrawerSettings({
<div className="flex flex-col gap-2">
<span className="text-[var(--text-secondary)] font-medium">Install helper?</span>
<div className="flex w-full bg-[var(--bg-primary)] rounded-md border border-[var(--border-primary)] p-1 h-10">
<div className="flex w-full bg-[var(--bg-tertiary)]/40 rounded-md border border-[var(--border-primary)]/30 p-1 h-10">
<button
onClick={() => setHasYayInstalled(true)}
className={`flex-1 rounded-sm font-medium transition-all ${hasYayInstalled ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}

View File

@@ -12,11 +12,7 @@ interface AurFloatingCardProps {
setSelectedHelper: (helper: 'yay' | 'paru') => void;
}
/**
* 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.
*/
// AUR setup prompt.
export function AurFloatingCard({
show,
aurAppNames,
@@ -27,14 +23,11 @@ export function AurFloatingCard({
const [dismissed, setDismissed] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
// Track if user has answered the first question
const [hasAnswered, setHasAnswered] = useState<boolean | null>(null);
// Track if user has selected a helper (completed flow)
const [helperChosen, setHelperChosen] = useState(false);
// 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
// Reset on new AUR packages if no interaction
useEffect(() => {
if (show && aurAppNames.length > 0 && !userInteractedRef.current) {
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -56,9 +49,8 @@ export function AurFloatingCard({
const handleHelperSelect = (helper: 'yay' | 'paru') => {
setSelectedHelper(helper);
setHelperChosen(true);
userInteractedRef.current = true; // Don't ask again
userInteractedRef.current = true;
// Start exit animation after a brief moment
setTimeout(() => {
setIsExiting(true);
setTimeout(() => {
@@ -68,7 +60,7 @@ export function AurFloatingCard({
};
const handleDismiss = () => {
userInteractedRef.current = true; // Don't ask again
userInteractedRef.current = true;
setIsExiting(true);
setTimeout(() => {
setDismissed(true);
@@ -76,9 +68,7 @@ export function AurFloatingCard({
}, 200);
};
// Show confirmation message after selecting helper, auto-dismiss after 3s
if (showConfirmation) {
// Auto dismiss after 3 seconds
setTimeout(() => {
setDismissed(true);
}, 3000);
@@ -97,20 +87,19 @@ export function AurFloatingCard({
);
}
// Hide cards while exiting
if (isExiting && helperChosen) {
return (
<div className="fixed top-4 left-1/2 -translate-x-1/2 md:left-auto md:right-4 md:translate-x-0 z-30 flex flex-col gap-3 items-center md:items-end">
<div
className="w-72 bg-[var(--bg-secondary)] backdrop-blur-xl border-l-4 shadow-lg overflow-hidden"
style={{ borderLeftColor: 'var(--accent)', animation: 'slideOutToRight 0.25s ease-out forwards' }}
className="w-72 bg-[var(--bg-primary)] border border-[var(--border-primary)]/30 rounded-xl shadow-lg overflow-hidden"
style={{ animation: 'slideOutToRight 0.25s ease-out forwards' }}
>
<div className="p-4" />
</div>
{hasAnswered !== null && (
<div
className="w-72 bg-[var(--bg-secondary)] backdrop-blur-xl border-l-4 shadow-lg overflow-hidden"
style={{ borderLeftColor: 'var(--accent)', animation: 'slideOutToRight 0.2s ease-out forwards' }}
className="w-72 bg-[var(--bg-primary)] border border-[var(--border-primary)]/30 rounded-xl shadow-lg overflow-hidden"
style={{ animation: 'slideOutToRight 0.2s ease-out forwards' }}
>
<div className="p-4" />
</div>
@@ -121,22 +110,18 @@ export function AurFloatingCard({
return (
<div className="fixed top-4 left-1/2 -translate-x-1/2 md:left-auto md:right-4 md:translate-x-0 z-30 flex flex-col gap-3 items-center md:items-end">
{/* Card 1: Do you have an AUR helper? - AccessGuide style */}
<div
className={`
w-72 bg-[var(--bg-secondary)] backdrop-blur-xl
border-l-4 shadow-lg
w-72 bg-[var(--bg-primary)] border border-[var(--border-primary)]/30 rounded-xl shadow-lg
overflow-hidden
transition-[box-shadow] duration-200
`}
style={{
borderLeftColor: 'var(--accent)',
animation: isExiting
? 'slideOutToRight 0.2s ease-out forwards'
: 'slideInFromRight 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards'
}}
>
{/* Header */}
<div className="px-4 py-3 flex items-start justify-between gap-3">
<div className="flex-1">
<p className="text-[11px] text-[var(--text-muted)] tracking-wide uppercase mb-1">
@@ -155,16 +140,15 @@ export function AurFloatingCard({
</button>
</div>
{/* Buttons */}
<div className="px-4 pb-3 flex gap-2">
<button
onClick={() => handleFirstAnswer(true)}
className={`
flex-1 py-2 px-4 text-sm font-medium
flex-1 py-2 px-4 rounded-lg text-sm font-medium
transition-[background-color,color] duration-200 ease-out
${hasAnswered === true
? 'bg-[var(--accent)]/20 text-[var(--text-primary)] border-l-2 border-l-[var(--accent)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
? 'bg-[var(--bg-secondary)] border border-[var(--border-primary)]/40 text-[var(--text-primary)] shadow-sm'
: 'bg-[var(--bg-tertiary)]/70 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
}
`}
>
@@ -173,11 +157,11 @@ export function AurFloatingCard({
<button
onClick={() => handleFirstAnswer(false)}
className={`
flex-1 py-2 px-4 text-sm font-medium
flex-1 py-2 px-4 rounded-lg text-sm font-medium
transition-[background-color,color] duration-200 ease-out
${hasAnswered === false
? 'bg-[var(--accent)]/20 text-[var(--text-primary)] border-l-2 border-l-[var(--accent)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
? 'bg-[var(--bg-secondary)] border border-[var(--border-primary)]/40 text-[var(--text-primary)] shadow-sm'
: 'bg-[var(--bg-tertiary)]/70 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
}
`}
>
@@ -185,7 +169,6 @@ export function AurFloatingCard({
</button>
</div>
{/* Hint */}
<div className="px-4 pb-3 -mt-1">
<p className="text-[10px] text-[var(--text-muted)]/50 leading-relaxed">
Change anytime in preview window
@@ -193,23 +176,19 @@ export function AurFloatingCard({
</div>
</div>
{/* Card 2: Which helper? (appears after first answer) - AccessGuide style */}
{hasAnswered !== null && (
<div
className={`
w-72 bg-[var(--bg-secondary)] backdrop-blur-xl
border-l-4 shadow-lg
w-72 bg-[var(--bg-primary)] border border-[var(--border-primary)]/30 rounded-xl shadow-lg
overflow-hidden
`}
style={{
borderLeftColor: 'var(--accent)',
animation: isExiting
? 'slideOutToRight 0.15s ease-out forwards'
: 'slideInFromRightSecond 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) 0.05s forwards',
opacity: 0
}}
>
{/* Header */}
<div className="px-4 py-3 flex items-center justify-between gap-3">
<p className="text-[15px] text-[var(--text-primary)] font-medium">
{hasAnswered
@@ -226,16 +205,15 @@ export function AurFloatingCard({
</button>
</div>
{/* Helper selection */}
<div className="px-4 pb-4 flex gap-2">
<button
onClick={() => handleHelperSelect('yay')}
className={`
flex-1 py-2.5 px-4 text-sm font-medium
flex-1 py-2.5 px-4 rounded-lg text-sm font-medium
transition-[background-color,color] duration-200 ease-out
${selectedHelper === 'yay' && helperChosen
? 'bg-[var(--accent)]/20 text-[var(--text-primary)] border-l-2 border-l-[var(--accent)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
? 'bg-[var(--bg-secondary)] border border-[var(--border-primary)]/40 text-[var(--text-primary)] shadow-sm'
: 'bg-[var(--bg-tertiary)]/70 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
}
`}
>
@@ -247,11 +225,11 @@ export function AurFloatingCard({
<button
onClick={() => handleHelperSelect('paru')}
className={`
flex-1 py-2.5 px-4 text-sm font-medium
flex-1 py-2.5 px-4 rounded-lg text-sm font-medium
transition-[background-color,color] duration-200 ease-out
${selectedHelper === 'paru' && helperChosen
? 'bg-[var(--accent)]/20 text-[var(--text-primary)] border-l-2 border-l-[var(--accent)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
? 'bg-[var(--bg-secondary)] border border-[var(--border-primary)]/40 text-[var(--text-primary)] shadow-sm'
: 'bg-[var(--bg-tertiary)]/70 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]'
}
`}
>

View File

@@ -50,11 +50,11 @@ export function AurPopover({
{/* Popover */}
{isOpen && (
<div
className="absolute bottom-full left-0 mb-2 w-64 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-xl shadow-2xl overflow-hidden"
className="absolute bottom-full left-0 mb-2 w-64 bg-[var(--bg-primary)] border border-[var(--border-primary)]/30 rounded-xl shadow-lg overflow-hidden"
style={{ animation: 'tooltipSlideUp 0.2s ease-out' }}
>
{/* Header */}
<div className="px-3 py-2 border-b border-[var(--border-primary)] bg-[var(--bg-tertiary)]">
<div className="px-3 py-2.5 border-b border-[var(--border-primary)]/15">
<p className="text-xs font-medium text-[var(--text-primary)]">AUR Packages</p>
<p className="text-xs text-[var(--text-muted)]">
{hasYayInstalled ? 'Using yay' : 'Will install yay first'}
@@ -74,7 +74,7 @@ export function AurPopover({
</div>
{/* Yay Checkbox */}
<div className="px-3 py-2 border-t border-[var(--border-primary)]">
<div className="px-3 py-2.5 border-t border-[var(--border-primary)]/15">
<label className="flex items-center gap-2 cursor-pointer select-none group">
<div className="relative">
<input

View File

@@ -23,13 +23,12 @@ interface CommandDrawerProps {
setSelectedHelper: (helper: 'yay' | 'paru') => void;
distroColor: string;
distroId: DistroId;
// Nix unfree warning
hasUnfreePackages?: boolean;
unfreeAppNames?: string[];
activeShortcut?: string | null;
}
// Command drawer - bottom sheet on mobile, modal on desktop.
// Nix gets special treatment: shows config file instead of terminal command.
// Command drawer modal/bottom sheet.
export function CommandDrawer({
isOpen,
isClosing,
@@ -49,13 +48,14 @@ export function CommandDrawer({
distroId,
hasUnfreePackages = false,
unfreeAppNames = [],
activeShortcut,
}: CommandDrawerProps) {
const isNix = distroId === 'nix';
// Swipe-to-dismiss for mobile users who hate tapping tiny X buttons
// Swipe to dismiss
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const dragStartY = useRef(0);
const DISMISS_THRESHOLD = 100; // Feels right™
const DISMISS_THRESHOLD = 100;
const handleTouchStart = (e: React.TouchEvent) => {
dragStartY.current = e.touches[0].clientY;
@@ -79,7 +79,7 @@ export function CommandDrawer({
if (!isOpen) return null;
// Copy command and auto-close after a celebratory 3 seconds
// Copy command and close after delay
const handleCopyAndClose = () => {
onCopy();
setTimeout(onClose, 3000);
@@ -87,7 +87,6 @@ export function CommandDrawer({
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40"
onClick={onClose}
@@ -95,26 +94,24 @@ export function CommandDrawer({
style={{ animation: isClosing ? 'fadeOut 0.3s ease-out forwards' : 'fadeIn 0.3s ease-out' }}
/>
{/* Drawer */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
className="fixed z-50 bg-[var(--bg-secondary)] rounded-t-xl md:rounded-lg shadow-2xl
className="fixed z-50 bg-[var(--bg-primary)] rounded-t-xl md:rounded-xl shadow-lg
bottom-0 left-0 right-0
md:bottom-auto md:top-1/2 md:left-1/2 md:-translate-x-1/2 md:-translate-y-1/2 md:max-w-2xl md:w-[90vw]"
style={{
boxShadow: '0 0 0 1px var(--border-primary), 0 20px 60px -10px rgba(0, 0, 0, 0.5)',
border: '1px solid color-mix(in srgb, var(--border-primary), transparent 70%)',
animation: isClosing
? 'slideDown 0.3s cubic-bezier(0.32, 0, 0.67, 0) forwards'
: dragOffset > 0 ? 'none' : 'slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
maxHeight: '80vh',
transform: dragOffset > 0 ? `translateY(${dragOffset}px)` : undefined,
transition: isDragging ? 'none' : 'transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
border: '1px solid var(--border-primary)',
boxShadow: '0 16px 48px -8px rgba(0, 0, 0, 0.35)',
}}
>
{/* Drawer Handle - mobile only, draggable */}
<div
className="flex justify-center pt-3 pb-2 md:hidden cursor-grab active:cursor-grabbing touch-none"
onTouchStart={handleTouchStart}
@@ -127,7 +124,7 @@ export function CommandDrawer({
/>
</div>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-primary)]/15">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-1 h-5 rounded-full" style={{ backgroundColor: distroColor }}></div>
@@ -150,7 +147,7 @@ export function CommandDrawer({
</button>
</div>
<div className="p-4 overflow-y-auto bg-[var(--bg-primary)]/50" style={{ maxHeight: 'calc(80vh - 140px)' }}>
<div className="p-4 overflow-y-auto" style={{ maxHeight: 'calc(80vh - 140px)' }}>
{showAur && (
<AurDrawerSettings
aurAppNames={aurAppNames}
@@ -162,7 +159,6 @@ export function CommandDrawer({
/>
)}
{/* Nix unfree packages warning */}
{isNix && hasUnfreePackages && (
<div className="mb-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
@@ -177,25 +173,28 @@ export function CommandDrawer({
</div>
)}
{/* 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)]">
<div className="bg-[var(--bg-secondary)]/60 rounded-lg border border-[var(--border-primary)]/15 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border-primary)]/10">
<span className="text-xs font-mono text-[var(--text-muted)]">{isNix ? 'nix' : 'bash'}</span>
{/* Desktop action buttons */}
<div className="hidden md:flex items-center gap-2">
<button
data-action="download"
onClick={onDownload}
className="h-8 px-4 flex items-center gap-2 rounded-md hover:bg-[var(--bg-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all text-xs font-medium"
className={`h-8 px-4 flex items-center gap-2 rounded-md transition-all text-xs font-medium ${activeShortcut === 'd' ? 'bg-[var(--bg-primary)] text-[var(--text-primary)] scale-[0.98]' : 'hover:bg-[var(--bg-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
>
<Download className="w-4 h-4" />
<span>{isNix ? 'configuration.nix' : 'Script'}</span>
</button>
<button
data-action="copy"
onClick={handleCopyAndClose}
className={`h-8 px-4 flex items-center gap-2 rounded-md text-xs font-medium transition-all ${copied
? 'shadow-sm'
: 'hover:bg-[var(--bg-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
? 'shadow-sm text-black'
: activeShortcut === 'y'
? 'bg-[var(--bg-primary)] text-[var(--text-primary)] scale-[0.98]'
: 'hover:bg-[var(--bg-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
}`}
style={{
backgroundColor: copied ? distroColor : 'transparent',
@@ -226,19 +225,23 @@ export function CommandDrawer({
</div>
{/* Mobile Actions */}
<div className="md:hidden flex items-stretch gap-3 px-4 py-3 border-t border-[var(--border-primary)] bg-[var(--bg-secondary)]">
<div className="md:hidden flex items-stretch gap-3 px-4 py-3 border-t border-[var(--border-primary)]/15">
<button
data-action="download"
onClick={onDownload}
className="flex-1 h-11 flex items-center justify-center gap-2 rounded-md bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] active:scale-[0.98] transition-all font-medium text-sm"
className={`flex-1 h-11 flex items-center justify-center gap-2 rounded-md border text-sm font-medium transition-all ${activeShortcut === 'd' ? 'bg-[var(--bg-hover)] text-[var(--text-primary)] border-[var(--border-secondary)] scale-[0.98]' : 'bg-[var(--bg-tertiary)] border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]'}`}
>
<Download className="w-4 h-4" />
Download
</button>
<button
data-action="copy"
onClick={handleCopyAndClose}
className={`flex-1 h-11 flex items-center justify-center gap-2 rounded-md font-medium text-sm active:scale-[0.98] transition-all shadow-sm ${copied
className={`flex-1 h-11 flex items-center justify-center gap-2 rounded-md font-medium text-sm transition-all shadow-sm ${copied
? 'text-black'
: 'text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border-primary)]'
: activeShortcut === 'y'
? 'bg-[var(--bg-hover)] border-[var(--border-secondary)] scale-[0.98]'
: 'text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border-primary)]'
}`}
style={{
backgroundColor: copied ? distroColor : undefined,

View File

@@ -5,7 +5,7 @@ import { Check, Copy, ChevronUp, Download, X } from 'lucide-react';
import { distros, type DistroId } from '@/lib/data';
import { generateInstallScript } from '@/lib/generateInstallScript';
import { analytics } from '@/lib/analytics';
import { useTheme } from '@/hooks/useTheme';
import { ShortcutsBar } from './ShortcutsBar';
import { AurFloatingCard } from './AurFloatingCard';
import { CommandDrawer } from './CommandDrawer';
@@ -25,9 +25,13 @@ interface CommandFooterProps {
clearAll: () => void;
selectedHelper: 'yay' | 'paru';
setSelectedHelper: (helper: 'yay' | 'paru') => void;
// Nix unfree
hasUnfreePackages?: boolean;
unfreeAppNames?: string[];
drawerOpen?: boolean;
drawerClosing?: boolean;
onDrawerOpen?: () => void;
onDrawerClose?: () => void;
activeShortcut?: string | null;
}
@@ -48,17 +52,21 @@ export function CommandFooter({
setSelectedHelper,
hasUnfreePackages,
unfreeAppNames,
drawerOpen: externalDrawerOpen,
drawerClosing: externalDrawerClosing,
onDrawerOpen: externalOnDrawerOpen,
onDrawerClose: externalOnDrawerClose,
activeShortcut,
}: CommandFooterProps) {
const [copied, setCopied] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerClosing, setDrawerClosing] = useState(false);
const [internalDrawerOpen, setInternalDrawerOpen] = useState(false);
const [internalDrawerClosing, setInternalDrawerClosing] = useState(false);
const [hasEverHadSelection, setHasEverHadSelection] = useState(false);
const initialCountRef = useRef(selectedCount);
const { toggle: toggleTheme } = useTheme();
const drawerOpen = externalDrawerOpen ?? internalDrawerOpen;
const drawerClosing = externalDrawerClosing ?? internalDrawerClosing;
// Track if user has actually interacted - 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
@@ -67,12 +75,16 @@ export function CommandFooter({
}, [selectedCount, hasEverHadSelection]);
const closeDrawer = useCallback(() => {
setDrawerClosing(true);
setTimeout(() => {
setDrawerOpen(false);
setDrawerClosing(false);
}, 250);
}, []);
if (externalOnDrawerClose) {
externalOnDrawerClose();
} else {
setInternalDrawerClosing(true);
setTimeout(() => {
setInternalDrawerOpen(false);
setInternalDrawerClosing(false);
}, 250);
}
}, [externalOnDrawerClose]);
// Close drawer on Escape
useEffect(() => {
@@ -117,45 +129,6 @@ export function CommandFooter({
analytics.scriptDownloaded(distroName, selectedCount);
}, [selectedCount, selectedDistro, selectedApps, selectedHelper]);
// Global keyboard shortcuts (vim-like)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
// Ignore modifier keys (prevents conflicts with browser shortcuts)
if (e.ctrlKey || e.altKey || e.metaKey) return;
const alwaysEnabled = ['t', 'c'];
if (selectedCount === 0 && !alwaysEnabled.includes(e.key)) return;
switch (e.key) {
case 'y': handleCopy(); break;
case 'd': handleDownload(); break;
case 't':
document.body.classList.add('theme-flash');
setTimeout(() => document.body.classList.remove('theme-flash'), 150);
toggleTheme();
break;
case 'c': clearAll(); break;
case '1': if (showAur) setSelectedHelper('yay'); break;
case '2': if (showAur) setSelectedHelper('paru'); break;
case 'Tab':
e.preventDefault();
if (selectedCount > 0) setDrawerOpen(prev => !prev);
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedCount, toggleTheme, clearAll, showAur, setSelectedHelper, handleCopy, handleDownload]);
return (
<>
{/* AUR Floating Card */}
@@ -168,7 +141,6 @@ export function CommandFooter({
setSelectedHelper={setSelectedHelper}
/>
{/* Command Drawer */}
<CommandDrawer
isOpen={drawerOpen}
isClosing={drawerClosing}
@@ -190,17 +162,16 @@ export function CommandFooter({
unfreeAppNames={unfreeAppNames}
/>
{/* Animated footer container - only shows after first selection */}
{/* Animated footer container - shows after first selection */}
{hasEverHadSelection && (
<div
className="fixed bottom-0 left-0 right-0 p-3"
className="fixed bottom-0 left-0 right-0 p-3 lg:hidden"
style={{
zIndex: 10,
animation: 'footerSlideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) both'
}}
>
<div className="relative w-[85%] mx-auto">
{/* Soft glow behind bars */}
<div
className="absolute -inset-12 pointer-events-none"
style={{
@@ -225,13 +196,20 @@ export function CommandFooter({
setSelectedHelper={setSelectedHelper}
/>
{/* Command Bar - AccessGuide style */}
<div className="bg-[var(--bg-tertiary)] font-mono text-xs overflow-hidden border-l-4 shadow-2xl"
style={{ borderLeftColor: distroColor }}>
<div className="flex items-stretch">
{/* Preview button (hidden on mobile) */}
{/* Preview button */}
<button
onClick={() => selectedCount > 0 && setDrawerOpen(true)}
onClick={() => {
if (selectedCount > 0) {
if (externalOnDrawerOpen) {
externalOnDrawerOpen();
} else {
setInternalDrawerOpen(true);
}
}
}}
disabled={selectedCount === 0}
className={`hidden md:flex items-center gap-2 px-5 py-3 border-r border-[var(--border-primary)]/20 transition-all shrink-0 font-medium ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
title="Toggle Preview (Tab)"
@@ -257,22 +235,28 @@ export function CommandFooter({
)}
</button>
{/* Command text */}
<div
className="flex-1 min-w-0 flex items-center justify-center px-4 py-4 overflow-hidden bg-[var(--bg-secondary)] cursor-pointer hover:bg-[var(--bg-hover)] transition-colors group"
onClick={() => selectedCount > 0 && setDrawerOpen(true)}
onClick={() => {
if (selectedCount > 0) {
if (externalOnDrawerOpen) {
externalOnDrawerOpen();
} else {
setInternalDrawerOpen(true);
}
}
}}
>
<code className={`whitespace-nowrap overflow-x-auto command-scroll leading-none text-sm font-semibold ${selectedCount > 0 ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'}`}>
{command}
</code>
</div>
{/* Clear button (hidden on mobile) */}
<button
onClick={clearAll}
disabled={selectedCount === 0}
className={`hidden md:flex items-center gap-2 px-4 py-3 border-l border-[var(--border-primary)]/20 transition-all duration-150 font-sans text-sm ${selectedCount > 0
? 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] active:scale-[0.97]'
? activeShortcut === 'c' ? 'bg-red-500/20 text-red-400 scale-[0.97]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] active:scale-[0.97]'
: 'text-[var(--text-muted)] opacity-50 cursor-not-allowed'
}`}
title="Clear All (c)"
@@ -291,12 +275,11 @@ export function CommandFooter({
<span className="hidden sm:inline whitespace-nowrap">Clear</span>
</button>
{/* Download button (hidden on mobile) */}
<button
onClick={handleDownload}
disabled={selectedCount === 0}
className={`hidden md:flex items-center gap-2 px-4 py-3 border-l border-[var(--border-primary)]/20 transition-all duration-150 font-sans text-sm ${selectedCount > 0
? 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] active:scale-[0.97]'
? activeShortcut === 'd' ? 'bg-[var(--bg-hover)] opacity-80 scale-[0.97]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] active:scale-[0.97]'
: 'text-[var(--text-muted)] opacity-50 cursor-not-allowed'
}`}
title="Download Script (d)"
@@ -315,14 +298,13 @@ export function CommandFooter({
<span className="hidden sm:inline whitespace-nowrap">Download</span>
</button>
{/* Copy button (hidden on mobile) */}
<button
onClick={handleCopy}
disabled={selectedCount === 0}
className={`hidden md:flex items-center gap-2 px-4 py-3 border-l border-[var(--border-primary)]/20 transition-all duration-150 font-sans text-sm ${selectedCount > 0
? (copied
? 'text-emerald-400 font-medium'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] active:scale-[0.97]')
: activeShortcut === 'y' ? 'bg-[var(--bg-hover)] opacity-80 scale-[0.97]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] active:scale-[0.97]')
: 'text-[var(--text-muted)] opacity-50 cursor-not-allowed'
}`}
title="Copy Command (y)"

View File

@@ -14,11 +14,7 @@ interface ShortcutsBarProps {
setSelectedHelper: (helper: 'yay' | 'paru') => void;
}
/**
* 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.
*/
// Bottom statusbar with controls and shortcuts.
export function ShortcutsBar({
searchQuery,
onSearchChange,
@@ -42,9 +38,7 @@ export function ShortcutsBar({
<div className="bg-[var(--bg-tertiary)] border-l-4 font-mono text-xs overflow-hidden"
style={{ borderLeftColor: distroColor }}>
<div className="flex items-stretch justify-between">
{/* LEFT SECTION */}
<div className="flex items-stretch">
{/* Mode Badge - like nvim NORMAL/INSERT (hidden on mobile) */}
<div
className="hidden md:flex text-white px-3 py-1 font-bold items-center whitespace-nowrap"
style={{ backgroundColor: distroColor }}
@@ -52,7 +46,6 @@ export function ShortcutsBar({
{distroName.toUpperCase()}
</div>
{/* Search Section */}
<div className="flex items-center gap-1.5 px-3 py-1 bg-[var(--bg-secondary)] border-r border-[var(--border-primary)]/30">
<span className="text-[var(--text-muted)]">/</span>
<input
@@ -80,14 +73,12 @@ export function ShortcutsBar({
)}
</div>
{/* App count */}
{selectedCount > 0 && (
<div className="flex items-center px-3 py-1 text-[var(--text-muted)] border-r border-[var(--border-primary)]/30 whitespace-nowrap">
[{selectedCount} app{selectedCount !== 1 ? 's' : ''}]
</div>
)}
{/* AUR Helper Switch */}
{showAur && (
<div className="flex items-stretch border-r border-[var(--border-primary)]/30">
<button
@@ -108,13 +99,10 @@ export function ShortcutsBar({
)}
</div>
{/* RIGHT SECTION - Compact Shortcuts (hidden on mobile) */}
<div className="hidden md:flex items-stretch">
<div className="hidden sm:flex items-center gap-3 px-3 py-1 text-[var(--text-muted)] text-[10px] border-l border-[var(--border-primary)]/30">
{/* Navigation */}
<span className="hidden lg:inline"><b className="text-[var(--text-secondary)]"> </b>/<b className="text-[var(--text-secondary)]"> hjkl</b> Navigation</span>
<span className="hidden lg:inline opacity-30">·</span>
{/* Actions */}
<span><b className="text-[var(--text-secondary)]">/</b> search</span>
<span className="opacity-30">·</span>
<span><b className="text-[var(--text-secondary)]">Space</b> toggle</span>
@@ -124,7 +112,6 @@ export function ShortcutsBar({
<span><b className="text-[var(--text-secondary)]">?</b> help</span>
</div>
{/* End badge - like nvim line:col */}
<div
className="text-white px-3 py-1 flex items-center font-bold text-xs tracking-wider"
style={{ backgroundColor: distroColor }}

View File

@@ -10,11 +10,10 @@ 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).
*/
const TOOLTIP_WIDTH = 300;
const ARROW_SIZE = 12;
// Tooltip component.
export function Tooltip({ tooltip, onMouseEnter, onMouseLeave, setRef }: TooltipProps) {
const [current, setCurrent] = useState<TooltipState | null>(null);
const [visible, setVisible] = useState(false);
@@ -66,15 +65,34 @@ export function Tooltip({ tooltip, onMouseEnter, onMouseLeave, setRef }: Tooltip
});
};
const isRightAnchored = typeof window !== 'undefined' && (current.x + 300 > window.innerWidth);
const contentTransform = isRightAnchored ? 'translateX(-278px)' : 'translateX(-22px)';
const zoom = typeof window !== 'undefined' ? (parseFloat(getComputedStyle(document.documentElement).zoom) || 1) : 1;
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth / zoom : 1920;
const mouseX = current.x;
const tooltipLeftNormal = mouseX - 22;
const overflowsRight = tooltipLeftNormal + TOOLTIP_WIDTH > viewportWidth - 12;
let tooltipLeft: number;
let arrowLeft: number;
if (overflowsRight) {
tooltipLeft = mouseX - TOOLTIP_WIDTH + 22;
if (tooltipLeft < 12) tooltipLeft = 12;
arrowLeft = mouseX - tooltipLeft - ARROW_SIZE / 2;
} else {
tooltipLeft = tooltipLeftNormal;
if (tooltipLeft < 12) tooltipLeft = 12;
arrowLeft = mouseX - tooltipLeft - ARROW_SIZE / 2;
}
arrowLeft = Math.max(8, Math.min(arrowLeft, TOOLTIP_WIDTH - 20));
return (
<div
ref={setRef}
role="tooltip"
className="fixed hidden md:block pointer-events-auto z-[9999]"
style={{ left: current.x, top: current.y - 12 }} // Moved up slightly to clear cursor
style={{ left: tooltipLeft, top: current.y - 12 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
@@ -83,16 +101,13 @@ export function Tooltip({ tooltip, onMouseEnter, onMouseLeave, setRef }: Tooltip
transition-opacity duration-75
${visible ? 'opacity-100' : 'opacity-0 pointer-events-none'}
`}>
{/* AccessGuide-style tooltip - rectangular with left border accent */}
<div
className="px-3.5 py-2.5 shadow-lg overflow-hidden border-l-4 relative"
style={{
minWidth: '300px',
maxWidth: '300px',
width: `${TOOLTIP_WIDTH}px`,
backgroundColor: 'var(--bg-secondary)',
borderLeftColor: 'var(--accent)',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
transform: contentTransform, // Shift tooltip so arrow aligns with mouse
}}
>
<p className="text-[13px] leading-[1.55] text-[var(--text-secondary)] break-words" style={{ wordBreak: 'break-word' }}>
@@ -100,9 +115,10 @@ export function Tooltip({ tooltip, onMouseEnter, onMouseLeave, setRef }: Tooltip
</p>
</div>
{/* Arrow aligned to mouse position */}
{/* Arrow at left: 16px matches the visual design, so we shift wrapper -22px to align 16px + 6px(half arrow) approx to 0 */}
<div className="absolute left-0 -bottom-[6px]" style={{ transform: 'translateX(-6px)' }}>
<div
className="absolute -bottom-[6px]"
style={{ left: `${arrowLeft}px` }}
>
<div
className="w-3 h-3 rotate-45"
style={{

View File

@@ -7,10 +7,7 @@ import { distros, type DistroId } from '@/lib/data';
import { analytics } from '@/lib/analytics';
import { DistroIcon } from './DistroIcon';
/**
* Distro picker dropdown. Uses portal rendering so the dropdown isn't
* clipped by parent overflow. Learned that lesson the hard way.
*/
// Distro picker dropdown.
export function DistroSelector({
selectedDistro,
onSelect
@@ -43,11 +40,8 @@ export function DistroSelector({
setIsOpen(!isOpen);
};
// 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 */}
<div
onClick={() => setIsOpen(false)}
className="backdrop-blur-[2px]"
@@ -58,7 +52,6 @@ export function DistroSelector({
background: 'rgba(0,0,0,0.05)',
}}
/>
{/* Dropdown - AccessGuide style: rectangular with left border */}
<div
className="distro-dropdown bg-[var(--bg-secondary)] border-l-4 rounded-md"
style={{
@@ -74,7 +67,6 @@ export function DistroSelector({
animation: 'distroDropdownOpen 0.25s ease-out',
}}
>
{/* Distro List */}
<div>
{distros.map((distro, i) => (
<button

View File

@@ -5,10 +5,7 @@ import { createPortal } from 'react-dom';
import { HelpCircle, X } from 'lucide-react';
import { analytics } from '@/lib/analytics';
/**
* Help modal with keyboard shortcuts and getting started guide.
* Opens with "?" key - because that's what you'd naturally press.
*/
// Help modal.
export function HowItWorks() {
const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
@@ -24,7 +21,6 @@ export function HowItWorks() {
const handleClose = () => {
setIsClosing(true);
analytics.helpClosed();
// Wait for exit animation to finish
setTimeout(() => {
setIsOpen(false);
setIsClosing(false);
@@ -36,7 +32,6 @@ export function HowItWorks() {
setMounted(true);
}, []);
// Lock body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
@@ -48,13 +43,10 @@ export function HowItWorks() {
};
}, [isOpen]);
// Global keyboard shortcut: ? to toggle modal
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
// Skip if Ctrl/Alt/Meta are pressed (Shift is allowed for ?)
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key === '?' || (e.shiftKey && e.key === '/')) {
@@ -66,7 +58,6 @@ export function HowItWorks() {
}
}
// Close on Escape
if (e.key === 'Escape' && isOpen) {
handleClose();
}
@@ -78,7 +69,6 @@ export function HowItWorks() {
const modal = (
<>
{/* Backdrop with blur */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-[99998]"
onClick={handleClose}
@@ -89,18 +79,15 @@ export function HowItWorks() {
}}
/>
{/* Modal - AccessGuide style: rectangular with left border accent */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="how-it-works-title"
className="fixed bg-[var(--bg-primary)] border-l-4 z-[99999]"
className="fixed bg-[var(--bg-primary)] border border-[var(--border-primary)]/30 rounded-xl z-[99999]"
style={{
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
borderRadius: '0 4px 4px 0',
borderLeftColor: 'var(--accent)',
width: '620px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'min(85vh, 720px)',
@@ -110,11 +97,10 @@ export function HowItWorks() {
? 'modalSlideOut 0.2s ease-out forwards'
: 'modalSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
overflow: 'hidden',
boxShadow: '0 16px 48px -8px rgba(0, 0, 0, 0.25)',
boxShadow: '0 16px 48px -8px rgba(0, 0, 0, 0.3)',
}}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-secondary)]">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-primary)]/15">
<h3 id="how-it-works-title" className="text-base font-semibold text-[var(--text-primary)]">
Help
</h3>
@@ -126,12 +112,10 @@ export function HowItWorks() {
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6" style={{ scrollbarGutter: 'stable' }}>
{/* Shortcuts - AccessGuide style */}
<section>
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-4 pl-3 border-l-2 border-[var(--accent)]">Keyboard Shortcuts</h4>
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-4 pl-3 border-l-2 border-[var(--text-muted)]/30">Keyboard Shortcuts</h4>
<div className="grid grid-cols-2 gap-x-8 gap-y-2">
{[
['↑↓←→', 'Navigate through apps'],
@@ -148,7 +132,7 @@ export function HowItWorks() {
['1 / 2', 'Switch AUR helper (yay/paru)'],
].map(([key, desc]) => (
<div key={key} className="flex items-center gap-3 text-sm">
<kbd className="inline-flex items-center justify-center min-w-[52px] px-2 py-1 bg-[var(--bg-secondary)] border-l-2 border-[var(--accent)] text-xs font-mono text-[var(--text-secondary)]">
<kbd className="inline-flex items-center justify-center min-w-[52px] px-2 py-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)]/20 rounded text-xs font-mono text-[var(--text-secondary)]">
{key}
</kbd>
<span className="text-[var(--text-muted)]">{desc}</span>
@@ -157,9 +141,8 @@ export function HowItWorks() {
</div>
</section>
{/* Getting Started - AccessGuide style */}
<section>
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 pl-3 border-l-2 border-[var(--accent)]">Getting Started</h4>
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 pl-3 border-l-2 border-[var(--text-muted)]/30">Getting Started</h4>
<ol className="space-y-2 text-sm text-[var(--text-muted)] leading-relaxed">
<li>
<strong className="text-[var(--text-secondary)]">1. Pick your distro</strong> Select your Linux distribution from the dropdown at the top. This determines which package manager commands TuxMate generates for you.
@@ -176,9 +159,8 @@ export function HowItWorks() {
</ol>
</section>
{/* Notes - AccessGuide style */}
<section>
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 pl-3 border-l-2 border-[var(--accent)]">Good to Know</h4>
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3 pl-3 border-l-2 border-[var(--text-muted)]/30">Good to Know</h4>
<ul className="space-y-2 text-sm text-[var(--text-muted)] leading-relaxed">
<li>
<strong className="text-[var(--text-secondary)]">Greyed out apps</strong> aren&apos;t available in your distro&apos;s official repositories. Try switching to Flatpak or Snap in the dropdown, or hover the info icon next to the app for alternative installation methods.
@@ -196,7 +178,7 @@ export function HowItWorks() {
<strong className="text-[var(--text-secondary)]">NixOS</strong> Generates `environment.systemPackages`. If you pick unfree apps, the download includes comments showing exactly what to whitelist with `allowUnfree`.
</li>
<li>
<strong className="text-[var(--text-secondary)]">Script Safety</strong> Downloaded scripts are robust and idempotent. They include error handling, network retries, and system checks. Run them with <code className="px-1 py-0.5 bg-[var(--bg-secondary)] border-l-2 border-[var(--accent)] text-xs font-mono">bash tuxmate-*.sh</code> to safely install your selection.
<strong className="text-[var(--text-secondary)]">Script Safety</strong> Downloaded scripts are robust and idempotent. They include error handling, network retries, and system checks. Run them with <code className="px-1 py-0.5 bg-[var(--bg-secondary)] border border-[var(--border-primary)]/20 rounded text-xs font-mono">bash tuxmate-*.sh</code> to safely install your selection.
</li>
</ul>
</section>

View File

@@ -0,0 +1,450 @@
'use client';
import { useState, useCallback } from 'react';
import { Check, Copy, Download, X, Search, ChevronDown, Github, Heart, Eye, Terminal, Trash2 } from 'lucide-react';
import { distros, type DistroId } from '@/lib/data';
import { generateInstallScript } from '@/lib/generateInstallScript';
import { analytics } from '@/lib/analytics';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { DistroIcon } from '@/components/distro/DistroIcon';
import { HowItWorks } from '@/components/header/HowItWorks';
interface SidebarProps {
selectedDistro: DistroId;
onDistroSelect: (id: DistroId) => void;
selectedApps: Set<string>;
selectedCount: number;
clearAll: () => void;
command: string;
searchQuery: string;
onSearchChange: (query: string) => void;
searchInputRef: React.RefObject<HTMLInputElement | null>;
hasAurPackages: boolean;
aurAppNames: string[];
selectedHelper: 'yay' | 'paru';
setSelectedHelper: (helper: 'yay' | 'paru') => void;
hasUnfreePackages?: boolean;
unfreeAppNames?: string[];
onOpenDrawer: () => void;
activeShortcut?: string | null;
}
export function Sidebar({
selectedDistro,
onDistroSelect,
selectedApps,
selectedCount,
clearAll,
command,
searchQuery,
onSearchChange,
searchInputRef,
hasAurPackages,
aurAppNames,
selectedHelper,
setSelectedHelper,
hasUnfreePackages,
unfreeAppNames,
onOpenDrawer,
activeShortcut,
}: SidebarProps) {
const [copied, setCopied] = useState(false);
const [distroOpen, setDistroOpen] = useState(false);
const showAur = selectedDistro === 'arch' && hasAurPackages;
const currentDistro = distros.find(d => d.id === selectedDistro);
const distroColor = currentDistro?.color || 'var(--accent)';
const handleCopy = useCallback(async () => {
if (selectedCount === 0) return;
await navigator.clipboard.writeText(command);
setCopied(true);
const distroName = distros.find(d => d.id === selectedDistro)?.name || selectedDistro;
analytics.commandCopied(distroName, selectedCount);
setTimeout(() => setCopied(false), 3000);
}, [command, selectedCount, selectedDistro]);
const handleDownload = useCallback(() => {
if (selectedCount === 0) return;
const script = generateInstallScript({
distroId: selectedDistro,
selectedAppIds: selectedApps,
helper: selectedHelper,
});
const isNix = selectedDistro === 'nix';
const mimeType = isNix ? 'text/plain' : 'text/x-shellscript';
const blob = new Blob([script], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = isNix ? 'configuration.nix' : `tuxmate-${selectedDistro}.sh`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
const distroName = distros.find(d => d.id === selectedDistro)?.name || selectedDistro;
analytics.scriptDownloaded(distroName, selectedCount);
}, [selectedCount, selectedDistro, selectedApps, selectedHelper]);
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape' || e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
};
return (
<aside className="sidebar fixed left-0 top-0 bottom-0 hidden lg:flex flex-col overflow-hidden"
style={{
width: '380px',
zIndex: 20,
}}
>
<div className="px-6 pt-7 pb-5">
<div className="flex items-center gap-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/tuxmate.png"
alt="TuxMate Logo"
className="w-16 h-16 object-contain shrink-0"
/>
<div className="flex flex-col items-start">
<h1 className="text-[28px] font-extrabold tracking-tight text-[var(--text-primary)] leading-none"
style={{ fontFamily: 'var(--font-heading)', transition: 'color 0.5s' }}>
TuxMate
</h1>
<p className="text-[11px] text-[var(--text-muted)] tracking-[0.14em] uppercase mt-2 font-medium leading-none"
style={{ transition: 'color 0.5s' }}>
Linux Bulk App Installer
</p>
</div>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden sidebar-scroll">
<div className="px-5 pb-5">
<div className="sidebar-search flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg border border-[var(--border-primary)]/20 bg-transparent">
<Search className="w-3.5 h-3.5 text-[var(--text-muted)] shrink-0 opacity-40" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search apps..."
className="flex-1 bg-transparent text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]/40 outline-none"
/>
{searchQuery ? (
<button
onClick={() => onSearchChange('')}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
) : (
<kbd className="text-[10px] text-[var(--text-muted)]/40 border border-[var(--border-primary)]/30 rounded px-1.5 py-0.5 font-mono">/</kbd>
)}
</div>
</div>
<div className="px-5 pb-5 relative">
<p className="text-[10px] uppercase tracking-[0.15em] text-[var(--text-muted)] mb-2 px-1 font-semibold">Distribution</p>
<button
onClick={() => setDistroOpen(prev => !prev)}
className="sidebar-distro-btn w-full flex items-center gap-3.5 px-4 py-3 rounded-xl border border-[var(--border-primary)]/20 bg-[var(--bg-secondary)]/50"
>
<div className="w-7 h-7 flex items-center justify-center shrink-0 rounded-lg"
style={{ backgroundColor: `color-mix(in srgb, ${distroColor}, transparent 80%)` }}>
<DistroIcon url={currentDistro?.iconUrl || ''} name={currentDistro?.name || ''} size={20} />
</div>
<span className="flex-1 text-left text-sm font-semibold text-[var(--text-primary)]">
{currentDistro?.name}
</span>
<ChevronDown className={`w-4 h-4 text-[var(--text-muted)] transition-transform duration-200 ${distroOpen ? 'rotate-180' : ''}`} />
</button>
{distroOpen && (
<div className="absolute left-5 right-5 mt-2 rounded-xl bg-[var(--bg-secondary)] border border-[var(--border-primary)]/30 overflow-hidden shadow-xl z-50"
style={{ animation: 'dropIn 0.2s ease-out' }}>
{distros.map((distro) => (
<button
key={distro.id}
onClick={() => {
onDistroSelect(distro.id);
setDistroOpen(false);
analytics.distroSelected(distro.name);
}}
className={`w-full flex items-center gap-3 py-2.5 px-4 text-left transition-all duration-100 ${selectedDistro === distro.id
? ''
: 'hover:bg-[var(--bg-hover)]'
}`}
style={{
backgroundColor: selectedDistro === distro.id
? `color-mix(in srgb, ${distro.color}, transparent 85%)`
: undefined,
}}
>
<div className="w-5 h-5 flex items-center justify-center">
<DistroIcon url={distro.iconUrl} name={distro.name} size={18} />
</div>
<span className={`flex-1 text-sm ${selectedDistro === distro.id
? 'text-[var(--text-primary)] font-semibold'
: 'text-[var(--text-secondary)]'
}`}>{distro.name}</span>
{selectedDistro === distro.id && (
<Check className="w-4 h-4" style={{ color: distro.color }} strokeWidth={2.5} />
)}
</button>
))}
</div>
)}
</div>
<div className="mx-5 mb-4 border-t border-[var(--border-primary)]/15" />
<div className="px-5 pb-4">
<div className="flex items-center justify-between mb-2.5 px-1">
<div className="flex items-center gap-2">
<Terminal className="w-3.5 h-3.5 text-[var(--text-muted)]" />
<p className="text-[10px] uppercase tracking-[0.15em] text-[var(--text-muted)] font-semibold">Command</p>
</div>
<div className="flex items-center gap-2">
{selectedCount > 0 && (
<span className="text-[12px] font-bold px-2 py-0.5 rounded-full"
style={{
backgroundColor: `color-mix(in srgb, ${distroColor}, transparent 82%)`,
color: distroColor,
}}>
{selectedCount} app{selectedCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div
className="sidebar-command-preview rounded-xl bg-[var(--bg-secondary)]/60 border border-[var(--border-primary)]/15 overflow-hidden cursor-pointer group"
onClick={() => selectedCount > 0 && onOpenDrawer()}
>
<div className="px-4 py-3.5">
<div className="flex items-start gap-2">
{selectedDistro !== 'nix' && selectedCount > 0 && (
<span className="text-xs font-bold shrink-0 mt-0.5 select-none opacity-50" style={{ color: distroColor }}>$</span>
)}
<code className={`text-[13px] font-mono leading-[1.6] break-all ${selectedCount > 0 ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)] opacity-50'
}`}
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{selectedCount > 0 ? command : 'Select apps to generate command...'}
</code>
</div>
</div>
{selectedCount > 0 && (
<div className="px-4 py-2 bg-[var(--bg-tertiary)]/50 border-t border-[var(--border-primary)]/15 flex items-center justify-center gap-1.5 text-[10px] text-[var(--text-muted)] group-hover:text-[var(--text-secondary)] transition-colors">
<Eye className="w-3 h-3" />
<span>Click for full preview</span>
<kbd className="ml-1 text-[9px] border border-[var(--border-primary)]/30 rounded px-1 py-px font-mono opacity-50">Tab</kbd>
</div>
)}
</div>
</div>
<div className="px-5 pb-3">
<div className="grid grid-cols-2 gap-2.5 mb-2.5">
<button
data-action="copy"
onClick={handleCopy}
disabled={selectedCount === 0}
className={`sidebar-action-btn flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-all ${selectedCount === 0
? 'opacity-30 cursor-not-allowed bg-[var(--bg-tertiary)] text-[var(--text-muted)]'
: copied
? 'text-white shadow-lg'
: activeShortcut === 'y'
? 'bg-[var(--bg-hover)] opacity-80 shadow-inner scale-[0.98]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
}`}
style={{
backgroundColor: copied ? distroColor : undefined,
}}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4 opacity-60" />}
<span>{copied ? 'Copied!' : 'Copy'}</span>
</button>
<button
data-action="download"
onClick={handleDownload}
disabled={selectedCount === 0}
className={`sidebar-action-btn flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-all ${selectedCount === 0
? 'opacity-30 cursor-not-allowed bg-[var(--bg-tertiary)] text-[var(--text-muted)]'
: activeShortcut === 'd'
? 'bg-[var(--bg-hover)] opacity-80 shadow-inner scale-[0.98]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
}`}
>
<Download className="w-4 h-4 opacity-60" />
<span>Download</span>
</button>
</div>
<div className="grid grid-cols-2 gap-2.5">
<button
onClick={() => selectedCount > 0 && onOpenDrawer()}
disabled={selectedCount === 0}
className={`sidebar-action-btn flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-all ${selectedCount === 0
? 'opacity-30 cursor-not-allowed bg-[var(--bg-tertiary)] text-[var(--text-muted)]'
: 'text-white shadow-md'
}`}
style={{
backgroundColor: selectedCount > 0 ? distroColor : undefined,
}}
>
<Eye className="w-4 h-4" />
<span>Preview</span>
</button>
<button
onClick={clearAll}
disabled={selectedCount === 0}
className={`sidebar-action-btn flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-all ${selectedCount === 0
? 'opacity-30 cursor-not-allowed bg-[var(--bg-tertiary)] text-[var(--text-muted)]'
: activeShortcut === 'c'
? 'bg-red-500/20 text-red-400 scale-[0.98]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-red-400 hover:bg-red-500/10'
}`}
>
<Trash2 className="w-4 h-4 opacity-60" />
<span>Clear All</span>
</button>
</div>
</div>
{showAur && (
<>
<div className="mx-5 my-3 border-t border-[var(--border-primary)]/25" />
<div className="px-5 pb-2">
<div className="flex items-center gap-2 mb-2.5 px-1">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="#1793d1">
<path d="M12 0c-.39 0-.77.126-1.11.365a2.22 2.22 0 0 0-.82 1.056L0 24h4.15l2.067-5.58h11.666L19.95 24h4.05L13.91 1.42A2.24 2.24 0 0 0 12 0zm0 4.542l5.77 15.548H6.23l5.77-15.548z" />
</svg>
<p className="text-[11px] uppercase tracking-[0.15em] text-[var(--text-muted)] font-semibold">AUR Helper</p>
<span className="text-[10px] text-[var(--text-muted)] opacity-50">·</span>
<span className="text-[10px] text-[var(--text-muted)]">{aurAppNames.length} pkg{aurAppNames.length !== 1 ? 's' : ''}</span>
</div>
<div className="grid grid-cols-2 gap-2">
{(['yay', 'paru'] as const).map((helper) => (
<button
key={helper}
onClick={() => setSelectedHelper(helper)}
className={`py-3 px-4 rounded-xl text-sm font-semibold transition-all ${selectedHelper === helper
? 'bg-[var(--text-primary)] text-[var(--bg-primary)] shadow-sm'
: 'bg-[var(--bg-tertiary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]'
}`}
>
{helper}
</button>
))}
</div>
</div>
</>
)}
{selectedDistro === 'nix' && hasUnfreePackages && unfreeAppNames && unfreeAppNames.length > 0 && (
<>
<div className="mx-5 my-3 border-t border-[var(--border-primary)]/25" />
<div className="px-5 pb-2">
<div className="p-3.5 rounded-xl bg-amber-500/8 border border-amber-500/20">
<p className="text-[11px] font-semibold text-amber-400 mb-1 flex items-center gap-1.5">
<span className="text-amber-400"></span> Unfree Packages
</p>
<p className="text-[11px] text-[var(--text-muted)] leading-relaxed">
{unfreeAppNames.join(', ')} require{' '}
<code className="px-1.5 py-0.5 bg-[var(--bg-tertiary)] text-[10px] font-mono rounded-md">allowUnfree = true</code>
</p>
</div>
</div>
</>
)}
<div className="min-h-4" />
<div className="px-5 pb-3">
<div className="px-4 py-4 rounded-xl bg-[var(--bg-secondary)]/40 border border-[var(--border-primary)]/15">
<p className="text-[12px] uppercase tracking-[0.15em] text-[var(--text-secondary)] font-bold mb-3">Keyboard</p>
<div className="grid grid-cols-2 gap-y-2.5 gap-x-6 text-[13px]">
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Search</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm">/</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Navigate</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm"></kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Select</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm">Space</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Copy</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm">y</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Download</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm">d</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Preview</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm">Tab</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Theme</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm">t</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Clear All</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm">c</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Help</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm">?</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-[var(--text-secondary)]">Close</span>
<kbd className="text-[11px] text-[var(--text-primary)] bg-[var(--bg-secondary)] border border-[var(--border-primary)]/50 rounded-md px-2 py-0.5 font-mono font-medium shadow-sm">Esc</kbd>
</div>
</div>
</div>
</div>
</div>
<div className="px-5 py-4 border-t border-[var(--border-primary)]/15">
<div className="flex items-center justify-between">
<ThemeToggle />
{/* Links */}
<div className="flex items-center gap-0.5">
<a
href="https://github.com/abusoww/tuxmate"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl hover:bg-[var(--bg-hover)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all text-[12px]"
title="View on GitHub"
onClick={() => analytics.githubClicked()}
>
<Github className="w-4 h-4" />
</a>
<a
href="https://github.com/abusoww/tuxmate/blob/main/CONTRIBUTING.md"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl hover:bg-[var(--bg-hover)] text-[var(--text-muted)] hover:text-rose-400 transition-all text-[12px]"
title="Contribute"
onClick={() => analytics.contributeClicked()}
>
<Heart className="w-4 h-4" />
</a>
<HowItWorks />
</div>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,3 @@
// Sidebar components
export { Sidebar } from './Sidebar';

View File

@@ -14,11 +14,6 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
const { theme, toggle } = useTheme()
const [mounted, setMounted] = useState(false)
/**
* 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)
@@ -26,12 +21,11 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
const isDark = theme === "dark"
// Render placeholder with same dimensions during SSR
if (!mounted) {
return (
<div
className={cn(
"flex w-20 h-10 p-1 rounded-md",
"flex w-20 h-10 p-1 rounded-full",
"bg-[var(--bg-secondary)] border border-[var(--border-primary)]",
className
)}
@@ -42,7 +36,7 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
return (
<div
className={cn(
"flex w-20 h-10 p-1 rounded-md cursor-pointer transition-[background-color,box-shadow] duration-300",
"flex w-20 h-10 p-1 rounded-full cursor-pointer transition-[background-color,box-shadow] duration-300",
"bg-[var(--bg-secondary)] border border-[var(--border-primary)]",
className
)}
@@ -56,7 +50,7 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
<div className="flex justify-between items-center w-full">
<div
className={cn(
"flex justify-center items-center w-8 h-8 rounded-sm transition-transform duration-300",
"flex justify-center items-center w-8 h-8 rounded-full transition-transform duration-300",
isDark ? "transform translate-x-0" : "transform translate-x-10",
"bg-[var(--bg-tertiary)]"
)}
@@ -75,7 +69,7 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
</div>
<div
className={cn(
"flex justify-center items-center w-8 h-8 rounded-sm transition-transform duration-300",
"flex justify-center items-center w-8 h-8 rounded-full transition-transform duration-300",
isDark
? "bg-transparent"
: "transform -translate-x-10"

View File

@@ -3,21 +3,19 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { type Category } from '@/lib/data';
// What we're navigating to
export interface NavItem {
type: 'category' | 'app';
id: string;
category: Category;
}
// Where we are in the grid
export interface FocusPosition {
col: number;
row: number;
}
// Vim-style keyboard navigation. Because real devs don't use mice.
// Keyboard navigation hook.
export function useKeyboardNavigation(
navItems: NavItem[][],
onToggleCategory: (id: string) => void,
@@ -25,29 +23,24 @@ 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), []);
/** Get the currently focused item */
const focusedItem = useMemo(() => {
if (!focusPos) return null;
return navItems[focusPos.col]?.[focusPos.row] || null;
}, [navItems, focusPos]);
/** Set focus position by item type and id (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
fromKeyboard.current = false;
setIsKeyboardNavigating(false);
setFocusPos({ col, row });
return;
}
@@ -55,18 +48,14 @@ export function useKeyboardNavigation(
}
}, [navItems]);
/** Keyboard event handler */
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if typing in input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
// Skip if modifier keys are pressed (prevents conflicts with browser shortcuts like Ctrl+D)
if (e.ctrlKey || e.altKey || e.metaKey) return;
const key = e.key;
// Space to toggle
if (key === ' ') {
e.preventDefault();
if (focusPos) {
@@ -77,21 +66,17 @@ export function useKeyboardNavigation(
return;
}
// Navigation keys (arrow keys + vim keys)
if (!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'j', 'k', 'h', 'l', 'Escape'].includes(key)) return;
e.preventDefault();
// Escape clears focus
if (key === 'Escape') {
setFocusPos(null);
return;
}
// 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 };
@@ -129,14 +114,12 @@ export function useKeyboardNavigation(
return () => window.removeEventListener('keydown', handleKeyDown);
}, [navItems, focusPos, onToggleCategory, onToggleApp]);
/* Scroll focused item into view - only when navigating via keyboard */
useEffect(() => {
if (!focusPos || !fromKeyboard.current) return;
const item = navItems[focusPos.col]?.[focusPos.row];
if (!item) return;
// 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}"]`
);

View File

@@ -5,11 +5,7 @@ import { distros, apps, type DistroId } from '@/lib/data';
import { isAurPackage } from '@/lib/aur';
import { isUnfreePackage } from '@/lib/nixUnfree';
// Re-export for backwards compatibility
export { isAurPackage, AUR_PATTERNS, KNOWN_AUR_PACKAGES } from '@/lib/aur';
// Everything the app needs to work
export interface UseLinuxInitReturn {
selectedDistro: DistroId;
selectedApps: Set<string>;
@@ -22,7 +18,6 @@ export interface UseLinuxInitReturn {
generatedCommand: string;
selectedCount: number;
availableCount: number;
// Arch/AUR specific
hasYayInstalled: boolean;
setHasYayInstalled: (value: boolean) => void;
selectedHelper: 'yay' | 'paru';
@@ -30,10 +25,8 @@ export interface UseLinuxInitReturn {
hasAurPackages: boolean;
aurPackageNames: string[];
aurAppNames: string[];
// Nix unfree specific
hasUnfreePackages: boolean;
unfreeAppNames: string[];
// Hydration state
isHydrated: boolean;
}
@@ -49,7 +42,6 @@ export function useLinuxInit(): UseLinuxInitReturn {
const [selectedHelper, setSelectedHelper] = useState<'yay' | 'paru'>('yay');
const [hydrated, setHydrated] = useState(false);
// Load saved preferences from localStorage
useEffect(() => {
try {
const savedDistro = localStorage.getItem(STORAGE_KEY_DISTRO) as DistroId | null;
@@ -64,7 +56,6 @@ export function useLinuxInit(): UseLinuxInitReturn {
if (savedApps) {
const appIds = JSON.parse(savedApps) as string[];
// Filter to only valid app IDs that are available on the distro
const validApps = appIds.filter(id => {
const app = apps.find(a => a.id === id);
if (!app) return false;
@@ -82,12 +73,10 @@ export function useLinuxInit(): UseLinuxInitReturn {
setSelectedHelper('paru');
}
} catch {
// Ignore localStorage errors
}
setHydrated(true);
}, []);
// Save to localStorage whenever state changes (but not on first render)
useEffect(() => {
if (!hydrated) return;
try {
@@ -96,11 +85,9 @@ export function useLinuxInit(): UseLinuxInitReturn {
localStorage.setItem(STORAGE_KEY_YAY, hasYayInstalled.toString());
localStorage.setItem(STORAGE_KEY_HELPER, selectedHelper);
} catch {
// Ignore localStorage errors
}
}, [selectedDistro, selectedApps, hasYayInstalled, selectedHelper, hydrated]);
// Compute AUR package info for Arch
const aurPackageInfo = useMemo(() => {
if (selectedDistro !== 'arch') {
return { hasAur: false, packages: [] as string[], appNames: [] as string[] };
@@ -122,7 +109,6 @@ export function useLinuxInit(): UseLinuxInitReturn {
return { hasAur: aurPkgs.length > 0, packages: aurPkgs, appNames: aurAppNames };
}, [selectedDistro, selectedApps]);
// Compute unfree package info for Nix
const unfreePackageInfo = useMemo(() => {
if (selectedDistro !== 'nix') {
return { hasUnfree: false, appNames: [] as string[] };
@@ -173,7 +159,6 @@ export function useLinuxInit(): UseLinuxInitReturn {
}, []);
const toggleApp = useCallback((appId: string) => {
// Check availability inline to avoid stale closure
const app = apps.find(a => a.id === appId);
if (!app) return;
const pkg = app.targets[selectedDistro];
@@ -230,7 +215,6 @@ export function useLinuxInit(): UseLinuxInitReturn {
if (packageNames.length === 0) return '# No packages selected';
// Nix: show declarative config (no unfree warning in preview - that's in download)
if (selectedDistro === 'nix') {
const sortedPkgs = packageNames.filter(p => p.trim()).sort();
const pkgList = sortedPkgs.map(p => ` ${p}`).join('\n');
@@ -238,36 +222,26 @@ export function useLinuxInit(): UseLinuxInitReturn {
}
if (selectedDistro === 'snap') {
// Snap needs separate commands for --classic packages
if (packageNames.length === 1) {
return `${distro.installPrefix} ${packageNames[0]}`;
}
// For multiple snap packages, we chain them with &&
// Note: snap doesn't support installing multiple packages in one command like apt
return packageNames.map(p => `sudo snap install ${p}`).join(' && ');
}
// 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
const helperName = selectedHelper; // yay or paru
// Common setup: sudo pacman -S --needed git base-devel
// Then clone, make, install
const installHelperCmd = `sudo pacman -S --needed git base-devel && git clone https://aur.archlinux.org/${helperName}.git /tmp/${helperName} && cd /tmp/${helperName} && makepkg -si --noconfirm && cd - && rm -rf /tmp/${helperName}`;
// Install packages using the helper
const installCmd = `${helperName} -S --needed --noconfirm ${packageNames.join(' ')}`;
return `${installHelperCmd} && ${installCmd}`;
} else {
// User has helper installed - use it for ALL packages
return `${selectedHelper} -S --needed --noconfirm ${packageNames.join(' ')}`;
}
}
// Handle Homebrew: separate formulae and casks into separate commands
if (selectedDistro === 'homebrew') {
const formulae = packageNames.filter(p => !p.startsWith('--cask '));
const casks = packageNames.filter(p => p.startsWith('--cask ')).map(p => p.replace('--cask ', ''));
@@ -296,7 +270,6 @@ export function useLinuxInit(): UseLinuxInitReturn {
generatedCommand,
selectedCount: selectedApps.size,
availableCount,
// Arch/AUR specific
hasYayInstalled,
setHasYayInstalled,
selectedHelper,
@@ -304,10 +277,8 @@ export function useLinuxInit(): UseLinuxInitReturn {
hasAurPackages: aurPackageInfo.hasAur,
aurPackageNames: aurPackageInfo.packages,
aurAppNames: aurPackageInfo.appNames,
// Nix unfree specific
hasUnfreePackages: unfreePackageInfo.hasUnfree,
unfreeAppNames: unfreePackageInfo.appNames,
// Hydration state
isHydrated: hydrated,
};
}

View File

@@ -11,17 +11,13 @@ 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.
*/
// Theme provider.
export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Initial state reads from DOM to match what the inline script set
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
return document.documentElement.classList.contains('light') ? 'light' : 'dark'
}
return 'light' // SSR default
return 'light'
})
const [hydrated, setHydrated] = useState(false)

View File

@@ -8,12 +8,7 @@ export interface TooltipState {
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
*/
// Tooltip hook.
export function useTooltip() {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const showTimeout = useRef<NodeJS.Timeout | null>(null);
@@ -22,7 +17,6 @@ export function useTooltip() {
const isOverTooltip = useRef(false);
const tooltipRef = useRef<HTMLDivElement | null>(null);
// Allow setting the tooltip element ref from the Tooltip component
const setTooltipRef = useCallback((el: HTMLDivElement | null) => {
tooltipRef.current = el;
}, []);
@@ -40,7 +34,6 @@ export function useTooltip() {
const tryHide = useCallback(() => {
cancel();
// Only hide if mouse is not over trigger or tooltip
hideTimeout.current = setTimeout(() => {
if (!isOverTrigger.current && !isOverTooltip.current) {
setTooltip(null);
@@ -54,13 +47,15 @@ export function useTooltip() {
cancel();
const rect = target.getBoundingClientRect();
const clientX = e.clientX; // Capture mouse X position
const clientX = e.clientX;
const zoom = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
showTimeout.current = setTimeout(() => {
setTooltip({
content,
x: clientX, // Use mouse X instead of element center
y: rect.top,
x: clientX / zoom,
y: rect.top / zoom,
});
}, 450);
}, [cancel]);
@@ -82,7 +77,6 @@ export function useTooltip() {
useEffect(() => {
const dismiss = (e: MouseEvent) => {
// Don't dismiss if clicking inside the tooltip
if (tooltipRef.current && tooltipRef.current.contains(e.target as Node)) {
return;
}

View File

@@ -8,16 +8,13 @@ import {
} from '@/lib/verification';
export interface UseVerificationResult {
// Kept for compatibility, always false now
isLoading: boolean;
hasError: boolean;
isVerified: (distro: DistroId, packageName: string) => boolean;
getVerificationSource: (distro: DistroId, packageName: string) => 'flathub' | 'snap' | null;
}
// Now purely synchronous using build-time generated data
export function useVerification(): UseVerificationResult {
// Check if package is verified for the distro
const isVerified = useCallback((distro: DistroId, packageName: string): boolean => {
if (distro === 'flatpak') {
return isFlathubVerified(packageName);
@@ -28,7 +25,6 @@ export function useVerification(): UseVerificationResult {
return false;
}, []);
// Get verification source for badge styling
const getVerificationSource = useCallback((distro: DistroId, packageName: string): 'flathub' | 'snap' | null => {
if (distro === 'flatpak' && isFlathubVerified(packageName)) {
return 'flathub';

View File

@@ -1,66 +1,137 @@
{
"fetchedAt": "2026-01-25T17:01:05.599Z",
"count": 303,
"meta": {
"fetchedAt": "2026-02-22T00:43:13.504Z"
},
"count": 697,
"apps": [
"ai.jan.Jan",
"app.bluebubbles.BlueBubbles",
"app.comaps.comaps",
"app.devsuite.Ptyxis",
"app.drey.Dialect",
"app.drey.EarTag",
"app.drey.Warp",
"app.eduroam.geteduroam",
"app.fotema.Fotema",
"app.grayjay.Grayjay",
"app.opencomic.OpenComic",
"app.organicmaps.desktop",
"app.polychromatic.controller",
"app.tintero.Tintero",
"app.twintaillauncher.ttl",
"app.xemu.xemu",
"app.zen_browser.zen",
"at.vintagestory.VintageStory",
"be.alexandervanhee.gradia",
"best.ellie.StartupConfiguration",
"br.com.wiselabs.simplexity",
"br.eng.silas.qpdftools",
"ca.desrt.dconf-editor",
"ca.edestcroix.Recordbox",
"ca.parallel_launcher.ParallelLauncher",
"cafe.avery.Delfin",
"ch.tlaun.TL",
"chat.delta.desktop",
"chat.simplex.simplex",
"cn.xfangfang.wiliwili",
"com.abisource.AbiWord",
"com.actualbudget.actual",
"com.adamcake.Bolt",
"com.adilhanney.saber",
"com.atlauncher.ATLauncher",
"com.bambulab.BambuStudio",
"com.beavernotes.beavernotes",
"com.belmoussaoui.Authenticator",
"com.belmoussaoui.Decoder",
"com.belmoussaoui.Obfuscate",
"com.bilingify.readest",
"com.bitwarden.desktop",
"com.bitwig.BitwigStudio",
"com.blitterstudio.amiberry",
"com.borgbase.Vorta",
"com.boxy_svg.BoxySVG",
"com.brave.Browser",
"com.cassidyjames.butler",
"com.chatterino.chatterino",
"com.collaboraoffice.Office",
"com.core447.StreamController",
"com.daniel15.wcc",
"com.dec05eba.gpu_screen_recorder",
"com.devolutions.remotedesktopmanager",
"com.discordapp.Discord",
"com.endlessm.photos",
"com.expidusos.file_manager",
"com.fastmail.Fastmail",
"com.feaneron.Boatswain",
"com.freerdp.FreeRDP",
"com.geeks3d.furmark",
"com.github.ADBeveridge.Raider",
"com.github.Darazaki.Spedread",
"com.github.IsmaelMartinez.teams_for_linux",
"com.github.KRTirtho.Spotube",
"com.github.Matoking.protontricks",
"com.github.Murmele.Gittyup",
"com.github.PintaProject.Pinta",
"com.github.Rosalie241.RMG",
"com.github.d4nj1.tlpui",
"com.github.dail8859.NotepadNext",
"com.github.dynobo.normcap",
"com.github.finefindus.eyedropper",
"com.github.flxzt.rnote",
"com.github.gabutakut.gabutdm",
"com.github.geigi.cozy",
"com.github.hluk.copyq",
"com.github.hugolabe.Wike",
"com.github.huluti.Curtail",
"com.github.iwalton3.jellyfin-media-player",
"com.github.iwalton3.jellyfin-mpv-shim",
"com.github.jeromerobert.pdfarranger",
"com.github.jkotra.eovpn",
"com.github.johnfactotum.Foliate",
"com.github.joseexposito.touche",
"com.github.louis77.tuner",
"com.github.maoschanz.drawing",
"com.github.marhkb.Pods",
"com.github.mtkennerly.ludusavi",
"com.github.neithern.g4music",
"com.github.phase1geo.minder",
"com.github.polymeilex.neothesia",
"com.github.qarmin.czkawka",
"com.github.rafostar.Clapper",
"com.github.ryonakano.pinit",
"com.github.ryonakano.reco",
"com.github.sdv43.whaler",
"com.github.skylot.jadx",
"com.github.taiko2k.tauonmb",
"com.github.tchx84.Flatseal",
"com.github.tenderowl.frog",
"com.github.unrud.VideoDownloader",
"com.github.unrud.djpdf",
"com.github.vikdevelop.photopea_app",
"com.github.vikdevelop.timer",
"com.github.vkohaupt.vokoscreenNG",
"com.github.vladimiry.ElectronMail",
"com.github.wwmm.easyeffects",
"com.github.wwmm.pulseeffects",
"com.github.xournalpp.xournalpp",
"com.github.zocker_160.SyncThingy",
"com.github.ztefn.haguichi",
"com.gitlab.bitseater.meteo",
"com.gopeed.Gopeed",
"com.heroicgameslauncher.hgl",
"com.icons8.Lunacy",
"com.indomitusgroup.indipdf",
"com.infinipaint.infinipaint",
"com.interversehq.qView",
"com.jeffser.Alpaca",
"com.jwestall.Forecast",
"com.kgurgul.cpuinfo",
"com.ktechpit.colorwall",
"com.ktechpit.orion",
"com.ktechpit.torrhunt",
"com.ktechpit.ultimate-media-downloader",
"com.ktechpit.whatsie",
"com.ktechpit.wonderwall",
"com.logseq.Logseq",
"com.lunarclient.LunarClient",
"com.markopejic.downloader",
@@ -69,142 +140,355 @@
"com.ml4w.dotfilesinstaller",
"com.modrinth.ModrinthApp",
"com.moonlight_stream.Moonlight",
"com.neatdecisions.Detwinner",
"com.notesnook.Notesnook",
"com.ntrack.n-track",
"com.obsproject.Studio",
"com.odnoyko.valot",
"com.pikatorrent.PikaTorrent",
"com.play0ad.zeroad",
"com.plexamp.Plexamp",
"com.pojtinger.felicitas.Sessions",
"com.pokemmo.PokeMMO",
"com.prusa3d.PrusaSlicer",
"com.rafaelmardojai.Blanket",
"com.raggesilver.BlackBox",
"com.ranfdev.DistroShelf",
"com.rcloneui.RcloneUI",
"com.realm667.Wolfenstein_Blade_of_Agony",
"com.redis.RedisInsight",
"com.rtosta.zapzap",
"com.rustdesk.RustDesk",
"com.saivert.pwvucontrol",
"com.sidevesh.Luminance",
"com.steamgriddb.SGDBoop",
"com.steamgriddb.steam-rom-manager",
"com.stremio.Service",
"com.stremio.Stremio",
"com.super_productivity.SuperProductivity",
"com.surfshark.Surfshark",
"com.thincast.client",
"com.tomjwatson.Emote",
"com.toolstack.Folio",
"com.ulaa.Ulaa",
"com.unicornsonlsd.finamp",
"com.usebottles.bottles",
"com.usebruno.Bruno",
"com.valvesoftware.SteamLink",
"com.vixalien.sticky",
"com.voxdsp.TuxFishing",
"com.vscodium.codium",
"com.vscodium.codium-insiders",
"com.vysp3r.ProtonPlus",
"com.warlordsoftwares.formatlab",
"com.warlordsoftwares.media-downloader",
"com.warlordsoftwares.tube2go",
"com.warlordsoftwares.youtube-downloader-4ktube",
"com.zettlr.Zettlr",
"de.capypara.FieldMonitor",
"de.haeckerfelix.AudioSharing",
"de.haeckerfelix.Fragments",
"de.haeckerfelix.Shortwave",
"de.k_bo.Televido",
"de.leopoldluley.Clapgrep",
"de.mediathekview.MediathekView",
"de.schmidhuberj.DieBahn",
"de.schmidhuberj.tubefeeder",
"de.z_ray.OptimusUI",
"dev.bragefuglseth.Fretboard",
"dev.bragefuglseth.Keypunch",
"dev.deedles.Trayscale",
"dev.dergs.Tonearm",
"dev.diegovsky.Riff",
"dev.edfloreshz.Calculator",
"dev.edfloreshz.CosmicTweaks",
"dev.edfloreshz.Tasks",
"dev.fredol.open-tv",
"dev.ftb.ftb-app",
"dev.geopjr.Collision",
"dev.geopjr.Tuba",
"dev.goats.xivlauncher",
"dev.heppen.webapps",
"dev.ibrahimcetin.reins",
"dev.lapce.lapce",
"dev.lasheen.qr",
"dev.lizardbyte.app.Sunshine",
"dev.mufeed.Wordbook",
"dev.qwery.AddWater",
"dev.ters.LocalTranslate",
"dev.vencord.Vesktop",
"dk.gqrx.gqrx",
"eu.betterbird.Betterbird",
"eu.ithz.umftpd",
"eu.jumplink.Learn6502",
"eu.nokun.MirrorHall",
"fr.handbrake.ghb",
"garden.jamie.Morphosis",
"gg.minion.Minion",
"gg.norisk.NoRiskClientLauncherV3",
"hu.irl.cameractrls",
"im.bernard.Nostalgia",
"im.dino.Dino",
"im.fluffychat.Fluffychat",
"im.nheko.Nheko",
"in.cinny.Cinny",
"info.beyondallreason.bar",
"info.bibletime.BibleTime",
"info.cemu.Cemu",
"info.febvre.Komikku",
"info.mumble.Mumble",
"info.portfolio_performance.PortfolioPerformance",
"info.smplayer.SMPlayer",
"ink.whis.Whis",
"io.anytype.anytype",
"io.appflowy.AppFlowy",
"io.bassi.Amberol",
"io.emeric.toolblex",
"io.ente.auth",
"io.ente.photos",
"io.frama.tractor.carburetor",
"io.freetubeapp.FreeTube",
"io.gdevelop.ide",
"io.github.BrisklyDev.Brisk",
"io.github.CyberTimon.RapidRAW",
"io.github.DenysMb.Kontainer",
"io.github.Ethanscharlie.albumripper",
"io.github.Faugus.faugus-launcher",
"io.github.Foldex.AdwSteamGtk",
"io.github.Geocld.XStreamingDesktop",
"io.github.IshuSinghSE.aurynk",
"io.github.JakubMelka.Pdf4qt",
"io.github.N3kosempai.hetairos-ai",
"io.github.N3kosempai.klia-store",
"io.github.Predidit.Kazumi",
"io.github.Qalculate",
"io.github.Qalculate.qalculate-qt",
"io.github.Soundux",
"io.github.TheWisker.Cavasik",
"io.github.aandrew_me.ytdn",
"io.github.alainm23.planify",
"io.github.alescdb.mailviewer",
"io.github.alfianlosari.GTKChatGPT",
"io.github.amit9838.mousam",
"io.github.antimicrox.antimicrox",
"io.github.arunsivaramanneo.GPUViewer",
"io.github.astralvixen.geforce-infinity",
"io.github.benjamimgois.goverlay",
"io.github.brunofin.Cohesion",
"io.github.bytezz.IPLookup",
"io.github.cboxdoerfer.FSearch",
"io.github.celluloid_player.Celluloid",
"io.github.cosmic_utils.Examine",
"io.github.cosmic_utils.camera",
"io.github.davidoc26.wallpaper_selector",
"io.github.debasish_patra_1987.linuxthemestore",
"io.github.diegopvlk.Cine",
"io.github.diegopvlk.Tomatillo",
"io.github.dimtpap.coppwr",
"io.github.dosbox-staging",
"io.github.dubstar_04.design",
"io.github.dvlv.boxbuddyrs",
"io.github.dyegoaurelio.simple-wireplumber-gui",
"io.github.ebonjaeger.bluejay",
"io.github.ecotubehq.player",
"io.github.efogdev.mpris-timer",
"io.github.endless_sky.endless_sky",
"io.github.fabrialberio.pinapp",
"io.github.fastrizwaan.WineCharm",
"io.github.fastrizwaan.WineZGUI",
"io.github.ferraridamiano.ConverterNOW",
"io.github.flattool.Ignition",
"io.github.flattool.Warehouse",
"io.github.gaheldev.Millisecond",
"io.github.gamingdoom.Datcord",
"io.github.getnf.embellish",
"io.github.giantpinkrobots.bootqt",
"io.github.giantpinkrobots.flatsweep",
"io.github.giantpinkrobots.varia",
"io.github.gopher64.gopher64",
"io.github.hedge_dev.hedgemodmanager",
"io.github.hkdb.Aerion",
"io.github.htkhiem.Euphonica",
"io.github.ilya_zlobintsev.LACT",
"io.github.jeffshee.Hidamari",
"io.github.jliljebl.Flowblade",
"io.github.jonmagon.kdiskmark",
"io.github.jorchube.monitorets",
"io.github.josephmawa.Gauge",
"io.github.kolunmi.Bazaar",
"io.github.kukuruzka165.materialgram",
"io.github.ladaapp.lada",
"io.github.lainsce.Notejot",
"io.github.lawstorant.boxflat",
"io.github.libvibrant.vibrantLinux",
"io.github.limo_app.limo",
"io.github.linx_systems.ClamUI",
"io.github.mandruis7.xbox-cloud-gaming-electron",
"io.github.marco_calautti.DeltaPatcher",
"io.github.martchus.syncthingtray",
"io.github.martinrotter.rssguard",
"io.github.mfat.sshpilot",
"io.github.mfat.tvhplayer",
"io.github.mhogomchungu.media-downloader",
"io.github.milkshiift.GoofCord",
"io.github.mpc_qt.mpc-qt",
"io.github.mpobaschnig.Vaults",
"io.github.mrvladus.List",
"io.github.nacho.mecalin",
"io.github.nokse22.Exhibit",
"io.github.nokse22.asciidraw",
"io.github.nokse22.high-tide",
"io.github.nokse22.inspector",
"io.github.nozwock.Packet",
"io.github.nroduit.Weasis",
"io.github.nuttyartist.notes",
"io.github.olaproeis.Ferrite",
"io.github.onionware_github.onionmedia",
"io.github.pantheon_tweaks.pantheon-tweaks",
"io.github.peazip.PeaZip",
"io.github.pieterdd.RcloneShuttle",
"io.github.plrigaux.sysd-manager",
"io.github.pol_rivero.github-desktop-plus",
"io.github.prateekmedia.appimagepool",
"io.github.prateekmedia.pstube",
"io.github.quodlibet.QuodLibet",
"io.github.qwersyk.Newelle",
"io.github.radiolamp.mangojuice",
"io.github.realmazharhussain.GdmSettings",
"io.github.revisto.drum-machine",
"io.github.rfrench3.scopebuddy-gui",
"io.github.schwarzen.colormydesktop",
"io.github.seadve.Kooha",
"io.github.seadve.Mousai",
"io.github.sepehr_rs.Sudoku",
"io.github.sharkwouter.Minigalaxy",
"io.github.shiiion.primehack",
"io.github.shonebinu.Brief",
"io.github.shonubot.Spruce",
"io.github.sigmasd.stimulator",
"io.github.sitraorg.sitra",
"io.github.streetpea.Chiaki4deck",
"io.github.swordpuffin.rewaita",
"io.github.swordpuffin.wardrobe",
"io.github.teacond.Morse",
"io.github.thetumultuousunicornofdarkness.cpu-x",
"io.github.tntwise.REAL-Video-Enhancer",
"io.github.tobagin.karere",
"io.github.tobagin.scramble",
"io.github.totoshko88.RustConn",
"io.github.troyeguo.koodo-reader",
"io.github.ungoogled_software.ungoogled_chromium",
"io.github.unknownskl.greenlight",
"io.github.v81d.Wattage",
"io.github.vikdevelop.SaveDesktop",
"io.github.vmkspv.lenspect",
"io.github.wartybix.Constrict",
"io.github.wiiznokes.fan-control",
"io.github.wivrn.wivrn",
"io.github.xyproto.zsnes",
"io.github.yairm210.unciv",
"io.github.zaedus.spider",
"io.github.zarestia_dev.rclone-manager",
"io.github.zingytomato.netpeek",
"io.gitlab.Goodvibes",
"io.gitlab.adhami3310.Converter",
"io.gitlab.adhami3310.Footage",
"io.gitlab.adhami3310.Impression",
"io.gitlab.librewolf-community",
"io.gitlab.news_flash.NewsFlash",
"io.gitlab.theevilskeleton.Upscaler",
"io.kapsa.drive",
"io.kinvolk.Headlamp",
"io.m51.Gelly",
"io.missioncenter.MissionCenter",
"io.openrct2.OpenRCT2",
"io.podman_desktop.PodmanDesktop",
"io.qt.QtCreator",
"io.sourceforge.pysolfc.PySolFC",
"it.dottorblaster.cauldron",
"it.fabiodistasio.AntaresSQL",
"it.mijorus.gearlever",
"it.mijorus.smile",
"it.mijorus.whisper",
"it.mq1.TinyWiiBackupManager",
"jp.nonbili.noutube",
"md.obsidian.Obsidian",
"me.ahola.aphototoollibre",
"me.amankhanna.opendeck",
"me.iepure.devtoolbox",
"me.timschneeberger.GalaxyBudsClient",
"me.timschneeberger.jdsp4linux",
"moe.launcher.an-anime-game-launcher",
"moe.launcher.sleepy-launcher",
"moe.launcher.the-honkers-railway-launcher",
"net.codelogistics.webapps",
"net.davidotek.pupgui2",
"net.fasterland.converseen",
"net.fhannenheim.musicfetch",
"net.giuspen.cherrytree",
"net.jami.Jami",
"net.kuribo64.melonDS",
"net.lutris.Lutris",
"net.mkiol.Jupii",
"net.mkiol.SpeechNote",
"net.nokyan.Resources",
"net.nymtech.NymVPN",
"net.openra.OpenRA",
"net.pcsx2.PCSX2",
"net.retrodeck.retrodeck",
"net.runelite.RuneLite",
"net.sapples.LiveCaptions",
"net.shadps4.shadPS4",
"net.sourceforge.VMPK",
"net.sourceforge.m64py.M64Py",
"net.supertuxkart.SuperTuxKart",
"net.trowell.typesetter",
"net.veloren.airshipper",
"net.waterfox.waterfox",
"net.werwolv.ImHex",
"net.wz2100.wz2100",
"net.xmind.XMind",
"net.zdechov.app.x2048",
"network.loki.Session",
"nl.openoffice.bluefish",
"one.ablaze.floorp",
"org.alienarena.alienarena",
"org.altlinux.Tuner",
"org.azahar_emu.Azahar",
"org.bunkus.mkvtoolnix-gui",
"org.cloudcompare.CloudCompare",
"org.cockpit_project.CockpitClient",
"org.contourterminal.Contour",
"org.cryptomator.Cryptomator",
"org.deskflow.deskflow",
"org.diasurgical.DevilutionX",
"org.dune3d.dune3d",
"org.dupot.easyflatpak",
"org.equicord.equibop",
"org.fcitx.Fcitx5",
"org.fedoraproject.MediaWriter",
"org.feichtmeier.Musicpod",
"org.ferdium.Ferdium",
"org.fkoehler.KTailctl",
"org.flameshot.Flameshot",
"org.flightgear.FlightGear",
"org.fooyin.fooyin",
"org.freac.freac",
"org.freecad.FreeCAD",
"org.freedownloadmanager.Manager",
"org.gabmus.gfeeds",
"org.gabmus.hydrapaper",
"org.gabmus.whatip",
"org.gajim.Gajim",
"org.gaphor.Gaphor",
"org.garudalinux.firedragon",
"org.gimp.GIMP",
"org.gnome.Boxes",
"org.gnome.Builder",
"org.gnome.Calculator",
"org.gnome.Calendar",
"org.gnome.Characters",
"org.gnome.Chess",
"org.gnome.Connections",
"org.gnome.Contacts",
"org.gnome.Decibels",
@@ -214,95 +498,207 @@
"org.gnome.Evolution",
"org.gnome.Extensions",
"org.gnome.Firmware",
"org.gnome.Fractal",
"org.gnome.Logs",
"org.gnome.Loupe",
"org.gnome.Mahjongg",
"org.gnome.Maps",
"org.gnome.Mines",
"org.gnome.Music",
"org.gnome.Papers",
"org.gnome.Quadrapassel",
"org.gnome.Shotwell",
"org.gnome.Showtime",
"org.gnome.SimpleScan",
"org.gnome.Snapshot",
"org.gnome.Sudoku",
"org.gnome.TextEditor",
"org.gnome.Totem",
"org.gnome.Weather",
"org.gnome.World.Iotas",
"org.gnome.World.PikaBackup",
"org.gnome.World.Secrets",
"org.gnome.baobab",
"org.gnome.clocks",
"org.gnome.design.IconLibrary",
"org.gnome.font-viewer",
"org.gnome.gedit",
"org.gnome.gitlab.YaLTeR.Identity",
"org.gnome.gitlab.YaLTeR.VideoTrimmer",
"org.gnome.gitlab.somas.Apostrophe",
"org.gnucash.GnuCash",
"org.gottcode.FocusWriter",
"org.gramps_project.Gramps",
"org.gtkhash.gtkhash",
"org.inkscape.Inkscape",
"org.jamovi.jamovi",
"org.jaspstats.JASP",
"org.jellyfin.JellyfinDesktop",
"org.jellyfin.JellyfinServer",
"org.jousse.vincent.Pomodorolm",
"org.js.nuclear.Nuclear",
"org.kartkrew.RingRacers",
"org.kde.CrowTranslate",
"org.kde.amarok",
"org.kde.arianna",
"org.kde.ark",
"org.kde.audiotube",
"org.kde.calligra",
"org.kde.digikam",
"org.kde.dolphin",
"org.kde.dragonplayer",
"org.kde.elisa",
"org.kde.falkon",
"org.kde.filelight",
"org.kde.gcompris",
"org.kde.ghostwriter",
"org.kde.gwenview",
"org.kde.haruna",
"org.kde.isoimagewriter",
"org.kde.kaffeine",
"org.kde.kalk",
"org.kde.kalzium",
"org.kde.kamoso",
"org.kde.kasts",
"org.kde.kate",
"org.kde.kcalc",
"org.kde.kclock",
"org.kde.kdenlive",
"org.kde.kdf",
"org.kde.kget",
"org.kde.kid3",
"org.kde.kleopatra",
"org.kde.kmahjongg",
"org.kde.kmines",
"org.kde.kmymoney",
"org.kde.koko",
"org.kde.kolourpaint",
"org.kde.konsole",
"org.kde.kontact",
"org.kde.kpat",
"org.kde.krdc",
"org.kde.krename",
"org.kde.krita",
"org.kde.kshisen",
"org.kde.kstars",
"org.kde.ksudoku",
"org.kde.ktorrent",
"org.kde.ktouch",
"org.kde.kwalletmanager5",
"org.kde.kweather",
"org.kde.kwrite",
"org.kde.labplot",
"org.kde.marble",
"org.kde.merkuro",
"org.kde.minuet",
"org.kde.neochat",
"org.kde.okular",
"org.kde.plasmatube",
"org.kde.qrca",
"org.kde.skanlite",
"org.kde.skanpage",
"org.kde.subtitlecomposer",
"org.kde.yakuake",
"org.keepassxc.KeePassXC",
"org.kicad.KiCad",
"org.kiwix.desktop",
"org.learningequality.Kolibri",
"org.leocad.LeoCAD",
"org.librecad.librecad",
"org.libreoffice.LibreOffice",
"org.librepcb.LibrePCB",
"org.libretro.RetroArch",
"org.linux_hardware.hw-probe",
"org.localsend.localsend_app",
"org.luanti.luanti",
"org.meshtastic.meshtasticd",
"org.mixxx.Mixxx",
"org.moneymanagerex.MMEX",
"org.mozilla.Thunderbird",
"org.mozilla.firefox",
"org.mozilla.vpn",
"org.nickvision.cavalier",
"org.nickvision.money",
"org.nickvision.tagger",
"org.nickvision.tubeconverter",
"org.nicotine_plus.Nicotine",
"org.nomacs.ImageLounge",
"org.onionshare.OnionShare",
"org.onlyoffice.desktopeditors",
"org.openandroidinstaller.OpenAndroidInstaller",
"org.openmw.OpenMW",
"org.openrgb.OpenRGB",
"org.openscad.OpenSCAD",
"org.opensurge2d.OpenSurge",
"org.pencil2d.Pencil2D",
"org.photoqt.PhotoQt",
"org.pitivi.Pitivi",
"org.ppsspp.PPSSPP",
"org.prismlauncher.PrismLauncher",
"org.pulseaudio.pavucontrol",
"org.pvermeer.WebAppHub",
"org.qbittorrent.qBittorrent",
"org.qownnotes.QOwnNotes",
"org.remmina.Remmina",
"org.rncbc.qpwgraph",
"org.sabnzbd.sabnzbd",
"org.scummvm.ScummVM",
"org.shotcut.Shotcut",
"org.siril.Siril",
"org.sqlitebrowser.sqlitebrowser",
"org.squidowl.halloy",
"org.srb2.SRB2",
"org.stellarium.Stellarium",
"org.strawberrymusicplayer.strawberry",
"org.telegram.desktop",
"org.tenacityaudio.Tenacity",
"org.texstudio.TeXstudio",
"org.thonny.Thonny",
"org.torproject.torbrowser-launcher",
"org.turbowarp.TurboWarp",
"org.tuxpaint.Tuxpaint",
"org.upscayl.Upscayl",
"org.vinegarhq.Sober",
"org.vinegarhq.Vinegar",
"org.virt_manager.virt-manager",
"org.wesnoth.Wesnoth",
"org.wezfurlong.wezterm",
"org.x.Warpinator",
"org.xonotic.Xonotic",
"org.zaproxy.ZAP",
"org.zrythm.Zrythm",
"page.codeberg.JakobDev.jdMinecraftLauncher",
"page.codeberg.censor.Censor",
"page.codeberg.impromptux.ytdl-gui",
"page.codeberg.libre_menu_editor.LibreMenuEditor",
"page.codeberg.lo_vely.Nucleus",
"page.codeberg.tahoso.azul-box",
"page.kramo.Cartridges",
"page.tesk.Refine",
"re.fossplant.songrec",
"re.sonny.Eloquent",
"re.sonny.Junction",
"re.sonny.Tangram",
"re.sonny.Workbench",
"rocks.koreader.KOReader",
"rocks.shy.VacuumTube",
"rs.ruffle.Ruffle",
"ru.linux_gaming.PortProton",
"ru.yandex.Browser",
"sa.sy.bluerecorder",
"se.sjoerd.Graphs",
"so.libdb.dissent",
"space.gaiasky.GaiaSky",
"space.rirusha.Cassette",
"studio.planetpeanut.Bobby",
"tv.kodi.Kodi",
"tv.plex.PlexDesktop",
"tv.plex.PlexHTPC",
"us.materialio.Materialious",
"vn.hoabinh.quan.CoBang",
"website.i2pd.i2pd",
"work.openpaper.Paperwork",
"xyz.hyperplay.HyperPlay",
"xyz.ketok.Speedtest",
"xyz.z3ntu.razergenie"
]