Implement space container

This commit is contained in:
Hakan Shehu
2025-01-28 22:46:20 +01:00
parent e34ca67440
commit 565fd2af50
26 changed files with 756 additions and 380 deletions

View File

@@ -50,7 +50,9 @@ import { SelectOptionUpdateMutationHandler } from '@/main/mutations/databases/se
import { ServerCreateMutationHandler } from '@/main/mutations/servers/server-create';
import { SpaceCreateMutationHandler } from '@/main/mutations/spaces/space-create';
import { SpaceDeleteMutationHandler } from '@/main/mutations/spaces/space-delete';
import { SpaceUpdateMutationHandler } from '@/main/mutations/spaces/space-update';
import { SpaceDescriptionUpdateMutationHandler } from '@/main/mutations/spaces/space-description-update';
import { SpaceAvatarUpdateMutationHandler } from '@/main/mutations/spaces/space-avatar-update';
import { SpaceNameUpdateMutationHandler } from '@/main/mutations/spaces/space-name-update';
import { ViewCreateMutationHandler } from '@/main/mutations/databases/view-create';
import { ViewDeleteMutationHandler } from '@/main/mutations/databases/view-delete';
import { ViewUpdateMutationHandler } from '@/main/mutations/databases/view-update';
@@ -121,7 +123,9 @@ export const mutationHandlerMap: MutationHandlerMap = {
file_mark_opened: new FileMarkOpenedMutationHandler(),
file_mark_seen: new FileMarkSeenMutationHandler(),
file_save_temp: new FileSaveTempMutationHandler(),
space_update: new SpaceUpdateMutationHandler(),
space_avatar_update: new SpaceAvatarUpdateMutationHandler(),
space_description_update: new SpaceDescriptionUpdateMutationHandler(),
space_name_update: new SpaceNameUpdateMutationHandler(),
account_update: new AccountUpdateMutationHandler(),
view_update: new ViewUpdateMutationHandler(),
view_delete: new ViewDeleteMutationHandler(),

View File

@@ -0,0 +1,39 @@
import { SpaceAttributes } from '@colanode/core';
import { MutationHandler } from '@/main/lib/types';
import { MutationError, MutationErrorCode } from '@/shared/mutations';
import {
SpaceAvatarUpdateMutationInput,
SpaceAvatarUpdateMutationOutput,
} from '@/shared/mutations/spaces/space-avatar-update';
import { WorkspaceMutationHandlerBase } from '@/main/mutations/workspace-mutation-handler-base';
export class SpaceAvatarUpdateMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<SpaceAvatarUpdateMutationInput>
{
async handleMutation(
input: SpaceAvatarUpdateMutationInput
): Promise<SpaceAvatarUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const result = await workspace.entries.updateEntry<SpaceAttributes>(
input.spaceId,
(attributes) => {
attributes.avatar = input.avatar;
return attributes;
}
);
if (result === 'unauthorized') {
throw new MutationError(
MutationErrorCode.SpaceUpdateForbidden,
"You don't have permission to update this space."
);
}
return {
success: true,
};
}
}

View File

@@ -3,27 +3,24 @@ import { SpaceAttributes } from '@colanode/core';
import { MutationHandler } from '@/main/lib/types';
import { MutationError, MutationErrorCode } from '@/shared/mutations';
import {
SpaceUpdateMutationInput,
SpaceUpdateMutationOutput,
} from '@/shared/mutations/spaces/space-update';
SpaceDescriptionUpdateMutationInput,
SpaceDescriptionUpdateMutationOutput,
} from '@/shared/mutations/spaces/space-description-update';
import { WorkspaceMutationHandlerBase } from '@/main/mutations/workspace-mutation-handler-base';
export class SpaceUpdateMutationHandler
export class SpaceDescriptionUpdateMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<SpaceUpdateMutationInput>
implements MutationHandler<SpaceDescriptionUpdateMutationInput>
{
async handleMutation(
input: SpaceUpdateMutationInput
): Promise<SpaceUpdateMutationOutput> {
input: SpaceDescriptionUpdateMutationInput
): Promise<SpaceDescriptionUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const result = await workspace.entries.updateEntry<SpaceAttributes>(
input.id,
input.spaceId,
(attributes) => {
attributes.name = input.name;
attributes.description = input.description;
attributes.avatar = input.avatar;
return attributes;
}
);
@@ -35,13 +32,6 @@ export class SpaceUpdateMutationHandler
);
}
if (result !== 'success') {
throw new MutationError(
MutationErrorCode.SpaceUpdateFailed,
'Something went wrong while updating the space. Please try again later.'
);
}
return {
success: true,
};

