From 086cf4f1eebfdc82d6bc543d74ee44e5c1d69804 Mon Sep 17 00:00:00 2001 From: talksik Date: Mon, 30 Mar 2026 12:51:15 -0700 Subject: [PATCH] keyboard-driven navigation Resolves #66 --- .../features/compose/controls-indicator.tsx | 55 ++++++----- js/src/features/network-root.tsx | 25 ++++- .../features/particles/particle-grid-view.tsx | 10 +- .../features/particles/particle-list-view.tsx | 26 +++++- .../features/particles/particle-preview.tsx | 4 +- js/src/features/particles/stream-card.tsx | 15 ++- js/src/hooks/use-stream-keyboard-nav.ts | 93 +++++++++++++++++++ 7 files changed, 188 insertions(+), 40 deletions(-) create mode 100644 js/src/hooks/use-stream-keyboard-nav.ts diff --git a/js/src/features/compose/controls-indicator.tsx b/js/src/features/compose/controls-indicator.tsx index 4e855b5..6a40622 100644 --- a/js/src/features/compose/controls-indicator.tsx +++ b/js/src/features/compose/controls-indicator.tsx @@ -26,30 +26,37 @@ export default function ControlsIndicator({ back )} - + {type === "new" && ( + + + ↑↓ + {" "} + + Enter + {" "} + navigate + + )} + + + setRecordingMode(recordingMode === "video" ? "audio" : "video") + } + title={ + recordingMode === "video" + ? "Switch to audio-only" + : "Switch to video" + } + className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80" + > + {recordingMode === "video" ? ( + <> + Hold ` diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index ebcf39f..0101a05 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -1,4 +1,5 @@ -import { useParams } from "react-router-dom"; +import { useCallback, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; import { List, LayoutGrid } from "lucide-react"; import { particlePath } from "@/lib/particle-path"; import { ParticleListView } from "@/features/particles/particle-list-view"; @@ -8,6 +9,8 @@ import ControlsIndicator from "@/features/compose/controls-indicator"; import { ComposeOverlay } from "./compose/compose-overlay"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useViewModeStore } from "@/stores/view-mode-store"; +import { useStreamParticles } from "@/hooks/use-stream-particles"; +import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav"; /** * Route-level component for /:networkId (index). @@ -15,18 +18,32 @@ import { useViewModeStore } from "@/stores/view-mode-store"; */ export default function NetworkRoot() { const { networkId } = useParams(); + const navigate = useNavigate(); const path = particlePath(networkId!, []); const viewMode = useViewModeStore((s) => s.viewMode); const setViewMode = useViewModeStore((s) => s.setViewMode); + const { streams } = useStreamParticles(path); + const [composeActive, setComposeActive] = useState(false); + + const { selectedIndex } = useStreamKeyboardNav({ + streams, + viewMode, + enabled: !composeActive, + onNavigate: useCallback( + (streamId: string) => navigate(`/${networkId}/${streamId}`), + [navigate, networkId], + ), + }); + return (
{/* Scrollable content */}
{viewMode === "list" ? ( - + ) : ( - + )}
@@ -51,7 +68,7 @@ export default function NetworkRoot() {
- +
diff --git a/js/src/features/particles/particle-grid-view.tsx b/js/src/features/particles/particle-grid-view.tsx index 38e9a8a..5fb569b 100644 --- a/js/src/features/particles/particle-grid-view.tsx +++ b/js/src/features/particles/particle-grid-view.tsx @@ -7,9 +7,10 @@ import { StreamCard } from "@/features/particles/stream-card"; interface ParticleGridViewProps { path: ParticlePath; + selectedIndex?: number | null; } -export function ParticleGridView({ path }: ParticleGridViewProps) { +export function ParticleGridView({ path, selectedIndex }: ParticleGridViewProps) { const { streams, isLoading, networkId } = useStreamParticles(path); const navigate = useNavigate(); @@ -30,13 +31,16 @@ export function ParticleGridView({ path }: ParticleGridViewProps) { } return ( -
- {streams.map((stream) => ( +
+ {streams.map((stream, index) => ( el?.scrollIntoView({ block: "nearest" }) : undefined} particle={stream} networkId={networkId} onClick={() => navigate(`/${networkId}/${stream.id}`)} + isSelected={index === selectedIndex} + shortcutKey={index < 9 ? index + 1 : undefined} /> ))}
diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index 86d7a60..8093e5b 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -77,10 +77,14 @@ function StreamRow({ particle, networkId, onClick, + isSelected, + shortcutKey, }: { particle: Particle & { type: "stream"; properties: StreamProperties }; networkId: string; onClick: () => void; + isSelected?: boolean; + shortcutKey?: number; }) { const streamPath = particlePath(networkId, [particle.id]); const { latestChild } = useLiveLatestChild(streamPath); @@ -148,8 +152,16 @@ function StreamRow({ tabIndex={0} onClick={onClick} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick(); }} - className="flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent" + className={cn( + "flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors hover:bg-accent", + isSelected && "bg-accent", + )} > + {shortcutKey && ( + + {shortcutKey} + + )} @@ -211,12 +223,13 @@ function StreamRow({ interface ParticleListViewProps { path: ParticlePath; + selectedIndex?: number | null; } /** * List of stream particles for a container (network root, folder, etc.). */ -export function ParticleListView({ path }: ParticleListViewProps) { +export function ParticleListView({ path, selectedIndex }: ParticleListViewProps) { const { streams, isLoading, networkId } = useStreamParticles(path); const navigate = useNavigate(); @@ -238,13 +251,18 @@ export function ParticleListView({ path }: ParticleListViewProps) { return (
{streams.map((stream, index) => ( -
+
el?.scrollIntoView({ block: "nearest" }) : undefined} + > navigate(`/${networkId}/${stream.id}`)} + isSelected={index === selectedIndex} + shortcutKey={index < 9 ? index + 1 : undefined} /> - {index < streams.length - 1 && } + {index < streams.length - 1 && }
))}
diff --git a/js/src/features/particles/particle-preview.tsx b/js/src/features/particles/particle-preview.tsx index 733d8e5..7229c15 100644 --- a/js/src/features/particles/particle-preview.tsx +++ b/js/src/features/particles/particle-preview.tsx @@ -37,7 +37,7 @@ function TextPreview({ particle }: { particle: Extract -

+

{truncated}

@@ -100,7 +100,7 @@ function VideoThumbnail({ } return ( -
+