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
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+34 -1
View File
@@ -1,7 +1,11 @@
import { useComposeStore } from "@/stores/compose-store"; import { useComposeStore } from "@/stores/compose-store";
import { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle } from "@/hooks/use-create-particle";
import { particlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
import { RecordingOverlay } from "@/features/compose/recording-overlay"; import { RecordingOverlay } from "@/features/compose/recording-overlay";
import { TextComposeStep } from "@/features/compose/text-compose-step"; import { TextComposeStep } from "@/features/compose/text-compose-step";
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step"; import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
import { useCallback } from "react";
/** /**
* Renders the current compose step as a fullscreen overlay. * Renders the current compose step as a fullscreen overlay.
@@ -19,6 +23,31 @@ export function ComposeOverlay() {
const reviewBlob = useComposeStore((s) => s.reviewBlob); const reviewBlob = useComposeStore((s) => s.reviewBlob);
const error = useComposeStore((s) => s.error); const error = useComposeStore((s) => s.error);
const userEmail = useAuthStore((s) => s.user?.email);
const createParticle = useCreateParticle();
const handleStreamSubmit = useCallback(
async (streamName: string, visibleTo: string[]) => {
if (!networkId || !userEmail) return;
const collectionPath = toFirestoreChildrenPath(particlePath(networkId));
await createParticle.mutateAsync({
collectionPath,
type: "stream",
properties: {
name: streamName,
status: "open",
visible_to: visibleTo,
},
createdByEmail: userEmail,
});
cancel();
},
[networkId, userEmail, createParticle, cancel],
);
if (step === "idle") return null; if (step === "idle") return null;
return ( return (
@@ -42,7 +71,11 @@ export function ComposeOverlay() {
/> />
)} )}
{step === "configuring" && ( {step === "configuring" && (
<ConfigureStreamStep networkId={networkId} onCancel={cancel} /> <ConfigureStreamStep
networkId={networkId}
onCancel={cancel}
onSubmit={handleStreamSubmit}
/>
)} )}
</> </>
); );
+84 -112
View File
@@ -1,43 +1,30 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useNetworks } from "@/hooks/use-networks"; import { useNetworks } from "@/hooks/use-networks";
import { cn } from "@/lib/utils"; 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 { interface ConfigureStreamStepProps {
networkId: string | null; networkId: string | null;
onCancel: () => void; onCancel: () => void;
onSubmit: (streamName: string, visibleTo: string[]) => void;
} }
export function ConfigureStreamStep({ export function ConfigureStreamStep({
networkId, networkId,
onCancel, onCancel,
onSubmit,
}: ConfigureStreamStepProps) { }: ConfigureStreamStepProps) {
const { data: networks } = useNetworks(); const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId); const network = networks?.find((n) => n.id === networkId);
const members = network?.humans ?? []; 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()); 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) => { const toggleMember = useCallback((email: string) => {
setSelectedEmails((prev) => { 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; if (!name.trim() || !networkId) return;
// TODO: create stream particle, then attach recorded/text content onSubmit(name.trim(), buildVisibleTo());
onCancel(); }, [name, networkId, onSubmit, buildVisibleTo]);
}, [name, networkId, onCancel]);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@@ -66,124 +57,105 @@ export function ConfigureStreamStep({
if (e.metaKey || e.ctrlKey) { if (e.metaKey || e.ctrlKey) {
e.preventDefault(); e.preventDefault();
handleSubmit(); 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; return;
} }
}, },
[onCancel, handleSubmit, focusedIndex, members, name, toggleMember], [onCancel, handleSubmit, members, name, toggleMember],
); );
return ( return (
<div <div
ref={containerRef} className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
className="absolute inset-0 z-50 flex items-center justify-center bg-black/90"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
tabIndex={-1} 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 */} {/* Stream name */}
<div> <div>
<label className="mb-1 block text-xs font-medium text-white/50"> <Label className="mb-1 text-xs text-white/50">Stream name</Label>
Stream name <Input
</label>
<input
ref={nameRef}
type="text" type="text"
value={name} value={name}
onChange={(e) => { onChange={(e) => {
setName(e.target.value); setName(e.target.value);
setFocusedIndex(-1);
}} }}
onFocus={() => setFocusedIndex(-1)}
placeholder="Give it a name..." 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> </div>
{/* Member selection */} {/* Visibility */}
{members.length > 0 && ( <div>
<div> <Label className="mb-1 text-xs text-white/50">Visible to</Label>
<label className="mb-1 block text-xs font-medium text-white/50"> <div className="rounded-md border border-white/10">
Visible to {/* Everyone in network */}
</label> <div
<div className="space-y-0.5 rounded-md border border-white/10 p-1"> role="button"
{members.map((member, index) => { onClick={() => setEveryone((prev) => !prev)}
const isSelected = selectedEmails.has(member.email); className={cn(
const isFocused = focusedIndex === index; "flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
const initials = member.email_prefix "text-white/70 hover:bg-white/5",
.slice(0, 2) )}
.toUpperCase(); >
<Checkbox
return ( checked={everyone}
<button onCheckedChange={(checked) => setEveryone(checked === true)}
key={member.email} tabIndex={-1}
type="button" className="pointer-events-none"
onClick={() => toggleMember(member.email)} />
className={cn( <span className="font-medium">Everyone in network</span>
"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>
);
})}
</div> </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>
</div> </div>
{/* Keyboard hints */} {/* 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> <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">
Esc Esc
</kbd>{" "} </kbd>{" "}
cancel cancel
</span> </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> <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">
+Enter +Enter
@@ -191,6 +163,6 @@ export function ConfigureStreamStep({
create create
</span> </span>
</div> </div>
</div> </div >
); );
} }
@@ -12,8 +12,22 @@ import { useParams } from "react-router-dom";
*/ */
export function useComposeKeyboard() { export function useComposeKeyboard() {
const networkId = useParams()["networkId"]; const networkId = useParams()["networkId"];
const recordingMode = useComposeStore((s) => s.recordingMode);
const setMediaStream = useComposeStore((s) => s.setMediaStream);
const finishRecording = useComposeStore((s) => s.finishRecording);
const setError = useComposeStore((s) => s.setError);
const beginRecording = useComposeStore((s) => s.startRecording);
const beginTyping = useComposeStore((s) => s.startTyping);
const cancel = useComposeStore((s) => s.cancel);
const advanceToConfigure = useComposeStore((s) => s.advanceToConfigure);
const { startRecording, stopRecording, cancelRecording } = useRecorder(); const { startRecording, stopRecording, cancelRecording } = useRecorder({
mode: recordingMode,
onStreamReady: (stream) => setMediaStream(stream),
onStreamCleanup: () => setMediaStream(null),
onFinish: (blob, durationMs) => finishRecording(blob, durationMs),
onError: (message) => setError(message),
});
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@@ -37,11 +51,11 @@ export function useComposeKeyboard() {
if (!networkId) return; if (!networkId) return;
if (e.key === "`" && !e.repeat) { if (e.key === "`" && !e.repeat) {
e.preventDefault(); e.preventDefault();
useComposeStore.getState().startRecording(networkId); beginRecording(networkId);
startRecording(); startRecording();
} else if (e.key === "t" || e.key === "T") { } else if (e.key === "t" || e.key === "T") {
e.preventDefault(); e.preventDefault();
useComposeStore.getState().startTyping(networkId); beginTyping(networkId);
} }
break; break;
} }
@@ -50,7 +64,7 @@ export function useComposeKeyboard() {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") { if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault(); e.preventDefault();
cancelRecording(); cancelRecording();
useComposeStore.getState().cancel(); cancel();
} }
break; break;
} }
@@ -59,16 +73,16 @@ export function useComposeKeyboard() {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") { if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault(); e.preventDefault();
cancelRecording(); cancelRecording();
useComposeStore.getState().cancel(); cancel();
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
useComposeStore.getState().advanceToConfigure(); advanceToConfigure();
} }
break; break;
} }
} }
}, },
[networkId, startRecording, cancelRecording], [networkId, startRecording, cancelRecording, beginRecording, beginTyping, cancel, advanceToConfigure],
); );
const handleKeyUp = useCallback( const handleKeyUp = useCallback(
+35 -20
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useComposeStore } from "@/stores/compose-store"; import type { RecordingMode } from "@/stores/compose-store";
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus"; const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm"; const VIDEO_FALLBACK_MIME = "video/webm";
@@ -17,47 +17,62 @@ function getMediaMime(mode: "video" | "audio"): string {
: VIDEO_FALLBACK_MIME; : VIDEO_FALLBACK_MIME;
} }
interface UseRecorderOptions {
mode: RecordingMode;
onStreamReady: (stream: MediaStream) => void;
onStreamCleanup: () => void;
onFinish: (blob: Blob, durationMs: number) => void;
onError: (message: string) => void;
}
/** /**
* Manages MediaRecorder lifecycle and writes results to compose-store. * Manages MediaRecorder lifecycle. Pure media utility — knows nothing
* * about application state. The consumer provides callbacks for all outputs.
* Does NOT handle uploads or particle creation — that responsibility
* belongs to the configure step after the user finalizes stream metadata.
*/ */
export function useRecorder() { export function useRecorder({
mode,
onStreamReady,
onStreamCleanup,
onFinish,
onError,
}: UseRecorderOptions) {
const recorderRef = useRef<MediaRecorder | null>(null); const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]); const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0); const startTimeRef = useRef<number>(0);
const recordingMode = useComposeStore((s) => s.recordingMode); // Refs to avoid stale closures in MediaRecorder event handlers
const setMediaStream = useComposeStore((s) => s.setMediaStream); const onStreamCleanupRef = useRef(onStreamCleanup);
const setError = useComposeStore((s) => s.setError); const onFinishRef = useRef(onFinish);
const finishRecording = useComposeStore((s) => s.finishRecording); const onErrorRef = useRef(onError);
useEffect(() => {
onStreamCleanupRef.current = onStreamCleanup;
onFinishRef.current = onFinish;
onErrorRef.current = onError;
});
const stopTracks = useCallback(() => { const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null; streamRef.current = null;
recorderRef.current = null; recorderRef.current = null;
chunksRef.current = []; chunksRef.current = [];
setMediaStream(null); onStreamCleanupRef.current();
}, [setMediaStream]); }, []);
const startRecording = useCallback(async () => { const startRecording = useCallback(async () => {
try { try {
const constraints = const constraints =
recordingMode === "video" mode === "video" ? { video: true, audio: true } : { audio: true };
? { video: true, audio: true }
: { audio: true };
const mediaStream = const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints); await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream; streamRef.current = mediaStream;
setMediaStream(mediaStream); onStreamReady(mediaStream);
chunksRef.current = []; chunksRef.current = [];
startTimeRef.current = Date.now(); startTimeRef.current = Date.now();
const mime = getMediaMime(recordingMode); const mime = getMediaMime(mode);
const recorder = new MediaRecorder(mediaStream, { mimeType: mime }); const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
recorderRef.current = recorder; recorderRef.current = recorder;
@@ -71,18 +86,18 @@ export function useRecorder() {
stopTracks(); stopTracks();
if (blob.size > 0) { if (blob.size > 0) {
finishRecording(blob, durationMs); onFinishRef.current(blob, durationMs);
} }
}; };
recorder.start(); recorder.start();
} catch (err) { } catch (err) {
stopTracks(); stopTracks();
setError( onErrorRef.current(
err instanceof Error ? err.message : "Failed to start recording", err instanceof Error ? err.message : "Failed to start recording",
); );
} }
}, [recordingMode, setMediaStream, setError, finishRecording, stopTracks]); }, [mode, onStreamReady, stopTracks]);
const stopRecording = useCallback(() => { const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") { if (recorderRef.current?.state === "recording") {
@@ -1,26 +1,73 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Radio } from "lucide-react";
import { useLiveParticleChildren } from "@/hooks/use-particle"; import { useLiveParticleChildren } from "@/hooks/use-particle";
import type { ParticlePath } from "@/lib/particle-path"; import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Progress } from "@/components/ui/progress";
import { Small } from "@/components/ui/typography";
import ControlsIndicator from "@/features/compose/controls-indicator"; import ControlsIndicator from "@/features/compose/controls-indicator";
import type { Particle, StreamProperties } from "@/api/types";
function StreamRow({
particle,
onClick,
}: {
particle: Particle & { type: "stream"; properties: StreamProperties };
onClick: () => void;
}) {
const initials = particle.properties.name.slice(0, 2).toUpperCase();
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
>
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{particle.properties.name}
</p>
<div className="text-muted-foreground flex items-center gap-1">
<Radio className="size-3" />
<Small className="text-muted-foreground">
{particle.properties.status}
</Small>
</div>
</div>
</button>
);
}
interface ParticleListViewProps { interface ParticleListViewProps {
path: ParticlePath; path: ParticlePath;
} }
/** /**
* Grid/list of child particles for a container (folder, stream root, or network root). * List of stream particles for a container (network root, folder, etc.).
*/ */
export function ParticleListView({ path }: ParticleListViewProps) { export function ParticleListView({ path }: ParticleListViewProps) {
const { children, isLoading } = useLiveParticleChildren(path); const { children, isLoading } = useLiveParticleChildren(path);
const { networkId } = parseParticlePath(path);
const navigate = useNavigate();
const streams = useMemo(
() => children.filter((c) => c.type === "stream"),
[children],
);
if (isLoading) { if (isLoading) {
return ( return <Progress />;
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading particles...</p>
</div>
);
} }
if (children.length === 0) { if (streams.length === 0) {
return ( return (
<div className="flex flex-col h-full items-center justify-center gap-2"> <div className="flex flex-col h-full items-center justify-center gap-2">
<ControlsIndicator type={"new"} /> <ControlsIndicator type={"new"} />
@@ -29,16 +76,18 @@ export function ParticleListView({ path }: ParticleListViewProps) {
} }
return ( return (
<div className="grid grid-cols-2 gap-3 p-4"> <ScrollArea className="h-full">
{children.map((child) => ( <div className="py-1">
<div {streams.map((stream, index) => (
key={child.id} <div key={stream.id}>
className="rounded-lg border p-3 text-sm" <StreamRow
> particle={stream}
<p className="font-medium">{child.id}</p> onClick={() => navigate(`/${networkId}/${stream.id}`)}
<p className="text-muted-foreground text-xs">{child.type}</p> />
</div> {index < streams.length - 1 && <Separator className="mx-4" />}
))} </div>
</div> ))}
</div>
</ScrollArea>
); );
} }
-2
View File
@@ -7,7 +7,6 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
type: T; type: T;
properties: ParticlePropertiesMap[T]; properties: ParticlePropertiesMap[T];
createdByEmail: string; createdByEmail: string;
visibleTo: string[];
} }
export function useCreateParticle() { export function useCreateParticle() {
@@ -18,7 +17,6 @@ export function useCreateParticle() {
params.type, params.type,
params.properties, params.properties,
params.createdByEmail, params.createdByEmail,
params.visibleTo,
), ),
}); });
} }
+19
View File
@@ -0,0 +1,19 @@
const ADJECTIVES = [
"amber", "bold", "calm", "crisp", "dark", "eager", "faint", "gentle",
"hasty", "icy", "jade", "keen", "lush", "misty", "nimble", "opal",
"pale", "quiet", "rapid", "sharp", "taut", "vivid", "warm", "zesty",
"bright", "clear", "deep", "fresh", "grand", "swift",
];
const NOUNS = [
"arrow", "bloom", "cedar", "drift", "ember", "flint", "grove", "harbor",
"iris", "jewel", "knoll", "lake", "moss", "nova", "orbit", "petal",
"quartz", "ridge", "spark", "trail", "vale", "wave", "yarn", "zenith",
"brook", "cliff", "delta", "frost", "glow", "reef",
];
export function generateRandomName(): string {
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
return `${adj}-${noun}`;
}