keyboard-driven navigation

Resolves #66
This commit is contained in:
talksik
2026-03-30 12:51:15 -07:00
parent 14285575e4
commit 086cf4f1ee
7 changed files with 188 additions and 40 deletions
+31 -24
View File
@@ -26,30 +26,37 @@ export default function ControlsIndicator({
back back
</span> </span>
)} )}
<button {type === "new" && (
type="button" <span>
onClick={() => <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
setRecordingMode(recordingMode === "video" ? "audio" : "video")
} </kbd>{" "}
title={ <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
recordingMode === "video" Enter
? "Switch to audio-only" </kbd>{" "}
: "Switch to video" navigate
} </span>
className="flex items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-xs text-white/50 transition-colors hover:text-white/80" )}
> <span>
{recordingMode === "video" ? ( <kbd
<> role="button"
<Video className="size-3" /> onClick={() =>
Video setRecordingMode(recordingMode === "video" ? "audio" : "video")
</> }
) : ( title={
<> recordingMode === "video"
<Mic className="size-3" /> ? "Switch to audio-only"
Audio : "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"
</button> >
{recordingMode === "video" ? (
<><Video className="inline size-3" /> Video</>
) : (
<><Mic className="inline size-3" /> Audio</>
)}
</kbd>
</span>
<span> <span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold ` Hold `
+21 -4
View File
@@ -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 { List, LayoutGrid } from "lucide-react";
import { particlePath } from "@/lib/particle-path"; import { particlePath } from "@/lib/particle-path";
import { ParticleListView } from "@/features/particles/particle-list-view"; 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 { ComposeOverlay } from "./compose/compose-overlay";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useViewModeStore } from "@/stores/view-mode-store"; 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). * Route-level component for /:networkId (index).
@@ -15,18 +18,32 @@ import { useViewModeStore } from "@/stores/view-mode-store";
*/ */
export default function NetworkRoot() { export default function NetworkRoot() {
const { networkId } = useParams(); const { networkId } = useParams();
const navigate = useNavigate();
const path = particlePath(networkId!, []); const path = particlePath(networkId!, []);
const viewMode = useViewModeStore((s) => s.viewMode); const viewMode = useViewModeStore((s) => s.viewMode);
const setViewMode = useViewModeStore((s) => s.setViewMode); 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 ( return (
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
{/* Scrollable content */} {/* Scrollable content */}
<div className="h-full overflow-y-auto overscroll-contain pt-10 pb-14"> <div className="h-full overflow-y-auto overscroll-contain pt-10 pb-14">
{viewMode === "list" ? ( {viewMode === "list" ? (
<ParticleListView path={path} /> <ParticleListView path={path} selectedIndex={selectedIndex} />
) : ( ) : (
<ParticleGridView path={path} /> <ParticleGridView path={path} selectedIndex={selectedIndex} />
)} )}
</div> </div>
@@ -51,7 +68,7 @@ export default function NetworkRoot() {
</div> </div>
<AutoplayOverlay networkId={networkId!} /> <AutoplayOverlay networkId={networkId!} />
<ComposeOverlay networkId={networkId!} /> <ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-3"> <div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-3">
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<ControlsIndicator type={"new"} /> <ControlsIndicator type={"new"} />
@@ -7,9 +7,10 @@ import { StreamCard } from "@/features/particles/stream-card";
interface ParticleGridViewProps { interface ParticleGridViewProps {
path: ParticlePath; path: ParticlePath;
selectedIndex?: number | null;
} }
export function ParticleGridView({ path }: ParticleGridViewProps) { export function ParticleGridView({ path, selectedIndex }: ParticleGridViewProps) {
const { streams, isLoading, networkId } = useStreamParticles(path); const { streams, isLoading, networkId } = useStreamParticles(path);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,13 +31,16 @@ export function ParticleGridView({ path }: ParticleGridViewProps) {
} }
return ( return (
<div className="grid grid-cols-2 gap-3 px-3"> <div className="grid grid-cols-3 gap-3 px-3">
{streams.map((stream) => ( {streams.map((stream, index) => (
<StreamCard <StreamCard
key={stream.id} key={stream.id}
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
particle={stream} particle={stream}
networkId={networkId} networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)} onClick={() => navigate(`/${networkId}/${stream.id}`)}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/> />
))} ))}
</div> </div>
@@ -77,10 +77,14 @@ function StreamRow({
particle, particle,
networkId, networkId,
onClick, onClick,
isSelected,
shortcutKey,
}: { }: {
particle: Particle & { type: "stream"; properties: StreamProperties }; particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string; networkId: string;
onClick: () => void; onClick: () => void;
isSelected?: boolean;
shortcutKey?: number;
}) { }) {
const streamPath = particlePath(networkId, [particle.id]); const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath); const { latestChild } = useLiveLatestChild(streamPath);
@@ -148,8 +152,16 @@ function StreamRow({
tabIndex={0} tabIndex={0}
onClick={onClick} onClick={onClick}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") 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 && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
{shortcutKey}
</kbd>
)}
<Avatar <Avatar
className={cn(isUnseen && "ring-2 ring-primary")} className={cn(isUnseen && "ring-2 ring-primary")}
> >
@@ -211,12 +223,13 @@ function StreamRow({
interface ParticleListViewProps { interface ParticleListViewProps {
path: ParticlePath; path: ParticlePath;
selectedIndex?: number | null;
} }
/** /**
* List of stream particles for a container (network root, folder, etc.). * 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 { streams, isLoading, networkId } = useStreamParticles(path);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -238,13 +251,18 @@ export function ParticleListView({ path }: ParticleListViewProps) {
return ( return (
<div> <div>
{streams.map((stream, index) => ( {streams.map((stream, index) => (
<div key={stream.id}> <div
key={stream.id}
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
>
<StreamRow <StreamRow
particle={stream} particle={stream}
networkId={networkId} networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)} onClick={() => navigate(`/${networkId}/${stream.id}`)}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/> />
{index < streams.length - 1 && <Separator className="mx-4" />} {index < streams.length - 1 && <Separator className="px-4" />}
</div> </div>
))} ))}
</div> </div>
@@ -37,7 +37,7 @@ function TextPreview({ particle }: { particle: Extract<Particle, { type: "text"
: particle.properties.content; : particle.properties.content;
return ( return (
<div className="flex h-full w-full items-center justify-center p-4"> <div className="flex h-full w-full items-center justify-center p-4">
<p className="line-clamp-4 text-center text-4xl leading-relaxed"> <p className="line-clamp-4 text-center text-xl leading-relaxed">
{truncated} {truncated}
</p> </p>
</div> </div>
@@ -100,7 +100,7 @@ function VideoThumbnail({
} }
return ( return (
<div className="relative h-full w-full bg-black"> <div className="relative h-full w-full bg-black blur-[2px]">
<video <video
src={`${url}#t=2`} src={`${url}#t=2`}
preload="metadata" preload="metadata"
+12 -3
View File
@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { forwardRef, useMemo } from "react";
import { cn, getInitials } from "@/lib/utils"; import { cn, getInitials } from "@/lib/utils";
import { useLiveLatestChild } from "@/hooks/use-particle"; import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
@@ -15,9 +15,11 @@ interface StreamCardProps {
particle: Particle & { type: "stream"; properties: StreamProperties }; particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string; networkId: string;
onClick: () => void; onClick: () => void;
isSelected?: boolean;
shortcutKey?: number;
} }
export function StreamCard({ particle, networkId, onClick }: StreamCardProps) { export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function StreamCard({ particle, networkId, onClick, isSelected, shortcutKey }, ref) {
const streamPath = particlePath(networkId, [particle.id]); const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath); const { latestChild } = useLiveLatestChild(streamPath);
const userId = useAuthStore((s) => s.user?.id) ?? ""; const userId = useAuthStore((s) => s.user?.id) ?? "";
@@ -74,6 +76,7 @@ export function StreamCard({ particle, networkId, onClick }: StreamCardProps) {
return ( return (
<div <div
ref={ref}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={onClick} onClick={onClick}
@@ -83,10 +86,16 @@ export function StreamCard({ particle, networkId, onClick }: StreamCardProps) {
className={cn( className={cn(
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20", "cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
isUnseen && "ring-2 ring-primary", isUnseen && "ring-2 ring-primary",
isSelected && "ring-2 ring-ring",
)} )}
> >
{/* Preview area */} {/* Preview area */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted"> <div className="relative aspect-[4/3] overflow-hidden bg-muted">
{shortcutKey && (
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
{shortcutKey}
</kbd>
)}
{latestChild ? ( {latestChild ? (
<ParticlePreview particle={latestChild} /> <ParticlePreview particle={latestChild} />
) : ( ) : (
@@ -142,4 +151,4 @@ export function StreamCard({ particle, networkId, onClick }: StreamCardProps) {
</div> </div>
</div> </div>
); );
} });
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
interface UseStreamKeyboardNavOptions {
streams: Array<{ id: string }>;
viewMode: "list" | "grid";
enabled: boolean;
onNavigate: (streamId: string) => void;
gridColumns?: number;
}
export function useStreamKeyboardNav({
streams,
viewMode,
enabled,
onNavigate,
gridColumns = 3,
}: UseStreamKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
// Reset selection to first item when streams change or view mode switches
useEffect(() => {
setSelectedIndex(streams.length > 0 ? 0 : null);
}, [streams.length, viewMode]);
useEffect(() => {
if (!enabled || streams.length === 0) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
// Number keys 1-9: immediate navigation
const digit = parseInt(e.key, 10);
if (digit >= 1 && digit <= 9) {
const index = digit - 1;
if (index < streams.length) {
e.preventDefault();
onNavigate(streams[index].id);
}
return;
}
// Enter: navigate to selected
if (e.key === "Enter") {
setSelectedIndex((idx) => {
if (idx !== null && idx < streams.length) {
e.preventDefault();
onNavigate(streams[idx].id);
}
return idx;
});
return;
}
// Arrow keys: move selection
let delta: number | null = null;
if (viewMode === "list") {
if (e.key === "ArrowDown") delta = 1;
else if (e.key === "ArrowUp") delta = -1;
} else {
if (e.key === "ArrowDown") delta = gridColumns;
else if (e.key === "ArrowUp") delta = -gridColumns;
else if (e.key === "ArrowRight") delta = 1;
else if (e.key === "ArrowLeft") delta = -1;
}
if (delta !== null) {
e.preventDefault();
setSelectedIndex((prev) => {
if (prev === null) return 0;
const next = prev + delta;
return Math.max(0, Math.min(next, streams.length - 1));
});
}
};
// Use capture phase so arrow keys are intercepted before Radix UI
// components (ToggleGroup, etc.) consume them for their own navigation.
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [enabled, viewMode, gridColumns, streams, onNavigate]);
return { selectedIndex };
}