* first attempt at stream sidebar, tasks, and events * fix folder from root * cleanup folders and events, and condense changes * cleanup and add toggle for sidebar * cleanup * fix nits
270 lines
8.5 KiB
TypeScript
270 lines
8.5 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { CircleCheckBig, CircleDot, FolderIcon } from 'lucide-react';
|
||
import type { Particle } from '@/api/types';
|
||
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
||
import { useAuthStore } from '@/stores/auth-store';
|
||
import { useContainerChildren } from '@/hooks/use-container-children';
|
||
import { useListKeyboardNav } from '@/hooks/use-list-keyboard-nav';
|
||
import {
|
||
useCreateParticle,
|
||
useCreateStreamParticle,
|
||
} from '@/hooks/use-create-particle';
|
||
import { ParticleChildrenList } from '@/features/particles/particle-children-list';
|
||
import { ComposeOverlay } from '@/features/compose/compose-overlay';
|
||
import { ComposeQuotaIndicator } from '@/features/compose/compose-quota-indicator';
|
||
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
|
||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||
import { KeyHint } from '@/components/key-hint';
|
||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
|
||
type ContainerKind = 'stream' | 'folder';
|
||
|
||
interface ContainerViewProps {
|
||
/** Path of the container; network root when it has no segments. */
|
||
path: ParticlePath;
|
||
/** Present when the container is a folder particle (drives the header). */
|
||
folderParticle?: Particle & { type: 'folder' };
|
||
}
|
||
|
||
/**
|
||
* Browsable view of a container's children — used for both the network root
|
||
* and folders, which share the same structure: a mixed-type child list, the
|
||
* compose overlay, and one keyboard grammar. The root is just a folder
|
||
* without a doc.
|
||
*/
|
||
export function ContainerView({ path, folderParticle }: ContainerViewProps) {
|
||
const { networkId, segments } = parseParticlePath(path);
|
||
const isRoot = segments.length === 0;
|
||
const navigate = useNavigate();
|
||
const userId = useAuthStore((s) => s.user?.id);
|
||
|
||
const [composeActive, setComposeActive] = useState(false);
|
||
const [creating, setCreating] = useState<ContainerKind | null>(null);
|
||
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const statusTab: 'open' | 'closed' =
|
||
searchParams.get('status') === 'closed' ? 'closed' : 'open';
|
||
const setStatusTab = (next: 'open' | 'closed') => {
|
||
setSearchParams(
|
||
(prev) => {
|
||
const params = new URLSearchParams(prev);
|
||
params.set('status', next);
|
||
return params;
|
||
},
|
||
{ replace: true },
|
||
);
|
||
};
|
||
|
||
// Folders are shelved for now: the root lists streams only, split by
|
||
// open/closed status (server-side filter, like before folders). Folder
|
||
// containers keep the mixed-type child list so the recursive container
|
||
// model can be revived later.
|
||
const { items, isLoading, canLoadMore, loadMore } = useContainerChildren(
|
||
path,
|
||
{ streamStatus: isRoot ? statusTab : undefined },
|
||
);
|
||
|
||
const createParticle = useCreateParticle();
|
||
const createStream = useCreateStreamParticle();
|
||
|
||
const handleOpen = useCallback(
|
||
(particleId: string) => {
|
||
navigate(`/${networkId}/${[...segments, particleId].join('/')}`);
|
||
},
|
||
[navigate, networkId, segments],
|
||
);
|
||
|
||
const { selectedIndex } = useListKeyboardNav({
|
||
items,
|
||
enabled: !composeActive && creating === null,
|
||
onOpen: handleOpen,
|
||
});
|
||
|
||
// N creates a stream inside a folder. Root stream creation goes through the
|
||
// compose flow.
|
||
useEffect(() => {
|
||
if (isRoot || composeActive || creating !== null) return;
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.defaultPrevented) return;
|
||
const target = e.target as HTMLElement;
|
||
if (
|
||
target.tagName === 'INPUT' ||
|
||
target.tagName === 'TEXTAREA' ||
|
||
target.isContentEditable
|
||
) {
|
||
return;
|
||
}
|
||
if (e.key === 'n' || e.key === 'N') {
|
||
e.preventDefault();
|
||
setCreating('stream');
|
||
}
|
||
};
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [isRoot, composeActive, creating]);
|
||
|
||
const handleCreateContainer = useCallback(
|
||
async (name: string, visibleTo: string[]) => {
|
||
if (!userId || !creating) return;
|
||
const kind = creating;
|
||
setCreating(null);
|
||
const id =
|
||
kind === 'folder'
|
||
? await createParticle.mutateAsync({
|
||
path,
|
||
type: 'folder',
|
||
properties: { name },
|
||
createdByHumanId: userId,
|
||
visibleTo,
|
||
})
|
||
: await createStream.mutateAsync({
|
||
networkId,
|
||
parentPath: path,
|
||
properties: { name },
|
||
createdByHumanId: userId,
|
||
visibleTo,
|
||
});
|
||
handleOpen(id);
|
||
},
|
||
[
|
||
userId,
|
||
creating,
|
||
path,
|
||
networkId,
|
||
createParticle,
|
||
createStream,
|
||
handleOpen,
|
||
],
|
||
);
|
||
|
||
return (
|
||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||
{isRoot && (
|
||
<div className="flex shrink-0 items-center border-b p-1">
|
||
<Tabs
|
||
value={statusTab}
|
||
onValueChange={(v) =>
|
||
setStatusTab(v === 'closed' ? 'closed' : 'open')
|
||
}
|
||
>
|
||
<TabsList>
|
||
<TabsTrigger value="open">
|
||
<CircleDot className="size-3 text-green-500" /> Open
|
||
</TabsTrigger>
|
||
<TabsTrigger value="closed">
|
||
<CircleCheckBig className="size-3" /> Closed
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</Tabs>
|
||
</div>
|
||
)}
|
||
{folderParticle && (
|
||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||
<span className="flex size-7 items-center justify-center rounded-md bg-amber-500/15">
|
||
<FolderIcon className="size-4 text-amber-500" />
|
||
</span>
|
||
<h1 className="truncate text-sm font-semibold">
|
||
{folderParticle.properties.name}
|
||
</h1>
|
||
</div>
|
||
)}
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
|
||
<ParticleChildrenList
|
||
items={items}
|
||
networkId={networkId}
|
||
isLoading={isLoading}
|
||
onOpen={handleOpen}
|
||
selectedIndex={selectedIndex}
|
||
canLoadMore={canLoadMore}
|
||
onLoadMore={loadMore}
|
||
emptyMessage={
|
||
!isRoot
|
||
? 'This folder is empty. Add something using the keyboard shortcuts below.'
|
||
: statusTab === 'closed'
|
||
? 'No closed streams.'
|
||
: 'No streams here. Start a conversation using the keyboard shortcuts below.'
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<ComposeOverlay
|
||
networkId={networkId}
|
||
targetPath={isRoot ? undefined : path}
|
||
onActiveChange={setComposeActive}
|
||
/>
|
||
{!composeActive && (
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
|
||
<ComposeQuotaIndicator networkId={networkId} />
|
||
</div>
|
||
)}
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||
<div className="pointer-events-auto">
|
||
<ContainerControls
|
||
isRoot={isRoot}
|
||
onCreateStream={() => setCreating('stream')}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{creating && (
|
||
<ConfigureContainerStep
|
||
kind={creating}
|
||
networkId={networkId}
|
||
onCancel={() => setCreating(null)}
|
||
onSubmit={handleCreateContainer}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ContainerControls({
|
||
isRoot,
|
||
onCreateStream,
|
||
}: {
|
||
isRoot: boolean;
|
||
onCreateStream: () => void;
|
||
}) {
|
||
const requestIntent = useComposeIntentStore((s) => s.request);
|
||
return (
|
||
<div className="flex items-center gap-4 text-sm text-white/50">
|
||
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
|
||
<KeyHint keys="1–9">jump</KeyHint>
|
||
<VideoAudioToggle />
|
||
<KeyHint
|
||
keys="Hold `"
|
||
onClick={() => requestIntent('record')}
|
||
title="Start recording (or hold `)"
|
||
>
|
||
to start
|
||
</KeyHint>
|
||
<KeyHint
|
||
keys="T"
|
||
onClick={() => requestIntent('text')}
|
||
title="Compose text (or press T)"
|
||
>
|
||
text
|
||
</KeyHint>
|
||
<KeyHint
|
||
keys="D"
|
||
onClick={() => requestIntent('task')}
|
||
title="Create a task (or press D)"
|
||
>
|
||
task
|
||
</KeyHint>
|
||
{!isRoot && (
|
||
<KeyHint
|
||
keys="N"
|
||
onClick={onCreateStream}
|
||
title="Create a stream here (or press N)"
|
||
>
|
||
stream
|
||
</KeyHint>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|