feat: compose new stream full flow

This commit is contained in:
talksik
2026-03-18 15:20:57 -07:00
parent d316ba1e09
commit fca125ab52
8 changed files with 292 additions and 161 deletions
+84 -112
View File
@@ -1,43 +1,30 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNetworks } from "@/hooks/use-networks";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { generateRandomName } from "@/lib/random-name";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ConfigureStreamStepProps {
networkId: string | null;
onCancel: () => void;
onSubmit: (streamName: string, visibleTo: string[]) => void;
}
export function ConfigureStreamStep({
networkId,
onCancel,
onSubmit,
}: ConfigureStreamStepProps) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const members = network?.humans ?? [];
const [name, setName] = useState("");
const [name, setName] = useState(() => generateRandomName());
const [everyone, setEveryone] = useState(true);
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
// -1 = name input is focused, 0+ = member list index
const [focusedIndex, setFocusedIndex] = useState(-1);
const nameRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
nameRef.current?.focus();
}, []);
// Return focus to the name input when navigating back up
useEffect(() => {
if (focusedIndex === -1) {
nameRef.current?.focus();
} else {
// Blur the input so arrow keys don't move the cursor
nameRef.current?.blur();
containerRef.current?.focus();
}
}, [focusedIndex]);
const toggleMember = useCallback((email: string) => {
setSelectedEmails((prev) => {
@@ -48,11 +35,15 @@ export function ConfigureStreamStep({
});
}, []);
const handleSubmit = useCallback(async () => {
const buildVisibleTo = useCallback((): string[] => {
if (everyone && networkId) return [`network:${networkId}`];
return Array.from(selectedEmails).map((e) => `human:${e}`);
}, [everyone, networkId, selectedEmails]);
const handleSubmit = useCallback(() => {
if (!name.trim() || !networkId) return;
// TODO: create stream particle, then attach recorded/text content
onCancel();
}, [name, networkId, onCancel]);
onSubmit(name.trim(), buildVisibleTo());
}, [name, networkId, onSubmit, buildVisibleTo]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -66,124 +57,105 @@ export function ConfigureStreamStep({
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
handleSubmit();
} else if (focusedIndex === -1 && name.trim() && members.length > 0) {
// Enter in name input → move to member list
e.preventDefault();
setFocusedIndex(0);
}
return;
case "ArrowDown":
e.preventDefault();
setFocusedIndex((i) => Math.min(i + 1, members.length - 1));
return;
case "ArrowUp":
e.preventDefault();
setFocusedIndex((i) => Math.max(i - 1, -1));
return;
case " ":
if (focusedIndex >= 0) {
e.preventDefault();
toggleMember(members[focusedIndex].email);
}
return;
}
},
[onCancel, handleSubmit, focusedIndex, members, name, toggleMember],
[onCancel, handleSubmit, members, name, toggleMember],
);
return (
<div
ref={containerRef}
className="absolute inset-0 z-50 flex items-center justify-center bg-black/90"
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="w-full max-w-sm space-y-4 px-6">
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
{/* Stream name */}
<div>
<label className="mb-1 block text-xs font-medium text-white/50">
Stream name
</label>
<input
ref={nameRef}
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setFocusedIndex(-1);
}}
onFocus={() => setFocusedIndex(-1)}
placeholder="Give it a name..."
className="w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/30 outline-none focus:border-white/30"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
{/* Member selection */}
{members.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-white/50">
Visible to
</label>
<div className="space-y-0.5 rounded-md border border-white/10 p-1">
{members.map((member, index) => {
const isSelected = selectedEmails.has(member.email);
const isFocused = focusedIndex === index;
const initials = member.email_prefix
.slice(0, 2)
.toUpperCase();
return (
<button
key={member.email}
type="button"
onClick={() => toggleMember(member.email)}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors",
isFocused
? "bg-white/10 text-white"
: "text-white/70 hover:bg-white/5",
)}
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
{initials}
</span>
<span className="flex-1 truncate">
{member.email_prefix}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-white/70" />
)}
</button>
);
})}
{/* Visibility */}
<div>
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
<div className="rounded-md border border-white/10">
{/* Everyone in network */}
<div
role="button"
onClick={() => setEveryone((prev) => !prev)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
"text-white/70 hover:bg-white/5",
)}
>
<Checkbox
checked={everyone}
onCheckedChange={(checked) => setEveryone(checked === true)}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="font-medium">Everyone in network</span>
</div>
{/* Per-member selection */}
{!everyone && members.length > 0 && (
<ScrollArea className="max-h-48">
<div className="space-y-0.5 p-1">
{members.map((member, index) => {
const isSelected = selectedEmails.has(member.email);
const initials = member.email_prefix
.slice(0, 2)
.toUpperCase();
return (
<div
key={member.email}
role="button"
onClick={() => toggleMember(member.email)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors",
"text-white/70 hover:bg-white/5",
)}
>
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
{initials}
</span>
<span className="flex-1 truncate">
{member.email_prefix}
</span>
</div>
);
})}
</div>
</ScrollArea>
)}
</div>
)}
</div>
</div>
{/* Keyboard hints */}
<div className="absolute bottom-8 flex items-center gap-4 text-sm text-white/50">
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-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">
Esc
</kbd>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>{" "}
navigate
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Space
</kbd>{" "}
toggle
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+Enter
@@ -191,6 +163,6 @@ export function ConfigureStreamStep({
create
</span>
</div>
</div>
</div >
);
}