mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 19:57:46 +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:
@@ -5,6 +5,7 @@ import {
|
||||
protocol,
|
||||
shell,
|
||||
globalShortcut,
|
||||
dialog,
|
||||
} from 'electron';
|
||||
|
||||
import started from 'electron-squirrel-startup';
|
||||
@@ -251,3 +252,22 @@ ipcMain.handle(
|
||||
ipcMain.handle('open-external-url', (_, url: string) => {
|
||||
shell.openExternal(url);
|
||||
});
|
||||
|
||||
ipcMain.handle('show-item-in-folder', (_, path: string) => {
|
||||
shell.showItemInFolder(path);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'show-file-save-dialog',
|
||||
async (_, { name }: { name: string }) => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath: name,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.filePath;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -55,6 +55,14 @@ contextBridge.exposeInMainWorld('colanode', {
|
||||
openExternalUrl: async (url: string) => {
|
||||
return ipcRenderer.invoke('open-external-url', url);
|
||||
},
|
||||
|
||||
showItemInFolder: async (path: string) => {
|
||||
return ipcRenderer.invoke('show-item-in-folder', path);
|
||||
},
|
||||
|
||||
showFileSaveDialog: async ({ name }: { name: string }) => {
|
||||
return ipcRenderer.invoke('show-file-save-dialog', { name });
|
||||
},
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('eventBus', {
|
||||
|
||||
@@ -40,6 +40,10 @@ const initializeApp = async () => {
|
||||
openExternalUrl: async (url) => {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
showItemInFolder: async () => {
|
||||
// No-op on web
|
||||
},
|
||||
showFileSaveDialog: async () => undefined,
|
||||
};
|
||||
|
||||
window.eventBus = eventBus;
|
||||
|
||||
@@ -90,7 +90,7 @@ export class FileDownloadMutationHandler
|
||||
type: 'file.state.updated',
|
||||
accountId: workspace.accountId,
|
||||
workspaceId: workspace.id,
|
||||
fileState: mapFileState(updatedFileState),
|
||||
fileState: mapFileState(updatedFileState, null),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// import path from 'path';
|
||||
// import fs from 'fs';
|
||||
|
||||
import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base';
|
||||
import { MutationHandler } from '@colanode/client/lib/types';
|
||||
import {
|
||||
FileSaveTempMutationInput,
|
||||
FileSaveTempMutationOutput,
|
||||
} from '@colanode/client/mutations';
|
||||
|
||||
export class FileSaveTempMutationHandler
|
||||
extends WorkspaceMutationHandlerBase
|
||||
implements MutationHandler<FileSaveTempMutationInput>
|
||||
{
|
||||
async handleMutation(
|
||||
_: FileSaveTempMutationInput
|
||||
): Promise<FileSaveTempMutationOutput> {
|
||||
throw new Error('Not implemented');
|
||||
|
||||
// const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
||||
// const directoryPath = this.app.paths.workspaceTempFiles(
|
||||
// workspace.accountId,
|
||||
// workspace.id
|
||||
// );
|
||||
|
||||
// const fileName = this.generateUniqueName(directoryPath, input.name);
|
||||
// const filePath = path.join(directoryPath, fileName);
|
||||
|
||||
// if (!fs.existsSync(directoryPath)) {
|
||||
// fs.mkdirSync(directoryPath, { recursive: true });
|
||||
// }
|
||||
|
||||
// const buffer = Buffer.from(input.buffer);
|
||||
// fs.writeFileSync(filePath, buffer);
|
||||
|
||||
// return {
|
||||
// path: filePath,
|
||||
// };
|
||||
}
|
||||
|
||||
// private generateUniqueName(directoryPath: string, name: string): string {
|
||||
// let result = name;
|
||||
// let counter = 1;
|
||||
// while (fs.existsSync(path.join(directoryPath, result))) {
|
||||
// const nameWithoutExtension = path.basename(name, path.extname(name));
|
||||
// const extension = path.extname(name);
|
||||
// result = `${nameWithoutExtension}_${counter}${extension}`;
|
||||
// counter++;
|
||||
// }
|
||||
|
||||
// return result;
|
||||
// }
|
||||
}
|
||||
48
packages/client/src/handlers/mutations/files/file-save.ts
Normal file
48
packages/client/src/handlers/mutations/files/file-save.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base';
|
||||
import { mapNode } from '@colanode/client/lib/mappers';
|
||||
import { MutationHandler } from '@colanode/client/lib/types';
|
||||
import { MutationError, MutationErrorCode } from '@colanode/client/mutations';
|
||||
import {
|
||||
FileSaveMutationInput,
|
||||
FileSaveMutationOutput,
|
||||
} from '@colanode/client/mutations/files/file-save';
|
||||
import { LocalFileNode } from '@colanode/client/types';
|
||||
import { FileStatus } from '@colanode/core';
|
||||
|
||||
export class FileSaveMutationHandler
|
||||
extends WorkspaceMutationHandlerBase
|
||||
implements MutationHandler<FileSaveMutationInput>
|
||||
{
|
||||
async handleMutation(
|
||||
input: FileSaveMutationInput
|
||||
): Promise<FileSaveMutationOutput> {
|
||||
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
||||
|
||||
const node = await workspace.database
|
||||
.selectFrom('nodes')
|
||||
.selectAll()
|
||||
.where('id', '=', input.fileId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!node) {
|
||||
throw new MutationError(
|
||||
MutationErrorCode.FileNotFound,
|
||||
'The file you are trying to save does not exist.'
|
||||
);
|
||||
}
|
||||
|
||||
const file = mapNode(node) as LocalFileNode;
|
||||
if (file.attributes.status !== FileStatus.Ready) {
|
||||
throw new MutationError(
|
||||
MutationErrorCode.FileNotReady,
|
||||
'The file you are trying to download is not uploaded by the author yet.'
|
||||
);
|
||||
}
|
||||
|
||||
workspace.files.saveFile(file, input.path);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import { DocumentUpdateMutationHandler } from './documents/document-update';
|
||||
import { FileCreateMutationHandler } from './files/file-create';
|
||||
import { FileDeleteMutationHandler } from './files/file-delete';
|
||||
import { FileDownloadMutationHandler } from './files/file-download';
|
||||
import { FileSaveTempMutationHandler } from './files/file-save-temp';
|
||||
import { FileSaveMutationHandler } from './files/file-save';
|
||||
import { FolderCreateMutationHandler } from './folders/folder-create';
|
||||
import { FolderDeleteMutationHandler } from './folders/folder-delete';
|
||||
import { FolderUpdateMutationHandler } from './folders/folder-update';
|
||||
@@ -129,7 +129,7 @@ export const buildMutationHandlerMap = (
|
||||
'folder.create': new FolderCreateMutationHandler(app),
|
||||
'file.create': new FileCreateMutationHandler(app),
|
||||
'file.download': new FileDownloadMutationHandler(app),
|
||||
'file.save.temp': new FileSaveTempMutationHandler(app),
|
||||
'file.save': new FileSaveMutationHandler(app),
|
||||
'space.avatar.update': new SpaceAvatarUpdateMutationHandler(app),
|
||||
'space.description.update': new SpaceDescriptionUpdateMutationHandler(app),
|
||||
'space.name.update': new SpaceNameUpdateMutationHandler(app),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
|
||||
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
|
||||
import {
|
||||
FileDownloadRequestGetQueryInput,
|
||||
FileDownloadRequestGetQueryOutput,
|
||||
} from '@colanode/client/queries/files/file-download-request-get';
|
||||
import { ApiHeader, build } from '@colanode/core';
|
||||
|
||||
export class FileDownloadRequestGetQueryHandler
|
||||
extends WorkspaceQueryHandlerBase
|
||||
implements QueryHandler<FileDownloadRequestGetQueryInput>
|
||||
{
|
||||
public async handleQuery(
|
||||
input: FileDownloadRequestGetQueryInput
|
||||
): Promise<FileDownloadRequestGetQueryOutput | null> {
|
||||
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
||||
const baseUrl = workspace.account.server.httpBaseUrl;
|
||||
|
||||
const url = `${baseUrl}/v1/workspaces/${workspace.id}/files/${input.id}`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${workspace.account.token}`,
|
||||
[ApiHeader.ClientType]: this.app.meta.type,
|
||||
[ApiHeader.ClientPlatform]: this.app.meta.platform,
|
||||
[ApiHeader.ClientVersion]: build.version,
|
||||
};
|
||||
|
||||
return {
|
||||
url,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
public async checkForChanges(): Promise<
|
||||
ChangeCheckResult<FileDownloadRequestGetQueryInput>
|
||||
> {
|
||||
return {
|
||||
hasChanges: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
54
packages/client/src/handlers/queries/files/file-save-list.ts
Normal file
54
packages/client/src/handlers/queries/files/file-save-list.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
|
||||
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
|
||||
import { FileSaveListQueryInput } from '@colanode/client/queries/files/file-save-list';
|
||||
import { Event } from '@colanode/client/types/events';
|
||||
import { FileSaveState } from '@colanode/client/types/files';
|
||||
|
||||
export class FileSaveListQueryHandler
|
||||
extends WorkspaceQueryHandlerBase
|
||||
implements QueryHandler<FileSaveListQueryInput>
|
||||
{
|
||||
public async handleQuery(
|
||||
input: FileSaveListQueryInput
|
||||
): Promise<FileSaveState[]> {
|
||||
return this.getSaves(input);
|
||||
}
|
||||
|
||||
public async checkForChanges(
|
||||
event: Event,
|
||||
input: FileSaveListQueryInput,
|
||||
_: FileSaveState[]
|
||||
): Promise<ChangeCheckResult<FileSaveListQueryInput>> {
|
||||
if (
|
||||
event.type === 'workspace.deleted' &&
|
||||
event.workspace.accountId === input.accountId &&
|
||||
event.workspace.id === input.workspaceId
|
||||
) {
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'file.save.updated' &&
|
||||
event.accountId === input.accountId &&
|
||||
event.workspaceId === input.workspaceId
|
||||
) {
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: this.getSaves(input),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasChanges: false,
|
||||
};
|
||||
}
|
||||
|
||||
private getSaves(input: FileSaveListQueryInput): FileSaveState[] {
|
||||
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
||||
const saves = workspace.files.getSaves();
|
||||
return saves;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
|
||||
import { mapFileState } from '@colanode/client/lib/mappers';
|
||||
import { mapFileState, mapNode } from '@colanode/client/lib/mappers';
|
||||
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
|
||||
import { FileStateGetQueryInput } from '@colanode/client/queries/files/file-state-get';
|
||||
import { LocalFileNode } from '@colanode/client/types';
|
||||
import { Event } from '@colanode/client/types/events';
|
||||
import { FileState } from '@colanode/client/types/files';
|
||||
import { DownloadStatus, FileState } from '@colanode/client/types/files';
|
||||
|
||||
export class FileStateGetQueryHandler
|
||||
extends WorkspaceQueryHandlerBase
|
||||
@@ -44,6 +45,18 @@ export class FileStateGetQueryHandler
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'file.state.deleted' &&
|
||||
event.accountId === input.accountId &&
|
||||
event.workspaceId === input.workspaceId &&
|
||||
event.fileId === input.id
|
||||
) {
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'node.deleted' &&
|
||||
event.accountId === input.accountId &&
|
||||
@@ -79,6 +92,17 @@ export class FileStateGetQueryHandler
|
||||
): Promise<FileState | null> {
|
||||
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
||||
|
||||
const node = await workspace.database
|
||||
.selectFrom('nodes')
|
||||
.selectAll()
|
||||
.where('id', '=', input.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = mapNode(node) as LocalFileNode;
|
||||
const fileState = await workspace.database
|
||||
.selectFrom('file_states')
|
||||
.selectAll()
|
||||
@@ -89,6 +113,21 @@ export class FileStateGetQueryHandler
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapFileState(fileState);
|
||||
let url: string | null = null;
|
||||
if (fileState.download_status === DownloadStatus.Completed) {
|
||||
const filePath = this.app.path.workspaceFile(
|
||||
input.accountId,
|
||||
input.workspaceId,
|
||||
input.id,
|
||||
file.attributes.extension
|
||||
);
|
||||
|
||||
const exists = await this.app.fs.exists(filePath);
|
||||
if (exists) {
|
||||
url = await this.app.fs.url(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return mapFileState(fileState, url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
|
||||
import {
|
||||
FileUrlGetQueryInput,
|
||||
FileUrlGetQueryOutput,
|
||||
} from '@colanode/client/queries/files/file-url-get';
|
||||
import { AppService } from '@colanode/client/services';
|
||||
import { Event } from '@colanode/client/types/events';
|
||||
|
||||
export class FileUrlGetQueryHandler
|
||||
implements QueryHandler<FileUrlGetQueryInput>
|
||||
{
|
||||
private readonly app: AppService;
|
||||
|
||||
constructor(app: AppService) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
public async handleQuery(
|
||||
input: FileUrlGetQueryInput
|
||||
): Promise<FileUrlGetQueryOutput> {
|
||||
return await this.buildFileUrl(input);
|
||||
}
|
||||
|
||||
public async checkForChanges(
|
||||
event: Event,
|
||||
input: FileUrlGetQueryInput,
|
||||
_: FileUrlGetQueryOutput
|
||||
): Promise<ChangeCheckResult<FileUrlGetQueryInput>> {
|
||||
if (
|
||||
event.type === 'workspace.deleted' &&
|
||||
event.workspace.accountId === input.accountId &&
|
||||
event.workspace.id === input.workspaceId
|
||||
) {
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: {
|
||||
url: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'file.state.updated' &&
|
||||
event.accountId === input.accountId &&
|
||||
event.workspaceId === input.workspaceId &&
|
||||
event.fileState.id === input.id
|
||||
) {
|
||||
const output = await this.handleQuery(input);
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: output,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'node.deleted' &&
|
||||
event.accountId === input.accountId &&
|
||||
event.workspaceId === input.workspaceId &&
|
||||
event.node.id === input.id
|
||||
) {
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: {
|
||||
url: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'node.created' &&
|
||||
event.accountId === input.accountId &&
|
||||
event.workspaceId === input.workspaceId &&
|
||||
event.node.id === input.id
|
||||
) {
|
||||
const newOutput = await this.handleQuery(input);
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: newOutput,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasChanges: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildFileUrl(
|
||||
input: FileUrlGetQueryInput
|
||||
): Promise<FileUrlGetQueryOutput> {
|
||||
const filePath = this.app.path.workspaceFile(
|
||||
input.accountId,
|
||||
input.workspaceId,
|
||||
input.id,
|
||||
input.extension
|
||||
);
|
||||
|
||||
const exists = await this.app.fs.exists(filePath);
|
||||
if (!exists) {
|
||||
return {
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
|
||||
const url = await this.app.fs.url(filePath);
|
||||
return {
|
||||
url,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,10 @@ import { EmojiGetQueryHandler } from './emojis/emoji-get';
|
||||
import { EmojiGetBySkinIdQueryHandler } from './emojis/emoji-get-by-skin-id';
|
||||
import { EmojiListQueryHandler } from './emojis/emoji-list';
|
||||
import { EmojiSearchQueryHandler } from './emojis/emoji-search';
|
||||
import { FileDownloadRequestGetQueryHandler } from './files/file-download-request-get';
|
||||
import { FileListQueryHandler } from './files/file-list';
|
||||
import { FileSaveListQueryHandler } from './files/file-save-list';
|
||||
import { FileStateGetQueryHandler } from './files/file-state-get';
|
||||
import { FileUrlGetQueryHandler } from './files/file-url-get';
|
||||
import { IconCategoryListQueryHandler } from './icons/icon-category-list';
|
||||
import { IconListQueryHandler } from './icons/icon-list';
|
||||
import { IconSearchQueryHandler } from './icons/icon-search';
|
||||
@@ -62,7 +63,6 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
|
||||
'workspace.list': new WorkspaceListQueryHandler(app),
|
||||
'user.list': new UserListQueryHandler(app),
|
||||
'file.list': new FileListQueryHandler(app),
|
||||
'file.url.get': new FileUrlGetQueryHandler(app),
|
||||
'emoji.list': new EmojiListQueryHandler(app),
|
||||
'emoji.get': new EmojiGetQueryHandler(app),
|
||||
'emoji.get.by.skin.id': new EmojiGetBySkinIdQueryHandler(app),
|
||||
@@ -80,6 +80,8 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
|
||||
'record.search': new RecordSearchQueryHandler(app),
|
||||
'user.get': new UserGetQueryHandler(app),
|
||||
'file.state.get': new FileStateGetQueryHandler(app),
|
||||
'file.download.request.get': new FileDownloadRequestGetQueryHandler(app),
|
||||
'file.save.list': new FileSaveListQueryHandler(app),
|
||||
'chat.list': new ChatListQueryHandler(app),
|
||||
'space.list': new SpaceListQueryHandler(app),
|
||||
'workspace.metadata.list': new WorkspaceMetadataListQueryHandler(app),
|
||||
|
||||
@@ -187,7 +187,10 @@ export const mapNodeInteraction = (
|
||||
};
|
||||
};
|
||||
|
||||
export const mapFileState = (row: SelectFileState): FileState => {
|
||||
export const mapFileState = (
|
||||
row: SelectFileState,
|
||||
url: string | null
|
||||
): FileState => {
|
||||
return {
|
||||
id: row.id,
|
||||
version: row.version,
|
||||
@@ -201,6 +204,7 @@ export const mapFileState = (row: SelectFileState): FileState => {
|
||||
uploadRetries: row.upload_retries,
|
||||
uploadStartedAt: row.upload_started_at,
|
||||
uploadCompletedAt: row.upload_completed_at,
|
||||
url,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export type FileDownloadMutationInput = {
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
fileId: string;
|
||||
path: string | null;
|
||||
};
|
||||
|
||||
export type FileDownloadMutationOutput = {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export type FileSaveTempMutationInput = {
|
||||
type: 'file.save.temp';
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
buffer: ArrayBuffer;
|
||||
};
|
||||
|
||||
export type FileSaveTempMutationOutput = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
declare module '@colanode/client/mutations' {
|
||||
interface MutationMap {
|
||||
'file.save.temp': {
|
||||
input: FileSaveTempMutationInput;
|
||||
output: FileSaveTempMutationOutput;
|
||||
};
|
||||
}
|
||||
}
|
||||
20
packages/client/src/mutations/files/file-save.ts
Normal file
20
packages/client/src/mutations/files/file-save.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type FileSaveMutationInput = {
|
||||
type: 'file.save';
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
fileId: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type FileSaveMutationOutput = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
declare module '@colanode/client/mutations' {
|
||||
interface MutationMap {
|
||||
'file.save': {
|
||||
input: FileSaveMutationInput;
|
||||
output: FileSaveMutationOutput;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export * from './documents/document-update';
|
||||
export * from './files/file-create';
|
||||
export * from './files/file-delete';
|
||||
export * from './files/file-download';
|
||||
export * from './files/file-save-temp';
|
||||
export * from './files/file-save';
|
||||
export * from './folders/folder-create';
|
||||
export * from './folders/folder-delete';
|
||||
export * from './folders/folder-update';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export type FileDownloadRequestGetQueryInput = {
|
||||
type: 'file.download.request.get';
|
||||
id: string;
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export type FileDownloadRequestGetQueryOutput = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
declare module '@colanode/client/queries' {
|
||||
interface QueryMap {
|
||||
'file.download.request.get': {
|
||||
input: FileDownloadRequestGetQueryInput;
|
||||
output: FileDownloadRequestGetQueryOutput | null;
|
||||
};
|
||||
}
|
||||
}
|
||||
16
packages/client/src/queries/files/file-save-list.ts
Normal file
16
packages/client/src/queries/files/file-save-list.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FileSaveState } from '@colanode/client/types/files';
|
||||
|
||||
export type FileSaveListQueryInput = {
|
||||
type: 'file.save.list';
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
declare module '@colanode/client/queries' {
|
||||
interface QueryMap {
|
||||
'file.save.list': {
|
||||
input: FileSaveListQueryInput;
|
||||
output: FileSaveState[];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export type FileUrlGetQueryInput = {
|
||||
type: 'file.url.get';
|
||||
id: string;
|
||||
extension: string;
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export type FileUrlGetQueryOutput = {
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
declare module '@colanode/client/queries' {
|
||||
interface QueryMap {
|
||||
'file.url.get': {
|
||||
input: FileUrlGetQueryInput;
|
||||
output: FileUrlGetQueryOutput;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export * from './emojis/emoji-list';
|
||||
export * from './emojis/emoji-search';
|
||||
export * from './files/file-list';
|
||||
export * from './files/file-state-get';
|
||||
export * from './files/file-download-request-get';
|
||||
export * from './files/file-save-list';
|
||||
export * from './icons/icon-category-list';
|
||||
export * from './icons/icon-list';
|
||||
export * from './icons/icon-search';
|
||||
@@ -36,7 +38,6 @@ export * from './workspaces/workspace-get';
|
||||
export * from './workspaces/workspace-list';
|
||||
export * from './workspaces/workspace-metadata-list';
|
||||
export * from './avatars/avatar-url-get';
|
||||
export * from './files/file-url-get';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface QueryMap {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import ms from 'ms';
|
||||
|
||||
import {
|
||||
@@ -13,6 +14,8 @@ import { AppService } from '@colanode/client/services/app-service';
|
||||
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
|
||||
import {
|
||||
DownloadStatus,
|
||||
FileSaveState,
|
||||
SaveStatus,
|
||||
TempFile,
|
||||
UploadStatus,
|
||||
} from '@colanode/client/types/files';
|
||||
@@ -36,6 +39,7 @@ export class FileService {
|
||||
private readonly app: AppService;
|
||||
private readonly workspace: WorkspaceService;
|
||||
private readonly filesDir: string;
|
||||
private readonly saves: FileSaveState[] = [];
|
||||
|
||||
private readonly uploadsEventLoop: EventLoop;
|
||||
private readonly downloadsEventLoop: EventLoop;
|
||||
@@ -64,9 +68,9 @@ export class FileService {
|
||||
);
|
||||
|
||||
this.cleanupEventLoop = new EventLoop(
|
||||
ms('10 minutes'),
|
||||
ms('5 minutes'),
|
||||
this.cleanDeletedFiles.bind(this)
|
||||
ms('1 minute'),
|
||||
this.cleanupFiles.bind(this)
|
||||
);
|
||||
|
||||
this.uploadsEventLoop.start();
|
||||
@@ -162,16 +166,48 @@ export class FileService {
|
||||
);
|
||||
}
|
||||
|
||||
const url = await this.app.fs.url(destinationFilePath);
|
||||
eventBus.publish({
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(createdFileState),
|
||||
fileState: mapFileState(createdFileState, url),
|
||||
});
|
||||
|
||||
this.triggerUploads();
|
||||
}
|
||||
|
||||
public saveFile(file: LocalFileNode, path: string): void {
|
||||
const id = generateId(IdType.Save);
|
||||
const state: FileSaveState = {
|
||||
id,
|
||||
file,
|
||||
status: SaveStatus.Active,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
path,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
this.saves.push(state);
|
||||
this.processSaveAsync(state);
|
||||
|
||||
eventBus.publish({
|
||||
type: 'file.save.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileSave: state,
|
||||
});
|
||||
}
|
||||
|
||||
public getSaves(): FileSaveState[] {
|
||||
const clonedSaves = cloneDeep(this.saves);
|
||||
return clonedSaves.sort((a, b) => {
|
||||
// latest first
|
||||
return -a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteFile(node: SelectNode): Promise<void> {
|
||||
const file = mapNode(node);
|
||||
|
||||
@@ -220,6 +256,37 @@ export class FileService {
|
||||
}
|
||||
|
||||
private async uploadFile(state: SelectFileState): Promise<void> {
|
||||
const node = await this.workspace.database
|
||||
.selectFrom('nodes')
|
||||
.selectAll()
|
||||
.where('id', '=', state.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.server_revision === '0') {
|
||||
// file is not synced with the server, we need to wait for the sync to complete
|
||||
return;
|
||||
}
|
||||
|
||||
const file = mapNode(node) as LocalFileNode;
|
||||
const filePath = this.buildFilePath(file.id, file.attributes.extension);
|
||||
const exists = await this.app.fs.exists(filePath);
|
||||
if (!exists) {
|
||||
debug(`File ${file.id} not found on disk`);
|
||||
|
||||
await this.workspace.database
|
||||
.deleteFrom('file_states')
|
||||
.returningAll()
|
||||
.where('id', '=', state.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const url = await this.app.fs.url(filePath);
|
||||
if (state.upload_retries && state.upload_retries >= UPLOAD_RETRIES_LIMIT) {
|
||||
debug(`File ${state.id} upload retries limit reached, marking as failed`);
|
||||
|
||||
@@ -238,29 +305,13 @@ export class FileService {
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(updatedFileState),
|
||||
fileState: mapFileState(updatedFileState, url),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const node = await this.workspace.database
|
||||
.selectFrom('nodes')
|
||||
.selectAll()
|
||||
.where('id', '=', state.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = mapNode(node) as LocalFileNode;
|
||||
if (node.server_revision === '0') {
|
||||
// file is not synced with the server, we need to wait for the sync to complete
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.attributes.status === FileStatus.Ready) {
|
||||
const updatedFileState = await this.workspace.database
|
||||
.updateTable('file_states')
|
||||
@@ -278,20 +329,13 @@ export class FileService {
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(updatedFileState),
|
||||
fileState: mapFileState(updatedFileState, url),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = this.buildFilePath(file.id, file.attributes.extension);
|
||||
const exists = await this.app.fs.exists(filePath);
|
||||
if (!exists) {
|
||||
debug(`File ${file.id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStream = await this.app.fs.readStream(filePath);
|
||||
|
||||
@@ -322,7 +366,7 @@ export class FileService {
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(finalFileState),
|
||||
fileState: mapFileState(finalFileState, url),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -342,7 +386,7 @@ export class FileService {
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(updatedFileState),
|
||||
fileState: mapFileState(updatedFileState, url),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -394,7 +438,7 @@ export class FileService {
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(updatedFileState),
|
||||
fileState: mapFileState(updatedFileState, null),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -421,6 +465,8 @@ export class FileService {
|
||||
const filePath = this.buildFilePath(file.id, file.attributes.extension);
|
||||
const exists = await this.app.fs.exists(filePath);
|
||||
if (exists) {
|
||||
const url = await this.app.fs.url(filePath);
|
||||
|
||||
const updatedFileState = await this.workspace.database
|
||||
.updateTable('file_states')
|
||||
.returningAll()
|
||||
@@ -437,7 +483,7 @@ export class FileService {
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(updatedFileState),
|
||||
fileState: mapFileState(updatedFileState, url),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -468,7 +514,7 @@ export class FileService {
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(updatedFileState),
|
||||
fileState: mapFileState(updatedFileState, null),
|
||||
});
|
||||
},
|
||||
}
|
||||
@@ -476,6 +522,7 @@ export class FileService {
|
||||
|
||||
const writeStream = await this.app.fs.writeStream(filePath);
|
||||
await response.body?.pipeTo(writeStream);
|
||||
const url = await this.app.fs.url(filePath);
|
||||
|
||||
const updatedFileState = await this.workspace.database
|
||||
.updateTable('file_states')
|
||||
@@ -493,7 +540,7 @@ export class FileService {
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(updatedFileState),
|
||||
fileState: mapFileState(updatedFileState, url),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -509,13 +556,115 @@ export class FileService {
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(updatedFileState),
|
||||
fileState: mapFileState(updatedFileState, null),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanDeletedFiles(): Promise<void> {
|
||||
private buildFilePath(id: string, extension: string): string {
|
||||
return this.app.path.join(this.filesDir, `${id}${extension}`);
|
||||
}
|
||||
|
||||
private async processSaveAsync(save: FileSaveState): Promise<void> {
|
||||
if (this.app.meta.type !== 'desktop') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileState = await this.workspace.database
|
||||
.selectFrom('file_states')
|
||||
.selectAll()
|
||||
.where('id', '=', save.file.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
// if file is already downloaded, copy it to the save path
|
||||
if (fileState && fileState.download_progress === 100) {
|
||||
const sourceFilePath = this.buildFilePath(
|
||||
save.file.id,
|
||||
save.file.attributes.extension
|
||||
);
|
||||
|
||||
await this.app.fs.copy(sourceFilePath, save.path);
|
||||
save.status = SaveStatus.Completed;
|
||||
save.completedAt = new Date().toISOString();
|
||||
save.progress = 100;
|
||||
|
||||
eventBus.publish({
|
||||
type: 'file.save.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileSave: save,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// if file is not downloaded, download it
|
||||
try {
|
||||
const response = await this.workspace.account.client.get(
|
||||
`v1/workspaces/${this.workspace.id}/files/${save.file.id}`,
|
||||
{
|
||||
onDownloadProgress: async (progress, _chunk) => {
|
||||
const percent = Math.round((progress.percent || 0) * 100);
|
||||
save.progress = percent;
|
||||
|
||||
eventBus.publish({
|
||||
type: 'file.save.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileSave: save,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const writeStream = await this.app.fs.writeStream(save.path);
|
||||
await response.body?.pipeTo(writeStream);
|
||||
|
||||
save.status = SaveStatus.Completed;
|
||||
save.completedAt = new Date().toISOString();
|
||||
save.progress = 100;
|
||||
|
||||
eventBus.publish({
|
||||
type: 'file.save.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileSave: save,
|
||||
});
|
||||
} catch {
|
||||
save.status = SaveStatus.Failed;
|
||||
save.completedAt = new Date().toISOString();
|
||||
save.progress = 0;
|
||||
|
||||
eventBus.publish({
|
||||
type: 'file.save.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileSave: save,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
debug(`Error saving file ${save.file.id}: ${error}`);
|
||||
save.status = SaveStatus.Failed;
|
||||
save.completedAt = new Date().toISOString();
|
||||
save.progress = 0;
|
||||
|
||||
eventBus.publish({
|
||||
type: 'file.save.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileSave: save,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupFiles(): Promise<void> {
|
||||
await this.cleanDeletedFiles();
|
||||
await this.cleanOldDownloadedFiles();
|
||||
}
|
||||
|
||||
private async cleanDeletedFiles(): Promise<void> {
|
||||
debug(`Checking deleted files for workspace ${this.workspace.id}`);
|
||||
|
||||
const fsFiles = await this.app.fs.listFiles(this.filesDir);
|
||||
@@ -547,7 +696,135 @@ export class FileService {
|
||||
}
|
||||
}
|
||||
|
||||
private buildFilePath(id: string, extension: string): string {
|
||||
return this.app.path.join(this.filesDir, `${id}${extension}`);
|
||||
private async cleanOldDownloadedFiles(): Promise<void> {
|
||||
debug(`Cleaning old downloaded files for workspace ${this.workspace.id}`);
|
||||
|
||||
const sevenDaysAgo = new Date(Date.now() - ms('7 days')).toISOString();
|
||||
let lastId = '';
|
||||
const batchSize = 100;
|
||||
|
||||
let hasMoreFiles = true;
|
||||
while (hasMoreFiles) {
|
||||
let query = this.workspace.database
|
||||
.selectFrom('file_states')
|
||||
.select(['id', 'upload_status', 'download_completed_at'])
|
||||
.where('download_status', '=', DownloadStatus.Completed)
|
||||
.where('download_progress', '=', 100)
|
||||
.where('download_completed_at', '<', sevenDaysAgo)
|
||||
.orderBy('id', 'asc')
|
||||
.limit(batchSize);
|
||||
|
||||
if (lastId) {
|
||||
query = query.where('id', '>', lastId);
|
||||
}
|
||||
|
||||
const fileStates = await query.execute();
|
||||
|
||||
if (fileStates.length === 0) {
|
||||
hasMoreFiles = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileIds = fileStates.map((f) => f.id);
|
||||
|
||||
const fileInteractions = await this.workspace.database
|
||||
.selectFrom('node_interactions')
|
||||
.select(['node_id', 'last_opened_at'])
|
||||
.where('node_id', 'in', fileIds)
|
||||
.where('collaborator_id', '=', this.workspace.userId)
|
||||
.execute();
|
||||
|
||||
const nodes = await this.workspace.database
|
||||
.selectFrom('nodes')
|
||||
.select(['id', 'attributes'])
|
||||
.where('id', 'in', fileIds)
|
||||
.execute();
|
||||
|
||||
const interactionMap = new Map(
|
||||
fileInteractions.map((fi) => [fi.node_id, fi.last_opened_at])
|
||||
);
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n.attributes]));
|
||||
|
||||
for (const fileState of fileStates) {
|
||||
try {
|
||||
const lastOpenedAt = interactionMap.get(fileState.id);
|
||||
const shouldDelete = !lastOpenedAt || lastOpenedAt < sevenDaysAgo;
|
||||
|
||||
if (!shouldDelete) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeAttributes = nodeMap.get(fileState.id);
|
||||
if (!nodeAttributes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attributes = JSON.parse(nodeAttributes);
|
||||
if (attributes.type !== 'file' || !attributes.extension) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = this.buildFilePath(
|
||||
fileState.id,
|
||||
attributes.extension
|
||||
);
|
||||
|
||||
const exists = await this.app.fs.exists(filePath);
|
||||
if (!exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
debug(`Deleting old downloaded file: ${fileState.id}`);
|
||||
await this.app.fs.delete(filePath);
|
||||
|
||||
if (
|
||||
fileState.upload_status !== null &&
|
||||
fileState.upload_status !== UploadStatus.None
|
||||
) {
|
||||
const updatedFileState = await this.workspace.database
|
||||
.updateTable('file_states')
|
||||
.returningAll()
|
||||
.set({
|
||||
download_status: DownloadStatus.None,
|
||||
download_progress: 0,
|
||||
download_completed_at: null,
|
||||
})
|
||||
.where('id', '=', fileState.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (updatedFileState) {
|
||||
eventBus.publish({
|
||||
type: 'file.state.updated',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileState: mapFileState(updatedFileState, null),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const deleted = await this.workspace.database
|
||||
.deleteFrom('file_states')
|
||||
.returningAll()
|
||||
.where('id', '=', fileState.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (deleted) {
|
||||
eventBus.publish({
|
||||
type: 'file.state.deleted',
|
||||
accountId: this.workspace.accountId,
|
||||
workspaceId: this.workspace.id,
|
||||
fileId: fileState.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
lastId = fileStates[fileStates.length - 1]!.id;
|
||||
if (fileStates.length < batchSize) {
|
||||
hasMoreFiles = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
DocumentState,
|
||||
DocumentUpdate,
|
||||
} from '@colanode/client/types/documents';
|
||||
import { FileState } from '@colanode/client/types/files';
|
||||
import { FileSaveState, FileState } from '@colanode/client/types/files';
|
||||
import {
|
||||
LocalNode,
|
||||
NodeCounter,
|
||||
@@ -91,6 +91,20 @@ export type FileStateUpdatedEvent = {
|
||||
fileState: FileState;
|
||||
};
|
||||
|
||||
export type FileStateDeletedEvent = {
|
||||
type: 'file.state.deleted';
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
fileId: string;
|
||||
};
|
||||
|
||||
export type FileSaveUpdatedEvent = {
|
||||
type: 'file.save.updated';
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
fileSave: FileSaveState;
|
||||
};
|
||||
|
||||
export type AccountCreatedEvent = {
|
||||
type: 'account.created';
|
||||
account: Account;
|
||||
@@ -309,6 +323,8 @@ export type Event =
|
||||
| ServerDeletedEvent
|
||||
| ServerAvailabilityChangedEvent
|
||||
| FileStateUpdatedEvent
|
||||
| FileStateDeletedEvent
|
||||
| FileSaveUpdatedEvent
|
||||
| QueryResultUpdatedEvent
|
||||
| RadarDataUpdatedEvent
|
||||
| CollaborationCreatedEvent
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocalFileNode } from '@colanode/client/types';
|
||||
import { FileSubtype } from '@colanode/core';
|
||||
|
||||
export type OpenFileDialogOptions = {
|
||||
@@ -29,6 +30,7 @@ export type FileState = {
|
||||
uploadRetries: number | null;
|
||||
uploadStartedAt: string | null;
|
||||
uploadCompletedAt: string | null;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
export enum DownloadStatus {
|
||||
@@ -44,3 +46,19 @@ export enum UploadStatus {
|
||||
Completed = 2,
|
||||
Failed = 3,
|
||||
}
|
||||
|
||||
export enum SaveStatus {
|
||||
Active = 1,
|
||||
Completed = 2,
|
||||
Failed = 3,
|
||||
}
|
||||
|
||||
export type FileSaveState = {
|
||||
id: string;
|
||||
file: LocalFileNode;
|
||||
status: SaveStatus;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
path: string;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ export enum IdType {
|
||||
Window = 'wi',
|
||||
TempFile = 'tf',
|
||||
Socket = 'sk',
|
||||
Save = 'sv',
|
||||
}
|
||||
|
||||
export const generateId = (type: IdType): string => {
|
||||
|
||||
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