@@ -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 `
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user