Implement save file (#108)

* Implement save file
* Delete locally downloaded files that have not been opened in last 7 days
This commit is contained in:
Hakan Shehu
2025-07-03 20:42:21 +02:00
committed by GitHub
parent b1c4069743
commit e8f56449d5
45 changed files with 1165 additions and 409 deletions

View File

@@ -0,0 +1,60 @@
import { Check, X } from 'lucide-react';
import { SaveStatus } from '@colanode/client/types';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@colanode/ui/components/ui/tooltip';
interface DownloadStatusProps {
status: SaveStatus;
progress: number;
}
export const DownloadStatus = ({ status, progress }: DownloadStatusProps) => {
switch (status) {
case SaveStatus.Active:
return (
<Tooltip>
<TooltipTrigger>
<div className="flex items-center justify-center p-1">
<Spinner className="size-5 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent className="flex flex-row items-center gap-2">
Downloading ... {progress}%
</TooltipContent>
</Tooltip>
);
case SaveStatus.Completed:
return (
<Tooltip>
<TooltipTrigger>
<div className="bg-green-500 rounded-full p-1 flex items-center justify-center">
<Check className="size-4 text-white" />
</div>
</TooltipTrigger>
<TooltipContent className="flex flex-row items-center gap-2">
Downloaded
</TooltipContent>
</Tooltip>
);
case SaveStatus.Failed:
return (
<Tooltip>
<TooltipTrigger>
<div className="bg-red-500 rounded-full p-1 flex items-center justify-center">
<X className="size-4 text-white" />
</div>
</TooltipTrigger>
<TooltipContent className="flex flex-row items-center gap-2">
Download failed
</TooltipContent>
</Tooltip>
);
default:
return null;
}
};

View File

@@ -0,0 +1,22 @@
import { Download } from 'lucide-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
} from '@colanode/ui/components/ui/breadcrumb';
export const DownloadsBreadcrumb = () => {
return (
<Breadcrumb className="flex-grow">
<BreadcrumbList>
<BreadcrumbItem className="cursor-pointer hover:text-foreground">
<div className="flex items-center space-x-2">
<Download className="size-5" />
<span>Downloads</span>
</div>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
};

View File

@@ -0,0 +1,30 @@
import { Download } from 'lucide-react';
import { SaveStatus } from '@colanode/client/types';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
export const DownloadsContainerTab = () => {
const workspace = useWorkspace();
const fileSaveListQuery = useQuery({
type: 'file.save.list',
accountId: workspace.accountId,
workspaceId: workspace.id,
});
if (fileSaveListQuery.isPending) {
return <p className="text-sm text-muted-foreground">Loading...</p>;
}
const activeSaves =
fileSaveListQuery.data?.filter((save) => save.status === SaveStatus.Active)
?.length ?? 0;
return (
<div className="flex items-center space-x-2">
<Download className="size-5" />
<span>Downloads {activeSaves > 0 ? `(${activeSaves})` : ''}</span>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import { DownloadsBreadcrumb } from '@colanode/ui/components/downloads/downloads-breadcrumb';
import { DownloadsList } from '@colanode/ui/components/downloads/downloads-list';
import {
Container,
ContainerBody,
ContainerHeader,
} from '@colanode/ui/components/ui/container';
export const DownloadsContainer = () => {
return (
<Container>
<ContainerHeader>
<DownloadsBreadcrumb />
</ContainerHeader>
<ContainerBody>
<DownloadsList />
</ContainerBody>
</Container>
);
};

View File

@@ -0,0 +1,80 @@
import { Folder } from 'lucide-react';
import { SaveStatus } from '@colanode/client/types';
import { DownloadStatus } from '@colanode/ui/components/downloads/download-status';
import { FileThumbnail } from '@colanode/ui/components/files/file-thumbnail';
import { Button } from '@colanode/ui/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@colanode/ui/components/ui/tooltip';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
export const DownloadsList = () => {
const workspace = useWorkspace();
const fileSaveListQuery = useQuery({
type: 'file.save.list',
accountId: workspace.accountId,
workspaceId: workspace.id,
});
const saves = fileSaveListQuery.data || [];
const handleOpenDirectory = (path: string) => {
window.colanode.showItemInFolder(path);
};
if (saves.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
No downloads yet
</div>
);
}
return (
<div className="px-10 py-4 max-w-[50rem] flex flex-col gap-4">
{saves.map((save) => (
<div
key={save.id}
className="border rounded-lg p-4 bg-card hover:bg-accent/50 transition-colors flex items-center gap-6"
>
<FileThumbnail
file={save.file}
className="size-10 text-muted-foreground"
/>
<div className="flex-1 flex flex-col gap-1 justify-center items-start">
<p className="font-medium text-sm truncate">
{save.file.attributes.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{save.path}
</p>
</div>
<div className="flex items-center gap-2">
{save.status === SaveStatus.Completed && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDirectory(save.path)}
className="h-8 w-8 p-0"
>
<Folder className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Show in folder</TooltipContent>
</Tooltip>
)}
<DownloadStatus status={save.status} progress={save.progress} />
</div>
</div>
))}
</div>
);
};

View File

@@ -2,6 +2,7 @@ import { LocalFileNode } from '@colanode/client/types';
import { FilePreview } from '@colanode/ui/components/files/file-preview';
import { useLayout } from '@colanode/ui/contexts/layout';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useNodeRadar } from '@colanode/ui/hooks/use-node-radar';
import { useQuery } from '@colanode/ui/hooks/use-query';
interface FileBlockProps {
@@ -18,6 +19,7 @@ export const FileBlock = ({ id }: FileBlockProps) => {
accountId: workspace.accountId,
workspaceId: workspace.id,
});
useNodeRadar(nodeGetQuery.data);
if (nodeGetQuery.isPending || !nodeGetQuery.data) {
return null;

View File

@@ -1,5 +1,7 @@
import { LocalFileNode } from '@colanode/client/types';
import { FileNoPreview } from '@colanode/ui/components/files/file-no-preview';
import { FilePreview } from '@colanode/ui/components/files/file-preview';
import { FileSaveButton } from '@colanode/ui/components/files/file-save-button';
import { FileSidebar } from '@colanode/ui/components/files/file-sidebar';
interface FileBodyProps {
@@ -7,10 +9,21 @@ interface FileBodyProps {
}
export const FileBody = ({ file }: FileBodyProps) => {
const canPreview =
file.attributes.subtype === 'image' || file.attributes.subtype === 'video';
return (
<div className="flex h-full max-h-full w-full flex-row items-center gap-2">
<div className="flex w-full max-w-full flex-grow items-center justify-center overflow-hidden p-10">
<FilePreview file={file} />
<div className="flex w-full max-w-full h-full flex-grow items-center justify-center overflow-hidden p-10 relative">
<div className="absolute top-4 right-4 z-10">
<FileSaveButton file={file} />
</div>
{canPreview ? (
<FilePreview file={file} />
) : (
<FileNoPreview mimeType={file.attributes.mimeType} />
)}
</div>
<div className="h-full w-72 min-w-72 overflow-hidden border-l border-gray-100 p-2 pl-3">
<FileSidebar file={file} />

View File

@@ -10,6 +10,7 @@ import {
ContainerSettings,
} from '@colanode/ui/components/ui/container';
import { useNodeContainer } from '@colanode/ui/hooks/use-node-container';
import { useNodeRadar } from '@colanode/ui/hooks/use-node-radar';
interface FileContainerProps {
fileId: string;
@@ -17,6 +18,7 @@ interface FileContainerProps {
export const FileContainer = ({ fileId }: FileContainerProps) => {
const data = useNodeContainer<LocalFileNode>(fileId);
useNodeRadar(data.node);
if (data.isPending) {
return null;

View File

@@ -0,0 +1,36 @@
import { Download } from 'lucide-react';
import { FileState } from '@colanode/client/types';
import { Spinner } from '@colanode/ui/components/ui/spinner';
interface FileDownloadProgressProps {
state: FileState | null | undefined;
}
export const FileDownloadProgress = ({ state }: FileDownloadProgressProps) => {
const progress = state?.downloadProgress || 0;
const showProgress = progress > 0;
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-4 text-muted-foreground">
<div className="relative">
<Spinner className="size-20 text-muted-foreground stroke-1" />
<div className="absolute inset-0 flex items-center justify-center">
<Download className="size-6 animate-pulse" />
</div>
</div>
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground">
Downloading file
</p>
{showProgress && (
<p className="mt-1 text-xs text-muted-foreground">
{Math.round(progress)}%
</p>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,63 +0,0 @@
import { Download } from 'lucide-react';
import { toast } from 'sonner';
import {
DownloadStatus,
FileState,
LocalFileNode,
} from '@colanode/client/types';
import { formatBytes } from '@colanode/core';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
interface FileDownloadProps {
file: LocalFileNode;
state: FileState | null | undefined;
}
export const FileDownload = ({ file, state }: FileDownloadProps) => {
const workspace = useWorkspace();
const isDownloading = state?.downloadStatus === DownloadStatus.Pending;
return (
<div className="flex h-full w-full items-center justify-center">
{isDownloading ? (
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Spinner className="size-8" />
<p className="text-sm">
Downloading file ({state?.downloadProgress}%)
</p>
</div>
) : (
<div
className="flex cursor-pointer flex-col items-center gap-3 text-muted-foreground hover:text-primary"
onClick={async (e) => {
e.stopPropagation();
e.preventDefault();
const result = await window.colanode.executeMutation({
type: 'file.download',
accountId: workspace.accountId,
workspaceId: workspace.id,
fileId: file.id,
});
if (!result.success) {
toast.error(result.error.message);
}
}}
>
<Download className="size-8" />
<p className="text-sm">
File is not downloaded in your device. Click to download.
</p>
<p className="text-xs text-muted-foreground">
{formatBytes(file.attributes.size)} -{' '}
{file.attributes.mimeType.split('/')[1]}
</p>
</div>
)}
</div>
);
};

View File

@@ -1,11 +1,11 @@
import { formatMimeType } from '@colanode/core';
import { FileIcon } from '@colanode/ui/components/files/file-icon';
interface FilePreviewOtherProps {
interface FileNoPreviewProps {
mimeType: string;
}
export const FilePreviewOther = ({ mimeType }: FilePreviewOtherProps) => {
export const FileNoPreview = ({ mimeType }: FileNoPreviewProps) => {
return (
<div className="flex flex-col items-center gap-3">
<FileIcon mimeType={mimeType} className="h-10 w-10" />

View File

@@ -1,11 +1,12 @@
import { match } from 'ts-pattern';
import { useEffect } from 'react';
import { LocalFileNode } from '@colanode/client/types';
import { FileDownload } from '@colanode/ui/components/files/file-download';
import { DownloadStatus, LocalFileNode } from '@colanode/client/types';
import { FileDownloadProgress } from '@colanode/ui/components/files/file-download-progress';
import { FileNoPreview } from '@colanode/ui/components/files/file-no-preview';
import { FilePreviewImage } from '@colanode/ui/components/files/previews/file-preview-image';
import { FilePreviewOther } from '@colanode/ui/components/files/previews/file-preview-other';
import { FilePreviewVideo } from '@colanode/ui/components/files/previews/file-preview-video';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { useQuery } from '@colanode/ui/hooks/use-query';
interface FilePreviewProps {
@@ -14,6 +15,7 @@ interface FilePreviewProps {
export const FilePreview = ({ file }: FilePreviewProps) => {
const workspace = useWorkspace();
const mutation = useMutation();
const fileStateQuery = useQuery({
type: 'file.state.get',
@@ -22,37 +24,56 @@ export const FilePreview = ({ file }: FilePreviewProps) => {
workspaceId: workspace.id,
});
const shouldFetchFileUrl = fileStateQuery.data?.downloadProgress === 100;
const isDownloading =
fileStateQuery.data?.downloadStatus === DownloadStatus.Pending;
const isDownloaded =
fileStateQuery.data?.downloadStatus === DownloadStatus.Completed;
const fileUrlGetQuery = useQuery(
{
type: 'file.url.get',
id: file.id,
extension: file.attributes.extension,
accountId: workspace.accountId,
workspaceId: workspace.id,
},
{
enabled: shouldFetchFileUrl,
useEffect(() => {
if (!fileStateQuery.isPending && !isDownloaded && !isDownloading) {
mutation.mutate({
input: {
type: 'file.download',
accountId: workspace.accountId,
workspaceId: workspace.id,
fileId: file.id,
path: null,
},
onError: (error) => {
console.error('Failed to start file download:', error.message);
},
});
}
);
}, [
fileStateQuery.isPending,
isDownloaded,
isDownloading,
mutation,
workspace.accountId,
workspace.id,
file.id,
]);
if (
fileStateQuery.isPending ||
(shouldFetchFileUrl && fileUrlGetQuery.isPending)
) {
if (fileStateQuery.isPending) {
return null;
}
const url = fileUrlGetQuery.data?.url;
if (fileStateQuery.data?.downloadProgress !== 100 || !url) {
return <FileDownload file={file} state={fileStateQuery.data} />;
if (isDownloading) {
return <FileDownloadProgress state={fileStateQuery.data} />;
}
return match(file.attributes.subtype)
.with('image', () => (
<FilePreviewImage url={url} name={file.attributes.name} />
))
.with('video', () => <FilePreviewVideo url={url} />)
.otherwise(() => <FilePreviewOther mimeType={file.attributes.mimeType} />);
const url = fileStateQuery.data?.url;
if (!url) {
return <FileNoPreview mimeType={file.attributes.mimeType} />;
}
if (file.attributes.subtype === 'image') {
return <FilePreviewImage url={url} name={file.attributes.name} />;
}
if (file.attributes.subtype === 'video') {
return <FilePreviewVideo url={url} />;
}
return <FileNoPreview mimeType={file.attributes.mimeType} />;
};

View File

@@ -0,0 +1,144 @@
import { Download } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { LocalFileNode } from '@colanode/client/types';
import { Button } from '@colanode/ui/components/ui/button';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useApp } from '@colanode/ui/contexts/app';
import { useLayout } from '@colanode/ui/contexts/layout';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { useQuery } from '@colanode/ui/hooks/use-query';
interface FileSaveButtonProps {
file: LocalFileNode;
}
export const FileSaveButton = ({ file }: FileSaveButtonProps) => {
const app = useApp();
const workspace = useWorkspace();
const mutation = useMutation();
const layout = useLayout();
const [isSaving, setIsSaving] = useState(false);
const fileStateQuery = useQuery({
type: 'file.state.get',
id: file.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
const handleDownloadDesktop = async () => {
const path = await window.colanode.showFileSaveDialog({
name: file.attributes.name,
});
if (!path) {
return;
}
mutation.mutate({
input: {
type: 'file.save',
accountId: workspace.accountId,
workspaceId: workspace.id,
fileId: file.id,
path,
},
onSuccess: () => {
layout.open('downloads');
},
onError: () => {
toast.error('Failed to save file');
},
});
};
const handleDownloadWeb = async () => {
if (fileStateQuery.isPending) {
return;
}
setIsSaving(true);
try {
const url = fileStateQuery.data?.url;
if (url) {
// the file is already downloaded locally, so we can just trigger a download
const link = document.createElement('a');
link.href = url;
link.download = file.attributes.name;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
// the file is not downloaded locally, so we need to download it
const request = await window.colanode.executeQuery({
type: 'file.download.request.get',
id: file.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
if (!request) {
toast.error('Failed to save file');
return;
}
const response = await fetch(request.url, {
method: 'GET',
headers: request.headers,
});
if (!response.ok) {
toast.error('Failed to save file');
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.attributes.name;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
}
} catch {
toast.error('Failed to save file');
} finally {
setIsSaving(false);
}
};
const handleDownload = () => {
if (app.type === 'desktop') {
handleDownloadDesktop();
} else if (app.type === 'web') {
handleDownloadWeb();
}
};
return (
<Button
variant="outline"
onClick={handleDownload}
disabled={fileStateQuery.isPending || isSaving}
>
{isSaving ? (
<Spinner className="size-4" />
) : (
<Download className="size-4" />
)}
Save
</Button>
);
};

View File

@@ -19,29 +19,14 @@ export const FileThumbnail = ({ file, className }: FileThumbnailProps) => {
workspaceId: workspace.id,
});
const fileUrlGetQuery = useQuery(
{
type: 'file.url.get',
id: file.id,
extension: file.attributes.extension,
accountId: workspace.accountId,
workspaceId: workspace.id,
},
{
enabled: fileStateGetQuery.data?.downloadProgress === 100,
}
);
const url = fileUrlGetQuery.data?.url;
if (
file.attributes.subtype === 'image' &&
fileStateGetQuery.data?.downloadProgress === 100 &&
url
fileStateGetQuery.data?.url
) {
return (
<img
src={url}
src={fileStateGetQuery.data?.url}
alt={file.attributes.name}
className={cn('object-contain object-center', className)}
/>

View File

@@ -5,6 +5,7 @@ import { getIdType, IdType } from '@colanode/core';
import { ChannelContainer } from '@colanode/ui/components/channels/channel-container';
import { ChatContainer } from '@colanode/ui/components/chats/chat-container';
import { DatabaseContainer } from '@colanode/ui/components/databases/database-container';
import { DownloadsList } from '@colanode/ui/components/downloads/downloads-list';
import { FileContainer } from '@colanode/ui/components/files/file-container';
import { FolderContainer } from '@colanode/ui/components/folders/folder-container';
import { MessageContainer } from '@colanode/ui/components/messages/message-container';
@@ -17,6 +18,24 @@ interface ContainerTabContentProps {
tab: ContainerTab;
}
const ContainerTabContentBody = ({ tab }: ContainerTabContentProps) => {
if (tab.path === 'downloads') {
return <DownloadsList />;
}
return 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, () => <DatabaseContainer databaseId={tab.path} />)
.with(IdType.Record, () => <RecordContainer recordId={tab.path} />)
.with(IdType.Chat, () => <ChatContainer chatId={tab.path} />)
.with(IdType.Folder, () => <FolderContainer folderId={tab.path} />)
.with(IdType.File, () => <FileContainer fileId={tab.path} />)
.with(IdType.Message, () => <MessageContainer messageId={tab.path} />)
.otherwise(() => null);
};
export const ContainerTabContent = ({ tab }: ContainerTabContentProps) => {
return (
<TabsContent
@@ -24,19 +43,7 @@ export const ContainerTabContent = ({ tab }: ContainerTabContentProps) => {
key={tab.path}
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, () => (
<DatabaseContainer databaseId={tab.path} />
))
.with(IdType.Record, () => <RecordContainer recordId={tab.path} />)
.with(IdType.Chat, () => <ChatContainer chatId={tab.path} />)
.with(IdType.Folder, () => <FolderContainer folderId={tab.path} />)
.with(IdType.File, () => <FileContainer fileId={tab.path} />)
.with(IdType.Message, () => <MessageContainer messageId={tab.path} />)
.otherwise(() => null)}
<ContainerTabContentBody tab={tab} />
</TabsContent>
);
};

View File

@@ -8,6 +8,7 @@ import { getIdType, IdType } from '@colanode/core';
import { ChannelContainerTab } from '@colanode/ui/components/channels/channel-container-tab';
import { ChatContainerTab } from '@colanode/ui/components/chats/chat-container-tab';
import { DatabaseContainerTab } from '@colanode/ui/components/databases/database-container-tab';
import { DownloadsContainerTab } from '@colanode/ui/components/downloads/downloads-container-tab';
import { FileContainerTab } from '@colanode/ui/components/files/file-container-tab';
import { FolderContainerTab } from '@colanode/ui/components/folders/folder-container-tab';
import { MessageContainerTab } from '@colanode/ui/components/messages/message-container-tab';
@@ -24,6 +25,31 @@ interface ContainerTabTriggerProps {
onMove: (before: string | null) => void;
}
const ContainerTabTriggerContent = ({ tab }: { tab: ContainerTab }) => {
if (tab.path === 'downloads') {
return <DownloadsContainerTab />;
}
return match(getIdType(tab.path))
.with(IdType.Space, () => <SpaceContainerTab spaceId={tab.path} />)
.with(IdType.Channel, () => (
<ChannelContainerTab
channelId={tab.path}
isActive={tab.active ?? false}
/>
))
.with(IdType.Page, () => <PageContainerTab pageId={tab.path} />)
.with(IdType.Database, () => <DatabaseContainerTab databaseId={tab.path} />)
.with(IdType.Record, () => <RecordContainerTab recordId={tab.path} />)
.with(IdType.Chat, () => (
<ChatContainerTab chatId={tab.path} isActive={tab.active ?? false} />
))
.with(IdType.Folder, () => <FolderContainerTab folderId={tab.path} />)
.with(IdType.File, () => <FileContainerTab fileId={tab.path} />)
.with(IdType.Message, () => <MessageContainerTab messageId={tab.path} />)
.otherwise(() => null);
};
export const ContainerTabTrigger = ({
tab,
onClose,
@@ -83,34 +109,10 @@ export const ContainerTabTrigger = ({
onOpen();
}
}}
ref={dragDropRef as React.LegacyRef<HTMLButtonElement>}
ref={dragDropRef as React.RefAttributes<HTMLButtonElement>['ref']}
>
<div className="overflow-hidden truncate">
{match(getIdType(tab.path))
.with(IdType.Space, () => <SpaceContainerTab spaceId={tab.path} />)
.with(IdType.Channel, () => (
<ChannelContainerTab
channelId={tab.path}
isActive={tab.active ?? false}
/>
))
.with(IdType.Page, () => <PageContainerTab pageId={tab.path} />)
.with(IdType.Database, () => (
<DatabaseContainerTab databaseId={tab.path} />
))
.with(IdType.Record, () => <RecordContainerTab recordId={tab.path} />)
.with(IdType.Chat, () => (
<ChatContainerTab
chatId={tab.path}
isActive={tab.active ?? false}
/>
))
.with(IdType.Folder, () => <FolderContainerTab folderId={tab.path} />)
.with(IdType.File, () => <FileContainerTab fileId={tab.path} />)
.with(IdType.Message, () => (
<MessageContainerTab messageId={tab.path} />
))
.otherwise(() => null)}
<ContainerTabTriggerContent tab={tab} />
</div>
<div
className="opacity-0 group-hover/tab:opacity-100 group-data-[state=active]/tab:opacity-100 transition-opacity duration-200 flex-shrink-0 cursor-pointer"

View File

@@ -18,7 +18,6 @@ interface PageContainerProps {
export const PageContainer = ({ pageId }: PageContainerProps) => {
const data = useNodeContainer<LocalPageNode>(pageId);
useNodeRadar(data.node);
if (data.isPending) {

View File

@@ -4,8 +4,8 @@ import { X } from 'lucide-react';
import { match } from 'ts-pattern';
import { TempFile } from '@colanode/client/types';
import { FileNoPreview } from '@colanode/ui/components/files/file-no-preview';
import { FilePreviewImage } from '@colanode/ui/components/files/previews/file-preview-image';
import { FilePreviewOther } from '@colanode/ui/components/files/previews/file-preview-other';
import { FilePreviewVideo } from '@colanode/ui/components/files/previews/file-preview-video';
export const TempFileNodeView = ({ node, deleteNode }: NodeViewProps) => {
@@ -34,8 +34,9 @@ export const TempFileNodeView = ({ node, deleteNode }: NodeViewProps) => {
{match(type)
.with('image', () => <FilePreviewImage url={file.url} name={name} />)
.with('video', () => <FilePreviewVideo url={file.url} />)
.with('other', () => <FilePreviewOther mimeType={mimeType} />)
.otherwise(() => null)}
.otherwise(() => (
<FileNoPreview mimeType={mimeType} />
))}
</div>
</NodeViewWrapper>
);

View File

@@ -4,7 +4,7 @@ import { Node } from '@colanode/core';
import { useRadar } from '@colanode/ui/contexts/radar';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
export const useNodeRadar = (node: Node | null) => {
export const useNodeRadar = (node: Node | null | undefined) => {
const workspace = useWorkspace();
const radar = useRadar();

View File

@@ -3,6 +3,10 @@ import { MutationInput, MutationResult } from '@colanode/client/mutations';
import { QueryInput, QueryMap } from '@colanode/client/queries';
import { TempFile } from '@colanode/client/types';
interface SaveDialogOptions {
name: string;
}
export interface ColanodeWindowApi {
init: () => Promise<void>;
executeMutation: <T extends MutationInput>(
@@ -18,6 +22,10 @@ export interface ColanodeWindowApi {
unsubscribeQuery: (key: string) => Promise<void>;
saveTempFile: (file: File) => Promise<TempFile>;
openExternalUrl: (url: string) => Promise<void>;
showItemInFolder: (path: string) => Promise<void>;
showFileSaveDialog: (
options: SaveDialogOptions
) => Promise<string | undefined>;
}
declare global {