diff --git a/web/contract/console/app-asset.ts b/web/contract/console/app-asset.ts new file mode 100644 index 0000000000..0c9ab34e8f --- /dev/null +++ b/web/contract/console/app-asset.ts @@ -0,0 +1,119 @@ +import type { + AppAssetDeleteResponse, + AppAssetFileContentResponse, + AppAssetNode, + AppAssetPublishResponse, + AppAssetTreeResponse, + CreateFolderPayload, + MoveNodePayload, + RenameNodePayload, + ReorderNodePayload, + UpdateFileContentPayload, +} from '@/types/app-asset' +import { type } from '@orpc/contract' +import { base } from '../base' + +export const treeContract = base + .route({ + path: '/apps/{appId}/assets/tree', + method: 'GET', + }) + .input(type<{ + params: { appId: string } + }>()) + .output(type()) + +export const createFolderContract = base + .route({ + path: '/apps/{appId}/assets/folders', + method: 'POST', + }) + .input(type<{ + params: { appId: string } + body: CreateFolderPayload + }>()) + .output(type()) + +export const createFileContract = base + .route({ + path: '/apps/{appId}/assets/files', + method: 'POST', + }) + .input(type<{ + params: { appId: string } + }>()) + .output(type()) + +export const getFileContentContract = base + .route({ + path: '/apps/{appId}/assets/files/{nodeId}', + method: 'GET', + }) + .input(type<{ + params: { appId: string, nodeId: string } + }>()) + .output(type()) + +export const updateFileContentContract = base + .route({ + path: '/apps/{appId}/assets/files/{nodeId}', + method: 'PUT', + }) + .input(type<{ + params: { appId: string, nodeId: string } + body: UpdateFileContentPayload + }>()) + .output(type()) + +export const deleteNodeContract = base + .route({ + path: '/apps/{appId}/assets/nodes/{nodeId}', + method: 'DELETE', + }) + .input(type<{ + params: { appId: string, nodeId: string } + }>()) + .output(type()) + +export const renameNodeContract = base + .route({ + path: '/apps/{appId}/assets/nodes/{nodeId}/rename', + method: 'POST', + }) + .input(type<{ + params: { appId: string, nodeId: string } + body: RenameNodePayload + }>()) + .output(type()) + +export const moveNodeContract = base + .route({ + path: '/apps/{appId}/assets/nodes/{nodeId}/move', + method: 'POST', + }) + .input(type<{ + params: { appId: string, nodeId: string } + body: MoveNodePayload + }>()) + .output(type()) + +export const reorderNodeContract = base + .route({ + path: '/apps/{appId}/assets/nodes/{nodeId}/reorder', + method: 'POST', + }) + .input(type<{ + params: { appId: string, nodeId: string } + body: ReorderNodePayload + }>()) + .output(type()) + +export const publishContract = base + .route({ + path: '/apps/{appId}/assets/publish', + method: 'POST', + }) + .input(type<{ + params: { appId: string } + }>()) + .output(type()) diff --git a/web/contract/router.ts b/web/contract/router.ts index 8202086ce8..70b5b76dbb 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -4,6 +4,18 @@ import { bindPartnerStackContract, systemFeaturesContract, } from './console' +import { + createFileContract, + createFolderContract, + deleteNodeContract, + getFileContentContract, + moveNodeContract, + publishContract, + renameNodeContract, + reorderNodeContract, + treeContract, + updateFileContentContract, +} from './console/app-asset' import { activateSandboxProviderContract, deleteSandboxProviderConfigContract, @@ -32,6 +44,18 @@ export const consoleRouterContract = { deleteSandboxProviderConfig: deleteSandboxProviderConfigContract, activateSandboxProvider: activateSandboxProviderContract, getActiveSandboxProvider: getActiveSandboxProviderContract, + appAsset: { + tree: treeContract, + createFolder: createFolderContract, + createFile: createFileContract, + getFileContent: getFileContentContract, + updateFileContent: updateFileContentContract, + deleteNode: deleteNodeContract, + renameNode: renameNodeContract, + moveNode: moveNodeContract, + reorderNode: reorderNodeContract, + publish: publishContract, + }, } export type ConsoleInputs = InferContractRouterInputs diff --git a/web/service/use-app-asset.ts b/web/service/use-app-asset.ts new file mode 100644 index 0000000000..356c90da59 --- /dev/null +++ b/web/service/use-app-asset.ts @@ -0,0 +1,286 @@ +import type { + AppAssetNode, + CreateFolderPayload, + MoveNodePayload, + RenameNodePayload, + ReorderNodePayload, + UpdateFileContentPayload, +} from '@/types/app-asset' +import { + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { consoleClient, consoleQuery } from '@/service/client' +import { upload } from './base' + +export const useGetAppAssetTree = (appId: string) => { + return useQuery({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId } } }), + queryFn: () => consoleClient.appAsset.tree({ params: { appId } }), + enabled: !!appId, + }) +} + +export const useCreateAppAssetFolder = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: consoleQuery.appAsset.createFolder.mutationKey(), + mutationFn: ({ appId, payload }: { appId: string, payload: CreateFolderPayload }) => { + return consoleClient.appAsset.createFolder({ + params: { appId }, + body: payload, + }) + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }), + }) + }, + }) +} + +export const useCreateAppAssetFile = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: consoleQuery.appAsset.createFile.mutationKey(), + mutationFn: async ({ + appId, + name, + file, + parentId, + onProgress, + }: { + appId: string + name: string + file: File + parentId?: string | null + onProgress?: (progress: number) => void + }): Promise => { + const formData = new FormData() + formData.append('name', name) + formData.append('file', file) + if (parentId) + formData.append('parent_id', parentId) + + const xhr = new XMLHttpRequest() + return upload( + { + xhr, + data: formData, + onprogress: onProgress + ? (e) => { + if (e.lengthComputable) + onProgress(Math.round((e.loaded / e.total) * 100)) + } + : undefined, + }, + false, + `/apps/${appId}/assets/files`, + ) as Promise + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }), + }) + }, + }) +} + +export const useGetAppAssetFileContent = (appId: string, nodeId: string) => { + return useQuery({ + queryKey: consoleQuery.appAsset.getFileContent.queryKey({ input: { params: { appId, nodeId } } }), + queryFn: () => consoleClient.appAsset.getFileContent({ params: { appId, nodeId } }), + enabled: !!appId && !!nodeId, + }) +} + +export const useUpdateAppAssetFileContent = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: consoleQuery.appAsset.updateFileContent.mutationKey(), + mutationFn: ({ + appId, + nodeId, + payload, + }: { + appId: string + nodeId: string + payload: UpdateFileContentPayload + }) => { + return consoleClient.appAsset.updateFileContent({ + params: { appId, nodeId }, + body: payload, + }) + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }), + }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.getFileContent.queryKey({ + input: { params: { appId: variables.appId, nodeId: variables.nodeId } }, + }), + }) + }, + }) +} + +export const useUpdateAppAssetFileByUpload = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + appId, + nodeId, + file, + onProgress, + }: { + appId: string + nodeId: string + file: File + onProgress?: (progress: number) => void + }): Promise => { + const formData = new FormData() + formData.append('file', file) + + const xhr = new XMLHttpRequest() + return upload( + { + xhr, + method: 'PUT', + data: formData, + onprogress: onProgress + ? (e) => { + if (e.lengthComputable) + onProgress(Math.round((e.loaded / e.total) * 100)) + } + : undefined, + }, + false, + `/apps/${appId}/assets/files/${nodeId}`, + ) as Promise + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }), + }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.getFileContent.queryKey({ + input: { params: { appId: variables.appId, nodeId: variables.nodeId } }, + }), + }) + }, + }) +} + +export const useDeleteAppAssetNode = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: consoleQuery.appAsset.deleteNode.mutationKey(), + mutationFn: ({ appId, nodeId }: { appId: string, nodeId: string }) => { + return consoleClient.appAsset.deleteNode({ + params: { appId, nodeId }, + }) + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }), + }) + }, + }) +} + +export const useRenameAppAssetNode = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: consoleQuery.appAsset.renameNode.mutationKey(), + mutationFn: ({ + appId, + nodeId, + payload, + }: { + appId: string + nodeId: string + payload: RenameNodePayload + }) => { + return consoleClient.appAsset.renameNode({ + params: { appId, nodeId }, + body: payload, + }) + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }), + }) + }, + }) +} + +export const useMoveAppAssetNode = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: consoleQuery.appAsset.moveNode.mutationKey(), + mutationFn: ({ + appId, + nodeId, + payload, + }: { + appId: string + nodeId: string + payload: MoveNodePayload + }) => { + return consoleClient.appAsset.moveNode({ + params: { appId, nodeId }, + body: payload, + }) + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }), + }) + }, + }) +} + +export const useReorderAppAssetNode = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: consoleQuery.appAsset.reorderNode.mutationKey(), + mutationFn: ({ + appId, + nodeId, + payload, + }: { + appId: string + nodeId: string + payload: ReorderNodePayload + }) => { + return consoleClient.appAsset.reorderNode({ + params: { appId, nodeId }, + body: payload, + }) + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: variables.appId } } }), + }) + }, + }) +} + +export const usePublishAppAssets = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationKey: consoleQuery.appAsset.publish.mutationKey(), + mutationFn: (appId: string) => { + return consoleClient.appAsset.publish({ + params: { appId }, + }) + }, + onSuccess: (_, appId) => { + queryClient.invalidateQueries({ + queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId } } }), + }) + }, + }) +} diff --git a/web/types/app-asset.ts b/web/types/app-asset.ts new file mode 100644 index 0000000000..7916de8856 --- /dev/null +++ b/web/types/app-asset.ts @@ -0,0 +1,149 @@ +/** + * App Asset Types + * + * Types for app asset management API - file tree operations, + * file content management, and asset publishing. + */ + +/** + * Node type enumeration for asset tree nodes + */ +export type AssetNodeType = 'file' | 'folder' + +/** + * Asset node representation (flat storage format) + * Used in responses for create, update, rename, move, reorder operations + */ +export type AppAssetNode = { + /** Unique identifier (UUID) */ + id: string + /** Node type: file or folder */ + node_type: AssetNodeType + /** Node name (filename or folder name) */ + name: string + /** Parent folder ID, null for root level */ + parent_id: string | null + /** Sort order within parent folder (0-based) */ + order: number + /** File extension without dot, empty for folders */ + extension: string + /** File size in bytes, 0 for folders */ + size: number + /** SHA-256 checksum of file content, empty for folders */ + checksum: string +} + +/** + * Asset tree view node (nested format with computed path) + * Used in tree response with hierarchical structure + */ +export type AppAssetTreeView = { + /** Unique identifier (UUID) */ + id: string + /** Node type: file or folder */ + node_type: AssetNodeType + /** Node name */ + name: string + /** Full path from root, e.g. '/folder/file.txt' */ + path: string + /** File extension without dot */ + extension: string + /** File size in bytes */ + size: number + /** SHA-256 checksum */ + checksum: string + /** Child nodes (for folders) */ + children: AppAssetTreeView[] +} + +/** + * Asset tree response (GET /apps/{app_id}/assets/tree) + */ +export type AppAssetTreeResponse = { + children: AppAssetTreeView[] +} + +/** + * File content response (GET /apps/{app_id}/assets/files/{node_id}) + */ +export type AppAssetFileContentResponse = { + content: string +} + +/** + * Delete node response (DELETE /apps/{app_id}/assets/nodes/{node_id}) + */ +export type AppAssetDeleteResponse = { + result: 'success' +} + +/** + * Published asset tree structure (flat node list) + */ +export type AppAssetFileTree = { + nodes: AppAssetNode[] +} + +/** + * Publish response (POST /apps/{app_id}/assets/publish) + */ +export type AppAssetPublishResponse = { + /** Published version ID */ + id: string + /** Version timestamp */ + version: string + /** Asset tree snapshot */ + asset_tree: AppAssetFileTree +} + +/** + * Request payload for creating a folder + */ +export type CreateFolderPayload = { + /** Folder name (1-255 characters) */ + name: string + /** Parent folder ID, null/undefined for root */ + parent_id?: string | null +} + +/** + * Request payload for creating a file (form data) + */ +export type CreateFilePayload = { + /** File name (1-255 characters) */ + name: string + /** Parent folder ID, empty or undefined for root */ + parent_id?: string | null +} + +/** + * Request payload for updating file content (JSON) + */ +export type UpdateFileContentPayload = { + /** New file content (UTF-8) */ + content: string +} + +/** + * Request payload for renaming a node + */ +export type RenameNodePayload = { + /** New name (1-255 characters) */ + name: string +} + +/** + * Request payload for moving a node + */ +export type MoveNodePayload = { + /** Target parent folder ID, null for root */ + parent_id: string | null +} + +/** + * Request payload for reordering a node + */ +export type ReorderNodePayload = { + /** Place after this node ID, null for first position */ + after_node_id: string | null +}