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
</span>
)}
<button
type="button"
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
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"
>
{recordingMode === "video" ? (
<>
<Video className="size-3" />
Video
</>
) : (
<>
<Mic className="size-3" />
Audio
</>
)}
</button>
{type === "new" && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>{" "}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{" "}
navigate
</span>
)}
<span>
<kbd
role="button"
onClick={() =>
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" ? (
<><Video className="inline size-3" /> Video</>
) : (
<><Mic className="inline size-3" /> Audio</>
)}
</kbd>
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
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 { 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 (
<div className="relative min-h-0 flex-1">
{/* Scrollable content */}
<div className="h-full overflow-y-auto overscroll-contain pt-10 pb-14">
{viewMode === "list" ? (
<ParticleListView path={path} />
<ParticleListView path={path} selectedIndex={selectedIndex} />
) : (
<ParticleGridView path={path} />
<ParticleGridView path={path} selectedIndex={selectedIndex} />
)}
</div>
@@ -51,7 +68,7 @@ export default function NetworkRoot() {
</div>
<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-auto">
<ControlsIndicator type={"new"} />
@@ -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 (
<div className="grid grid-cols-2 gap-3 px-3">
{streams.map((stream) => (
<div className="grid grid-cols-3 gap-3 px-3">
{streams.map((stream, index) => (
<StreamCard
key={stream.id}
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
))}
</div>
@@ -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 && (
<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
className={cn(isUnseen && "ring-2 ring-primary")}
>
@@ -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 (
<div>
{streams.map((stream, index) => (
<div key={stream.id}>
<div
key={stream.id}
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
>
<StreamRow
particle={stream}
networkId={networkId}
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>
@@ -37,7 +37,7 @@ function TextPreview({ particle }: { particle: Extract<Particle, { type: "text"
: particle.properties.content;
return (
<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}
</p>
</div>
@@ -100,7 +100,7 @@ function VideoThumbnail({
}
return (
<div className="relative h-full w-full bg-black">
<div className="relative h-full w-full bg-black blur-[2px]">
<video
src={`${url}#t=2`}
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 { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
@@ -15,9 +15,11 @@ interface StreamCardProps {
particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
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 { latestChild } = useLiveLatestChild(streamPath);
const userId = useAuthStore((s) => s.user?.id) ?? "";
@@ -74,6 +76,7 @@ export function StreamCard({ particle, networkId, onClick }: StreamCardProps) {
return (
<div
ref={ref}
role="button"
tabIndex={0}
onClick={onClick}
@@ -83,10 +86,16 @@ export function StreamCard({ particle, networkId, onClick }: StreamCardProps) {
className={cn(
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
isUnseen && "ring-2 ring-primary",
isSelected && "ring-2 ring-ring",
)}
>
{/* Preview area */}
<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 ? (
<ParticlePreview particle={latestChild} />
) : (
@@ -142,4 +151,4 @@ export function StreamCard({ particle, networkId, onClick }: StreamCardProps) {
</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 };
}