diff --git a/package-lock.json b/package-lock.json index 736e77c..f267a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,29 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -734,7 +757,6 @@ "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -795,7 +817,6 @@ "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", @@ -1359,7 +1380,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1788,7 +1808,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2534,7 +2553,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2704,7 +2722,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4433,7 +4450,6 @@ "integrity": "sha512-s98mCOMOWLGGpGOfgKSnleXLuegvvH415qtRZXpSp00HeEgdmrxmwL9cgKU+h4XrhB16zEI5d/7BnkS3ATInsA==", "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "14.2.29", "@swc/helpers": "0.5.5", @@ -5032,7 +5048,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -5196,7 +5211,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5236,7 +5250,6 @@ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -5329,7 +5342,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5342,7 +5354,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6134,7 +6145,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6338,7 +6348,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6506,7 +6515,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/app/models/page.tsx b/src/app/models/page.tsx index 179c674..2bfc7c0 100644 --- a/src/app/models/page.tsx +++ b/src/app/models/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useMemo, useEffect } from "react"; +import { useState, useMemo, useEffect, useRef, useCallback } from "react"; +import { useRouter } from "next/navigation"; import Link from "next/link"; import { mockModels, getUniqueProviders, getUniqueModalities, getUniqueLicenses } from "@/lib/mock-data"; import { ModelFilters as ModelFiltersType, Model } from "@/types"; @@ -8,12 +9,17 @@ import { ModelTable } from "@/components/models/ModelTable"; import { ModelCard } from "@/components/models/ModelCard"; import { ModelFilters } from "@/components/models/ModelFilters"; import { ModelTableSkeleton, ModelCardSkeleton } from "@/components/ui/Skeletons"; +import { ShortcutHints } from "@/components/models/ShortcutHints"; export default function ModelsPage() { const [filters, setFilters] = useState({}); const [view, setView] = useState<"table" | "cards">("table"); const [models, setModels] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [selectedRowIndex, setSelectedRowIndex] = useState(0); + const [showShortcutHints, setShowShortcutHints] = useState(false); + const searchInputRef = useRef(null); + const router = useRouter(); useEffect(() => { const controller = new AbortController(); @@ -61,6 +67,93 @@ export default function ModelsPage() { return result; }, [filters, models]); + const isInputFocused = useCallback(() => { + const active = document.activeElement; + if (!active) return false; + + const tag = active.tagName.toLowerCase(); + return ( + tag === "input" || + tag === "textarea" || + active.getAttribute("contenteditable") === "true" + ); + }, []); + + useEffect(() => { + setSelectedRowIndex((prev) => Math.min(prev, Math.max(filteredModels.length - 1, 0))); + }, [filteredModels.length]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + return; + } + + if (event.key === "Escape") { + if (showShortcutHints) { + event.preventDefault(); + setShowShortcutHints(false); + return; + } + + if (filters.search) { + event.preventDefault(); + setFilters((prev) => ({ ...prev, search: "" })); + } + + return; + } + + if (isInputFocused()) { + return; + } + + if (event.key === "/") { + event.preventDefault(); + searchInputRef.current?.focus({ preventScroll: true }); + return; + } + + if (filteredModels.length === 0 && event.key === "?") { + event.preventDefault(); + setShowShortcutHints((prev) => !prev); + return; + } + + if (event.key.toLowerCase() === "j") { + event.preventDefault(); + setSelectedRowIndex((prev) => Math.min(prev + 1, filteredModels.length - 1)); + return; + } + + if (event.key.toLowerCase() === "k") { + event.preventDefault(); + setSelectedRowIndex((prev) => Math.max(prev - 1, 0)); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + const selectedModel = filteredModels[selectedRowIndex]; + if (selectedModel) { + router.push(`/models/${selectedModel.slug}`); + } + return; + } + + if (event.key === "?") { + event.preventDefault(); + setShowShortcutHints((prev) => !prev); + } + }, + [filteredModels, filters.search, isInputFocused, router, selectedRowIndex, showShortcutHints] + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + return (
{/* Header */} @@ -124,6 +217,7 @@ export default function ModelsPage() { providers={getUniqueProviders()} modalities={getUniqueModalities()} licenses={getUniqueLicenses()} + searchInputRef={searchInputRef} />
@@ -143,7 +237,11 @@ export default function ModelsPage() { ) ) : view === "table" ? ( - + ) : (
{filteredModels.map((model, idx) => ( @@ -159,6 +257,12 @@ export default function ModelsPage() {

)} + + setShowShortcutHints(true)} + onClose={() => setShowShortcutHints(false)} + /> ); } diff --git a/src/components/models/ModelFilters.tsx b/src/components/models/ModelFilters.tsx index 9f4dbda..834a960 100644 --- a/src/components/models/ModelFilters.tsx +++ b/src/components/models/ModelFilters.tsx @@ -1,6 +1,7 @@ "use client"; import { ModelFilters as ModelFiltersType } from "@/types"; +import { type RefObject } from "react"; interface ModelFiltersProps { filters: ModelFiltersType; @@ -8,6 +9,7 @@ interface ModelFiltersProps { providers: string[]; modalities: string[]; licenses: string[]; + searchInputRef?: RefObject; } export function ModelFilters({ @@ -16,6 +18,7 @@ export function ModelFilters({ providers, modalities, licenses, + searchInputRef, }: ModelFiltersProps) { const updateFilter = (key: keyof ModelFiltersType, value: unknown) => { onFiltersChange({ ...filters, [key]: value }); @@ -41,8 +44,9 @@ export function ModelFilters({ updateFilter("search", e.target.value)} className="w-full pl-9 pr-3 py-2 text-sm bg-atlas-bg-primary border border-atlas-border rounded-md text-atlas-text-primary placeholder:text-atlas-text-muted focus:outline-none focus:ring-1 focus:ring-atlas-green/50 focus:border-atlas-green/50 transition-colors" diff --git a/src/components/models/ModelTable.tsx b/src/components/models/ModelTable.tsx index 922bcdd..42cf15f 100644 --- a/src/components/models/ModelTable.tsx +++ b/src/components/models/ModelTable.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; import Link from "next/link"; import { Model, ModelSortField } from "@/types"; import { cn, formatPrice, formatContextWindow, formatBenchmark, getBenchmarkColor } from "@/lib/utils"; @@ -10,6 +11,8 @@ import { modalityIcons } from "@/lib/utils"; interface ModelTableProps { models: Model[]; showRank?: boolean; + selectedIndex?: number; + onSelectedIndexChange?: (index: number) => void; } type SortConfig = { @@ -17,23 +20,18 @@ type SortConfig = { direction: "asc" | "desc"; }; -export function ModelTable({ models, showRank = true }: ModelTableProps) { +export function ModelTable({ + models, + showRank = true, + selectedIndex = 0, + onSelectedIndexChange, +}: ModelTableProps) { + const router = useRouter(); const [sort, setSort] = useState({ field: "benchmarkGpqa", direction: "desc", }); - const sortedModels = useMemo(() => { - return [...models].sort((a, b) => { - const aVal = a[sort.field]; - const bVal = b[sort.field]; - if (aVal === undefined || aVal === null) return 1; - if (bVal === undefined || bVal === null) return -1; - const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; - return sort.direction === "desc" ? -cmp : cmp; - }); - }, [models, sort]); - const handleSort = (field: ModelSortField) => { setSort((prev) => prev.field === field @@ -69,6 +67,21 @@ export function ModelTable({ models, showRank = true }: ModelTableProps) { ); + const sortedModels = useMemo(() => { + return [...models].sort((a, b) => { + const aVal = a[sort.field]; + const bVal = b[sort.field]; + if (aVal === undefined || aVal === null) return 1; + if (bVal === undefined || bVal === null) return -1; + const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + return sort.direction === "desc" ? -cmp : cmp; + }); + }, [models, sort]); + + const handleOpenModel = (model: Model) => { + router.push(`/models/${model.slug}`); + }; + return (
{/* Table Header Bar */} @@ -113,8 +126,11 @@ export function ModelTable({ models, showRank = true }: ModelTableProps) { key={model.id} className={cn( "border-b border-atlas-border/50 hover:bg-atlas-bg-tertiary transition-colors cursor-pointer group", - idx % 2 === 0 ? "bg-atlas-bg-primary" : "bg-atlas-bg-secondary" + idx % 2 === 0 ? "bg-atlas-bg-primary" : "bg-atlas-bg-secondary", + idx === selectedIndex && "ring-2 ring-atlas-blue/70 bg-atlas-blue/5" )} + onMouseEnter={() => onSelectedIndexChange?.(idx)} + onClick={() => handleOpenModel(model)} > {showRank && ( @@ -124,6 +140,8 @@ export function ModelTable({ models, showRank = true }: ModelTableProps) { event.preventDefault()} className="flex items-center gap-2 group-hover:text-atlas-green transition-colors" > diff --git a/src/components/models/ShortcutHints.tsx b/src/components/models/ShortcutHints.tsx new file mode 100644 index 0000000..859147e --- /dev/null +++ b/src/components/models/ShortcutHints.tsx @@ -0,0 +1,67 @@ +"use client"; + +interface ShortcutHintsProps { + onOpen: () => void; + isOpen: boolean; + onClose: () => void; +} + +export function ShortcutHints({ onOpen, isOpen, onClose }: ShortcutHintsProps) { + return ( + <> + + + {isOpen ? ( +
+
+
+

+ Keyboard Shortcuts +

+ +
+ +
    +
  • + Focus search + / +
  • +
  • + Move selection down + J +
  • +
  • + Move selection up + K +
  • +
  • + Open selected model + Enter +
  • +
  • + Clear search / close modal + Esc +
  • +
  • + Open command palette + ⌘K / Ctrl+K +
  • +
+
+
+ ) : null} + + ); +}