mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +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,
|
protocol,
|
||||||
shell,
|
shell,
|
||||||
globalShortcut,
|
globalShortcut,
|
||||||
|
dialog,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
|
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
@@ -251,3 +252,22 @@ ipcMain.handle(
|
|||||||
ipcMain.handle('open-external-url', (_, url: string) => {
|
ipcMain.handle('open-external-url', (_, url: string) => {
|
||||||
shell.openExternal(url);
|
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) => {
|
openExternalUrl: async (url: string) => {
|
||||||
return ipcRenderer.invoke('open-external-url', url);
|
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', {
|
contextBridge.exposeInMainWorld('eventBus', {
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const initializeApp = async () => {
|
|||||||
openExternalUrl: async (url) => {
|
openExternalUrl: async (url) => {
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
},
|
},
|
||||||
|
showItemInFolder: async () => {
|
||||||
|
// No-op on web
|
||||||
|
},
|
||||||
|
showFileSaveDialog: async () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
window.eventBus = eventBus;
|
window.eventBus = eventBus;
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export class FileDownloadMutationHandler
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: workspace.accountId,
|
accountId: workspace.accountId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
fileState: mapFileState(updatedFileState),
|
fileState: mapFileState(updatedFileState, null),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
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 { FileCreateMutationHandler } from './files/file-create';
|
||||||
import { FileDeleteMutationHandler } from './files/file-delete';
|
import { FileDeleteMutationHandler } from './files/file-delete';
|
||||||
import { FileDownloadMutationHandler } from './files/file-download';
|
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 { FolderCreateMutationHandler } from './folders/folder-create';
|
||||||
import { FolderDeleteMutationHandler } from './folders/folder-delete';
|
import { FolderDeleteMutationHandler } from './folders/folder-delete';
|
||||||
import { FolderUpdateMutationHandler } from './folders/folder-update';
|
import { FolderUpdateMutationHandler } from './folders/folder-update';
|
||||||
@@ -129,7 +129,7 @@ export const buildMutationHandlerMap = (
|
|||||||
'folder.create': new FolderCreateMutationHandler(app),
|
'folder.create': new FolderCreateMutationHandler(app),
|
||||||
'file.create': new FileCreateMutationHandler(app),
|
'file.create': new FileCreateMutationHandler(app),
|
||||||
'file.download': new FileDownloadMutationHandler(app),
|
'file.download': new FileDownloadMutationHandler(app),
|
||||||
'file.save.temp': new FileSaveTempMutationHandler(app),
|
'file.save': new FileSaveMutationHandler(app),
|
||||||
'space.avatar.update': new SpaceAvatarUpdateMutationHandler(app),
|
'space.avatar.update': new SpaceAvatarUpdateMutationHandler(app),
|
||||||
'space.description.update': new SpaceDescriptionUpdateMutationHandler(app),
|
'space.description.update': new SpaceDescriptionUpdateMutationHandler(app),
|
||||||
'space.name.update': new SpaceNameUpdateMutationHandler(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 { 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 { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
|
||||||
import { FileStateGetQueryInput } from '@colanode/client/queries/files/file-state-get';
|
import { FileStateGetQueryInput } from '@colanode/client/queries/files/file-state-get';
|
||||||
|
import { LocalFileNode } from '@colanode/client/types';
|
||||||
import { Event } from '@colanode/client/types/events';
|
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
|
export class FileStateGetQueryHandler
|
||||||
extends WorkspaceQueryHandlerBase
|
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 (
|
if (
|
||||||
event.type === 'node.deleted' &&
|
event.type === 'node.deleted' &&
|
||||||
event.accountId === input.accountId &&
|
event.accountId === input.accountId &&
|
||||||
@@ -79,6 +92,17 @@ export class FileStateGetQueryHandler
|
|||||||
): Promise<FileState | null> {
|
): Promise<FileState | null> {
|
||||||
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
|
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
|
const fileState = await workspace.database
|
||||||
.selectFrom('file_states')
|
.selectFrom('file_states')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -89,6 +113,21 @@ export class FileStateGetQueryHandler
|
|||||||
return null;
|
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 { EmojiGetBySkinIdQueryHandler } from './emojis/emoji-get-by-skin-id';
|
||||||
import { EmojiListQueryHandler } from './emojis/emoji-list';
|
import { EmojiListQueryHandler } from './emojis/emoji-list';
|
||||||
import { EmojiSearchQueryHandler } from './emojis/emoji-search';
|
import { EmojiSearchQueryHandler } from './emojis/emoji-search';
|
||||||
|
import { FileDownloadRequestGetQueryHandler } from './files/file-download-request-get';
|
||||||
import { FileListQueryHandler } from './files/file-list';
|
import { FileListQueryHandler } from './files/file-list';
|
||||||
|
import { FileSaveListQueryHandler } from './files/file-save-list';
|
||||||
import { FileStateGetQueryHandler } from './files/file-state-get';
|
import { FileStateGetQueryHandler } from './files/file-state-get';
|
||||||
import { FileUrlGetQueryHandler } from './files/file-url-get';
|
|
||||||
import { IconCategoryListQueryHandler } from './icons/icon-category-list';
|
import { IconCategoryListQueryHandler } from './icons/icon-category-list';
|
||||||
import { IconListQueryHandler } from './icons/icon-list';
|
import { IconListQueryHandler } from './icons/icon-list';
|
||||||
import { IconSearchQueryHandler } from './icons/icon-search';
|
import { IconSearchQueryHandler } from './icons/icon-search';
|
||||||
@@ -62,7 +63,6 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
|
|||||||
'workspace.list': new WorkspaceListQueryHandler(app),
|
'workspace.list': new WorkspaceListQueryHandler(app),
|
||||||
'user.list': new UserListQueryHandler(app),
|
'user.list': new UserListQueryHandler(app),
|
||||||
'file.list': new FileListQueryHandler(app),
|
'file.list': new FileListQueryHandler(app),
|
||||||
'file.url.get': new FileUrlGetQueryHandler(app),
|
|
||||||
'emoji.list': new EmojiListQueryHandler(app),
|
'emoji.list': new EmojiListQueryHandler(app),
|
||||||
'emoji.get': new EmojiGetQueryHandler(app),
|
'emoji.get': new EmojiGetQueryHandler(app),
|
||||||
'emoji.get.by.skin.id': new EmojiGetBySkinIdQueryHandler(app),
|
'emoji.get.by.skin.id': new EmojiGetBySkinIdQueryHandler(app),
|
||||||
@@ -80,6 +80,8 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
|
|||||||
'record.search': new RecordSearchQueryHandler(app),
|
'record.search': new RecordSearchQueryHandler(app),
|
||||||
'user.get': new UserGetQueryHandler(app),
|
'user.get': new UserGetQueryHandler(app),
|
||||||
'file.state.get': new FileStateGetQueryHandler(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),
|
'chat.list': new ChatListQueryHandler(app),
|
||||||
'space.list': new SpaceListQueryHandler(app),
|
'space.list': new SpaceListQueryHandler(app),
|
||||||
'workspace.metadata.list': new WorkspaceMetadataListQueryHandler(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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
version: row.version,
|
version: row.version,
|
||||||
@@ -201,6 +204,7 @@ export const mapFileState = (row: SelectFileState): FileState => {
|
|||||||
uploadRetries: row.upload_retries,
|
uploadRetries: row.upload_retries,
|
||||||
uploadStartedAt: row.upload_started_at,
|
uploadStartedAt: row.upload_started_at,
|
||||||
uploadCompletedAt: row.upload_completed_at,
|
uploadCompletedAt: row.upload_completed_at,
|
||||||
|
url,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type FileDownloadMutationInput = {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
fileId: string;
|
fileId: string;
|
||||||
|
path: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FileDownloadMutationOutput = {
|
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-create';
|
||||||
export * from './files/file-delete';
|
export * from './files/file-delete';
|
||||||
export * from './files/file-download';
|
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-create';
|
||||||
export * from './folders/folder-delete';
|
export * from './folders/folder-delete';
|
||||||
export * from './folders/folder-update';
|
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 './emojis/emoji-search';
|
||||||
export * from './files/file-list';
|
export * from './files/file-list';
|
||||||
export * from './files/file-state-get';
|
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-category-list';
|
||||||
export * from './icons/icon-list';
|
export * from './icons/icon-list';
|
||||||
export * from './icons/icon-search';
|
export * from './icons/icon-search';
|
||||||
@@ -36,7 +38,6 @@ export * from './workspaces/workspace-get';
|
|||||||
export * from './workspaces/workspace-list';
|
export * from './workspaces/workspace-list';
|
||||||
export * from './workspaces/workspace-metadata-list';
|
export * from './workspaces/workspace-metadata-list';
|
||||||
export * from './avatars/avatar-url-get';
|
export * from './avatars/avatar-url-get';
|
||||||
export * from './files/file-url-get';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
export interface QueryMap {}
|
export interface QueryMap {}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cloneDeep } from 'lodash-es';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,8 @@ import { AppService } from '@colanode/client/services/app-service';
|
|||||||
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
|
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
|
||||||
import {
|
import {
|
||||||
DownloadStatus,
|
DownloadStatus,
|
||||||
|
FileSaveState,
|
||||||
|
SaveStatus,
|
||||||
TempFile,
|
TempFile,
|
||||||
UploadStatus,
|
UploadStatus,
|
||||||
} from '@colanode/client/types/files';
|
} from '@colanode/client/types/files';
|
||||||
@@ -36,6 +39,7 @@ export class FileService {
|
|||||||
private readonly app: AppService;
|
private readonly app: AppService;
|
||||||
private readonly workspace: WorkspaceService;
|
private readonly workspace: WorkspaceService;
|
||||||
private readonly filesDir: string;
|
private readonly filesDir: string;
|
||||||
|
private readonly saves: FileSaveState[] = [];
|
||||||
|
|
||||||
private readonly uploadsEventLoop: EventLoop;
|
private readonly uploadsEventLoop: EventLoop;
|
||||||
private readonly downloadsEventLoop: EventLoop;
|
private readonly downloadsEventLoop: EventLoop;
|
||||||
@@ -64,9 +68,9 @@ export class FileService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.cleanupEventLoop = new EventLoop(
|
this.cleanupEventLoop = new EventLoop(
|
||||||
ms('10 minutes'),
|
|
||||||
ms('5 minutes'),
|
ms('5 minutes'),
|
||||||
this.cleanDeletedFiles.bind(this)
|
ms('1 minute'),
|
||||||
|
this.cleanupFiles.bind(this)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.uploadsEventLoop.start();
|
this.uploadsEventLoop.start();
|
||||||
@@ -162,16 +166,48 @@ export class FileService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = await this.app.fs.url(destinationFilePath);
|
||||||
eventBus.publish({
|
eventBus.publish({
|
||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
workspaceId: this.workspace.id,
|
||||||
fileState: mapFileState(createdFileState),
|
fileState: mapFileState(createdFileState, url),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.triggerUploads();
|
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> {
|
public async deleteFile(node: SelectNode): Promise<void> {
|
||||||
const file = mapNode(node);
|
const file = mapNode(node);
|
||||||
|
|
||||||
@@ -220,6 +256,37 @@ export class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async uploadFile(state: SelectFileState): Promise<void> {
|
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) {
|
if (state.upload_retries && state.upload_retries >= UPLOAD_RETRIES_LIMIT) {
|
||||||
debug(`File ${state.id} upload retries limit reached, marking as failed`);
|
debug(`File ${state.id} upload retries limit reached, marking as failed`);
|
||||||
|
|
||||||
@@ -238,29 +305,13 @@ export class FileService {
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
workspaceId: this.workspace.id,
|
||||||
fileState: mapFileState(updatedFileState),
|
fileState: mapFileState(updatedFileState, url),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
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) {
|
if (file.attributes.status === FileStatus.Ready) {
|
||||||
const updatedFileState = await this.workspace.database
|
const updatedFileState = await this.workspace.database
|
||||||
.updateTable('file_states')
|
.updateTable('file_states')
|
||||||
@@ -278,20 +329,13 @@ export class FileService {
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
workspaceId: this.workspace.id,
|
||||||
fileState: mapFileState(updatedFileState),
|
fileState: mapFileState(updatedFileState, url),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
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 {
|
try {
|
||||||
const fileStream = await this.app.fs.readStream(filePath);
|
const fileStream = await this.app.fs.readStream(filePath);
|
||||||
|
|
||||||
@@ -322,7 +366,7 @@ export class FileService {
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
workspaceId: this.workspace.id,
|
||||||
fileState: mapFileState(finalFileState),
|
fileState: mapFileState(finalFileState, url),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +386,7 @@ export class FileService {
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
workspaceId: this.workspace.id,
|
||||||
fileState: mapFileState(updatedFileState),
|
fileState: mapFileState(updatedFileState, url),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +438,7 @@ export class FileService {
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
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 filePath = this.buildFilePath(file.id, file.attributes.extension);
|
||||||
const exists = await this.app.fs.exists(filePath);
|
const exists = await this.app.fs.exists(filePath);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
|
const url = await this.app.fs.url(filePath);
|
||||||
|
|
||||||
const updatedFileState = await this.workspace.database
|
const updatedFileState = await this.workspace.database
|
||||||
.updateTable('file_states')
|
.updateTable('file_states')
|
||||||
.returningAll()
|
.returningAll()
|
||||||
@@ -437,7 +483,7 @@ export class FileService {
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
workspaceId: this.workspace.id,
|
||||||
fileState: mapFileState(updatedFileState),
|
fileState: mapFileState(updatedFileState, url),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +514,7 @@ export class FileService {
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
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);
|
const writeStream = await this.app.fs.writeStream(filePath);
|
||||||
await response.body?.pipeTo(writeStream);
|
await response.body?.pipeTo(writeStream);
|
||||||
|
const url = await this.app.fs.url(filePath);
|
||||||
|
|
||||||
const updatedFileState = await this.workspace.database
|
const updatedFileState = await this.workspace.database
|
||||||
.updateTable('file_states')
|
.updateTable('file_states')
|
||||||
@@ -493,7 +540,7 @@ export class FileService {
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
workspaceId: this.workspace.id,
|
||||||
fileState: mapFileState(updatedFileState),
|
fileState: mapFileState(updatedFileState, url),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -509,13 +556,115 @@ export class FileService {
|
|||||||
type: 'file.state.updated',
|
type: 'file.state.updated',
|
||||||
accountId: this.workspace.accountId,
|
accountId: this.workspace.accountId,
|
||||||
workspaceId: this.workspace.id,
|
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}`);
|
debug(`Checking deleted files for workspace ${this.workspace.id}`);
|
||||||
|
|
||||||
const fsFiles = await this.app.fs.listFiles(this.filesDir);
|
const fsFiles = await this.app.fs.listFiles(this.filesDir);
|
||||||
@@ -547,7 +696,135 @@ export class FileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildFilePath(id: string, extension: string): string {
|
private async cleanOldDownloadedFiles(): Promise<void> {
|
||||||
return this.app.path.join(this.filesDir, `${id}${extension}`);
|
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,
|
DocumentState,
|
||||||
DocumentUpdate,
|
DocumentUpdate,
|
||||||
} from '@colanode/client/types/documents';
|
} from '@colanode/client/types/documents';
|
||||||
import { FileState } from '@colanode/client/types/files';
|
import { FileSaveState, FileState } from '@colanode/client/types/files';
|
||||||
import {
|
import {
|
||||||
LocalNode,
|
LocalNode,
|
||||||
NodeCounter,
|
NodeCounter,
|
||||||
@@ -91,6 +91,20 @@ export type FileStateUpdatedEvent = {
|
|||||||
fileState: FileState;
|
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 = {
|
export type AccountCreatedEvent = {
|
||||||
type: 'account.created';
|
type: 'account.created';
|
||||||
account: Account;
|
account: Account;
|
||||||
@@ -309,6 +323,8 @@ export type Event =
|
|||||||
| ServerDeletedEvent
|
| ServerDeletedEvent
|
||||||
| ServerAvailabilityChangedEvent
|
| ServerAvailabilityChangedEvent
|
||||||
| FileStateUpdatedEvent
|
| FileStateUpdatedEvent
|
||||||
|
| FileStateDeletedEvent
|
||||||
|
| FileSaveUpdatedEvent
|
||||||
| QueryResultUpdatedEvent
|
| QueryResultUpdatedEvent
|
||||||
| RadarDataUpdatedEvent
|
| RadarDataUpdatedEvent
|
||||||
| CollaborationCreatedEvent
|
| CollaborationCreatedEvent
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LocalFileNode } from '@colanode/client/types';
|
||||||
import { FileSubtype } from '@colanode/core';
|
import { FileSubtype } from '@colanode/core';
|
||||||
|
|
||||||
export type OpenFileDialogOptions = {
|
export type OpenFileDialogOptions = {
|
||||||
@@ -29,6 +30,7 @@ export type FileState = {
|
|||||||
uploadRetries: number | null;
|
uploadRetries: number | null;
|
||||||
uploadStartedAt: string | null;
|
uploadStartedAt: string | null;
|
||||||
uploadCompletedAt: string | null;
|
uploadCompletedAt: string | null;
|
||||||
|
url: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum DownloadStatus {
|
export enum DownloadStatus {
|
||||||
@@ -44,3 +46,19 @@ export enum UploadStatus {
|
|||||||
Completed = 2,
|
Completed = 2,
|
||||||
Failed = 3,
|
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',
|
Window = 'wi',
|
||||||
TempFile = 'tf',
|
TempFile = 'tf',
|
||||||
Socket = 'sk',
|
Socket = 'sk',
|
||||||
|
Save = 'sv',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateId = (type: IdType): string => {
|
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 { FilePreview } from '@colanode/ui/components/files/file-preview';
|
||||||
import { useLayout } from '@colanode/ui/contexts/layout';
|
import { useLayout } from '@colanode/ui/contexts/layout';
|
||||||
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
import { useNodeRadar } from '@colanode/ui/hooks/use-node-radar';
|
||||||
import { useQuery } from '@colanode/ui/hooks/use-query';
|
import { useQuery } from '@colanode/ui/hooks/use-query';
|
||||||
|
|
||||||
interface FileBlockProps {
|
interface FileBlockProps {
|
||||||
@@ -18,6 +19,7 @@ export const FileBlock = ({ id }: FileBlockProps) => {
|
|||||||
accountId: workspace.accountId,
|
accountId: workspace.accountId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
|
useNodeRadar(nodeGetQuery.data);
|
||||||
|
|
||||||
if (nodeGetQuery.isPending || !nodeGetQuery.data) {
|
if (nodeGetQuery.isPending || !nodeGetQuery.data) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { LocalFileNode } from '@colanode/client/types';
|
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 { 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';
|
import { FileSidebar } from '@colanode/ui/components/files/file-sidebar';
|
||||||
|
|
||||||
interface FileBodyProps {
|
interface FileBodyProps {
|
||||||
@@ -7,10 +9,21 @@ interface FileBodyProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FileBody = ({ file }: FileBodyProps) => {
|
export const FileBody = ({ file }: FileBodyProps) => {
|
||||||
|
const canPreview =
|
||||||
|
file.attributes.subtype === 'image' || file.attributes.subtype === 'video';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full max-h-full w-full flex-row items-center gap-2">
|
<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">
|
<div className="flex w-full max-w-full h-full flex-grow items-center justify-center overflow-hidden p-10 relative">
|
||||||
<FilePreview file={file} />
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<FileSaveButton file={file} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canPreview ? (
|
||||||
|
<FilePreview file={file} />
|
||||||
|
) : (
|
||||||
|
<FileNoPreview mimeType={file.attributes.mimeType} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full w-72 min-w-72 overflow-hidden border-l border-gray-100 p-2 pl-3">
|
<div className="h-full w-72 min-w-72 overflow-hidden border-l border-gray-100 p-2 pl-3">
|
||||||
<FileSidebar file={file} />
|
<FileSidebar file={file} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ContainerSettings,
|
ContainerSettings,
|
||||||
} from '@colanode/ui/components/ui/container';
|
} from '@colanode/ui/components/ui/container';
|
||||||
import { useNodeContainer } from '@colanode/ui/hooks/use-node-container';
|
import { useNodeContainer } from '@colanode/ui/hooks/use-node-container';
|
||||||
|
import { useNodeRadar } from '@colanode/ui/hooks/use-node-radar';
|
||||||
|
|
||||||
interface FileContainerProps {
|
interface FileContainerProps {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
@@ -17,6 +18,7 @@ interface FileContainerProps {
|
|||||||
|
|
||||||
export const FileContainer = ({ fileId }: FileContainerProps) => {
|
export const FileContainer = ({ fileId }: FileContainerProps) => {
|
||||||
const data = useNodeContainer<LocalFileNode>(fileId);
|
const data = useNodeContainer<LocalFileNode>(fileId);
|
||||||
|
useNodeRadar(data.node);
|
||||||
|
|
||||||
if (data.isPending) {
|
if (data.isPending) {
|
||||||
return null;
|
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 { formatMimeType } from '@colanode/core';
|
||||||
import { FileIcon } from '@colanode/ui/components/files/file-icon';
|
import { FileIcon } from '@colanode/ui/components/files/file-icon';
|
||||||
|
|
||||||
interface FilePreviewOtherProps {
|
interface FileNoPreviewProps {
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilePreviewOther = ({ mimeType }: FilePreviewOtherProps) => {
|
export const FileNoPreview = ({ mimeType }: FileNoPreviewProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<FileIcon mimeType={mimeType} className="h-10 w-10" />
|
<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 { DownloadStatus, LocalFileNode } from '@colanode/client/types';
|
||||||
import { FileDownload } from '@colanode/ui/components/files/file-download';
|
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 { 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 { FilePreviewVideo } from '@colanode/ui/components/files/previews/file-preview-video';
|
||||||
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
import { useMutation } from '@colanode/ui/hooks/use-mutation';
|
||||||
import { useQuery } from '@colanode/ui/hooks/use-query';
|
import { useQuery } from '@colanode/ui/hooks/use-query';
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
@@ -14,6 +15,7 @@ interface FilePreviewProps {
|
|||||||
|
|
||||||
export const FilePreview = ({ file }: FilePreviewProps) => {
|
export const FilePreview = ({ file }: FilePreviewProps) => {
|
||||||
const workspace = useWorkspace();
|
const workspace = useWorkspace();
|
||||||
|
const mutation = useMutation();
|
||||||
|
|
||||||
const fileStateQuery = useQuery({
|
const fileStateQuery = useQuery({
|
||||||
type: 'file.state.get',
|
type: 'file.state.get',
|
||||||
@@ -22,37 +24,56 @@ export const FilePreview = ({ file }: FilePreviewProps) => {
|
|||||||
workspaceId: workspace.id,
|
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(
|
useEffect(() => {
|
||||||
{
|
if (!fileStateQuery.isPending && !isDownloaded && !isDownloading) {
|
||||||
type: 'file.url.get',
|
mutation.mutate({
|
||||||
id: file.id,
|
input: {
|
||||||
extension: file.attributes.extension,
|
type: 'file.download',
|
||||||
accountId: workspace.accountId,
|
accountId: workspace.accountId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
},
|
fileId: file.id,
|
||||||
{
|
path: null,
|
||||||
enabled: shouldFetchFileUrl,
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to start file download:', error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
}, [
|
||||||
|
fileStateQuery.isPending,
|
||||||
|
isDownloaded,
|
||||||
|
isDownloading,
|
||||||
|
mutation,
|
||||||
|
workspace.accountId,
|
||||||
|
workspace.id,
|
||||||
|
file.id,
|
||||||
|
]);
|
||||||
|
|
||||||
if (
|
if (fileStateQuery.isPending) {
|
||||||
fileStateQuery.isPending ||
|
|
||||||
(shouldFetchFileUrl && fileUrlGetQuery.isPending)
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = fileUrlGetQuery.data?.url;
|
if (isDownloading) {
|
||||||
if (fileStateQuery.data?.downloadProgress !== 100 || !url) {
|
return <FileDownloadProgress state={fileStateQuery.data} />;
|
||||||
return <FileDownload file={file} state={fileStateQuery.data} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return match(file.attributes.subtype)
|
const url = fileStateQuery.data?.url;
|
||||||
.with('image', () => (
|
if (!url) {
|
||||||
<FilePreviewImage url={url} name={file.attributes.name} />
|
return <FileNoPreview mimeType={file.attributes.mimeType} />;
|
||||||
))
|
}
|
||||||
.with('video', () => <FilePreviewVideo url={url} />)
|
|
||||||
.otherwise(() => <FilePreviewOther 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,
|
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 (
|
if (
|
||||||
file.attributes.subtype === 'image' &&
|
file.attributes.subtype === 'image' &&
|
||||||
fileStateGetQuery.data?.downloadProgress === 100 &&
|
fileStateGetQuery.data?.downloadProgress === 100 &&
|
||||||
url
|
fileStateGetQuery.data?.url
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={fileStateGetQuery.data?.url}
|
||||||
alt={file.attributes.name}
|
alt={file.attributes.name}
|
||||||
className={cn('object-contain object-center', className)}
|
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 { ChannelContainer } from '@colanode/ui/components/channels/channel-container';
|
||||||
import { ChatContainer } from '@colanode/ui/components/chats/chat-container';
|
import { ChatContainer } from '@colanode/ui/components/chats/chat-container';
|
||||||
import { DatabaseContainer } from '@colanode/ui/components/databases/database-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 { FileContainer } from '@colanode/ui/components/files/file-container';
|
||||||
import { FolderContainer } from '@colanode/ui/components/folders/folder-container';
|
import { FolderContainer } from '@colanode/ui/components/folders/folder-container';
|
||||||
import { MessageContainer } from '@colanode/ui/components/messages/message-container';
|
import { MessageContainer } from '@colanode/ui/components/messages/message-container';
|
||||||
@@ -17,6 +18,24 @@ interface ContainerTabContentProps {
|
|||||||
tab: ContainerTab;
|
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) => {
|
export const ContainerTabContent = ({ tab }: ContainerTabContentProps) => {
|
||||||
return (
|
return (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
@@ -24,19 +43,7 @@ export const ContainerTabContent = ({ tab }: ContainerTabContentProps) => {
|
|||||||
key={tab.path}
|
key={tab.path}
|
||||||
className="h-full min-h-full w-full min-w-full m-0 pt-2"
|
className="h-full min-h-full w-full min-w-full m-0 pt-2"
|
||||||
>
|
>
|
||||||
{match(getIdType(tab.path))
|
<ContainerTabContentBody tab={tab} />
|
||||||
.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)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getIdType, IdType } from '@colanode/core';
|
|||||||
import { ChannelContainerTab } from '@colanode/ui/components/channels/channel-container-tab';
|
import { ChannelContainerTab } from '@colanode/ui/components/channels/channel-container-tab';
|
||||||
import { ChatContainerTab } from '@colanode/ui/components/chats/chat-container-tab';
|
import { ChatContainerTab } from '@colanode/ui/components/chats/chat-container-tab';
|
||||||
import { DatabaseContainerTab } from '@colanode/ui/components/databases/database-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 { FileContainerTab } from '@colanode/ui/components/files/file-container-tab';
|
||||||
import { FolderContainerTab } from '@colanode/ui/components/folders/folder-container-tab';
|
import { FolderContainerTab } from '@colanode/ui/components/folders/folder-container-tab';
|
||||||
import { MessageContainerTab } from '@colanode/ui/components/messages/message-container-tab';
|
import { MessageContainerTab } from '@colanode/ui/components/messages/message-container-tab';
|
||||||
@@ -24,6 +25,31 @@ interface ContainerTabTriggerProps {
|
|||||||
onMove: (before: string | null) => void;
|
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 = ({
|
export const ContainerTabTrigger = ({
|
||||||
tab,
|
tab,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -83,34 +109,10 @@ export const ContainerTabTrigger = ({
|
|||||||
onOpen();
|
onOpen();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={dragDropRef as React.LegacyRef<HTMLButtonElement>}
|
ref={dragDropRef as React.RefAttributes<HTMLButtonElement>['ref']}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden truncate">
|
<div className="overflow-hidden truncate">
|
||||||
{match(getIdType(tab.path))
|
<ContainerTabTriggerContent tab={tab} />
|
||||||
.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)}
|
|
||||||
</div>
|
</div>
|
||||||
<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"
|
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) => {
|
export const PageContainer = ({ pageId }: PageContainerProps) => {
|
||||||
const data = useNodeContainer<LocalPageNode>(pageId);
|
const data = useNodeContainer<LocalPageNode>(pageId);
|
||||||
|
|
||||||
useNodeRadar(data.node);
|
useNodeRadar(data.node);
|
||||||
|
|
||||||
if (data.isPending) {
|
if (data.isPending) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { X } from 'lucide-react';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { TempFile } from '@colanode/client/types';
|
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 { 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 { FilePreviewVideo } from '@colanode/ui/components/files/previews/file-preview-video';
|
||||||
|
|
||||||
export const TempFileNodeView = ({ node, deleteNode }: NodeViewProps) => {
|
export const TempFileNodeView = ({ node, deleteNode }: NodeViewProps) => {
|
||||||
@@ -34,8 +34,9 @@ export const TempFileNodeView = ({ node, deleteNode }: NodeViewProps) => {
|
|||||||
{match(type)
|
{match(type)
|
||||||
.with('image', () => <FilePreviewImage url={file.url} name={name} />)
|
.with('image', () => <FilePreviewImage url={file.url} name={name} />)
|
||||||
.with('video', () => <FilePreviewVideo url={file.url} />)
|
.with('video', () => <FilePreviewVideo url={file.url} />)
|
||||||
.with('other', () => <FilePreviewOther mimeType={mimeType} />)
|
.otherwise(() => (
|
||||||
.otherwise(() => null)}
|
<FileNoPreview mimeType={mimeType} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Node } from '@colanode/core';
|
|||||||
import { useRadar } from '@colanode/ui/contexts/radar';
|
import { useRadar } from '@colanode/ui/contexts/radar';
|
||||||
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
|
||||||
export const useNodeRadar = (node: Node | null) => {
|
export const useNodeRadar = (node: Node | null | undefined) => {
|
||||||
const workspace = useWorkspace();
|
const workspace = useWorkspace();
|
||||||
const radar = useRadar();
|
const radar = useRadar();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { MutationInput, MutationResult } from '@colanode/client/mutations';
|
|||||||
import { QueryInput, QueryMap } from '@colanode/client/queries';
|
import { QueryInput, QueryMap } from '@colanode/client/queries';
|
||||||
import { TempFile } from '@colanode/client/types';
|
import { TempFile } from '@colanode/client/types';
|
||||||
|
|
||||||
|
interface SaveDialogOptions {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ColanodeWindowApi {
|
export interface ColanodeWindowApi {
|
||||||
init: () => Promise<void>;
|
init: () => Promise<void>;
|
||||||
executeMutation: <T extends MutationInput>(
|
executeMutation: <T extends MutationInput>(
|
||||||
@@ -18,6 +22,10 @@ export interface ColanodeWindowApi {
|
|||||||
unsubscribeQuery: (key: string) => Promise<void>;
|
unsubscribeQuery: (key: string) => Promise<void>;
|
||||||
saveTempFile: (file: File) => Promise<TempFile>;
|
saveTempFile: (file: File) => Promise<TempFile>;
|
||||||
openExternalUrl: (url: string) => Promise<void>;
|
openExternalUrl: (url: string) => Promise<void>;
|
||||||
|
showItemInFolder: (path: string) => Promise<void>;
|
||||||
|
showFileSaveDialog: (
|
||||||
|
options: SaveDialogOptions
|
||||||
|
) => Promise<string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user