@@ -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 `
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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