mirror of
https://github.com/colanode/colanode.git
synced 2025-12-29 00:25:03 +01:00
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:
60
packages/ui/src/components/downloads/download-status.tsx
Normal file
60
packages/ui/src/components/downloads/download-status.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
20
packages/ui/src/components/downloads/downloads-container.tsx
Normal file
20
packages/ui/src/components/downloads/downloads-container.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
80
packages/ui/src/components/downloads/downloads-list.tsx
Normal file
80
packages/ui/src/components/downloads/downloads-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
packages/ui/src/components/files/file-download-progress.tsx
Normal file
36
packages/ui/src/components/files/file-download-progress.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
144
packages/ui/src/components/files/file-save-button.tsx
Normal file
144
packages/ui/src/components/files/file-save-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -18,7 +18,6 @@ interface PageContainerProps {
|
||||
|
||||
export const PageContainer = ({ pageId }: PageContainerProps) => {
|
||||
const data = useNodeContainer<LocalPageNode>(pageId);
|
||||
|
||||
useNodeRadar(data.node);
|
||||
|
||||
if (data.isPending) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user