enhance keyboard experience
Now we have network root and stream-view defining their own hints instead of trying to re-use one component. And we also introduce an overlay for additional commands for people.
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export interface KeybindingEntry {
|
||||||
|
keys: string[];
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeybindingGroup {
|
||||||
|
label: string;
|
||||||
|
bindings: KeybindingEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeybindingsOverlayProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
groups: KeybindingGroup[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeybindingsOverlay({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
groups,
|
||||||
|
title = "Keyboard Shortcuts",
|
||||||
|
}: KeybindingsOverlayProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" || e.key === "?") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler, { capture: true });
|
||||||
|
return () => window.removeEventListener("keydown", handler, { capture: true });
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[100]">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||||
|
<span className="text-xs text-white/30">
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Esc
|
||||||
|
</kbd>{" "}
|
||||||
|
or{" "}
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
?
|
||||||
|
</kbd>{" "}
|
||||||
|
to close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<section key={group.label}>
|
||||||
|
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||||
|
{group.label}
|
||||||
|
</h3>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{group.bindings.map((binding) => (
|
||||||
|
<li
|
||||||
|
key={binding.description}
|
||||||
|
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-white/70">
|
||||||
|
{binding.description}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{binding.keys.map((k) => (
|
||||||
|
<kbd
|
||||||
|
key={k}
|
||||||
|
className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs text-white/60"
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Video, Mic } from "lucide-react";
|
||||||
|
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||||
|
|
||||||
|
export function VideoAudioToggle() {
|
||||||
|
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||||
|
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { Video, Mic, Paperclip } from "lucide-react";
|
|
||||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
|
||||||
import { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
interface ControlsIndicatorProps {
|
|
||||||
type: "reply" | "new";
|
|
||||||
attachmentCount?: number;
|
|
||||||
onOpenAttachments?: () => void;
|
|
||||||
showEscape?: boolean;
|
|
||||||
}
|
|
||||||
export default function ControlsIndicator({
|
|
||||||
type,
|
|
||||||
attachmentCount = 0,
|
|
||||||
showEscape = false,
|
|
||||||
onOpenAttachments,
|
|
||||||
children
|
|
||||||
}: PropsWithChildren<ControlsIndicatorProps>) {
|
|
||||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
|
||||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
|
||||||
{showEscape && (
|
|
||||||
<span>
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Esc
|
|
||||||
</kbd>{" "}
|
|
||||||
back
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{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 `
|
|
||||||
</kbd>{" "}
|
|
||||||
to {type === "reply" ? "reply" : "start"}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
T
|
|
||||||
</kbd>{" "}
|
|
||||||
text
|
|
||||||
</span>
|
|
||||||
{attachmentCount > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenAttachments}
|
|
||||||
className="flex items-center gap-1 text-white/50 transition-colors hover:text-white/80"
|
|
||||||
>
|
|
||||||
<kbd className="self-end rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
A
|
|
||||||
</kbd>{" "}
|
|
||||||
<Paperclip className="size-3" />
|
|
||||||
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { CircleDot, CircleCheckBig } from "lucide-react";
|
import { CircleDot, CircleCheckBig } 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";
|
||||||
import ControlsIndicator from "@/features/compose/controls-indicator";
|
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
||||||
import { ComposeOverlay } from "./compose/compose-overlay";
|
import { ComposeOverlay } from "./compose/compose-overlay";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||||
@@ -63,9 +63,44 @@ export default function NetworkRoot() {
|
|||||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<ControlsIndicator type={"new"} />
|
<NetworkRootControls />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NetworkRootControls() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||||
|
<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 className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
1–9
|
||||||
|
</kbd>{" "}
|
||||||
|
jump
|
||||||
|
</span>
|
||||||
|
<VideoAudioToggle />
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Hold `
|
||||||
|
</kbd>{" "}
|
||||||
|
to start
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
T
|
||||||
|
</kbd>{" "}
|
||||||
|
text
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { MediaParticleView, type MediaParticleHandle } from "@/features/particle
|
|||||||
import { TextParticleView } from "@/features/particles/text-particle-view";
|
import { TextParticleView } from "@/features/particles/text-particle-view";
|
||||||
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
|
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
|
||||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||||
import ControlsIndicator from "@/features/compose/controls-indicator";
|
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
||||||
|
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useNetwork, useNetworks } from "@/hooks/use-networks";
|
import { useNetwork, useNetworks } from "@/hooks/use-networks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -101,6 +102,34 @@ function useExitCountdown(
|
|||||||
return remainingMs;
|
return remainingMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Keybindings ---
|
||||||
|
|
||||||
|
const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
||||||
|
{
|
||||||
|
label: "Navigation",
|
||||||
|
bindings: [
|
||||||
|
{ keys: ["←", "→"], description: "Previous / next particle" },
|
||||||
|
{ keys: ["↑", "↓"], description: "Previous / next particle" },
|
||||||
|
{ keys: ["Esc"], description: "Back to network" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Playback",
|
||||||
|
bindings: [
|
||||||
|
{ keys: ["Hold", "Space"], description: "Pause" },
|
||||||
|
{ keys: ["Hold", "Shift"], description: "1.5× speed" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Compose",
|
||||||
|
bindings: [
|
||||||
|
{ keys: ["Hold", "`"], description: "Reply" },
|
||||||
|
{ keys: ["T"], description: "Text compose" },
|
||||||
|
{ keys: ["H"], description: "Join huddle" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// --- StreamView ---
|
// --- StreamView ---
|
||||||
|
|
||||||
interface StreamViewProps {
|
interface StreamViewProps {
|
||||||
@@ -145,6 +174,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
const [composeActive, setComposeActive] = useState(false);
|
const [composeActive, setComposeActive] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [fastPlayback, setFastPlayback] = useState(false);
|
const [fastPlayback, setFastPlayback] = useState(false);
|
||||||
|
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||||
|
|
||||||
// Show/hide chrome on mouse activity (YouTube-style)
|
// Show/hide chrome on mouse activity (YouTube-style)
|
||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
@@ -228,6 +258,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
navigate(`/${networkId}`);
|
navigate(`/${networkId}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "?":
|
||||||
|
e.preventDefault();
|
||||||
|
setShowKeybindings((v) => !v);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,7 +286,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id],
|
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Click-to-navigate: left 30% = prev, right 70% = next
|
// Click-to-navigate: left 30% = prev, right 70% = next
|
||||||
@@ -276,14 +310,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
No particles in this stream yet
|
No particles in this stream yet
|
||||||
</p>
|
</p>
|
||||||
<ControlsIndicator type="reply" showEscape={true}>
|
<StreamViewControls
|
||||||
<span>
|
showEscape
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
onOpenKeybindings={() => setShowKeybindings(true)}
|
||||||
H
|
/>
|
||||||
</kbd>{" "}
|
|
||||||
huddle
|
|
||||||
</span>
|
|
||||||
</ControlsIndicator>
|
|
||||||
<ComposeOverlay
|
<ComposeOverlay
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
targetPath={path}
|
targetPath={path}
|
||||||
@@ -373,6 +403,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
onGoTo={goTo}
|
onGoTo={goTo}
|
||||||
presenceBySegment={presenceBySegment}
|
presenceBySegment={presenceBySegment}
|
||||||
exitRemainingMs={exitRemainingMs}
|
exitRemainingMs={exitRemainingMs}
|
||||||
|
onOpenKeybindings={() => setShowKeybindings(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeybindingsOverlay
|
||||||
|
open={showKeybindings}
|
||||||
|
onClose={() => setShowKeybindings(false)}
|
||||||
|
groups={STREAM_VIEW_KEYBINDINGS}
|
||||||
|
title="Stream View"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -386,6 +424,7 @@ function BottomBar({
|
|||||||
onGoTo,
|
onGoTo,
|
||||||
presenceBySegment,
|
presenceBySegment,
|
||||||
exitRemainingMs,
|
exitRemainingMs,
|
||||||
|
onOpenKeybindings,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -394,6 +433,7 @@ function BottomBar({
|
|||||||
onGoTo: (index: number) => void;
|
onGoTo: (index: number) => void;
|
||||||
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
|
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
|
||||||
exitRemainingMs: number | null;
|
exitRemainingMs: number | null;
|
||||||
|
onOpenKeybindings: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -418,7 +458,7 @@ function BottomBar({
|
|||||||
onGoTo={onGoTo}
|
onGoTo={onGoTo}
|
||||||
layer="tracks"
|
layer="tracks"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-center px-3 pt-2">
|
<div className="flex items-center justify-center px-3 pt-2 gap-2">
|
||||||
{exitRemainingMs !== null && (
|
{exitRemainingMs !== null && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
|
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
|
||||||
@@ -426,20 +466,64 @@ function BottomBar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ControlsIndicator type="reply" showEscape={true}>
|
<StreamViewControls
|
||||||
<span>
|
showEscape
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
onOpenKeybindings={onOpenKeybindings}
|
||||||
H
|
/>
|
||||||
</kbd>{" "}
|
|
||||||
huddle
|
|
||||||
</span>
|
|
||||||
</ControlsIndicator>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StreamViewControls({
|
||||||
|
showEscape,
|
||||||
|
onOpenKeybindings,
|
||||||
|
}: {
|
||||||
|
showEscape?: boolean;
|
||||||
|
onOpenKeybindings: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||||
|
{showEscape && (
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Esc
|
||||||
|
</kbd>{" "}
|
||||||
|
back
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<VideoAudioToggle />
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Hold `
|
||||||
|
</kbd>{" "}
|
||||||
|
to reply
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
T
|
||||||
|
</kbd>{" "}
|
||||||
|
text
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
H
|
||||||
|
</kbd>{" "}
|
||||||
|
huddle
|
||||||
|
</span>
|
||||||
|
<kbd
|
||||||
|
role="button"
|
||||||
|
onClick={onOpenKeybindings}
|
||||||
|
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
|
||||||
|
title="Show all shortcuts"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
|
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
|
|||||||
Reference in New Issue
Block a user