mirror of
https://github.com/colanode/colanode.git
synced 2025-12-29 00:25:03 +01:00
File upload/download improvements
This commit is contained in:
@@ -6,6 +6,7 @@ import { serverService } from '@/main/services/server-service';
|
||||
import { accountService } from '@/main/services/account-service';
|
||||
import { syncService } from '@/main/services/sync-service';
|
||||
import { notificationService } from '@/main/services/notification-service';
|
||||
import { fileService } from '@/main/services/file-service';
|
||||
|
||||
const EVENT_LOOP_INTERVAL = 1000 * 60;
|
||||
|
||||
@@ -34,7 +35,7 @@ class Bootstrapper {
|
||||
notificationService.init();
|
||||
|
||||
if (!this.eventLoop) {
|
||||
this.eventLoop = setTimeout(this.executeEventLoop, EVENT_LOOP_INTERVAL);
|
||||
this.eventLoop = setTimeout(this.executeEventLoop, 50);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +46,8 @@ class Bootstrapper {
|
||||
await socketService.checkConnections();
|
||||
await accountService.syncDeletedTokens();
|
||||
await syncService.syncAllWorkspaces();
|
||||
await fileService.syncFiles();
|
||||
|
||||
notificationService.checkBadge();
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
|
||||
@@ -90,8 +90,10 @@ const createDownloadsTable: Migration = {
|
||||
.addColumn('node_id', 'text', (col) =>
|
||||
col.notNull().primaryKey().references('nodes.id')
|
||||
)
|
||||
.addColumn('upload_id', 'text', (col) => col.notNull())
|
||||
.addColumn('created_at', 'text', (col) => col.notNull())
|
||||
.addColumn('updated_at', 'text')
|
||||
.addColumn('completed_at', 'text')
|
||||
.addColumn('progress', 'integer', (col) => col.defaultTo(0))
|
||||
.addColumn('retry_count', 'integer', (col) => col.defaultTo(0))
|
||||
.execute();
|
||||
@@ -108,6 +110,7 @@ const createUploadsTable: Migration = {
|
||||
.addColumn('node_id', 'text', (col) =>
|
||||
col.notNull().primaryKey().references('nodes.id')
|
||||
)
|
||||
.addColumn('upload_id', 'text', (col) => col.notNull())
|
||||
.addColumn('created_at', 'text', (col) => col.notNull())
|
||||
.addColumn('updated_at', 'text')
|
||||
.addColumn('progress', 'integer', (col) => col.defaultTo(0))
|
||||
|
||||
@@ -58,6 +58,7 @@ export type UpdateChange = Updateable<ChangeTable>;
|
||||
|
||||
interface UploadTable {
|
||||
node_id: ColumnType<string, string, never>;
|
||||
upload_id: ColumnType<string, string, never>;
|
||||
created_at: ColumnType<string, string, never>;
|
||||
updated_at: ColumnType<string | null, string | null, string | null>;
|
||||
progress: ColumnType<number, number, number>;
|
||||
@@ -70,8 +71,10 @@ export type UpdateUpload = Updateable<UploadTable>;
|
||||
|
||||
interface DownloadTable {
|
||||
node_id: ColumnType<string, string, never>;
|
||||
upload_id: ColumnType<string, string, never>;
|
||||
created_at: ColumnType<string, string, never>;
|
||||
updated_at: ColumnType<string | null, string | null, string | null>;
|
||||
completed_at: ColumnType<string | null, string | null, string | null>;
|
||||
progress: ColumnType<number, number, number>;
|
||||
retry_count: ColumnType<number, number, number>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MutationHandler } from '@/main/types';
|
||||
import { generateId, IdType } from '@colanode/core';
|
||||
import { extractFileType, generateId, IdType } from '@colanode/core';
|
||||
import {
|
||||
FileCreateMutationInput,
|
||||
FileCreateMutationOutput,
|
||||
@@ -19,43 +19,50 @@ export class FileCreateMutationHandler
|
||||
throw new Error('Invalid file');
|
||||
}
|
||||
|
||||
const id = generateId(IdType.File);
|
||||
const fileId = generateId(IdType.File);
|
||||
const uploadId = generateId(IdType.Upload);
|
||||
fileService.copyFileToWorkspace(
|
||||
input.filePath,
|
||||
id,
|
||||
fileId,
|
||||
metadata.extension,
|
||||
input.userId
|
||||
);
|
||||
|
||||
const attributes: FileAttributes = {
|
||||
type: 'file',
|
||||
subtype: extractFileType(metadata.mimeType),
|
||||
parentId: input.parentId,
|
||||
name: metadata.name,
|
||||
fileName: metadata.name,
|
||||
extension: metadata.extension,
|
||||
size: metadata.size,
|
||||
mimeType: metadata.mimeType,
|
||||
uploadId,
|
||||
uploadStatus: 'pending',
|
||||
};
|
||||
|
||||
await nodeService.createNode(input.userId, {
|
||||
id,
|
||||
id: fileId,
|
||||
attributes,
|
||||
upload: {
|
||||
node_id: id,
|
||||
node_id: fileId,
|
||||
created_at: new Date().toISOString(),
|
||||
progress: 0,
|
||||
retry_count: 0,
|
||||
upload_id: uploadId,
|
||||
},
|
||||
download: {
|
||||
node_id: id,
|
||||
node_id: fileId,
|
||||
upload_id: uploadId,
|
||||
created_at: new Date().toISOString(),
|
||||
progress: 0,
|
||||
progress: 100,
|
||||
retry_count: 0,
|
||||
completed_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
id: fileId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FileDownloadMutationInput,
|
||||
FileDownloadMutationOutput,
|
||||
} from '@/shared/mutations/file-download';
|
||||
import { mapNode } from '@/main/utils';
|
||||
|
||||
export class FileDownloadMutationHandler
|
||||
implements MutationHandler<FileDownloadMutationInput>
|
||||
@@ -28,6 +29,13 @@ export class FileDownloadMutationHandler
|
||||
};
|
||||
}
|
||||
|
||||
const file = mapNode(node);
|
||||
if (file.attributes.type !== 'file') {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const download = await workspaceDatabase
|
||||
.selectFrom('downloads')
|
||||
.selectAll()
|
||||
@@ -45,6 +53,7 @@ export class FileDownloadMutationHandler
|
||||
.insertInto('downloads')
|
||||
.values({
|
||||
node_id: input.fileId,
|
||||
upload_id: file.attributes.uploadId,
|
||||
created_at: createdAt.toISOString(),
|
||||
progress: 0,
|
||||
retry_count: 0,
|
||||
@@ -56,6 +65,7 @@ export class FileDownloadMutationHandler
|
||||
userId: input.userId,
|
||||
download: {
|
||||
nodeId: node.id,
|
||||
uploadId: file.attributes.uploadId,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: null,
|
||||
progress: 0,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { generateId, IdType, EditorNodeTypes, NodeTypes } from '@colanode/core';
|
||||
import {
|
||||
generateId,
|
||||
IdType,
|
||||
EditorNodeTypes,
|
||||
NodeTypes,
|
||||
extractFileType,
|
||||
} from '@colanode/core';
|
||||
import { MutationHandler } from '@/main/types';
|
||||
import {
|
||||
MessageCreateMutationInput,
|
||||
@@ -57,6 +63,7 @@ export class MessageCreateMutationHandler
|
||||
}
|
||||
|
||||
const fileId = generateId(IdType.File);
|
||||
const uploadId = generateId(IdType.Upload);
|
||||
|
||||
block.id = fileId;
|
||||
block.type = NodeTypes.File;
|
||||
@@ -65,18 +72,22 @@ export class MessageCreateMutationHandler
|
||||
fileService.copyFileToWorkspace(
|
||||
path,
|
||||
fileId,
|
||||
uploadId,
|
||||
metadata.extension,
|
||||
input.userId
|
||||
);
|
||||
|
||||
const fileAttributes: FileAttributes = {
|
||||
type: 'file',
|
||||
subtype: extractFileType(metadata.mimeType),
|
||||
parentId: messageId,
|
||||
name: metadata.name,
|
||||
fileName: metadata.name,
|
||||
mimeType: metadata.mimeType,
|
||||
size: metadata.size,
|
||||
extension: metadata.extension,
|
||||
uploadId,
|
||||
uploadStatus: 'pending',
|
||||
};
|
||||
|
||||
inputs.push({
|
||||
@@ -84,12 +95,15 @@ export class MessageCreateMutationHandler
|
||||
attributes: fileAttributes,
|
||||
download: {
|
||||
node_id: fileId,
|
||||
upload_id: uploadId,
|
||||
created_at: createdAt,
|
||||
progress: 100,
|
||||
retry_count: 0,
|
||||
completed_at: new Date().toISOString(),
|
||||
},
|
||||
upload: {
|
||||
node_id: fileId,
|
||||
upload_id: uploadId,
|
||||
created_at: createdAt,
|
||||
progress: 0,
|
||||
retry_count: 0,
|
||||
|
||||
87
apps/desktop/src/main/queries/download-get.ts
Normal file
87
apps/desktop/src/main/queries/download-get.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { DownloadGetQueryInput } from '@/shared/queries/download-get';
|
||||
import { databaseService } from '@/main/data/database-service';
|
||||
import { Download } from '@/shared/types/nodes';
|
||||
import { ChangeCheckResult, QueryHandler } from '@/main/types';
|
||||
import { mapDownload } from '@/main/utils';
|
||||
import { Event } from '@/shared/types/events';
|
||||
import { SelectDownload } from '@/main/data/workspace/schema';
|
||||
|
||||
export class DownloadGetQueryHandler
|
||||
implements QueryHandler<DownloadGetQueryInput>
|
||||
{
|
||||
public async handleQuery(
|
||||
input: DownloadGetQueryInput
|
||||
): Promise<Download | null> {
|
||||
const row = await this.fetchDownload(input);
|
||||
return row ? mapDownload(row) : null;
|
||||
}
|
||||
|
||||
public async checkForChanges(
|
||||
event: Event,
|
||||
input: DownloadGetQueryInput,
|
||||
_: Download | null
|
||||
): Promise<ChangeCheckResult<DownloadGetQueryInput>> {
|
||||
if (
|
||||
event.type === 'workspace_deleted' &&
|
||||
event.workspace.userId === input.userId
|
||||
) {
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'download_created' &&
|
||||
event.userId === input.userId &&
|
||||
event.download.nodeId === input.nodeId
|
||||
) {
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: event.download,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'download_updated' &&
|
||||
event.userId === input.userId &&
|
||||
event.download.nodeId === input.nodeId
|
||||
) {
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: event.download,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'download_deleted' &&
|
||||
event.userId === input.userId &&
|
||||
event.download.nodeId === input.nodeId
|
||||
) {
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasChanges: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchDownload(
|
||||
input: DownloadGetQueryInput
|
||||
): Promise<SelectDownload | undefined> {
|
||||
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
|
||||
input.userId
|
||||
);
|
||||
|
||||
const row = await workspaceDatabase
|
||||
.selectFrom('downloads')
|
||||
.selectAll()
|
||||
.where('node_id', '=', input.nodeId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,11 @@
|
||||
import { FileListQueryInput } from '@/shared/queries/file-list';
|
||||
import { databaseService } from '@/main/data/database-service';
|
||||
import { ChangeCheckResult, QueryHandler } from '@/main/types';
|
||||
import { NodeTypes } from '@colanode/core';
|
||||
import { NodeTypes, FileNode } from '@colanode/core';
|
||||
import { compareString } from '@/shared/lib/utils';
|
||||
import { FileNode } from '@/shared/types/files';
|
||||
import { Event } from '@/shared/types/events';
|
||||
|
||||
interface FileRow {
|
||||
id: string;
|
||||
attributes: string;
|
||||
parent_id: string | null;
|
||||
type: string;
|
||||
download_progress?: number | null;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
}
|
||||
import { SelectNode } from '@/main/data/workspace/schema';
|
||||
import { mapNode } from '@/main/utils';
|
||||
|
||||
export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
|
||||
public async handleQuery(input: FileListQueryInput): Promise<FileNode[]> {
|
||||
@@ -56,18 +47,19 @@ export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
|
||||
event.node.type === 'file' &&
|
||||
event.node.parentId === input.parentId
|
||||
) {
|
||||
const newOutput = [...output];
|
||||
const file = newOutput.find((file) => file.id === event.node.id);
|
||||
const file = output.find((file) => file.id === event.node.id);
|
||||
if (file) {
|
||||
file.name = event.node.attributes.name;
|
||||
file.mimeType = event.node.attributes.mimeType;
|
||||
file.size = event.node.attributes.size;
|
||||
file.extension = event.node.attributes.extension;
|
||||
file.fileName = event.node.attributes.fileName;
|
||||
const newResult = output.map((file) => {
|
||||
if (file.id === event.node.id) {
|
||||
return event.node as FileNode;
|
||||
}
|
||||
|
||||
return file;
|
||||
});
|
||||
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: newOutput,
|
||||
result: newResult,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -80,27 +72,10 @@ export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
|
||||
) {
|
||||
const file = output.find((file) => file.id === event.node.id);
|
||||
if (file) {
|
||||
const newOutput = output.filter((file) => file.id !== event.node.id);
|
||||
const newResult = await this.handleQuery(input);
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: newOutput,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(event.type === 'download_created' ||
|
||||
event.type === 'download_updated') &&
|
||||
event.userId === input.userId
|
||||
) {
|
||||
const newOutput = [...output];
|
||||
const nodeId = event.download.nodeId;
|
||||
const file = newOutput.find((file) => file.id === nodeId);
|
||||
if (file) {
|
||||
file.downloadProgress = event.download.progress;
|
||||
return {
|
||||
hasChanges: true,
|
||||
result: newOutput,
|
||||
result: newResult,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -110,7 +85,7 @@ export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchFiles(input: FileListQueryInput): Promise<FileRow[]> {
|
||||
private async fetchFiles(input: FileListQueryInput): Promise<SelectNode[]> {
|
||||
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
|
||||
input.userId
|
||||
);
|
||||
@@ -118,18 +93,7 @@ export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
|
||||
const offset = (input.page - 1) * input.count;
|
||||
const files = await workspaceDatabase
|
||||
.selectFrom('nodes')
|
||||
.leftJoin('downloads', (join) =>
|
||||
join.onRef('nodes.id', '=', 'downloads.node_id')
|
||||
)
|
||||
.select([
|
||||
'nodes.id',
|
||||
'nodes.attributes',
|
||||
'nodes.parent_id',
|
||||
'nodes.type',
|
||||
'downloads.progress as download_progress',
|
||||
'nodes.created_at',
|
||||
'nodes.created_by',
|
||||
])
|
||||
.selectAll()
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb('parent_id', '=', input.parentId),
|
||||
@@ -144,20 +108,16 @@ export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
|
||||
return files;
|
||||
}
|
||||
|
||||
private buildFiles = (fileRows: FileRow[]): FileNode[] => {
|
||||
private buildFiles = (rows: SelectNode[]): FileNode[] => {
|
||||
const nodes = rows.map(mapNode);
|
||||
const files: FileNode[] = [];
|
||||
for (const fileRow of fileRows) {
|
||||
const attributes = JSON.parse(fileRow.attributes);
|
||||
files.push({
|
||||
id: fileRow.id,
|
||||
name: attributes.name,
|
||||
mimeType: attributes.mimeType,
|
||||
size: attributes.size,
|
||||
extension: attributes.extension,
|
||||
fileName: attributes.fileName,
|
||||
createdAt: fileRow.created_at,
|
||||
downloadProgress: fileRow.download_progress,
|
||||
});
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 'file') {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(node);
|
||||
}
|
||||
|
||||
return files.sort((a, b) => compareString(a.id, b.id));
|
||||
|
||||
@@ -17,6 +17,7 @@ import { RadarDataGetQueryHandler } from '@/main/queries/radar-data-get';
|
||||
import { FileMetadataGetQueryHandler } from '@/main/queries/file-metadata-get';
|
||||
import { AccountGetQueryHandler } from '@/main/queries/account-get';
|
||||
import { WorkspaceGetQueryHandler } from '@/main/queries/workspace-get';
|
||||
import { DownloadGetQueryHandler } from '@/main/queries/download-get';
|
||||
|
||||
type QueryHandlerMap = {
|
||||
[K in keyof QueryMap]: QueryHandler<QueryMap[K]['input']>;
|
||||
@@ -40,4 +41,5 @@ export const queryHandlerMap: QueryHandlerMap = {
|
||||
file_metadata_get: new FileMetadataGetQueryHandler(),
|
||||
account_get: new AccountGetQueryHandler(),
|
||||
workspace_get: new WorkspaceGetQueryHandler(),
|
||||
download_get: new DownloadGetQueryHandler(),
|
||||
};
|
||||
|
||||
@@ -20,8 +20,10 @@ class AccountService {
|
||||
.execute();
|
||||
|
||||
for (const account of accounts) {
|
||||
this.syncAccount(account);
|
||||
await this.syncAccount(account);
|
||||
}
|
||||
|
||||
await this.syncDeletedTokens();
|
||||
}
|
||||
|
||||
private async syncAccount(account: SelectAccount) {
|
||||
|
||||
@@ -3,20 +3,40 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import axios from 'axios';
|
||||
import mime from 'mime-types';
|
||||
import {
|
||||
FileMetadata,
|
||||
ServerFileDownloadResponse,
|
||||
ServerFileUploadResponse,
|
||||
} from '@/shared/types/files';
|
||||
import { WorkspaceCredentials } from '@/shared/types/workspaces';
|
||||
import { FileMetadata } from '@/shared/types/files';
|
||||
import { databaseService } from '@/main/data/database-service';
|
||||
import { httpClient } from '@/shared/lib/http-client';
|
||||
import { getWorkspaceFilesDirectoryPath } from '@/main/utils';
|
||||
import { FileAttributes } from '@colanode/core';
|
||||
import {
|
||||
CompleteUploadOutput,
|
||||
CreateDownloadOutput,
|
||||
CreateUploadOutput,
|
||||
extractFileType,
|
||||
FileAttributes,
|
||||
} from '@colanode/core';
|
||||
import { eventBus } from '@/shared/lib/event-bus';
|
||||
import { serverService } from '@/main/services/server-service';
|
||||
|
||||
type WorkspaceFileState = {
|
||||
isUploading: boolean;
|
||||
isDownloading: boolean;
|
||||
isUploadScheduled: boolean;
|
||||
isDownloadScheduled: boolean;
|
||||
};
|
||||
|
||||
class FileService {
|
||||
private fileStates: Map<string, WorkspaceFileState> = new Map();
|
||||
|
||||
constructor() {
|
||||
eventBus.subscribe((event) => {
|
||||
if (event.type === 'download_created') {
|
||||
this.syncWorkspaceDownloads(event.userId);
|
||||
} else if (event.type === 'upload_created') {
|
||||
this.syncWorkspaceUploads(event.userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async handleFileRequest(request: Request): Promise<Response> {
|
||||
const url = request.url.replace('local-file://', '');
|
||||
const [userId, file] = url.split('/');
|
||||
@@ -39,6 +59,7 @@ class FileService {
|
||||
public copyFileToWorkspace(
|
||||
filePath: string,
|
||||
fileId: string,
|
||||
uploadId: string,
|
||||
fileExtension: string,
|
||||
userId: string
|
||||
): void {
|
||||
@@ -50,7 +71,7 @@ class FileService {
|
||||
|
||||
const destinationFilePath = path.join(
|
||||
filesDir,
|
||||
`${fileId}${fileExtension}`
|
||||
`${fileId}_${uploadId}${fileExtension}`
|
||||
);
|
||||
fs.copyFileSync(filePath, destinationFilePath);
|
||||
}
|
||||
@@ -78,6 +99,7 @@ class FileService {
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const type = extractFileType(mimeType);
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
@@ -85,15 +107,101 @@ class FileService {
|
||||
extension: path.extname(filePath),
|
||||
name: path.basename(filePath),
|
||||
size: stats.size,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
public async checkForUploads(
|
||||
credentials: WorkspaceCredentials
|
||||
): Promise<void> {
|
||||
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
|
||||
credentials.userId
|
||||
);
|
||||
public async syncFiles() {
|
||||
const workspaces = await databaseService.appDatabase
|
||||
.selectFrom('workspaces')
|
||||
.select(['user_id'])
|
||||
.execute();
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
this.uploadWorkspaceFiles(workspace.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
public async syncWorkspaceUploads(userId: string): Promise<void> {
|
||||
if (!this.fileStates.has(userId)) {
|
||||
this.fileStates.set(userId, {
|
||||
isUploading: false,
|
||||
isDownloading: false,
|
||||
isUploadScheduled: false,
|
||||
isDownloadScheduled: false,
|
||||
});
|
||||
}
|
||||
|
||||
const fileState = this.fileStates.get(userId)!;
|
||||
if (fileState.isUploading) {
|
||||
fileState.isUploadScheduled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
fileState.isUploading = true;
|
||||
try {
|
||||
await this.uploadWorkspaceFiles(userId);
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
} finally {
|
||||
fileState.isUploading = false;
|
||||
if (fileState.isUploadScheduled) {
|
||||
fileState.isUploadScheduled = false;
|
||||
this.syncWorkspaceUploads(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async syncWorkspaceDownloads(userId: string): Promise<void> {
|
||||
if (!this.fileStates.has(userId)) {
|
||||
this.fileStates.set(userId, {
|
||||
isUploading: false,
|
||||
isDownloading: false,
|
||||
isUploadScheduled: false,
|
||||
isDownloadScheduled: false,
|
||||
});
|
||||
}
|
||||
|
||||
const fileState = this.fileStates.get(userId)!;
|
||||
if (fileState.isDownloading) {
|
||||
fileState.isDownloadScheduled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
fileState.isDownloading = true;
|
||||
try {
|
||||
await this.downloadWorkspaceFiles(userId);
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
} finally {
|
||||
fileState.isDownloading = false;
|
||||
if (fileState.isDownloadScheduled) {
|
||||
fileState.isDownloadScheduled = false;
|
||||
this.syncWorkspaceDownloads(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadWorkspaceFiles(userId: string): Promise<void> {
|
||||
if (!this.fileStates.has(userId)) {
|
||||
this.fileStates.set(userId, {
|
||||
isUploading: false,
|
||||
isDownloading: false,
|
||||
isUploadScheduled: false,
|
||||
isDownloadScheduled: false,
|
||||
});
|
||||
}
|
||||
|
||||
const fileState = this.fileStates.get(userId)!;
|
||||
if (fileState.isUploading) {
|
||||
fileState.isUploadScheduled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
fileState.isUploading = true;
|
||||
|
||||
const workspaceDatabase =
|
||||
await databaseService.getWorkspaceDatabase(userId);
|
||||
|
||||
const uploads = await workspaceDatabase
|
||||
.selectFrom('uploads')
|
||||
@@ -105,7 +213,26 @@ class FileService {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesDir = getWorkspaceFilesDirectoryPath(credentials.userId);
|
||||
const workspace = await databaseService.appDatabase
|
||||
.selectFrom('workspaces')
|
||||
.innerJoin('accounts', 'workspaces.account_id', 'accounts.id')
|
||||
.innerJoin('servers', 'accounts.server', 'servers.domain')
|
||||
.select([
|
||||
'workspaces.workspace_id',
|
||||
'workspaces.user_id',
|
||||
'workspaces.account_id',
|
||||
'accounts.token',
|
||||
'servers.domain',
|
||||
'servers.attributes',
|
||||
])
|
||||
.where('workspaces.user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesDir = getWorkspaceFilesDirectoryPath(userId);
|
||||
for (const upload of uploads) {
|
||||
if (upload.retry_count >= 5) {
|
||||
await workspaceDatabase
|
||||
@@ -134,7 +261,7 @@ class FileService {
|
||||
const attributes: FileAttributes = JSON.parse(file.attributes);
|
||||
const filePath = path.join(
|
||||
filesDir,
|
||||
`${upload.node_id}${attributes.extension}`
|
||||
`${upload.node_id}_${upload.upload_id}${attributes.extension}`
|
||||
);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
@@ -146,17 +273,20 @@ class FileService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!serverService.isAvailable(credentials.serverDomain)) {
|
||||
if (!serverService.isAvailable(workspace.domain)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await httpClient.post<ServerFileUploadResponse>(
|
||||
`/v1/files/${credentials.workspaceId}/${upload.node_id}`,
|
||||
{},
|
||||
const { data } = await httpClient.post<CreateUploadOutput>(
|
||||
`/v1/files/${workspace.workspace_id}`,
|
||||
{
|
||||
domain: credentials.serverDomain,
|
||||
token: credentials.token,
|
||||
fileId: file.id,
|
||||
uploadId: upload.upload_id,
|
||||
},
|
||||
{
|
||||
domain: workspace.domain,
|
||||
token: workspace.token,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -169,6 +299,19 @@ class FileService {
|
||||
},
|
||||
});
|
||||
|
||||
const { status } = await httpClient.put<CompleteUploadOutput>(
|
||||
`/v1/files/${workspace.workspace_id}/${data.uploadId}`,
|
||||
{},
|
||||
{
|
||||
domain: workspace.domain,
|
||||
token: workspace.token,
|
||||
}
|
||||
);
|
||||
|
||||
if (status !== 200) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await workspaceDatabase
|
||||
.deleteFrom('uploads')
|
||||
.where('node_id', '=', upload.node_id)
|
||||
@@ -184,12 +327,9 @@ class FileService {
|
||||
}
|
||||
}
|
||||
|
||||
public async checkForDownloads(
|
||||
credentials: WorkspaceCredentials
|
||||
): Promise<void> {
|
||||
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
|
||||
credentials.userId
|
||||
);
|
||||
public async downloadWorkspaceFiles(userId: string): Promise<void> {
|
||||
const workspaceDatabase =
|
||||
await databaseService.getWorkspaceDatabase(userId);
|
||||
|
||||
const downloads = await workspaceDatabase
|
||||
.selectFrom('downloads')
|
||||
@@ -201,7 +341,15 @@ class FileService {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesDir = getWorkspaceFilesDirectoryPath(credentials.userId);
|
||||
const workspace = await databaseService.appDatabase
|
||||
.selectFrom('workspaces')
|
||||
.innerJoin('accounts', 'workspaces.account_id', 'accounts.id')
|
||||
.innerJoin('servers', 'accounts.server', 'servers.domain')
|
||||
.select(['workspaces.workspace_id', 'accounts.token', 'servers.domain'])
|
||||
.where('workspaces.user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
const filesDir = getWorkspaceFilesDirectoryPath(userId);
|
||||
if (!fs.existsSync(filesDir)) {
|
||||
fs.mkdirSync(filesDir, { recursive: true });
|
||||
}
|
||||
@@ -221,9 +369,10 @@ class FileService {
|
||||
|
||||
eventBus.publish({
|
||||
type: 'download_deleted',
|
||||
userId: credentials.userId,
|
||||
userId,
|
||||
download: {
|
||||
nodeId: download.node_id,
|
||||
uploadId: download.upload_id,
|
||||
createdAt: download.created_at,
|
||||
updatedAt: download.updated_at,
|
||||
progress: download.progress,
|
||||
@@ -237,7 +386,7 @@ class FileService {
|
||||
const attributes: FileAttributes = JSON.parse(file.attributes);
|
||||
const filePath = path.join(
|
||||
filesDir,
|
||||
`${download.node_id}${attributes.extension}`
|
||||
`${download.node_id}_${download.upload_id}${attributes.extension}`
|
||||
);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
@@ -245,15 +394,17 @@ class FileService {
|
||||
.updateTable('downloads')
|
||||
.set({
|
||||
progress: 100,
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.where('node_id', '=', download.node_id)
|
||||
.execute();
|
||||
|
||||
eventBus.publish({
|
||||
type: 'download_updated',
|
||||
userId: credentials.userId,
|
||||
userId,
|
||||
download: {
|
||||
nodeId: download.node_id,
|
||||
uploadId: download.upload_id,
|
||||
createdAt: download.created_at,
|
||||
updatedAt: download.updated_at,
|
||||
progress: 100,
|
||||
@@ -264,16 +415,16 @@ class FileService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!serverService.isAvailable(credentials.serverDomain)) {
|
||||
if (!serverService.isAvailable(workspace.domain)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await httpClient.get<ServerFileDownloadResponse>(
|
||||
`/v1/files/${credentials.workspaceId}/${download.node_id}`,
|
||||
const { data } = await httpClient.get<CreateDownloadOutput>(
|
||||
`/v1/files/${workspace.workspace_id}/${download.node_id}`,
|
||||
{
|
||||
domain: credentials.serverDomain,
|
||||
token: credentials.token,
|
||||
domain: workspace.domain,
|
||||
token: workspace.token,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -293,9 +444,10 @@ class FileService {
|
||||
|
||||
eventBus.publish({
|
||||
type: 'download_updated',
|
||||
userId: credentials.userId,
|
||||
userId,
|
||||
download: {
|
||||
nodeId: download.node_id,
|
||||
uploadId: download.upload_id,
|
||||
createdAt: download.created_at,
|
||||
updatedAt: download.updated_at,
|
||||
progress: 100,
|
||||
@@ -313,9 +465,10 @@ class FileService {
|
||||
|
||||
eventBus.publish({
|
||||
type: 'download_updated',
|
||||
userId: credentials.userId,
|
||||
userId,
|
||||
download: {
|
||||
nodeId: download.node_id,
|
||||
uploadId: download.upload_id,
|
||||
createdAt: download.created_at,
|
||||
updatedAt: download.updated_at,
|
||||
progress: download.progress,
|
||||
|
||||
@@ -162,6 +162,7 @@ class NodeService {
|
||||
if (createdDownloadRow) {
|
||||
createdDownloads.push({
|
||||
nodeId: createdDownloadRow.node_id,
|
||||
uploadId: createdDownloadRow.upload_id,
|
||||
createdAt: createdDownloadRow.created_at,
|
||||
updatedAt: createdDownloadRow.updated_at,
|
||||
progress: createdDownloadRow.progress,
|
||||
|
||||
@@ -66,6 +66,7 @@ class QueryService {
|
||||
>;
|
||||
|
||||
let result = query.result;
|
||||
let hasChanges = false;
|
||||
for (const event of events) {
|
||||
const changeCheckResult = await handler.checkForChanges(
|
||||
event,
|
||||
@@ -75,9 +76,14 @@ class QueryService {
|
||||
|
||||
if (changeCheckResult.hasChanges) {
|
||||
result = changeCheckResult.result;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEqual(result, query.result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import path from 'path';
|
||||
import {
|
||||
SelectChange,
|
||||
SelectDownload,
|
||||
SelectNode,
|
||||
WorkspaceDatabaseSchema,
|
||||
} from '@/main/data/workspace/schema';
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from './data/app/schema';
|
||||
import { Workspace } from '@/shared/types/workspaces';
|
||||
import { Server } from '@/shared/types/servers';
|
||||
import { Download } from '@/shared/types/nodes';
|
||||
|
||||
export const appPath = app.getPath('userData');
|
||||
|
||||
@@ -145,3 +147,14 @@ export const mapServer = (row: SelectServer): Server => {
|
||||
lastSyncedAt: row.last_synced_at ? new Date(row.last_synced_at) : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapDownload = (row: SelectDownload): Download => {
|
||||
return {
|
||||
nodeId: row.node_id,
|
||||
uploadId: row.upload_id,
|
||||
progress: row.progress,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
retryCount: row.retry_count,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getFileUrl } from '@/shared/lib/files';
|
||||
import { useWorkspace } from '@/renderer/contexts/workspace';
|
||||
import { useQuery } from '@/renderer/hooks/use-query';
|
||||
import { FilePreview } from '@/renderer/components/files/file-preview';
|
||||
import { FileDownload } from '@/renderer/components/files/file-download';
|
||||
|
||||
interface FileBlockProps {
|
||||
id: string;
|
||||
@@ -21,8 +19,6 @@ export const FileBlock = ({ id }: FileBlockProps) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadProgress: number = 0;
|
||||
const url = getFileUrl(workspace.userId, data.id, data.attributes.extension);
|
||||
return (
|
||||
<div
|
||||
className="flex h-72 max-h-72 w-full cursor-pointer overflow-hidden rounded-md p-2 hover:bg-gray-100"
|
||||
@@ -30,15 +26,7 @@ export const FileBlock = ({ id }: FileBlockProps) => {
|
||||
workspace.openInModal(id);
|
||||
}}
|
||||
>
|
||||
{downloadProgress !== 100 ? (
|
||||
<FileDownload id={data.id} downloadProgress={downloadProgress} />
|
||||
) : (
|
||||
<FilePreview
|
||||
url={url}
|
||||
name={data.attributes.name}
|
||||
mimeType={data.attributes.mimeType}
|
||||
/>
|
||||
)}
|
||||
<FilePreview file={data} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,8 @@ import { FileNode } from '@colanode/core';
|
||||
import { Button } from '@/renderer/components/ui/button';
|
||||
import { useWorkspace } from '@/renderer/contexts/workspace';
|
||||
import { SquareArrowOutUpRight } from 'lucide-react';
|
||||
import { FileDownload } from '@/renderer/components/files/file-download';
|
||||
import { FilePreview } from '@/renderer/components/files/file-preview';
|
||||
import { FileSidebar } from '@/renderer/components/files/file-sidebar';
|
||||
import { getFileUrl } from '@/shared/lib/files';
|
||||
|
||||
interface FileBodyProps {
|
||||
file: FileNode;
|
||||
@@ -13,9 +11,7 @@ interface FileBodyProps {
|
||||
|
||||
export const FileBody = ({ file }: FileBodyProps) => {
|
||||
const workspace = useWorkspace();
|
||||
const downloadProgress = 100;
|
||||
|
||||
const url = getFileUrl(workspace.userId, file.id, file.attributes.extension);
|
||||
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 max-w-full flex-grow flex-col items-center justify-center overflow-hidden">
|
||||
@@ -35,20 +31,12 @@ export const FileBody = ({ file }: FileBodyProps) => {
|
||||
<SquareArrowOutUpRight className="mr-1 size-4" /> Open
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-full max-w-full flex-grow items-center justify-center overflow-hidden">
|
||||
{downloadProgress !== 100 ? (
|
||||
<FileDownload id={file.id} downloadProgress={downloadProgress} />
|
||||
) : (
|
||||
<FilePreview
|
||||
url={url}
|
||||
name={file.attributes.name}
|
||||
mimeType={file.attributes.mimeType}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full max-w-full flex-grow items-center justify-center overflow-hidden p-10">
|
||||
<FilePreview file={file} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full w-72 min-w-72 overflow-hidden border-l border-gray-100 p-2 pl-3">
|
||||
<FileSidebar file={file} downloadProgress={downloadProgress} />
|
||||
<FileSidebar file={file} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ export const FileDownload = ({ id, downloadProgress }: FileDownloadProps) => {
|
||||
const workspace = useWorkspace();
|
||||
const { mutate } = useMutation();
|
||||
|
||||
if (downloadProgress === null) {
|
||||
const isDownloading = typeof downloadProgress === 'number';
|
||||
if (!isDownloading) {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer flex-col items-center gap-3 text-muted-foreground hover:text-primary"
|
||||
|
||||
@@ -19,7 +19,7 @@ export const FileHeader = ({ nodes, file, role }: FileHeaderProps) => {
|
||||
<Header>
|
||||
<div className="flex w-full items-center gap-2 px-4">
|
||||
<div className="flex-grow">
|
||||
<NodeBreadcrumb nodes={nodes} />
|
||||
{container.mode === 'main' && <NodeBreadcrumb nodes={nodes} />}
|
||||
{container.mode === 'modal' && (
|
||||
<NodeFullscreenButton nodeId={file.id} />
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import { match } from 'ts-pattern';
|
||||
import { getFilePreviewType } from '@/shared/lib/files';
|
||||
import { FilePreviewImage } from '@/renderer/components/files/previews/file-preview-image';
|
||||
import { FilePreviewVideo } from '@/renderer/components/files/previews/file-preview-video';
|
||||
import { FilePreviewOther } from '@/renderer/components/files/previews/file-preview-other';
|
||||
import { FileNode } from '@colanode/core';
|
||||
import { useWorkspace } from '@/renderer/contexts/workspace';
|
||||
import { useQuery } from '@/renderer/hooks/use-query';
|
||||
import { getFileUrl } from '@/shared/lib/files';
|
||||
import { FileDownload } from '@/renderer/components/files/file-download';
|
||||
|
||||
interface FilePreviewProps {
|
||||
url: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
file: FileNode;
|
||||
}
|
||||
|
||||
export const FilePreview = ({ url, name, mimeType }: FilePreviewProps) => {
|
||||
const previewType = getFilePreviewType(mimeType);
|
||||
return match(previewType)
|
||||
.with('image', () => <FilePreviewImage url={url} name={name} />)
|
||||
export const FilePreview = ({ file }: FilePreviewProps) => {
|
||||
const workspace = useWorkspace();
|
||||
const { data } = useQuery({
|
||||
type: 'download_get',
|
||||
nodeId: file.id,
|
||||
userId: workspace.userId,
|
||||
});
|
||||
|
||||
if (!data || data.progress !== 100) {
|
||||
return <FileDownload id={file.id} downloadProgress={data?.progress} />;
|
||||
}
|
||||
|
||||
const url = getFileUrl(
|
||||
workspace.userId,
|
||||
file.id,
|
||||
file.attributes.uploadId,
|
||||
file.attributes.extension
|
||||
);
|
||||
|
||||
return match(file.attributes.subtype)
|
||||
.with('image', () => (
|
||||
<FilePreviewImage url={url} name={file.attributes.name} />
|
||||
))
|
||||
.with('video', () => <FilePreviewVideo url={url} />)
|
||||
.with('other', () => <FilePreviewOther mimeType={mimeType} />)
|
||||
.with('other', () => (
|
||||
<FilePreviewOther mimeType={file.attributes.mimeType} />
|
||||
))
|
||||
.otherwise(() => null);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useQuery } from '@/renderer/hooks/use-query';
|
||||
|
||||
interface FileSidebarProps {
|
||||
file: FileNode;
|
||||
downloadProgress: number;
|
||||
}
|
||||
|
||||
const FileMeta = ({ title, value }: { title: string; value: string }) => {
|
||||
@@ -21,7 +20,7 @@ const FileMeta = ({ title, value }: { title: string; value: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const FileSidebar = ({ file, downloadProgress }: FileSidebarProps) => {
|
||||
export const FileSidebar = ({ file }: FileSidebarProps) => {
|
||||
const workspace = useWorkspace();
|
||||
const { data } = useQuery({
|
||||
type: 'node_get',
|
||||
@@ -35,11 +34,7 @@ export const FileSidebar = ({ file, downloadProgress }: FileSidebarProps) => {
|
||||
<React.Fragment>
|
||||
<div className="flex items-center gap-x-4 p-2">
|
||||
<FileThumbnail
|
||||
id={file.id}
|
||||
name={file.attributes.name}
|
||||
mimeType={file.attributes.mimeType}
|
||||
extension={file.attributes.extension}
|
||||
downloadProgress={downloadProgress}
|
||||
file={file}
|
||||
className="h-12 w-9 min-w-[36px] overflow-hidden rounded object-contain"
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
import { getFileUrl } from '@/shared/lib/files';
|
||||
import { useWorkspace } from '@/renderer/contexts/workspace';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { FileIcon } from '@/renderer/components/files/file-icon';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { FileNode } from '@colanode/core';
|
||||
import { useQuery } from '@/renderer/hooks/use-query';
|
||||
|
||||
interface FileThumbnailProps {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
downloadProgress?: number | null;
|
||||
file: FileNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FileThumbnail = ({
|
||||
id,
|
||||
name,
|
||||
mimeType,
|
||||
extension,
|
||||
downloadProgress,
|
||||
className,
|
||||
}: FileThumbnailProps) => {
|
||||
export const FileThumbnail = ({ file, className }: FileThumbnailProps) => {
|
||||
const workspace = useWorkspace();
|
||||
const { data } = useQuery({
|
||||
type: 'download_get',
|
||||
nodeId: file.id,
|
||||
userId: workspace.userId,
|
||||
});
|
||||
|
||||
if (downloadProgress === 100 && mimeType.startsWith('image')) {
|
||||
const url = getFileUrl(workspace.userId, id, extension);
|
||||
if (
|
||||
file.attributes.subtype === 'image' &&
|
||||
data &&
|
||||
data.progress === 100 &&
|
||||
data.uploadId === file.attributes.uploadId
|
||||
) {
|
||||
const url = getFileUrl(
|
||||
workspace.userId,
|
||||
file.id,
|
||||
file.attributes.uploadId,
|
||||
file.attributes.extension
|
||||
);
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={name}
|
||||
alt={file.attributes.name}
|
||||
className={cn('object-contain object-center', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <FileIcon mimeType={mimeType} className="size-10" />;
|
||||
return <FileIcon mimeType={file.attributes.mimeType} className="size-10" />;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FileNode } from '@/shared/types/files';
|
||||
import { FileNode } from '@colanode/core';
|
||||
import { FileThumbnail } from '@/renderer/components/files/file-thumbnail';
|
||||
import { FileContextMenu } from '@/renderer/components/files/file-context-menu';
|
||||
import { GridItem } from './grid-item';
|
||||
import { GridItem } from '@/renderer/components/folders/grids/grid-item';
|
||||
|
||||
interface GridFileProps {
|
||||
file: FileNode;
|
||||
@@ -12,20 +12,13 @@ export const GridFile = ({ file }: GridFileProps) => {
|
||||
<FileContextMenu id={file.id}>
|
||||
<GridItem id={file.id}>
|
||||
<div className="flex w-full justify-center">
|
||||
<FileThumbnail
|
||||
id={file.id}
|
||||
mimeType={file.mimeType}
|
||||
extension={file.extension}
|
||||
downloadProgress={file.downloadProgress}
|
||||
name={file.name}
|
||||
className="h-14 w-14"
|
||||
/>
|
||||
<FileThumbnail file={file} className="h-14 w-14" />
|
||||
</div>
|
||||
<p
|
||||
className="line-clamp-2 w-full break-words text-center text-xs text-foreground/80"
|
||||
title={file.name}
|
||||
title={file.attributes.name}
|
||||
>
|
||||
{file.name}
|
||||
{file.attributes.name}
|
||||
</p>
|
||||
</GridItem>
|
||||
</FileContextMenu>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileNode } from '@/shared/types/files';
|
||||
import { FileNode } from '@colanode/core';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface FolderContext {
|
||||
|
||||
@@ -32,6 +32,9 @@ export const FilePlaceholderNode = Node.create({
|
||||
mimeType: {
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
@@ -57,6 +60,7 @@ export const FilePlaceholderNode = Node.create({
|
||||
extension: metadata.extension,
|
||||
mimeType: metadata.mimeType,
|
||||
name: metadata.name,
|
||||
type: metadata.type,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { type NodeViewProps } from '@tiptap/core';
|
||||
import { match } from 'ts-pattern';
|
||||
import { NodeViewWrapper } from '@tiptap/react';
|
||||
import { FilePreview } from '@/renderer/components/files/file-preview';
|
||||
import { getFilePlaceholderUrl } from '@/shared/lib/files';
|
||||
import { X } from 'lucide-react';
|
||||
import { FilePreviewImage } from '@/renderer/components/files/previews/file-preview-image';
|
||||
import { FilePreviewVideo } from '@/renderer/components/files/previews/file-preview-video';
|
||||
import { FilePreviewOther } from '@/renderer/components/files/previews/file-preview-other';
|
||||
|
||||
export const FilePlaceholderNodeView = ({
|
||||
node,
|
||||
@@ -11,6 +14,7 @@ export const FilePlaceholderNodeView = ({
|
||||
const path = node.attrs.path;
|
||||
const mimeType = node.attrs.mimeType;
|
||||
const name = node.attrs.name;
|
||||
const type = node.attrs.type;
|
||||
|
||||
if (!path || !mimeType) {
|
||||
return null;
|
||||
@@ -29,7 +33,11 @@ export const FilePlaceholderNodeView = ({
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<FilePreview url={url} name={name} mimeType={mimeType} />
|
||||
{match(type)
|
||||
.with('image', () => <FilePreviewImage url={url} name={name} />)
|
||||
.with('video', () => <FilePreviewVideo url={url} />)
|
||||
.with('other', () => <FilePreviewOther mimeType={mimeType} />)
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
||||
@@ -74,14 +74,7 @@ const Root = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingData = queryClient.getQueryData<any>([queryId]);
|
||||
|
||||
if (!existingData) {
|
||||
window.colanode.unsubscribeQuery(queryId);
|
||||
return;
|
||||
} else {
|
||||
queryClient.setQueryData([queryId], result);
|
||||
}
|
||||
queryClient.setQueryData([queryId], result);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { FilePreviewType } from '@/shared/types/files';
|
||||
|
||||
export const formatBytes = (bytes: number, decimals?: number): string => {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes';
|
||||
@@ -12,24 +10,13 @@ export const formatBytes = (bytes: number, decimals?: number): string => {
|
||||
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
export const getFilePreviewType = (mimeType: string): FilePreviewType => {
|
||||
if (mimeType.startsWith('image')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (mimeType.startsWith('video')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
};
|
||||
|
||||
export const getFileUrl = (
|
||||
userId: string,
|
||||
fileId: string,
|
||||
uploadId: string,
|
||||
extension: string
|
||||
) => {
|
||||
return `local-file://${userId}/${fileId}${extension}`;
|
||||
return `local-file://${userId}/${fileId}_${uploadId}${extension}`;
|
||||
};
|
||||
|
||||
export const getFilePlaceholderUrl = (path: string) => {
|
||||
|
||||
16
apps/desktop/src/shared/queries/download-get.ts
Normal file
16
apps/desktop/src/shared/queries/download-get.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Download } from '@/shared/types/nodes';
|
||||
|
||||
export type DownloadGetQueryInput = {
|
||||
type: 'download_get';
|
||||
nodeId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
declare module '@/shared/queries' {
|
||||
interface QueryMap {
|
||||
download_get: {
|
||||
input: DownloadGetQueryInput;
|
||||
output: Download | null;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileNode } from '@/shared/types/files';
|
||||
import { FileNode } from '@colanode/core';
|
||||
|
||||
export type FileListQueryInput = {
|
||||
type: 'file_list';
|
||||
|
||||
@@ -1,25 +1,4 @@
|
||||
export type ServerFileUploadResponse = {
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ServerFileDownloadResponse = {
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type FileNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
extension: string;
|
||||
fileName: string;
|
||||
createdAt: string;
|
||||
downloadProgress?: number | null;
|
||||
};
|
||||
|
||||
export type FilePreviewType = 'image' | 'video' | 'other';
|
||||
import { FileType } from '@colanode/core';
|
||||
|
||||
export type FileMetadata = {
|
||||
path: string;
|
||||
@@ -27,4 +6,5 @@ export type FileMetadata = {
|
||||
extension: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: FileType;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export type UserNode = {
|
||||
|
||||
export type Download = {
|
||||
nodeId: string;
|
||||
uploadId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
progress: number;
|
||||
|
||||
@@ -253,6 +253,29 @@ const createDeviceNodesTable: Migration = {
|
||||
},
|
||||
};
|
||||
|
||||
const createUploadsTable: Migration = {
|
||||
up: async (db) => {
|
||||
await db.schema
|
||||
.createTable('uploads')
|
||||
.addColumn('node_id', 'varchar(30)', (col) =>
|
||||
col.notNull().references('nodes.id').onDelete('no action').primaryKey()
|
||||
)
|
||||
.addColumn('upload_id', 'varchar(30)', (col) => col.notNull())
|
||||
.addColumn('workspace_id', 'varchar(30)', (col) => col.notNull())
|
||||
.addColumn('path', 'varchar(256)', (col) => col.notNull())
|
||||
.addColumn('size', 'integer', (col) => col.notNull())
|
||||
.addColumn('mime_type', 'varchar(256)', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar(30)', (col) => col.notNull())
|
||||
.addColumn('created_by', 'varchar(30)', (col) => col.notNull())
|
||||
.addColumn('created_at', 'timestamptz', (col) => col.notNull())
|
||||
.addColumn('completed_at', 'timestamptz', (col) => col.notNull())
|
||||
.execute();
|
||||
},
|
||||
down: async (db) => {
|
||||
await db.schema.dropTable('uploads').execute();
|
||||
},
|
||||
};
|
||||
|
||||
export const databaseMigrations: Record<string, Migration> = {
|
||||
'00001_create_accounts_table': createAccountsTable,
|
||||
'00002_create_devices_table': createDevicesTable,
|
||||
@@ -262,4 +285,5 @@ export const databaseMigrations: Record<string, Migration> = {
|
||||
'00006_create_node_paths_table': createNodePathsTable,
|
||||
'00007_create_user_nodes_table': createUserNodesTable,
|
||||
'00008_create_device_nodes_table': createDeviceNodesTable,
|
||||
'00009_create_uploads_table': createUploadsTable,
|
||||
};
|
||||
|
||||
@@ -146,6 +146,19 @@ export type SelectDeviceNode = Selectable<DeviceNodeTable>;
|
||||
export type CreateDeviceNode = Insertable<DeviceNodeTable>;
|
||||
export type UpdateDeviceNode = Updateable<DeviceNodeTable>;
|
||||
|
||||
interface UploadTable {
|
||||
node_id: ColumnType<string, string, never>;
|
||||
upload_id: ColumnType<string, string, never>;
|
||||
workspace_id: ColumnType<string, string, never>;
|
||||
path: ColumnType<string, string, string>;
|
||||
size: ColumnType<number, number, number>;
|
||||
mime_type: ColumnType<string, string, string>;
|
||||
type: ColumnType<string, string, string>;
|
||||
created_by: ColumnType<string, string, never>;
|
||||
created_at: ColumnType<Date, Date, never>;
|
||||
completed_at: ColumnType<Date, Date, never>;
|
||||
}
|
||||
|
||||
export interface DatabaseSchema {
|
||||
accounts: AccountTable;
|
||||
devices: DeviceTable;
|
||||
@@ -155,4 +168,5 @@ export interface DatabaseSchema {
|
||||
node_paths: NodePathTable;
|
||||
user_nodes: UserNodeTable;
|
||||
device_nodes: DeviceNodeTable;
|
||||
uploads: UploadTable;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,25 @@ import { BUCKET_NAMES, filesStorage } from '@/data/storage';
|
||||
import { hasCollaboratorAccess, hasViewerAccess } from '@/lib/constants';
|
||||
import { fetchNodeRole } from '@/lib/nodes';
|
||||
import { ApiError, ColanodeRequest, ColanodeResponse } from '@/types/api';
|
||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
CreateDownloadOutput,
|
||||
CreateUploadInput,
|
||||
CreateUploadOutput,
|
||||
extractFileType,
|
||||
generateId,
|
||||
IdType,
|
||||
UploadMetadata,
|
||||
} from '@colanode/core';
|
||||
import { redis } from '@/data/redis';
|
||||
import { YDoc } from '@colanode/crdt';
|
||||
import { enqueueEvent } from '@/queues/events';
|
||||
|
||||
export const filesRouter = Router();
|
||||
|
||||
@@ -77,25 +93,43 @@ filesRouter.get(
|
||||
});
|
||||
}
|
||||
|
||||
// check if the upload is completed
|
||||
const upload = await database
|
||||
.selectFrom('uploads')
|
||||
.selectAll()
|
||||
.where('node_id', '=', fileId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!upload) {
|
||||
return res.status(400).json({
|
||||
code: ApiError.BadRequest,
|
||||
message: 'Upload not completed.',
|
||||
});
|
||||
}
|
||||
|
||||
//generate presigned url for download
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: BUCKET_NAMES.FILES,
|
||||
Key: `files/${fileId}${node.attributes.extension}`,
|
||||
Key: upload.path,
|
||||
});
|
||||
|
||||
const presignedUrl = await getSignedUrl(filesStorage, command, {
|
||||
expiresIn: 60 * 60 * 4, // 4 hours
|
||||
});
|
||||
|
||||
res.status(200).json({ url: presignedUrl });
|
||||
const output: CreateDownloadOutput = {
|
||||
url: presignedUrl,
|
||||
};
|
||||
|
||||
res.status(200).json(output);
|
||||
}
|
||||
);
|
||||
|
||||
filesRouter.post(
|
||||
'/:workspaceId/:fileId',
|
||||
'/:workspaceId',
|
||||
async (req: ColanodeRequest, res: ColanodeResponse) => {
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const fileId = req.params.fileId as string;
|
||||
const input = req.body as CreateUploadInput;
|
||||
|
||||
if (!req.account) {
|
||||
return res.status(401).json({
|
||||
@@ -131,18 +165,10 @@ filesRouter.post(
|
||||
});
|
||||
}
|
||||
|
||||
const role = await fetchNodeRole(fileId, workspaceUser.id);
|
||||
if (role === null || !hasViewerAccess(role)) {
|
||||
return res.status(403).json({
|
||||
code: ApiError.Forbidden,
|
||||
message: 'Forbidden.',
|
||||
});
|
||||
}
|
||||
|
||||
const node = await database
|
||||
.selectFrom('nodes')
|
||||
.selectAll()
|
||||
.where('id', '=', fileId)
|
||||
.where('id', '=', input.fileId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!node) {
|
||||
@@ -159,18 +185,214 @@ filesRouter.post(
|
||||
});
|
||||
}
|
||||
|
||||
if (node.created_by !== workspaceUser.id) {
|
||||
return res.status(403).json({
|
||||
code: ApiError.Forbidden,
|
||||
message: 'Forbidden.',
|
||||
});
|
||||
}
|
||||
|
||||
//generate presigned url for upload
|
||||
const path = `files/${workspaceId}/${input.fileId}_${input.uploadId}${node.attributes.extension}`;
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: BUCKET_NAMES.FILES,
|
||||
Key: `files/${fileId}${node.attributes.extension}`,
|
||||
Key: path,
|
||||
ContentLength: node.attributes.size,
|
||||
ContentType: node.attributes.mimeType,
|
||||
});
|
||||
|
||||
const expiresIn = 60 * 60 * 4; // 4 hours
|
||||
const presignedUrl = await getSignedUrl(filesStorage, command, {
|
||||
expiresIn: 60 * 60 * 4, // 4 hours
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
res.status(200).json({ url: presignedUrl });
|
||||
const data: UploadMetadata = {
|
||||
fileId: input.fileId,
|
||||
path,
|
||||
mimeType: node.attributes.mimeType,
|
||||
size: node.attributes.size,
|
||||
uploadId: input.uploadId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await redis.set(input.uploadId, JSON.stringify(data), {
|
||||
EX: expiresIn,
|
||||
});
|
||||
|
||||
const output: CreateUploadOutput = {
|
||||
uploadId: input.uploadId,
|
||||
url: presignedUrl,
|
||||
};
|
||||
|
||||
res.status(200).json(output);
|
||||
}
|
||||
);
|
||||
|
||||
filesRouter.put(
|
||||
'/:workspaceId/:uploadId',
|
||||
async (req: ColanodeRequest, res: ColanodeResponse) => {
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const uploadId = req.params.uploadId as string;
|
||||
|
||||
if (!req.account) {
|
||||
return res.status(401).json({
|
||||
code: ApiError.Unauthorized,
|
||||
message: 'Unauthorized.',
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await database
|
||||
.selectFrom('workspaces')
|
||||
.selectAll()
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!workspace) {
|
||||
return res.status(404).json({
|
||||
code: ApiError.ResourceNotFound,
|
||||
message: 'Workspace not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceUser = await database
|
||||
.selectFrom('workspace_users')
|
||||
.selectAll()
|
||||
.where('workspace_id', '=', workspace.id)
|
||||
.where('account_id', '=', req.account.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!workspaceUser) {
|
||||
return res.status(403).json({
|
||||
code: ApiError.Forbidden,
|
||||
message: 'Forbidden.',
|
||||
});
|
||||
}
|
||||
|
||||
const metadataJson = await redis.get(uploadId);
|
||||
if (!metadataJson) {
|
||||
return res.status(404).json({
|
||||
code: ApiError.ResourceNotFound,
|
||||
message: 'Upload not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const metadata: UploadMetadata = JSON.parse(metadataJson);
|
||||
const file = await database
|
||||
.selectFrom('nodes')
|
||||
.selectAll()
|
||||
.where('id', '=', metadata.fileId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!file) {
|
||||
return res.status(404).json({
|
||||
code: ApiError.ResourceNotFound,
|
||||
message: 'File not found.',
|
||||
});
|
||||
}
|
||||
|
||||
if (file.attributes.type !== 'file') {
|
||||
return res.status(400).json({
|
||||
code: ApiError.BadRequest,
|
||||
message: 'File not found.',
|
||||
});
|
||||
}
|
||||
|
||||
if (file.attributes.size !== metadata.size) {
|
||||
return res.status(400).json({
|
||||
code: ApiError.BadRequest,
|
||||
message: 'Size mismatch.',
|
||||
});
|
||||
}
|
||||
|
||||
const path = metadata.path;
|
||||
// check if the file exists in the bucket
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: BUCKET_NAMES.FILES,
|
||||
Key: path,
|
||||
});
|
||||
|
||||
try {
|
||||
const headObject = await filesStorage.send(command);
|
||||
|
||||
// Verify file size matches expected size
|
||||
if (headObject.ContentLength !== metadata.size) {
|
||||
return res.status(400).json({
|
||||
code: ApiError.BadRequest,
|
||||
message: 'Uploaded file size does not match expected size',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify mime type matches expected type
|
||||
if (headObject.ContentType !== metadata.mimeType) {
|
||||
return res.status(400).json({
|
||||
code: ApiError.BadRequest,
|
||||
message: 'Uploaded file type does not match expected type',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
code: ApiError.BadRequest,
|
||||
message: 'File upload verification failed',
|
||||
});
|
||||
}
|
||||
|
||||
const ydoc = new YDoc(file.id, file.state);
|
||||
ydoc.updateAttributes({
|
||||
...file.attributes,
|
||||
uploadStatus: 'completed',
|
||||
uploadId: metadata.uploadId,
|
||||
});
|
||||
|
||||
const attributes = ydoc.getAttributes();
|
||||
const state = ydoc.getState();
|
||||
const fileVersionId = generateId(IdType.Version);
|
||||
const updatedAt = new Date();
|
||||
|
||||
await database.transaction().execute(async (tx) => {
|
||||
await database
|
||||
.insertInto('uploads')
|
||||
.values({
|
||||
node_id: file.id,
|
||||
upload_id: uploadId,
|
||||
workspace_id: workspace.id,
|
||||
path: metadata.path,
|
||||
mime_type: metadata.mimeType,
|
||||
size: metadata.size,
|
||||
type: extractFileType(metadata.mimeType),
|
||||
created_by: workspaceUser.id,
|
||||
created_at: new Date(metadata.createdAt),
|
||||
completed_at: updatedAt,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await tx
|
||||
.updateTable('nodes')
|
||||
.set({
|
||||
attributes: JSON.stringify(attributes),
|
||||
state: state,
|
||||
updated_at: updatedAt,
|
||||
updated_by: workspaceUser.id,
|
||||
version_id: fileVersionId,
|
||||
server_updated_at: updatedAt,
|
||||
})
|
||||
.where('id', '=', file.id)
|
||||
.execute();
|
||||
});
|
||||
|
||||
await enqueueEvent({
|
||||
type: 'node_updated',
|
||||
id: file.id,
|
||||
workspaceId: workspace.id,
|
||||
beforeAttributes: file.attributes,
|
||||
afterAttributes: attributes,
|
||||
updatedBy: workspaceUser.id,
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
serverUpdatedAt: updatedAt.toISOString(),
|
||||
versionId: fileVersionId,
|
||||
});
|
||||
|
||||
await redis.del(uploadId);
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './lib/constants';
|
||||
export * from './lib/id';
|
||||
export * from './lib/nodes';
|
||||
export * from './lib/files';
|
||||
export * from './registry/block';
|
||||
export * from './registry/channel';
|
||||
export * from './registry/chat';
|
||||
@@ -23,3 +24,4 @@ export * from './types/sync';
|
||||
export * from './types/accounts';
|
||||
export * from './types/messages';
|
||||
export * from './types/servers';
|
||||
export * from './types/files';
|
||||
|
||||
21
packages/core/src/lib/files.ts
Normal file
21
packages/core/src/lib/files.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FileType } from '../types/files';
|
||||
|
||||
export const extractFileType = (mimeType: string): FileType => {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (mimeType.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (mimeType.startsWith('audio/')) {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
if (mimeType.startsWith('application/pdf')) {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
};
|
||||
@@ -44,6 +44,7 @@ export enum IdType {
|
||||
File = 'fi',
|
||||
FilePlaceholder = 'fp',
|
||||
Device = 'dv',
|
||||
Upload = 'up',
|
||||
}
|
||||
|
||||
export const generateId = (type: IdType): string => {
|
||||
|
||||
@@ -3,12 +3,17 @@ import { NodeModel } from './core';
|
||||
|
||||
export const fileAttributesSchema = z.object({
|
||||
type: z.literal('file'),
|
||||
subtype: z.enum(['image', 'video', 'audio', 'document', 'other']),
|
||||
name: z.string(),
|
||||
parentId: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number(),
|
||||
extension: z.string(),
|
||||
fileName: z.string(),
|
||||
uploadStatus: z
|
||||
.enum(['pending', 'completed', 'failed', 'no_space'])
|
||||
.default('pending'),
|
||||
uploadId: z.string(),
|
||||
});
|
||||
|
||||
export type FileAttributes = z.infer<typeof fileAttributesSchema>;
|
||||
|
||||
28
packages/core/src/types/files.ts
Normal file
28
packages/core/src/types/files.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type CreateUploadInput = {
|
||||
fileId: string;
|
||||
uploadId: string;
|
||||
};
|
||||
|
||||
export type UploadMetadata = {
|
||||
fileId: string;
|
||||
uploadId: string;
|
||||
path: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CreateUploadOutput = {
|
||||
uploadId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type CreateDownloadOutput = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type CompleteUploadOutput = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type FileType = 'image' | 'video' | 'audio' | 'document' | 'other';
|
||||
Reference in New Issue
Block a user