support open / closed streams
- Tabs for viewing separately - Context menu to close / open streams - Update stream particle status field
This commit is contained in:
+2
-2
@@ -80,7 +80,6 @@ export type DepotObject = z.infer<typeof DepotObjectSchema>;
|
||||
|
||||
export const StreamPropertiesSchema = z.object({
|
||||
name: z.string(),
|
||||
status: z.enum(["open", "closed"]),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -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<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -303,7 +303,6 @@ export function ComposeOverlay({
|
||||
networkId,
|
||||
properties: {
|
||||
name: streamName,
|
||||
status: "open",
|
||||
},
|
||||
createdByHumanId: userId,
|
||||
visibleTo,
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{/* Scrollable content */}
|
||||
<div className="h-full overflow-y-auto overscroll-contain pt-10 pb-14">
|
||||
{viewMode === "list" ? (
|
||||
<ParticleListView path={path} selectedIndex={selectedIndex} />
|
||||
) : (
|
||||
<ParticleGridView path={path} selectedIndex={selectedIndex} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed overlays */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 flex justify-end px-3 pt-2">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{/* Top bar — stays in place */}
|
||||
<div className="flex shrink-0 items-center justify-between px-3 pt-2 pb-1">
|
||||
<Tabs
|
||||
value={statusTab}
|
||||
onValueChange={(v) => setStatusTab(v as "open" | "closed")}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="open">Open</TabsTrigger>
|
||||
<TabsTrigger value="closed">Closed</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={viewMode}
|
||||
@@ -59,7 +66,6 @@ export default function NetworkRoot() {
|
||||
if (v) setViewMode(v as "list" | "grid");
|
||||
}}
|
||||
size="sm"
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<ToggleGroupItem value="list" aria-label="List view">
|
||||
<List className="size-3.5" />
|
||||
@@ -70,6 +76,15 @@ export default function NetworkRoot() {
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14">
|
||||
{viewMode === "list" ? (
|
||||
<ParticleListView streams={filteredStreams} networkId={networkId!} isLoading={isLoading} selectedIndex={selectedIndex} />
|
||||
) : (
|
||||
<ParticleGridView streams={filteredStreams} networkId={networkId!} isLoading={isLoading} selectedIndex={selectedIndex} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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-auto">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid grid-cols-3 gap-3 px-3">
|
||||
{streams.map((stream, index) => (
|
||||
<StreamCard
|
||||
key={stream.id}
|
||||
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
||||
particle={stream}
|
||||
networkId={networkId}
|
||||
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
||||
isSelected={index === selectedIndex}
|
||||
shortcutKey={index < 9 ? index + 1 : undefined}
|
||||
/>
|
||||
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}>
|
||||
<StreamCard
|
||||
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
||||
particle={stream}
|
||||
networkId={networkId}
|
||||
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
||||
isSelected={index === selectedIndex}
|
||||
shortcutKey={index < 9 ? index + 1 : undefined}
|
||||
/>
|
||||
</StreamContextMenu>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
{streams.map((stream, index) => (
|
||||
<div
|
||||
key={stream.id}
|
||||
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
||||
>
|
||||
<StreamRow
|
||||
particle={stream}
|
||||
networkId={networkId}
|
||||
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
||||
isSelected={index === selectedIndex}
|
||||
shortcutKey={index < 9 ? index + 1 : undefined}
|
||||
/>
|
||||
{index < streams.length - 1 && <Separator className="px-4" />}
|
||||
</div>
|
||||
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}>
|
||||
<div
|
||||
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
||||
>
|
||||
<StreamRow
|
||||
particle={stream}
|
||||
networkId={networkId}
|
||||
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
||||
isSelected={index === selectedIndex}
|
||||
shortcutKey={index < 9 ? index + 1 : undefined}
|
||||
/>
|
||||
{index < streams.length - 1 && <Separator className="px-4" />}
|
||||
</div>
|
||||
</StreamContextMenu>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 <FolderView folderParticle={particle} path={path} />;
|
||||
default:
|
||||
if (isContainerType(particle.type)) {
|
||||
return <ParticleListView path={path} />;
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -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 (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={toggleStatus}>
|
||||
{isOpen ? "Close stream" : "Open stream"}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -74,7 +74,7 @@ export function useLiveParticleChildren(
|
||||
onAdded,
|
||||
onRemoved,
|
||||
whereFilter,
|
||||
}: UseLiveParticleChildrenParams
|
||||
}: UseLiveParticleChildrenParams = {}
|
||||
): UseLiveParticleChildrenResult {
|
||||
const [children, setChildren] = useState<Particle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -63,6 +63,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
: 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<T extends ParticleType>(
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
export async function createStreamParticle(
|
||||
collectionPath: string,
|
||||
properties: ParticlePropertiesMap["stream"],
|
||||
createdByHumanId: string,
|
||||
visibleTo?: string[],
|
||||
): Promise<string> {
|
||||
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<T extends ParticleType>(
|
||||
docPath: string,
|
||||
@@ -298,6 +322,14 @@ export async function updateParticle(
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateStreamStatus(
|
||||
docPath: string,
|
||||
status: "open" | "closed",
|
||||
): Promise<void> {
|
||||
const particleRef = typedDoc(docPath);
|
||||
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
|
||||
}
|
||||
|
||||
export async function updateStreamPlaybackMarker(
|
||||
docPath: string,
|
||||
humanId: string,
|
||||
|
||||
Reference in New Issue
Block a user