mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 19:57:46 +01:00
Implement space container
This commit is contained in:
@@ -50,7 +50,9 @@ import { SelectOptionUpdateMutationHandler } from '@/main/mutations/databases/se
|
|||||||
import { ServerCreateMutationHandler } from '@/main/mutations/servers/server-create';
|
import { ServerCreateMutationHandler } from '@/main/mutations/servers/server-create';
|
||||||
import { SpaceCreateMutationHandler } from '@/main/mutations/spaces/space-create';
|
import { SpaceCreateMutationHandler } from '@/main/mutations/spaces/space-create';
|
||||||
import { SpaceDeleteMutationHandler } from '@/main/mutations/spaces/space-delete';
|
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 { ViewCreateMutationHandler } from '@/main/mutations/databases/view-create';
|
||||||
import { ViewDeleteMutationHandler } from '@/main/mutations/databases/view-delete';
|
import { ViewDeleteMutationHandler } from '@/main/mutations/databases/view-delete';
|
||||||
import { ViewUpdateMutationHandler } from '@/main/mutations/databases/view-update';
|
import { ViewUpdateMutationHandler } from '@/main/mutations/databases/view-update';
|
||||||
@@ -121,7 +123,9 @@ export const mutationHandlerMap: MutationHandlerMap = {
|
|||||||
file_mark_opened: new FileMarkOpenedMutationHandler(),
|
file_mark_opened: new FileMarkOpenedMutationHandler(),
|
||||||
file_mark_seen: new FileMarkSeenMutationHandler(),
|
file_mark_seen: new FileMarkSeenMutationHandler(),
|
||||||
file_save_temp: new FileSaveTempMutationHandler(),
|
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(),
|
account_update: new AccountUpdateMutationHandler(),
|
||||||
view_update: new ViewUpdateMutationHandler(),
|
view_update: new ViewUpdateMutationHandler(),
|
||||||
view_delete: new ViewDeleteMutationHandler(),
|
view_delete: new ViewDeleteMutationHandler(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,27 +3,24 @@ import { SpaceAttributes } from '@colanode/core';
|
|||||||
import { MutationHandler } from '@/main/lib/types';
|
import { MutationHandler } from '@/main/lib/types';
|
||||||
import { MutationError, MutationErrorCode } from '@/shared/mutations';
|
import { MutationError, MutationErrorCode } from '@/shared/mutations';
|
||||||
import {
|
import {
|
||||||
SpaceUpdateMutationInput,
|
SpaceDescriptionUpdateMutationInput,
|
||||||
SpaceUpdateMutationOutput,
|
SpaceDescriptionUpdateMutationOutput,
|
||||||
} from '@/shared/mutations/spaces/space-update';
|
} from '@/shared/mutations/spaces/space-description-update';
|
||||||
import { WorkspaceMutationHandlerBase } from '@/main/mutations/workspace-mutation-handler-base';
|
import { WorkspaceMutationHandlerBase } from '@/main/mutations/workspace-mutation-handler-base';
|
||||||
|
|
||||||
export class SpaceUpdateMutationHandler
|
export class SpaceDescriptionUpdateMutationHandler
|
||||||
extends WorkspaceMutationHandlerBase
|
extends WorkspaceMutationHandlerBase
|
||||||
implements MutationHandler<SpaceUpdateMutationInput>
|
implements MutationHandler<SpaceDescriptionUpdateMutationInput>
|
||||||
{
|
{
|
||||||
async handleMutation(
|
async handleMutation(
|
||||||
input: SpaceUpdateMutationInput
|
input: SpaceDescriptionUpdateMutationInput
|
||||||
): Promise<SpaceUpdateMutationOutput> {
|
): Promise<SpaceDescriptionUpdateMutationOutput> {
|
||||||
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
||||||
|
|
||||||
const result = await workspace.entries.updateEntry<SpaceAttributes>(
|
const result = await workspace.entries.updateEntry<SpaceAttributes>(
|
||||||
input.id,
|
input.spaceId,
|
||||||
(attributes) => {
|
(attributes) => {
|
||||||
attributes.name = input.name;
|
|
||||||
attributes.description = input.description;
|
attributes.description = input.description;
|
||||||
attributes.avatar = input.avatar;
|
|
||||||
|
|
||||||
return attributes;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
39
apps/desktop/src/main/mutations/spaces/space-name-update.ts
Normal file
39
apps/desktop/src/main/mutations/spaces/space-name-update.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export const Avatar = (props: AvatarProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const avatarType = getIdType(avatar);
|
const avatarType = getIdType(avatar);
|
||||||
if (avatarType === IdType.Emoji) {
|
if (avatarType === IdType.EmojiSkin) {
|
||||||
return <EmojiAvatar {...props} />;
|
return <EmojiAvatar {...props} />;
|
||||||
} else if (avatarType === IdType.Icon) {
|
} else if (avatarType === IdType.Icon) {
|
||||||
return <IconAvatar {...props} />;
|
return <IconAvatar {...props} />;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getIdType, IdType } from '@colanode/core';
|
|||||||
|
|
||||||
import { ContainerTab } from '@/shared/types/workspaces';
|
import { ContainerTab } from '@/shared/types/workspaces';
|
||||||
import { TabsContent } from '@/renderer/components/ui/tabs';
|
import { TabsContent } from '@/renderer/components/ui/tabs';
|
||||||
|
import { SpaceContainer } from '@/renderer/components/spaces/space-container';
|
||||||
import { ChannelContainer } from '@/renderer/components/channels/channel-container';
|
import { ChannelContainer } from '@/renderer/components/channels/channel-container';
|
||||||
import { ChatContainer } from '@/renderer/components/chats/chat-container';
|
import { ChatContainer } from '@/renderer/components/chats/chat-container';
|
||||||
import { DatabaseContainer } from '@/renderer/components/databases/database-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"
|
className="h-full min-h-full w-full min-w-full m-0 pt-2"
|
||||||
>
|
>
|
||||||
{match(getIdType(tab.path))
|
{match(getIdType(tab.path))
|
||||||
|
.with(IdType.Space, () => <SpaceContainer spaceId={tab.path} />)
|
||||||
.with(IdType.Channel, () => <ChannelContainer channelId={tab.path} />)
|
.with(IdType.Channel, () => <ChannelContainer channelId={tab.path} />)
|
||||||
.with(IdType.Page, () => <PageContainer pageId={tab.path} />)
|
.with(IdType.Page, () => <PageContainer pageId={tab.path} />)
|
||||||
.with(IdType.Database, () => (
|
.with(IdType.Database, () => (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useDrag, useDrop } from 'react-dnd';
|
|||||||
import { TabsTrigger } from '@/renderer/components/ui/tabs';
|
import { TabsTrigger } from '@/renderer/components/ui/tabs';
|
||||||
import { ContainerTab } from '@/shared/types/workspaces';
|
import { ContainerTab } from '@/shared/types/workspaces';
|
||||||
import { cn } from '@/shared/lib/utils';
|
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 { ChannelContainerTab } from '@/renderer/components/channels/channel-container-tab';
|
||||||
import { FileContainerTab } from '@/renderer/components/files/file-container-tab';
|
import { FileContainerTab } from '@/renderer/components/files/file-container-tab';
|
||||||
import { DatabaseContainerTab } from '@/renderer/components/databases/database-container-tab';
|
import { DatabaseContainerTab } from '@/renderer/components/databases/database-container-tab';
|
||||||
@@ -62,7 +63,7 @@ export const ContainerTabTrigger = ({
|
|||||||
value={tab.path}
|
value={tab.path}
|
||||||
key={tab.path}
|
key={tab.path}
|
||||||
className={cn(
|
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',
|
tab.preview && 'italic',
|
||||||
dropMonitor.isOver &&
|
dropMonitor.isOver &&
|
||||||
dropMonitor.canDrop &&
|
dropMonitor.canDrop &&
|
||||||
@@ -78,6 +79,7 @@ export const ContainerTabTrigger = ({
|
|||||||
>
|
>
|
||||||
<div className="overflow-hidden truncate">
|
<div className="overflow-hidden truncate">
|
||||||
{match(getIdType(tab.path))
|
{match(getIdType(tab.path))
|
||||||
|
.with(IdType.Space, () => <SpaceContainerTab spaceId={tab.path} />)
|
||||||
.with(IdType.Channel, () => (
|
.with(IdType.Channel, () => (
|
||||||
<ChannelContainerTab channelId={tab.path} />
|
<ChannelContainerTab channelId={tab.path} />
|
||||||
))
|
))
|
||||||
|
|||||||
49
apps/desktop/src/renderer/components/spaces/space-avatar.tsx
Normal file
49
apps/desktop/src/renderer/components/spaces/space-avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
90
apps/desktop/src/renderer/components/spaces/space-body.tsx
Normal file
90
apps/desktop/src/renderer/components/spaces/space-body.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,21 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { Button } from '@/renderer/components/ui/button';
|
import { Button } from '@/renderer/components/ui/button';
|
||||||
import { Spinner } from '@/renderer/components/ui/spinner';
|
|
||||||
import { useWorkspace } from '@/renderer/contexts/workspace';
|
import { useWorkspace } from '@/renderer/contexts/workspace';
|
||||||
import { useMutation } from '@/renderer/hooks/use-mutation';
|
import { useMutation } from '@/renderer/hooks/use-mutation';
|
||||||
import { toast } from '@/renderer/hooks/use-toast';
|
import { toast } from '@/renderer/hooks/use-toast';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/renderer/components/ui/alert-dialog';
|
||||||
|
|
||||||
interface SpaceDeleteFormProps {
|
interface SpaceDeleteFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SpaceDeleteForm = ({ id }: SpaceDeleteFormProps) => {
|
export const SpaceDeleteForm = ({ id, onDeleted }: SpaceDeleteFormProps) => {
|
||||||
const workspace = useWorkspace();
|
const workspace = useWorkspace();
|
||||||
const { mutate, isPending } = useMutation();
|
const { mutate, isPending } = useMutation();
|
||||||
|
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h3 className="font-heading mb-px mt-2 text-xl font-semibold tracking-tight">
|
<h3 className="font-heading mb-px text-2xl font-semibold tracking-tight">
|
||||||
Delete space
|
Delete space
|
||||||
</h3>
|
</h3>
|
||||||
<p>Deleting a space is permanent and cannot be undone.</p>
|
<p>Deleting a space is permanent and cannot be undone.</p>
|
||||||
@@ -27,33 +39,60 @@ export const SpaceDeleteForm = ({ id }: SpaceDeleteFormProps) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutate({
|
setShowDeleteModal(true);
|
||||||
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',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPending && <Spinner className="mr-1" />}Delete space
|
Delete space
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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'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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
45
apps/desktop/src/renderer/components/spaces/space-name.tsx
Normal file
45
apps/desktop/src/renderer/components/spaces/space-name.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -17,7 +17,6 @@ import { DatabaseCreateDialog } from '@/renderer/components/databases/database-c
|
|||||||
import { FolderCreateDialog } from '@/renderer/components/folders/folder-create-dialog';
|
import { FolderCreateDialog } from '@/renderer/components/folders/folder-create-dialog';
|
||||||
import { SidebarItem } from '@/renderer/components/layouts/sidebars/sidebar-item';
|
import { SidebarItem } from '@/renderer/components/layouts/sidebars/sidebar-item';
|
||||||
import { PageCreateDialog } from '@/renderer/components/pages/page-create-dialog';
|
import { PageCreateDialog } from '@/renderer/components/pages/page-create-dialog';
|
||||||
import { SpaceSettingsDialog } from '@/renderer/components/spaces/space-settings-dialog';
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -36,11 +35,6 @@ import { useQuery } from '@/renderer/hooks/use-query';
|
|||||||
import { cn } from '@/shared/lib/utils';
|
import { cn } from '@/shared/lib/utils';
|
||||||
import { useLayout } from '@/renderer/contexts/layout';
|
import { useLayout } from '@/renderer/contexts/layout';
|
||||||
|
|
||||||
interface SettingsState {
|
|
||||||
open: boolean;
|
|
||||||
tab?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpaceSidebarItemProps {
|
interface SpaceSidebarItemProps {
|
||||||
space: SpaceEntry;
|
space: SpaceEntry;
|
||||||
}
|
}
|
||||||
@@ -63,9 +57,6 @@ export const SpaceSidebarItem = ({ space }: SpaceSidebarItemProps) => {
|
|||||||
const [openCreateChannel, setOpenCreateChannel] = React.useState(false);
|
const [openCreateChannel, setOpenCreateChannel] = React.useState(false);
|
||||||
const [openCreateDatabase, setOpenCreateDatabase] = React.useState(false);
|
const [openCreateDatabase, setOpenCreateDatabase] = React.useState(false);
|
||||||
const [openCreateFolder, setOpenCreateFolder] = React.useState(false);
|
const [openCreateFolder, setOpenCreateFolder] = React.useState(false);
|
||||||
const [settingsState, setSettingsState] = React.useState<SettingsState>({
|
|
||||||
open: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
@@ -125,22 +116,13 @@ export const SpaceSidebarItem = ({ space }: SpaceSidebarItemProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => layout.previewLeft(space.id)}>
|
||||||
onClick={() => setSettingsState({ open: true })}
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => layout.previewLeft(space.id)}>
|
||||||
onClick={() =>
|
|
||||||
setSettingsState({
|
|
||||||
open: true,
|
|
||||||
tab: 'collaborators',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
<span>Add collaborators</span>
|
<span>Add collaborators</span>
|
||||||
@@ -205,16 +187,6 @@ export const SpaceSidebarItem = ({ space }: SpaceSidebarItemProps) => {
|
|||||||
onOpenChange={setOpenCreateFolder}
|
onOpenChange={setOpenCreateFolder}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{settingsState.open && (
|
|
||||||
<SpaceSettingsDialog
|
|
||||||
space={space}
|
|
||||||
open={settingsState.open}
|
|
||||||
onOpenChange={(open) =>
|
|
||||||
setSettingsState({ open, tab: settingsState.tab })
|
|
||||||
}
|
|
||||||
defaultTab={settingsState.tab}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
84
apps/desktop/src/renderer/components/ui/smart-textarea.tsx
Normal file
84
apps/desktop/src/renderer/components/ui/smart-textarea.tsx
Normal 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 };
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -354,19 +354,21 @@ export class YDoc {
|
|||||||
const currentText = yText.toString();
|
const currentText = yText.toString();
|
||||||
const newText = value ? value.toString() : '';
|
const newText = value ? value.toString() : '';
|
||||||
|
|
||||||
if (!isEqual(currentText, newText)) {
|
if (isEqual(currentText, newText)) {
|
||||||
const diffs = diffChars(currentText, newText);
|
return;
|
||||||
let index = 0;
|
}
|
||||||
|
|
||||||
for (const diff of diffs) {
|
const diffs = diffChars(currentText, newText);
|
||||||
if (diff.added) {
|
let index = 0;
|
||||||
yText.insert(index, diff.value);
|
|
||||||
index += diff.value.length;
|
for (const diff of diffs) {
|
||||||
} else if (diff.removed) {
|
if (diff.added) {
|
||||||
yText.delete(index, diff.value.length);
|
yText.insert(index, diff.value);
|
||||||
} else {
|
index += diff.value.length;
|
||||||
index += diff.value.length;
|
} else if (diff.removed) {
|
||||||
}
|
yText.delete(index, diff.value.length);
|
||||||
|
} else {
|
||||||
|
index += diff.value.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const createAccount = async (
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
email: account.email,
|
email: account.email,
|
||||||
password: account.password,
|
password: account.password,
|
||||||
|
platform: 'seed',
|
||||||
|
version: '0.0.4',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.type !== 'success') {
|
if (data.type !== 'success') {
|
||||||
|
|||||||
Reference in New Issue
Block a user