diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 0619974..49cf4bf 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -80,7 +80,6 @@ export type DepotObject = z.infer; export const StreamPropertiesSchema = z.object({ name: z.string(), - status: z.enum(["open", "closed"]), description: z.string().optional(), }); export type StreamProperties = z.infer; @@ -174,7 +173,8 @@ const ParticleBaseSchema = z.object({ export const ParticleSchema = z.discriminatedUnion("type", [ ParticleBaseSchema.extend({ - type: z.literal("stream"), properties: StreamPropertiesSchema, + type: z.literal("stream"), + properties: StreamPropertiesSchema, // e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John // e.g. ["network:xywx"] - visible to everyone in the network visible_to: z.array(z.string()), diff --git a/js/src/components/ui/context-menu.tsx b/js/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..25fd8ab --- /dev/null +++ b/js/src/components/ui/context-menu.tsx @@ -0,0 +1,261 @@ +import * as React from "react" +import { ContextMenu as ContextMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/js/src/components/ui/tabs.tsx b/js/src/components/ui/tabs.tsx new file mode 100644 index 0000000..05f469f --- /dev/null +++ b/js/src/components/ui/tabs.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index 26ff7b9..460d7b5 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -303,7 +303,6 @@ export function ComposeOverlay({ networkId, properties: { name: streamName, - status: "open", }, createdByHumanId: userId, visibleTo, diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 174bc76..a83f8d8 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { List, LayoutGrid } from "lucide-react"; import { particlePath } from "@/lib/particle-path"; @@ -6,6 +6,7 @@ import { ParticleListView } from "@/features/particles/particle-list-view"; import { ParticleGridView } from "@/features/particles/particle-grid-view"; import ControlsIndicator from "@/features/compose/controls-indicator"; import { ComposeOverlay } from "./compose/compose-overlay"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useViewModeStore } from "@/stores/view-mode-store"; import { useStreamParticles } from "@/hooks/use-stream-particles"; @@ -24,13 +25,19 @@ export default function NetworkRoot() { const viewMode = useViewModeStore((s) => s.viewMode); const setViewMode = useViewModeStore((s) => s.setViewMode); - const { streams } = useStreamParticles(path); + const { streams, isLoading } = useStreamParticles(path); const userId = useAuthStore((s) => s.user?.id); useDockBadge(streams, userId); const [composeActive, setComposeActive] = useState(false); + const [statusTab, setStatusTab] = useState<"open" | "closed">("open"); + + const filteredStreams = useMemo( + () => streams.filter((s) => s.status === statusTab), + [streams, statusTab], + ); const { selectedIndex } = useStreamKeyboardNav({ - streams, + streams: filteredStreams, viewMode, enabled: !composeActive, onNavigate: useCallback( @@ -40,18 +47,18 @@ export default function NetworkRoot() { }); return ( -
- {/* Scrollable content */} -
- {viewMode === "list" ? ( - - ) : ( - - )} -
- - {/* Fixed overlays */} -
+
+ {/* Top bar — stays in place */} +
+ setStatusTab(v as "open" | "closed")} + > + + Open + Closed + + @@ -70,6 +76,15 @@ export default function NetworkRoot() {
+ {/* Scrollable content */} +
+ {viewMode === "list" ? ( + + ) : ( + + )} +
+
diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx index b71ca82..34bcaed 100644 --- a/js/src/features/particles/folder-view.tsx +++ b/js/src/features/particles/folder-view.tsx @@ -9,7 +9,7 @@ interface FolderViewProps { } export function FolderView({ path, folderParticle }: FolderViewProps) { - const { children, error, isLoading } = useLiveParticleChildren({ path }); + const { children, error, isLoading } = useLiveParticleChildren(path); const { networkId } = parseParticlePath(path); return ( diff --git a/js/src/features/particles/particle-grid-view.tsx b/js/src/features/particles/particle-grid-view.tsx index 5fb569b..1961668 100644 --- a/js/src/features/particles/particle-grid-view.tsx +++ b/js/src/features/particles/particle-grid-view.tsx @@ -1,17 +1,18 @@ import { useNavigate } from "react-router-dom"; import { Radio } from "lucide-react"; import { Progress } from "@/components/ui/progress"; -import type { ParticlePath } from "@/lib/particle-path"; -import { useStreamParticles } from "@/hooks/use-stream-particles"; +import type { StreamParticle } from "@/hooks/use-stream-particles"; import { StreamCard } from "@/features/particles/stream-card"; +import { StreamContextMenu } from "@/features/particles/stream-context-menu"; interface ParticleGridViewProps { - path: ParticlePath; + streams: StreamParticle[]; + networkId: string; + isLoading: boolean; selectedIndex?: number | null; } -export function ParticleGridView({ path, selectedIndex }: ParticleGridViewProps) { - const { streams, isLoading, networkId } = useStreamParticles(path); +export function ParticleGridView({ streams, networkId, isLoading, selectedIndex }: ParticleGridViewProps) { const navigate = useNavigate(); if (isLoading) { @@ -33,15 +34,16 @@ export function ParticleGridView({ path, selectedIndex }: ParticleGridViewProps) return (
{streams.map((stream, index) => ( - el?.scrollIntoView({ block: "nearest" }) : undefined} - particle={stream} - networkId={networkId} - onClick={() => navigate(`/${networkId}/${stream.id}`)} - isSelected={index === selectedIndex} - shortcutKey={index < 9 ? index + 1 : undefined} - /> + + el?.scrollIntoView({ block: "nearest" }) : undefined} + particle={stream} + networkId={networkId} + onClick={() => navigate(`/${networkId}/${stream.id}`)} + isSelected={index === selectedIndex} + shortcutKey={index < 9 ? index + 1 : undefined} + /> + ))}
); diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index cb49c18..d765ef2 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -15,10 +15,7 @@ import { import { cn } from "@/lib/utils"; import { useLiveLatestChild } from "@/hooks/use-particle"; import { useAuthStore } from "@/stores/auth-store"; -import { - particlePath, - type ParticlePath, -} from "@/lib/particle-path"; +import { particlePath } from "@/lib/particle-path"; import { getInitials } from "@/lib/utils"; import { RelativeTimestamp } from "@/components/relative-timestamp"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -26,9 +23,10 @@ import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; import { Small } from "@/components/ui/typography"; import type { Particle, StreamProperties } from "@/api/types"; +import type { StreamParticle } from "@/hooks/use-stream-particles"; import { useNetwork } from "@/hooks/use-networks"; -import { useStreamParticles } from "@/hooks/use-stream-particles"; import { useStreamAutoplay } from "@/hooks/use-stream-autoplay"; +import { StreamContextMenu } from "@/features/particles/stream-context-menu"; function getParticleTypeIcon(particle: Particle): LucideIcon { switch (particle.type) { @@ -147,7 +145,7 @@ function StreamRow({ const subtitle = latestChild ? getMessagePreview(latestChild) - : particle.properties.status; + : particle.properties.name; const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio; @@ -236,15 +234,16 @@ function StreamRow({ } interface ParticleListViewProps { - path: ParticlePath; + streams: StreamParticle[]; + networkId: string; + isLoading: boolean; selectedIndex?: number | null; } /** * List of stream particles for a container (network root, folder, etc.). */ -export function ParticleListView({ path, selectedIndex }: ParticleListViewProps) { - const { streams, isLoading, networkId } = useStreamParticles(path); +export function ParticleListView({ streams, networkId, isLoading, selectedIndex }: ParticleListViewProps) { const navigate = useNavigate(); if (isLoading) { @@ -265,19 +264,20 @@ export function ParticleListView({ path, selectedIndex }: ParticleListViewProps) return (
{streams.map((stream, index) => ( -
el?.scrollIntoView({ block: "nearest" }) : undefined} - > - navigate(`/${networkId}/${stream.id}`)} - isSelected={index === selectedIndex} - shortcutKey={index < 9 ? index + 1 : undefined} - /> - {index < streams.length - 1 && } -
+ +
el?.scrollIntoView({ block: "nearest" }) : undefined} + > + navigate(`/${networkId}/${stream.id}`)} + isSelected={index === selectedIndex} + shortcutKey={index < 9 ? index + 1 : undefined} + /> + {index < streams.length - 1 && } +
+
))}
); diff --git a/js/src/features/particles/particle-view-resolver.tsx b/js/src/features/particles/particle-view-resolver.tsx index 469f0b0..7c984a1 100644 --- a/js/src/features/particles/particle-view-resolver.tsx +++ b/js/src/features/particles/particle-view-resolver.tsx @@ -1,10 +1,10 @@ import { useParams } from "react-router-dom"; import { useLiveParticle } from "@/hooks/use-particle"; import { particlePath } from "@/lib/particle-path"; -import { isContainerType } from "@/api/types"; + import { StreamView } from "@/features/particles/stream-view"; import { FolderView } from "@/features/particles/folder-view"; -import { ParticleListView } from "@/features/particles/particle-list-view"; + /** * Route-level component for /:networkId/*. @@ -50,9 +50,6 @@ export default function ParticleViewResolver() { case "folder": return ; default: - if (isContainerType(particle.type)) { - return ; - } return (

diff --git a/js/src/features/particles/stream-context-menu.tsx b/js/src/features/particles/stream-context-menu.tsx new file mode 100644 index 0000000..d065f3b --- /dev/null +++ b/js/src/features/particles/stream-context-menu.tsx @@ -0,0 +1,35 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { updateStreamStatus } from "@/lib/firestore-particles"; +import { toFirestoreDocPath, particlePath } from "@/lib/particle-path"; +import type { StreamParticle } from "@/hooks/use-stream-particles"; + +interface StreamContextMenuProps { + particle: StreamParticle; + networkId: string; + children: React.ReactNode; +} + +export function StreamContextMenu({ particle, networkId, children }: StreamContextMenuProps) { + const isOpen = particle.status === "open"; + const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id])); + + const toggleStatus = async () => { + await updateStreamStatus(docPath, isOpen ? "closed" : "open"); + }; + + return ( + + {children} + + + {isOpen ? "Close stream" : "Open stream"} + + + + ); +} diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts index 263e18e..66e8def 100644 --- a/js/src/hooks/use-create-particle.ts +++ b/js/src/hooks/use-create-particle.ts @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query"; -import { createParticle } from "@/lib/firestore-particles"; +import { createParticle, createStreamParticle } from "@/lib/firestore-particles"; import type { ParticleType, ParticlePropertiesMap } from "@/api/types"; import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path"; @@ -37,9 +37,8 @@ export function useCreateStreamParticle() { mutationFn: async (params: CreateStreamParticleParams) => { const path = particlePath(params.networkId, []); const networkCollectionPath = toFirestoreChildrenPath(path); - return await createParticle( + return await createStreamParticle( networkCollectionPath, - "stream", params.properties, params.createdByHumanId, params.visibleTo, diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts index 1397c66..e989a96 100644 --- a/js/src/hooks/use-particle.ts +++ b/js/src/hooks/use-particle.ts @@ -74,7 +74,7 @@ export function useLiveParticleChildren( onAdded, onRemoved, whereFilter, - }: UseLiveParticleChildrenParams + }: UseLiveParticleChildrenParams = {} ): UseLiveParticleChildrenResult { const [children, setChildren] = useState([]); const [isLoading, setIsLoading] = useState(true); diff --git a/js/src/hooks/use-stream-particles.ts b/js/src/hooks/use-stream-particles.ts index 3ad1254..3e2ab64 100644 --- a/js/src/hooks/use-stream-particles.ts +++ b/js/src/hooks/use-stream-particles.ts @@ -4,7 +4,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; import type { Particle, StreamProperties } from "@/api/types"; -type StreamParticle = Particle & { type: "stream"; properties: StreamProperties }; +export type StreamParticle = Particle & { type: "stream"; properties: StreamProperties }; function useVisibilityScopes(userId?: string, networkId?: string) { return useMemo(() => { diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index fa2327f..9f20a17 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -63,6 +63,7 @@ const particleConverter: FirestoreDataConverter = { : undefined, last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined, huddle_active_participants: raw.huddle_active_participants ?? undefined, + status: raw.status ?? undefined, }); case "folder": return ParticleSchema.parse({ @@ -245,6 +246,29 @@ export async function createParticle( return ref.id; } +export async function createStreamParticle( + collectionPath: string, + properties: ParticlePropertiesMap["stream"], + createdByHumanId: string, + visibleTo?: string[], +): Promise { + if (!visibleTo || visibleTo.length === 0) { + throw new Error("visibleTo is required for streams and cannot be empty"); + } + + const particle: Particle = ParticleSchema.parse({ + id: "", + type: "stream", + properties, + created_at: new Date(), + created_by_human_id: createdByHumanId, + visible_to: visibleTo, + status: "open", + }); + const ref = await addDoc(typedCollection(collectionPath), particle); + return ref.id; +} + // This allows updating properties without overwriting the entire properties object export async function updateParticleProperties( docPath: string, @@ -298,6 +322,14 @@ export async function updateParticle( }); } +export async function updateStreamStatus( + docPath: string, + status: "open" | "closed", +): Promise { + const particleRef = typedDoc(docPath); + await updateDoc(particleRef, { status, updated_at: serverTimestamp() }); +} + export async function updateStreamPlaybackMarker( docPath: string, humanId: string,