View File

@@ -0,0 +1,39 @@
import { SpaceAttributes } from '@colanode/core';
import { MutationHandler } from '@/main/lib/types';
import { MutationError, MutationErrorCode } from '@/shared/mutations';
import {
SpaceNameUpdateMutationInput,
SpaceNameUpdateMutationOutput,
} from '@/shared/mutations/spaces/space-name-update';
import { WorkspaceMutationHandlerBase } from '@/main/mutations/workspace-mutation-handler-base';
export class SpaceNameUpdateMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<SpaceNameUpdateMutationInput>
{
async handleMutation(
input: SpaceNameUpdateMutationInput
): Promise<SpaceNameUpdateMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const result = await workspace.entries.updateEntry<SpaceAttributes>(
input.spaceId,
(attributes) => {
attributes.name = input.name;
return attributes;
}
);
if (result === 'unauthorized') {
throw new MutationError(
MutationErrorCode.SpaceUpdateForbidden,
"You don't have permission to update this space."
);
}
return {
success: true,
};
}
}

View File

@@ -26,7 +26,7 @@ export const Avatar = (props: AvatarProps) => {
}
const avatarType = getIdType(avatar);
if (avatarType === IdType.Emoji) {
if (avatarType === IdType.EmojiSkin) {
return <EmojiAvatar {...props} />;
} else if (avatarType === IdType.Icon) {
return <IconAvatar {...props} />;

View File

@@ -3,6 +3,7 @@ import { getIdType, IdType } from '@colanode/core';
import { ContainerTab } from '@/shared/types/workspaces';
import { TabsContent } from '@/renderer/components/ui/tabs';
import { SpaceContainer } from '@/renderer/components/spaces/space-container';
import { ChannelContainer } from '@/renderer/components/channels/channel-container';
import { ChatContainer } from '@/renderer/components/chats/chat-container';
import { DatabaseContainer } from '@/renderer/components/databases/database-container';
@@ -23,6 +24,7 @@ export const ContainerTabContent = ({ tab }: ContainerTabContentProps) => {
className="h-full min-h-full w-full min-w-full m-0 pt-2"
>
{match(getIdType(tab.path))
.with(IdType.Space, () => <SpaceContainer spaceId={tab.path} />)
.with(IdType.Channel, () => <ChannelContainer channelId={tab.path} />)
.with(IdType.Page, () => <PageContainer pageId={tab.path} />)
.with(IdType.Database, () => (

View File

@@ -7,6 +7,7 @@ import { useDrag, useDrop } from 'react-dnd';
import { TabsTrigger } from '@/renderer/components/ui/tabs';
import { ContainerTab } from '@/shared/types/workspaces';
import { cn } from '@/shared/lib/utils';
import { SpaceContainerTab } from '@/renderer/components/spaces/space-container-tab';
import { ChannelContainerTab } from '@/renderer/components/channels/channel-container-tab';
import { FileContainerTab } from '@/renderer/components/files/file-container-tab';
import { DatabaseContainerTab } from '@/renderer/components/databases/database-container-tab';
@@ -62,7 +63,7 @@ export const ContainerTabTrigger = ({
value={tab.path}
key={tab.path}
className={cn(
'overflow-hidden rounded-b-none bg-muted py-2 data-[state=active]:z-10 data-[state=active]:shadow-none h-10 group/tab app-no-drag-region flex items-center justify-between gap-2',
'overflow-hidden rounded-b-none bg-muted py-2 data-[state=active]:z-10 data-[state=active]:shadow-none h-10 group/tab app-no-drag-region flex items-center justify-between gap-2 max-w-60',
tab.preview && 'italic',
dropMonitor.isOver &&
dropMonitor.canDrop &&
@@ -78,6 +79,7 @@ export const ContainerTabTrigger = ({
>
<div className="overflow-hidden truncate">
{match(getIdType(tab.path))
.with(IdType.Space, () => <SpaceContainerTab spaceId={tab.path} />)
.with(IdType.Channel, () => (
<ChannelContainerTab channelId={tab.path} />
))

View File

@@ -0,0 +1,49 @@
import { SpaceEntry } from '@colanode/core';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { AvatarPopover } from '@/renderer/components/avatars/avatar-popover';
import { Button } from '@/renderer/components/ui/button';
interface SpaceAvatarProps {
space: SpaceEntry;
readonly: boolean;
onUpdate: (avatar: string) => void;
}
export const SpaceAvatar = ({
space,
readonly,
onUpdate,
}: SpaceAvatarProps) => {
if (readonly) {
return (
<Button type="button" variant="outline" size="icon">
<Avatar
id={space.id}
name={space.attributes.name}
avatar={space.attributes.avatar}
className="h-6 w-6"
/>
</Button>
);
}
return (
<AvatarPopover
onPick={(avatar) => {
if (avatar === space.attributes.avatar) return;
onUpdate(avatar);
}}
>
<Button type="button" variant="outline" size="icon">
<Avatar
id={space.id}
name={space.attributes.name}
avatar={space.attributes.avatar}
className="h-6 w-6"
/>
</Button>
</AvatarPopover>
);
};

View File

@@ -0,0 +1,90 @@
import { EntryRole, hasEntryRole, SpaceEntry } from '@colanode/core';
import { Info, Trash2, Users } from 'lucide-react';
import { EntryCollaborators } from '@/renderer/components/collaborators/entry-collaborators';
import { SpaceDeleteForm } from '@/renderer/components/spaces/space-delete-form';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/renderer/components/ui/tabs';
import { useLayout } from '@/renderer/contexts/layout';
import { SpaceGeneralTab } from '@/renderer/components/spaces/space-general-tab';
interface SpaceBodyProps {
space: SpaceEntry;
role: EntryRole;
}
export const SpaceBody = ({ space, role }: SpaceBodyProps) => {
const layout = useLayout();
const canEdit = hasEntryRole(role, 'admin');
const canDelete = hasEntryRole(role, 'admin');
return (
<Tabs
defaultValue="general"
className="grid h-full max-h-full grid-cols-[200px_minmax(0,1fr)] overflow-hidden gap-4"
>
<TabsList className="flex h-full max-h-full flex-col items-start justify-start gap-1 rounded-none bg-white">
<TabsTrigger
key={`tab-trigger-general`}
className="w-full justify-start p-2 hover:bg-gray-50"
value="general"
>
<Info className="mr-2 size-4" />
General
</TabsTrigger>
<TabsTrigger
key={`tab-trigger-collaborators`}
className="w-full justify-start p-2 hover:bg-gray-50"
value="collaborators"
>
<Users className="mr-2 size-4" />
Collaborators
</TabsTrigger>
{canDelete && (
<TabsTrigger
key={`tab-trigger-delete`}
className="w-full justify-start p-2 hover:bg-gray-50"
value="delete"
>
<Trash2 className="mr-2 size-4" />
Delete
</TabsTrigger>
)}
</TabsList>
<div className="overflow-auto pl-1 max-w-[50rem]">
<TabsContent
key="tab-content-info"
className="focus-visible:ring-0 focus-visible:ring-offset-0"
value="general"
>
<SpaceGeneralTab space={space} readonly={!canEdit} />
</TabsContent>
<TabsContent
key="tab-content-collaborators"
className="focus-visible:ring-0 focus-visible:ring-offset-0"
value="collaborators"
>
<EntryCollaborators entry={space} entries={[space]} role={role} />
</TabsContent>
{canDelete && (
<TabsContent
key="tab-content-delete"
className="focus-visible:ring-0 focus-visible:ring-offset-0"
value="delete"
>
<SpaceDeleteForm
id={space.id}
onDeleted={() => {
layout.close(space.id);
}}
/>
</TabsContent>
)}
</div>
</Tabs>
);
};

View File

@@ -0,0 +1,37 @@
import { SpaceEntry } from '@colanode/core';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useQuery } from '@/renderer/hooks/use-query';
interface SpaceContainerTabProps {
spaceId: string;
}
export const SpaceContainerTab = ({ spaceId }: SpaceContainerTabProps) => {
const workspace = useWorkspace();
const { data: entry } = useQuery({
type: 'entry_get',
entryId: spaceId,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
const space = entry as SpaceEntry;
if (!space) {
return <p>Not found</p>;
}
return (
<div className="flex items-center space-x-2">
<Avatar
size="small"
id={space.id}
name={space.attributes.name}
avatar={space.attributes.avatar}
/>
<span>{space.attributes.name}</span>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import { SpaceEntry } from '@colanode/core';
import {
Container,
ContainerBody,
ContainerHeader,
ContainerSettings,
} from '@/renderer/components/ui/container';
import { ContainerBreadcrumb } from '@/renderer/components/layouts/containers/container-breadrumb';
import { SpaceNotFound } from '@/renderer/components/spaces/space-not-found';
import { EntryCollaboratorsPopover } from '@/renderer/components/collaborators/entry-collaborators-popover';
import { useEntryRadar } from '@/renderer/hooks/use-entry-radar';
import { useEntryContainer } from '@/renderer/hooks/use-entry-container';
import { SpaceBody } from '@/renderer/components/spaces/space-body';
interface SpaceContainerProps {
spaceId: string;
}
export const SpaceContainer = ({ spaceId }: SpaceContainerProps) => {
const data = useEntryContainer<SpaceEntry>(spaceId);
useEntryRadar(data.entry);
if (data.isPending) {
return null;
}
if (!data.entry) {
return <SpaceNotFound />;
}
const { entry, role } = data;
return (
<Container>
<ContainerHeader>
<ContainerBreadcrumb breadcrumb={data.breadcrumb} />
<ContainerSettings>
<EntryCollaboratorsPopover
entry={entry}
entries={[entry]}
role={role}
/>
</ContainerSettings>
</ContainerHeader>
<ContainerBody>
<SpaceBody space={entry} role={role} />
</ContainerBody>
</Container>
);
};

View File

@@ -1,21 +1,33 @@
import React from 'react';
import { Button } from '@/renderer/components/ui/button';
import { Spinner } from '@/renderer/components/ui/spinner';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/hooks/use-toast';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/renderer/components/ui/alert-dialog';
interface SpaceDeleteFormProps {
id: string;
onDeleted: () => void;
}
export const SpaceDeleteForm = ({ id }: SpaceDeleteFormProps) => {
export const SpaceDeleteForm = ({ id, onDeleted }: SpaceDeleteFormProps) => {
const workspace = useWorkspace();
const { mutate, isPending } = useMutation();
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
return (
<div className="flex flex-col space-y-4">
<h3 className="font-heading mb-px mt-2 text-xl font-semibold tracking-tight">
<div className="flex flex-col gap-4">
<h3 className="font-heading mb-px text-2xl font-semibold tracking-tight">
Delete space
</h3>
<p>Deleting a space is permanent and cannot be undone.</p>
@@ -27,33 +39,60 @@ export const SpaceDeleteForm = ({ id }: SpaceDeleteFormProps) => {
<Button
variant="destructive"
onClick={() => {
mutate({
input: {
type: 'space_delete',
accountId: workspace.accountId,
workspaceId: workspace.id,
spaceId: id,
},
onSuccess() {
toast({
title: 'Space deleted',
description: 'Space was deleted successfully',
variant: 'default',
});
},
onError(error) {
toast({
title: 'Failed to delete space',
description: error.message,
variant: 'destructive',
});
},
});
setShowDeleteModal(true);
}}
>
{isPending && <Spinner className="mr-1" />}Delete space
Delete space
</Button>
</div>
<AlertDialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want delete this space?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This space will no longer be
accessible by you or others you&apos;ve shared it with.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
variant="destructive"
disabled={isPending}
onClick={() => {
mutate({
input: {
type: 'space_delete',
accountId: workspace.accountId,
workspaceId: workspace.id,
spaceId: id,
},
onSuccess() {
setShowDeleteModal(false);
onDeleted();
toast({
title: 'Space deleted',
description: 'Space was deleted successfully',
variant: 'default',
});
},
onError(error) {
toast({
title: 'Failed to delete space',
description: error.message,
variant: 'destructive',
});
},
});
}}
>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,48 @@
import { SpaceEntry } from '@colanode/core';
import { useEffect, useRef } from 'react';
import { SmartTextarea } from '@/renderer/components/ui/smart-textarea';
interface SpaceDescriptionProps {
space: SpaceEntry;
readonly: boolean;
onUpdate: (description: string) => void;
}
export const SpaceDescription = ({
space,
readonly,
onUpdate,
}: SpaceDescriptionProps) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (readonly) return;
const timeoutId = setTimeout(() => {
textareaRef.current?.focus();
}, 0);
return () => clearTimeout(timeoutId);
}, [readonly, textareaRef]);
return (
<SmartTextarea
value={space.attributes.description ?? ''}
readOnly={readonly}
ref={textareaRef}
onChange={(value) => {
if (readonly) {
return;
}
if (value === space.attributes.description) {
return;
}
onUpdate(value);
}}
placeholder="No description"
/>
);
};

View File

@@ -0,0 +1,95 @@
import { SpaceEntry } from '@colanode/core';
import { SpaceAvatar } from '@/renderer/components/spaces/space-avatar';
import { SpaceDescription } from '@/renderer/components/spaces/space-description';
import { SpaceName } from '@/renderer/components/spaces/space-name';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/hooks/use-toast';
interface SpaceGeneralTabProps {
space: SpaceEntry;
readonly: boolean;
}
export const SpaceGeneralTab = ({ space, readonly }: SpaceGeneralTabProps) => {
const workspace = useWorkspace();
const { mutate, isPending } = useMutation();
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2">
<SpaceAvatar
space={space}
readonly={readonly || isPending}
onUpdate={(avatar) => {
mutate({
input: {
type: 'space_avatar_update',
spaceId: space.id,
avatar,
accountId: workspace.accountId,
workspaceId: workspace.id,
},
onError(error) {
toast({
title: 'Failed to update space avatar',
description: error.message,
variant: 'destructive',
});
},
});
}}
/>
<SpaceName
space={space}
readonly={readonly || isPending}
onUpdate={(name) => {
mutate({
input: {
type: 'space_name_update',
spaceId: space.id,
name,
accountId: workspace.accountId,
workspaceId: workspace.id,
},
onError(error) {
toast({
title: 'Failed to update space name',
description: error.message,
variant: 'destructive',
});
},
});
}}
/>
</div>
<div className="flex flex-col gap-2 mt-4">
<p className="text-sm font-medium">Description</p>
<SpaceDescription
space={space}
readonly={readonly || isPending}
onUpdate={(description) => {
mutate({
input: {
type: 'space_description_update',
spaceId: space.id,
description,
accountId: workspace.accountId,
workspaceId: workspace.id,
},
onError(error) {
toast({
title: 'Failed to update space description',
description: error.message,
variant: 'destructive',
});
},
});
}}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { SpaceEntry } from '@colanode/core';
import { useEffect, useRef } from 'react';
import { SmartTextInput } from '@/renderer/components/ui/smart-text-input';
interface SpaceNameProps {
space: SpaceEntry;
readonly: boolean;
onUpdate: (name: string) => void;
}
export const SpaceName = ({ space, readonly, onUpdate }: SpaceNameProps) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (readonly) return;
const timeoutId = setTimeout(() => {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(timeoutId);
}, [readonly, inputRef]);
return (
<SmartTextInput
value={space.attributes.name}
readOnly={readonly}
ref={inputRef}
onChange={(value) => {
if (readonly) {
return;
}
if (value === space.attributes.name) {
return;
}
onUpdate(value);
}}
className="font-heading border-0 pl-1 text-4xl font-bold shadow-none focus-visible:ring-0"
placeholder="Unnamed"
/>
);
};

View File

@@ -0,0 +1,14 @@
import { BadgeAlert } from 'lucide-react';
export const SpaceNotFound = () => {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
<BadgeAlert className="size-12 mb-4" />
<h1 className="text-2xl font-semibold tracking-tight">Space not found</h1>
<p className="mt-2 text-sm font-medium text-muted-foreground">
The space you are looking for does not exist. It may have been deleted
or your access has been removed.
</p>
</div>
);
};

View File

@@ -1,126 +0,0 @@
import { extractEntryRole, SpaceEntry, hasEntryRole } from '@colanode/core';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Info, Trash2, Users } from 'lucide-react';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { EntryCollaborators } from '@/renderer/components/collaborators/entry-collaborators';
import { SpaceDeleteForm } from '@/renderer/components/spaces/space-delete-form';
import { SpaceUpdateForm } from '@/renderer/components/spaces/space-update-form';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/renderer/components/ui/dialog';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/renderer/components/ui/tabs';
import { useWorkspace } from '@/renderer/contexts/workspace';
interface SpaceSettingsDialogProps {
space: SpaceEntry;
open: boolean;
onOpenChange: (open: boolean) => void;
defaultTab?: string;
}
export const SpaceSettingsDialog = ({
space,
open,
onOpenChange,
defaultTab,
}: SpaceSettingsDialogProps) => {
const workspace = useWorkspace();
const role = extractEntryRole(space, workspace.userId);
if (!role) {
return null;
}
const canDelete = hasEntryRole(role, 'editor');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="md:min-h-3/4 md:max-h-3/4 p-3 md:h-3/4 md:w-3/4 md:max-w-full">
<VisuallyHidden>
<DialogTitle>Space Settings</DialogTitle>
</VisuallyHidden>
<Tabs
defaultValue={defaultTab ?? 'info'}
className="grid h-full max-h-full grid-cols-[240px_minmax(0,1fr)] overflow-hidden"
>
<TabsList className="flex h-full max-h-full flex-col items-start justify-start gap-1 rounded-none border-r border-r-gray-100 bg-white pr-3">
<div className="mb-1 flex h-10 w-full items-center justify-between bg-gray-50 p-1 text-foreground/80">
<div className="flex items-center gap-2">
<Avatar
id={space.id}
avatar={space.attributes.avatar}
name={space.attributes.name}
size="small"
/>
<span>{space.attributes.name ?? 'Error'}</span>
</div>
</div>
<TabsTrigger
key={`tab-trigger-info`}
className="w-full justify-start p-2 hover:bg-gray-50"
value="info"
>
<Info className="mr-2 size-4" />
Info
</TabsTrigger>
<TabsTrigger
key={`tab-trigger-collaborators`}
className="w-full justify-start p-2 hover:bg-gray-50"
value="collaborators"
>
<Users className="mr-2 size-4" />
Collaborators
</TabsTrigger>
{canDelete && (
<TabsTrigger
key={`tab-trigger-delete`}
className="w-full justify-start p-2 hover:bg-gray-50"
value="delete"
>
<Trash2 className="mr-2 size-4" />
Delete
</TabsTrigger>
)}
</TabsList>
<div className="overflow-auto p-4">
<TabsContent
key="tab-content-info"
className="focus-visible:ring-0 focus-visible:ring-offset-0"
value="info"
>
<SpaceUpdateForm space={space} />
</TabsContent>
<TabsContent
key="tab-content-collaborators"
className="focus-visible:ring-0 focus-visible:ring-offset-0"
value="collaborators"
>
<EntryCollaborators entry={space} entries={[space]} role={role} />
</TabsContent>
{canDelete && (
<TabsContent
key="tab-content-delete"
className="focus-visible:ring-0 focus-visible:ring-offset-0"
value="delete"
>
<SpaceDeleteForm
id={space.id}
onDeleted={() => {
onOpenChange(false);
}}
/>
</TabsContent>
)}
</div>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -17,7 +17,6 @@ import { DatabaseCreateDialog } from '@/renderer/components/databases/database-c
import { FolderCreateDialog } from '@/renderer/components/folders/folder-create-dialog';
import { SidebarItem } from '@/renderer/components/layouts/sidebars/sidebar-item';
import { PageCreateDialog } from '@/renderer/components/pages/page-create-dialog';
import { SpaceSettingsDialog } from '@/renderer/components/spaces/space-settings-dialog';
import {
Collapsible,
CollapsibleContent,
@@ -36,11 +35,6 @@ import { useQuery } from '@/renderer/hooks/use-query';
import { cn } from '@/shared/lib/utils';
import { useLayout } from '@/renderer/contexts/layout';
interface SettingsState {
open: boolean;
tab?: string;
}
interface SpaceSidebarItemProps {
space: SpaceEntry;
}
@@ -63,9 +57,6 @@ export const SpaceSidebarItem = ({ space }: SpaceSidebarItemProps) => {
const [openCreateChannel, setOpenCreateChannel] = React.useState(false);
const [openCreateDatabase, setOpenCreateDatabase] = React.useState(false);
const [openCreateFolder, setOpenCreateFolder] = React.useState(false);
const [settingsState, setSettingsState] = React.useState<SettingsState>({
open: false,
});
return (
<React.Fragment>
@@ -125,22 +116,13 @@ export const SpaceSidebarItem = ({ space }: SpaceSidebarItemProps) => {
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setSettingsState({ open: true })}
>
<DropdownMenuItem onClick={() => layout.previewLeft(space.id)}>
<div className="flex flex-row items-center gap-2">
<Settings className="size-4" />
<span>Settings</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setSettingsState({
open: true,
tab: 'collaborators',
})
}
>
<DropdownMenuItem onClick={() => layout.previewLeft(space.id)}>
<div className="flex flex-row items-center gap-2">
<Plus className="size-4" />
<span>Add collaborators</span>
@@ -205,16 +187,6 @@ export const SpaceSidebarItem = ({ space }: SpaceSidebarItemProps) => {
onOpenChange={setOpenCreateFolder}
/>
)}
{settingsState.open && (
<SpaceSettingsDialog
space={space}
open={settingsState.open}
onOpenChange={(open) =>
setSettingsState({ open, tab: settingsState.tab })
}
defaultTab={settingsState.tab}
/>
)}
</React.Fragment>
);
};

View File

@@ -1,141 +0,0 @@
import { SpaceEntry } from '@colanode/core';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Avatar } from '@/renderer/components/avatars/avatar';
import { AvatarPopover } from '@/renderer/components/avatars/avatar-popover';
import { Button } from '@/renderer/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/renderer/components/ui/form';
import { Input } from '@/renderer/components/ui/input';
import { Spinner } from '@/renderer/components/ui/spinner';
import { Textarea } from '@/renderer/components/ui/textarea';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { useMutation } from '@/renderer/hooks/use-mutation';
import { toast } from '@/renderer/hooks/use-toast';
interface SpaceUpdateFormProps {
space: SpaceEntry;
}
const formSchema = z.object({
name: z.string(),
description: z.string(),
avatar: z.string().nullable().optional(),
});
export const SpaceUpdateForm = ({ space }: SpaceUpdateFormProps) => {
const workspace = useWorkspace();
const canEdit = true;
const { mutate: updateSpace, isPending: isUpdatingSpace } = useMutation();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: space.attributes.name,
description: space.attributes.description ?? '',
avatar: space.attributes.avatar ?? null,
},
});
const handleSubmit = (values: z.infer<typeof formSchema>) => {
updateSpace({
input: {
type: 'space_update',
accountId: workspace.accountId,
workspaceId: workspace.id,
id: space.id,
name: values.name,
description: values.description,
avatar: values.avatar,
},
onSuccess() {
toast({
title: 'Space updated',
description: 'Space was updated successfully',
variant: 'default',
});
},
onError(error) {
toast({
title: 'Failed to update space',
description: error.message,
variant: 'destructive',
});
},
});
};
const name = form.watch('name');
const avatar = form.watch('avatar');
return (
<Form {...form}>
<form
className="flex flex-col"
onSubmit={form.handleSubmit(handleSubmit)}
>
<div className="space-y-4 pb-4">
<div className="flex flex-row items-end gap-4">
<AvatarPopover onPick={(avatar) => form.setValue('avatar', avatar)}>
<Button type="button" variant="outline" size="icon">
<Avatar
id={space.id}
name={name}
avatar={avatar}
className="h-6 w-6"
/>
</Button>
</AvatarPopover>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Name" {...field} disabled={!canEdit} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Write a short description about the network"
{...field}
disabled={!canEdit}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{canEdit && (
<div className="flex justify-end">
<Button type="submit" disabled={isUpdatingSpace}>
{isUpdatingSpace && <Spinner className="mr-1" />}
Update
</Button>
</div>
)}
</form>
</Form>
);
};

View File

@@ -0,0 +1,84 @@
import debounce from 'lodash/debounce';
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
interface SmartTextareaProps {
value: string | null;
onChange: (newValue: string) => void;
className?: string;
readOnly?: boolean;
placeholder?: string;
}
const SmartTextarea = React.forwardRef<HTMLTextAreaElement, SmartTextareaProps>(
({ value, onChange, className, readOnly, placeholder, ...props }, ref) => {
const [localValue, setLocalValue] = React.useState(value ?? '');
const initialValue = React.useRef(value ?? '');
// Create a debounced version of onChange
const debouncedOnChange = React.useMemo(
() => debounce((value: string) => onChange(value), 500),
[onChange]
);
// Update localValue when value prop changes
React.useEffect(() => {
setLocalValue(value ?? '');
initialValue.current = value ?? '';
}, [value]);
// Cleanup debounce on unmount
React.useEffect(() => {
return () => {
debouncedOnChange.cancel();
};
}, [debouncedOnChange]);
const handleBlur = () => {
if (localValue !== initialValue.current) {
debouncedOnChange.cancel(); // Cancel any pending debounced calls
onChange(localValue);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Escape') {
setLocalValue(initialValue.current); // Revert to initial value
debouncedOnChange.cancel(); // Cancel any pending debounced calls
} else if (event.key === 'Enter') {
if (localValue !== initialValue.current) {
onChange(localValue); // Fire onChange immediately when Enter is pressed
debouncedOnChange.cancel(); // Cancel any pending debounced calls
}
}
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = event.target.value;
setLocalValue(newValue);
debouncedOnChange(newValue);
};
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
readOnly={readOnly}
placeholder={placeholder}
{...props}
/>
);
}
);
SmartTextarea.displayName = 'SmartTextarea';
export { SmartTextarea };

View File

@@ -0,0 +1,20 @@
export type SpaceAvatarUpdateMutationInput = {
type: 'space_avatar_update';
accountId: string;
workspaceId: string;
spaceId: string;
avatar: string;
};
export type SpaceAvatarUpdateMutationOutput = {
success: boolean;
};
declare module '@/shared/mutations' {
interface MutationMap {
space_avatar_update: {
input: SpaceAvatarUpdateMutationInput;
output: SpaceAvatarUpdateMutationOutput;
};
}
}

View File

@@ -0,0 +1,20 @@
export type SpaceDescriptionUpdateMutationInput = {
type: 'space_description_update';
accountId: string;
workspaceId: string;
spaceId: string;
description: string;
};
export type SpaceDescriptionUpdateMutationOutput = {
success: boolean;
};
declare module '@/shared/mutations' {
interface MutationMap {
space_description_update: {
input: SpaceDescriptionUpdateMutationInput;
output: SpaceDescriptionUpdateMutationOutput;
};
}
}

View File

@@ -0,0 +1,20 @@
export type SpaceNameUpdateMutationInput = {
type: 'space_name_update';
accountId: string;
workspaceId: string;
spaceId: string;
name: string;
};
export type SpaceNameUpdateMutationOutput = {
success: boolean;
};
declare module '@/shared/mutations' {
interface MutationMap {
space_name_update: {
input: SpaceNameUpdateMutationInput;
output: SpaceNameUpdateMutationOutput;
};
}
}

View File

@@ -1,22 +0,0 @@
export type SpaceUpdateMutationInput = {
type: 'space_update';
accountId: string;
workspaceId: string;
id: string;
name: string;
description: string;
avatar?: string | null;
};
export type SpaceUpdateMutationOutput = {
success: boolean;
};
declare module '@/shared/mutations' {
interface MutationMap {
space_update: {
input: SpaceUpdateMutationInput;
output: SpaceUpdateMutationOutput;
};
}
}

View File

@@ -354,19 +354,21 @@ export class YDoc {
const currentText = yText.toString();
const newText = value ? value.toString() : '';
if (!isEqual(currentText, newText)) {
const diffs = diffChars(currentText, newText);
let index = 0;
if (isEqual(currentText, newText)) {
return;
}
for (const diff of diffs) {
if (diff.added) {
yText.insert(index, diff.value);
index += diff.value.length;
} else if (diff.removed) {
yText.delete(index, diff.value.length);
} else {
index += diff.value.length;
}
const diffs = diffChars(currentText, newText);
let index = 0;
for (const diff of diffs) {
if (diff.added) {
yText.insert(index, diff.value);
index += diff.value.length;
} else if (diff.removed) {
yText.delete(index, diff.value.length);
} else {
index += diff.value.length;
}
}
}

View File

@@ -46,6 +46,8 @@ const createAccount = async (
name: account.name,
email: account.email,
password: account.password,
platform: 'seed',
version: '0.0.4',
});
if (data.type !== 'success') {