diff --git a/spx-gui/env.d.ts b/spx-gui/env.d.ts deleted file mode 100644 index c09f5f39c..000000000 --- a/spx-gui/env.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -/// - -interface ImportMetaEnv { - readonly VITE_API_BASE_URL: string - readonly VITE_PUBLISH_BASE_URL:string - readonly VITE_CASDOOR_ENDPOINT: string - readonly VITE_CASDOOR_CLIENT_ID: string - readonly VITE_CASDOOR_ORGANIZATION_NAME: string - readonly VITE_CASDOOR_APP_NAME: string -} - -interface ImportMeta { - readonly env: ImportMetaEnv -} diff --git a/spx-gui/src/api/asset.ts b/spx-gui/src/api/asset.ts index c7d7364d5..2ce125f5e 100644 --- a/spx-gui/src/api/asset.ts +++ b/spx-gui/src/api/asset.ts @@ -1,244 +1,74 @@ -/* - * @Author: Yao xinyue - * @Date: 2024-01-22 11:17:08 - * @LastEditors: xuning 453594138@qq.com - * @LastEditTime: 2024-03-14 11:22:33 - * @FilePath: \spx-gui\src\api\asset.ts - * @Description: - */ -import { service } from '@/axios' -import type { Asset, PageAssetResponse } from '@/interface/library.ts' // Adjust the import paths as needed -import type { ResponseData } from '@/axios' -import type { AxiosResponse } from 'axios' -import { PublicStatus } from '@/class/project' +import type { ByPage, PaginationParams, FileCollection } from './common' +import { client, IsPublic } from './common' -export enum PublishState { - NotPublished = -1, - PrivateLibrary = 0, - PublicAndPrivateLibrary = 1 -} - -/** - * Fetches a list of assets - * - * @param assetLibraryType 'public' / 'private'; - * @param pageIndex The index of the page to retrieve in a paginated list. - * @param pageSize The number of assets to retrieve per page. - * @param assetType The type of the asset. See src/constant/constant.ts for details. - * @param category (Optional) The category of the assets to filter by. - * @param isOrderByTime (Optional) Whether to order assets by time. - * @param isOrderByHot (Optional) Whether to order assets by popularity. - * @returns PageAssetResponse - */ -export function getAssetList({ - isPublic, - pageIndex, - pageSize, - assetType, - category, - isOrderByTime, - isOrderByHot, - author -}: { - pageIndex: number - pageSize: number - assetType: number - category?: string - isOrderByTime?: boolean - isOrderByHot?: boolean - isPublic?: PublicStatus - author?: string -}): Promise { - const baseAssetUrl = '/assets/list' - const params = new URLSearchParams() - params.append('pageIndex', pageIndex.toString()) - params.append('pageSize', pageSize.toString()) - params.append('assetType', assetType.toString()) - - if (isPublic != null) { - params.append('isPublic', isPublic.toString()) - } - if (category) { - params.append('category', category) - } - if (isOrderByTime) { - params.append('isOrderByTime', '1') - } - if (isOrderByHot) { - params.append('isOrderByHot', '1') - } - if (author) { - params.append('author', author) - } - - const url = `${baseAssetUrl}?${params.toString()}` +export { IsPublic } - return service({ - url: url, - method: 'get' - }) +export enum AssetType { + Sprite = 0, + Backdrop = 1, + Sound = 2 } -/** - * Fetches a single asset - * - * @param id - * @returns Asset - */ -export function getAsset(id: number): Promise { - const url = `/asset/${id}` - return service({ - url: url, - method: 'get' - }) +export type AssetData = { + // Globally unique ID + id: string + // Name to display + displayName: string + // Name of asset owner + owner: string + // Asset Category + category: string + // Public status + isPublic: IsPublic + // Files the asset contains + files: FileCollection + // Preview URL for the asset, e.g., a gif for a sprite + preview: string + // Asset Type + assetType: AssetType + // Click count of the asset + clickCount: number } -/** - * @description: Search Asset by name. - * @param {string} search - * @param {number} assetType - * @return { SearchAssetResponse } - */ -export function searchAssetByName( - pageIndex: number, - pageSize: number, - search: string, - assetType: number -): Promise { - const baseAssetUrl = `/assets/search` +export type AddAssetParams = Pick - const params = new URLSearchParams() - params.append('pageIndex', pageIndex.toString()) - params.append('pageSize', pageSize.toString()) - params.append('search', search) - params.append('assetType', assetType.toString()) +export function addAsset(params: AddAssetParams) { + return client.post('/asset', params) as Promise +} - const url = `${baseAssetUrl}?${params.toString()}` +export type UpdateAssetParams = AddAssetParams - return service({ - url: url, - method: 'get' - }) +export function updateAsset(id: string, params: UpdateAssetParams) { + return client.put(`/asset/${encodeURIComponent(id)}`, params) as Promise } -/** - * Save asset - * - * @param id - * @param name - * @param uid - * @param category - * @param isPublic - * @param assetType The type of the asset. See src/constant/constant.ts for details. - * @param file - */ -export async function saveAsset( - id: number, - name: string, - uid: number, - category: string, - isPublic: number, - assetType: number, - file: File -): Promise { - const url = '/asset/save' - const formData = new FormData() - formData.append('id', id.toString()) - formData.append('name', name) - formData.append('uid', uid.toString()) - formData.append('category', category) - formData.append('isPublic', isPublic ? '1' : '0') - formData.append('assetType', assetType.toString()) - formData.append('file', file) - - return service({ - url: url, - method: 'post', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data' - } - }) +export function deleteAsset(id: string) { + return client.delete(`/asset/${encodeURIComponent(id)}`) as Promise } -/** - * @description: Add asset click count. - * @param id - * @param assetType The type of the asset. See src/constant/constant.ts for details. - * @return {Promise>>} - */ -export function addAssetClickCount(id: number): Promise>> { - const url = `/asset/${id}/click-count` - return service({ - url: url, - method: 'post' - }) +export enum ListAssetParamOrderBy { + Default = 'default', + TimeDesc = 'time', + ClickCountDesc = 'clickCount' } -/** - * @description: Publish asset to library. - * @param { string } name - sprite name named by user. - * @param { File[] } files - sprite costumes files, saved to show in lib. - * @param { string } assetType - sprite assetType, 0: sprite, 1: backdrop, 2: sound - * @param { PublishState } publishState - The publishing state of the asset. -1: not publish, 0: private lib, 1: public lib. - * @param { string|undefined } [gif] - Optional. The address of the sprite's GIF. - * Only provide this parameter if there is more than one file. - * It is used to display in the library when the sprite is hovering. - * @param { string|undefined } category - the category of the sprite(used to classify in library). - * @return { Promise } - The result of the publishing operation. - */ -export function publishAsset( - name: string, - files: File[], - assetType: number, - publishState: PublishState, - previewAddress?: string, +export type ListAssetParams = PaginationParams & { + keyword?: string + assetType?: AssetType category?: string -): Promise { - const url = `/asset` - const formData = new FormData() - formData.append('name', name) - formData.append('assetType', assetType.toString()) - files.forEach((file) => { - formData.append('files', file) - }) - if (previewAddress) { - formData.append('previewAddress', previewAddress) - } - if (category) { - formData.append('category', category) - } - formData.append('publishState', publishState.toString()) + isPublic?: IsPublic + owner?: string + orderBy?: ListAssetParamOrderBy +} - // Assume `service` is a predefined function for handling HTTP requests. - return service({ - url: url, - method: 'post', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data' - } - }) +export function listAsset(params?: ListAssetParams) { + return client.get('/assets/list', params) as Promise> } -/** - * @description: generate gif by costumes files - * @param {File} files - sprite costumes files - * @return {string} get sprites gif address. - */ -export function generateGifByCostumes(files: File[]): Promise>> { - const url = `/util/to-gif` - const formData = new FormData() - files.forEach((file) => { - formData.append('files', file) - }) +export function getAsset(id: string) { + return client.get(`/asset/${encodeURIComponent(id)}`) as Promise +} - return service({ - url: url, - method: 'post', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data' - } - }) +export function increaseAssetClickCount(id: string) { + return client.post(`/asset/${encodeURIComponent(id)}/click`) as Promise } diff --git a/spx-gui/src/api/common/client.ts b/spx-gui/src/api/common/client.ts new file mode 100644 index 000000000..1ab9481a8 --- /dev/null +++ b/spx-gui/src/api/common/client.ts @@ -0,0 +1,111 @@ +/** + * @desc Client (& error) definition for spx-backend APIs + */ + +import { apiBaseUrl } from '@/util/env' + +export type RequestOptions = { + method: string + headers?: Headers +} + +/** Response body when error encountered for API calling */ +export type ApiErrorPayload = { + /** Code for program comsuming */ + code: number + /** Message for developer reading */ + msg: string +} + +function isApiErrorPayload(body: any): body is ApiErrorPayload { + return body && (typeof body.code === 'number') && (typeof body.msg === 'string') +} + +export class ApiError extends Error { + name = 'ApiError' + constructor( + public code: number, + msg: string + ) { + super(`[${code}] ${msg}`) + } +} + +/** AuthProvider provide value for header Authorization */ +export type AuthProvider = () => string | null | Promise + +export class Client { + + private getAuth: AuthProvider = () => null + + setAuthProvider(provider: AuthProvider) { + this.getAuth = provider + } + + private async prepareRequest(url: string, payload: unknown, options?: RequestOptions) { + url = apiBaseUrl + url + const method = options?.method ?? 'GET' + const body = payload != null ? JSON.stringify(payload) : null + const authorization = await this.getAuth() + const headers = options?.headers ?? new Headers() + headers.set('Content-Type', 'application/json') + if (authorization != null) { + headers.set('Authorization', authorization) + } + return new Request(url, { method, headers, body }) + } + + private async handleResponse(resp: Response): Promise { + if (!resp.ok) { + const body = await resp.json() + if (!isApiErrorPayload(body)) { + throw new Error('api call failed') + } + throw new ApiError(body.code, body.msg) + } + return resp.json() + } + + private async request(url: string, payload: unknown, options?: RequestOptions) { + const req = await this.prepareRequest(url, payload, options) + const resp = await fetch(req) + return this.handleResponse(resp) + } + + get(url: string, params?: QueryParams) { + url = params == null ? url : withQueryParams(url, params) + return this.request(url, null, { method: 'GET' }) + } + + post(url: string, payload?: unknown) { + return this.request(url, payload, { method: 'POST' }) + } + + put(url: string, payload?: unknown) { + return this.request(url, payload, { method: 'PUT' }) + } + + delete(url: string, params?: QueryParams) { + url = params == null ? url : withQueryParams(url, params) + return this.request(url, null, { method: 'DELETE' }) + } + +} + +type QueryParams = { + [k: string]: unknown +} + +function withQueryParams(url: string, params: QueryParams) { + const usp = new URLSearchParams() + Object.keys(params).forEach(k => { + const v = params[k] + if (v != null) usp.append(k, v + '') + }) + const querystring = usp.toString() + if (querystring !== '') { + const sep = url.includes('?') ? '&' : '?' + url = url + sep + querystring + } + return url +} diff --git a/spx-gui/src/api/common/index.ts b/spx-gui/src/api/common/index.ts new file mode 100644 index 000000000..672493bf8 --- /dev/null +++ b/spx-gui/src/api/common/index.ts @@ -0,0 +1,25 @@ +import { Client } from './client' + +export type PaginationParams = { + pageSize?: number + pageIndex?: number +} + +export type ByPage = { + total: number + data: T[] +} + +export const OwnerAll = '*' + +export enum IsPublic { + Personal = 0, + Public = 1 +} + +/** Map from relative path to URL */ +export type FileCollection = { + [path: string]: string +} + +export const client = new Client() diff --git a/spx-gui/src/api/project.ts b/spx-gui/src/api/project.ts index 7122dfd57..5daad5a6f 100644 --- a/spx-gui/src/api/project.ts +++ b/spx-gui/src/api/project.ts @@ -1,110 +1,64 @@ -import type { PageData, Project } from '@/interface/library' -import { service } from '@/axios' -import type { ResponseData } from '@/axios' -import type { FormatResponse } from '@/components/code-editor' -import type { AxiosResponse } from 'axios' -import { PublicStatus } from '@/class/project' +import type { ByPage, PaginationParams, FileCollection } from './common' +import { client, IsPublic } from './common' -/** - * Saves a project. - * - * @param name The name of the project. - * @param file The code file(zip) to be uploaded. - * @param id - * @returns Project - */ -export async function saveProject(name: string, file: File, id?: string): Promise { - const url = '/project' - const formData = new FormData() - formData.append('name', name) - formData.append('file', file) - id && formData.append('id', id) +export { IsPublic } - const res: AxiosResponse> = await service({ - url: url, - method: 'post', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data' - } - }) - if (res.data.code >= 200 && res.data.code < 300) { - return res.data.data - } else { - throw new Error(res.data.msg) - } +export type { FileCollection } + +export enum ProjectDataType { + Sprite = 0, + Backdrop = 1, + Sound = 2 +} + +export type ProjectData = { + // Globally Unique ID + id: string + // Project name, unique for projects of same owner + name: string + // Name of project owner + owner: string + // Public status + isPublic: IsPublic + // Files the project contains + files: FileCollection + // Project version + version: number + // Create time + cTime: string + // Update time + uTime: string +} + +export type AddProjectParams = Pick + +export function addProject(params: AddProjectParams) { + return client.post('/project', params) as Promise } -/** - * Fetches a list of projects. - * @param pageIndex The index of the page to retrieve in a paginated list. - * @param pageSize The number of projects to retrieve per page. - * @param isPublic Public projects or user projects. - * @returns Project[] - */ -export async function getProjects( - pageIndex: number, - pageSize: number, - isPublic?: PublicStatus, - author?: string -): Promise> { - const baseUrl = `/projects/list` - const params = new URLSearchParams() - params.append('pageIndex', String(pageIndex)) - params.append('pageSize', String(pageSize)) - isPublic !== undefined && params.append('isPublic', String(isPublic)) - author && params.append('author', author) - const url = `${baseUrl}?${params.toString()}` - return service({ url: url, method: 'get' }).then((res) => res.data.data) +export type UpdateProjectParams = Pick + +function encode(owner: string, name: string) { + return `${encodeURIComponent(owner)}/${encodeURIComponent(name)}` } -/** - * Fetches a single project. - * @param id The id of the project - * @returns Project - */ -export async function getProject(id: string): Promise { - const url = `/project/${id}` - return service({ url: url, method: 'get' }).then((res) => res.data.data) +export function updateProject(owner: string, name: string, params: UpdateProjectParams) { + return client.put(`/project/${encode(owner, name)}`, params) as Promise } -/** - * Removes a project. - * @param id The id of the project - * @returns string - */ -export async function removeProject(id: string): Promise { - const url = `/project/${id}` - return service({ url: url, method: 'delete' }).then((res) => res.data.data) +export function deleteProject(owner: string, name: string) { + return client.delete(`/project/${encode(owner, name)}`) as Promise } -/** - * Update project isPublic status. - * @param id project id that will be public - * @returns - */ -export async function updateProjectIsPublic(id: string, status: PublicStatus): Promise { - const url = `/project/${id}/is-public?isPublic=${status}` - return service({ url: url, method: 'put' }).then((res) => res.data.data) +export type ListProjectParams = PaginationParams & { + isPublic?: IsPublic + owner?: string } -/** - * Format spx code - * - * @param body The string content to be formatted. - * @returns string - */ -export function formatSpxCode(body: string): Promise>> { - const url = '/util/fmt' - const formData = new FormData() - formData.append('body', body) +export function listProject(params?: ListProjectParams) { + return client.get('/projects/list', params) as Promise> +} - return service({ - url: url, - method: 'post', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data' - } - }) +export function getProject(owner: string, name: string) { + return client.get(`/project/${encode(owner, name)}`) as Promise } diff --git a/spx-gui/src/api/test.ts b/spx-gui/src/api/test.ts new file mode 100644 index 000000000..0ff261a5c --- /dev/null +++ b/spx-gui/src/api/test.ts @@ -0,0 +1,254 @@ +import { IsPublic } from './common'; +import * as assetApis from './asset-2' +import * as projectApis from './project-2' + +;(window as any).assetApis = assetApis +;(window as any).projectApis = projectApis + +function is(src: T, target: T) { + if (src !== target) throw new Error('not equal') +} + +;(window as any).testAsset = async function testAsset() { + { + const { data: assets } = await assetApis.listAsset({ pageSize: 100 }) + for (const p of assets) { + await assetApis.deleteAsset(p.id) + } + } + { + const res = await assetApis.listAsset() + is(res.total, 0) + is(res.data.length, 0) + } + { + const res = await assetApis.listAsset({ owner: "*" }) + is(res.total, 0) + is(res.data.length, 0) + } + { + const res = await assetApis.listAsset({ owner: "xxx" }) + is(res.total, 0) + is(res.data.length, 0) + } + { + const p = await assetApis.addAsset({ + displayName: 't1', + isPublic: IsPublic.Public, + files: { a: 'aaa' }, + assetType: assetApis.AssetType.Sprite, + category: 'test', + preview: 'a' + }) + is(p.displayName, 't1') + is(p.isPublic, IsPublic.Public) + is(p.files.a, 'aaa') + is(p.assetType, assetApis.AssetType.Sprite) + is(p.category, 'test') + is(p.preview, 'a') + is(p.clickCount, 0) + } + let t2: assetApis.AssetData + { + const p = t2 = await assetApis.addAsset({ + displayName: 't2', + isPublic: IsPublic.Personal, + files: { a: 'aaa', b: 'bbb' }, + assetType: assetApis.AssetType.Backdrop, + category: 'test', + preview: 'b' + }) + is(p.displayName, 't2') + is(p.isPublic, IsPublic.Personal) + is(p.files.a, 'aaa') + is(p.files.b, 'bbb') + is(p.assetType, assetApis.AssetType.Backdrop) + is(p.category, 'test') + is(p.preview, 'b') + is(p.clickCount, 0) + } + { + const p = await assetApis.addAsset({ + displayName: 't3', + isPublic: IsPublic.Public, + files: {}, + assetType: assetApis.AssetType.Sound, + category: 'test', + preview: 'c' + }) + is(p.displayName, 't3') + is(p.isPublic, IsPublic.Public) + is(JSON.stringify(p.files), '{}') + is(p.assetType, assetApis.AssetType.Sound) + is(p.category, 'test') + is(p.preview, 'c') + is(p.clickCount, 0) + } + { + const p = await assetApis.addAsset({ + displayName: 't4', + isPublic: IsPublic.Personal, + files: {}, + assetType: assetApis.AssetType.Sound, + category: 'test-2', + preview: 'd' + }) + is(p.displayName, 't4') + is(p.isPublic, IsPublic.Personal) + is(JSON.stringify(p.files), '{}') + is(p.assetType, assetApis.AssetType.Sound) + is(p.category, 'test-2') + is(p.preview, 'd') + is(p.clickCount, 0) + } + { + const { total, data } = await assetApis.listAsset() + is(total, 4) + is(data.length, 4) + is(data.map(p => p.displayName).join(','), 't1,t2,t3,t4') + } + { + const { total, data } = await assetApis.listAsset({ pageSize: 2 }) + is(total, 4) + is(data.length, 2) + is(data.map(p => p.displayName).join(','), 't1,t2') + } + { + const { total, data } = await assetApis.listAsset({ pageIndex: 2, pageSize: 10 }) + is(total, 4) + is(data.length, 0) + } + { + const { total, data } = await assetApis.listAsset({ pageIndex: 2, pageSize: 2 }) + is(total, 4) + is(data.length, 2) + is(data.map(p => p.displayName).join(','), 't3,t4') + } + { + const p = await assetApis.updateAsset(t2.id, { + ...t2, + isPublic: IsPublic.Public, + files: {} + }) + is(p.displayName, 't2') + is(p.isPublic, IsPublic.Public) + is(JSON.stringify(p.files), '{}') + } + { + const { total, data } = await assetApis.listAsset({ isPublic: IsPublic.Public }) + is(total, 3) + is(data.length, 3) + is(data.map(p => p.displayName).join(','), 't1,t2,t3') + } + { + const { total, data } = await assetApis.listAsset({ owner: '*' }) + is(total, 3) + is(data.length, 3) + is(data.map(p => p.displayName).join(','), 't1,t2,t3') + } + { + await assetApis.increaseAssetClickCount(t2.id) + t2 = await assetApis.getAsset(t2.id) + is(t2.clickCount, 1) + } +} + +;(window as any).testProject = async function testProject() { + { + const { data: projects } = await projectApis.listProject({ pageSize: 100 }) + for (const p of projects) { + await projectApis.deleteProject(p.owner, p.name) + } + } + { + const res = await projectApis.listProject() + is(res.total, 0) + is(res.data.length, 0) + } + { + const res = await projectApis.listProject({ owner: "*" }) + is(res.total, 0) + is(res.data.length, 0) + } + { + const res = await projectApis.listProject({ owner: "xxx" }) + is(res.total, 0) + is(res.data.length, 0) + } + { + const p = await projectApis.addProject({ name: 't1', isPublic: IsPublic.Public, files: { a: 'aaa' } }) + is(p.name, 't1') + is(p.isPublic, IsPublic.Public) + is(p.files.a, 'aaa') + is(p.version, 1) + } + { + const p = await projectApis.addProject({ name: 't2', isPublic: IsPublic.Personal, files: { a: 'aaa', b: 'bbb' } }) + is(p.name, 't2') + is(p.isPublic, IsPublic.Personal) + is(p.files.a, 'aaa') + is(p.files.b, 'bbb') + is(p.version, 1) + } + { + const p = await projectApis.addProject({ name: 't3', isPublic: IsPublic.Public, files: { a: 'aaa', b: 'bbb', c: 'ccc' } }) + is(p.name, 't3') + is(p.isPublic, IsPublic.Public) + is(p.files.a, 'aaa') + is(p.files.b, 'bbb') + is(p.files.c, 'ccc') + is(p.version, 1) + } + { + const p = await projectApis.addProject({ name: 't4', isPublic: IsPublic.Personal, files: {} }) + is(p.name, 't4') + is(p.isPublic, IsPublic.Personal) + is(JSON.stringify(p.files), '{}') + is(p.version, 1) + } + { + const { total, data } = await projectApis.listProject() + is(total, 4) + is(data.length, 4) + is(data.map(p => p.name).join(','), 't1,t2,t3,t4') + } + { + const { total, data } = await projectApis.listProject({ pageSize: 2 }) + is(total, 4) + is(data.length, 2) + is(data.map(p => p.name).join(','), 't1,t2') + } + { + const { total, data } = await projectApis.listProject({ pageIndex: 2, pageSize: 10 }) + is(total, 4) + is(data.length, 0) + } + { + const { total, data } = await projectApis.listProject({ pageIndex: 2, pageSize: 2 }) + is(total, 4) + is(data.length, 2) + is(data.map(p => p.name).join(','), 't3,t4') + } + { + const p = await projectApis.updateProject('nighca', 't2', { + isPublic: IsPublic.Public, + files: {} + }) + is(p.name, 't2') + is(p.version, 2) + is(p.isPublic, IsPublic.Public) + is(JSON.stringify(p.files), '{}') + } + { + const { total, data } = await projectApis.listProject({ isPublic: IsPublic.Public }) + is(total, 3) + is(data.length, 3) + is(data.map(p => p.name).join(','), 't1,t2,t3') + } + { + const { total, data } = await projectApis.listProject({ owner: '*' }) + is(total, 3) + is(data.length, 3) + is(data.map(p => p.name).join(','), 't1,t2,t3') + } +} \ No newline at end of file diff --git a/spx-gui/src/api/util.ts b/spx-gui/src/api/util.ts new file mode 100644 index 000000000..0c0a82ba1 --- /dev/null +++ b/spx-gui/src/api/util.ts @@ -0,0 +1,20 @@ +/** + * @desc util-related APIs of spx-backend + */ + +import { client } from './common' + +export interface FormatError { + Column: number + Line: number + Msg: string +} + +export interface FormatResponse { + Body: string + Error: FormatError +} + +export function formatSpxCode(body: string) { + return client.post('/util/fmt', { body }) as Promise +} diff --git a/spx-gui/src/main.ts b/spx-gui/src/main.ts index 3c7283e5c..bc38cc737 100644 --- a/spx-gui/src/main.ts +++ b/spx-gui/src/main.ts @@ -14,16 +14,11 @@ import { initI18n } from '@/language' import { addFileUrl } from './util/file' import VueKonva from 'vue-konva' import { initStore, useUserStore } from './store' -import { serviceManager } from '@/axios' -import { createDiscreteApi } from 'naive-ui' +import { client } from './api/common' -const { message } = createDiscreteApi(['message']) const initServive = async () => { const userStore = useUserStore() - serviceManager.setAccessTokenFn(userStore.getFreshAccessToken) - serviceManager.setNotifyErrorFn((msg: string) => { - message.error(msg) - }) + client.setAuthProvider(userStore.getFreshAccessToken) } async function initApp() { diff --git a/spx-gui/src/model/Costume.ts b/spx-gui/src/model/Costume.ts new file mode 100644 index 000000000..7f595af23 --- /dev/null +++ b/spx-gui/src/model/Costume.ts @@ -0,0 +1,39 @@ +import { reactive } from 'vue' +import { File, type ReadFile } from './common/file' +import { Model } from './common/model' + +export type CostumeConfig = { + name: string + path: string + x: number + y: number + faceRight: number + bitmapResolution: number +} + +export class Costume extends Model { + + name!: string + path!: string + file!: File + x!: number + y!: number + faceRight!: number + bitmapResolution!: number + + constructor(config: CostumeConfig, public _read: ReadFile) { + super(config) + return reactive(this) + } + + async init() { + this.file = await this._read(this.path) + } + + getConfig(): CostumeConfig { + return { + name: this.name, path: this.path, x: this.x, y: this.y, + faceRight: this.faceRight, bitmapResolution: this.bitmapResolution + } + } +} diff --git a/spx-gui/src/model/backdrop.ts b/spx-gui/src/model/backdrop.ts new file mode 100644 index 000000000..b48b4c581 --- /dev/null +++ b/spx-gui/src/model/backdrop.ts @@ -0,0 +1,5 @@ +import { type CostumeConfig, Costume } from './Costume' + +export type BackdropConfig = CostumeConfig + +export class Backdrop extends Costume {} diff --git a/spx-gui/src/model/common/file.ts b/spx-gui/src/model/common/file.ts new file mode 100644 index 000000000..6d8eba841 --- /dev/null +++ b/spx-gui/src/model/common/file.ts @@ -0,0 +1,97 @@ +/** + * @file class File + * @desc File-like class, while load lazily from network + */ + +export class File { + + /** File content */ + content: ArrayBuffer | null = null + + constructor( + /** File name */ + public name: string, + /** Public URL */ + public url: string | null = null, + contentInit: ArrayBuffer | string | null = null + ) { + if (contentInit != null) { + if (typeof contentInit === 'string') { + this.content = str2Ab(contentInit) + } else { + this.content = contentInit + } + } + } + + _promisedContent: Promise | null = null + + async _load() { + if (this.url == null) throw new Error('file url expected') + const resp = await fetch(this.url) + const blob = await resp.blob() + return blob.arrayBuffer() + } + + async arrayBuffer() { + if (this.content != null) return this.content + if (this._promisedContent != null) return this._promisedContent + return this._promisedContent = this._load().then(ab => { + return this.content = ab + }) + } + + async text() { + const ab = await this.arrayBuffer() + const decoder = new TextDecoder() + return decoder.decode(ab) + } + + // async url() { + // const ab = await this.arrayBuffer() + // return URL.createObjectURL(new Blob([ab])) + // } + +} + +export type Files = { + [path: string]: File +} + +// export class MemFile extends File { + +// constructor(name: string, private ab: ArrayBuffer) { +// super(name) +// } + +// protected async load() { +// return this.ab +// } + +// } + +// export class CloudFile extends File { + +// constructor(private urlStr: string, name?: string) { +// super(name ?? getName(urlStr)) +// } + +// protected async load() { +// const resp = await fetch(this.urlStr) // TODO: request details +// const blob = await resp.blob() +// return blob.arrayBuffer() +// } + +// override async url() { +// return this.urlStr +// } + +// } + +export type ReadFile = (path: string) => Promise + +function str2Ab(str: string) { + const encoder = new TextEncoder() + const view = encoder.encode(str) + return view.buffer +} \ No newline at end of file diff --git a/spx-gui/src/model/common/model.ts b/spx-gui/src/model/common/model.ts new file mode 100644 index 000000000..e10fd43bb --- /dev/null +++ b/spx-gui/src/model/common/model.ts @@ -0,0 +1,29 @@ +/** + * @file class Model + * @desc Base class for object-models + */ + +type Disposer = () => void + +export abstract class Model { + + constructor(initialValues?: unknown) { + Object.assign(this, initialValues) + } + + abstract init(): Promise + + _disposers: Disposer[] = [] + + addDisposer(disposer: Disposer) { + this._disposers.push(disposer) + } + + dispose() { + const disposers = this._disposers.splice(0) + for (const disposer of disposers) { + disposer() + } + } + +} diff --git a/spx-gui/src/model/project/drivers/base.ts b/spx-gui/src/model/project/drivers/base.ts new file mode 100644 index 000000000..3e5c8b0f0 --- /dev/null +++ b/spx-gui/src/model/project/drivers/base.ts @@ -0,0 +1,22 @@ +import type { IsPublic } from '@/api/common' +import { File, type Files } from '../../common/file' + +export type Metadata = { + owner: string | null + name: string | null + isPublic: IsPublic | null +} + +export interface ReadableDriver { + /** Get metadata of project */ + getMetaData(): Promise + /** List all files' relative path in project. Relative path starts with slash, e.g. `index.json` */ + listFiles(): Promise + /** Read file content for given relative path. Relative path starts with slash, e.g. `index.json` */ + readFile(path: string): Promise +} + +export interface WritableDriver { + /** TODO */ + write(metadata: Metadata, files: Files): Promise +} diff --git a/spx-gui/src/model/project/drivers/cloud.ts b/spx-gui/src/model/project/drivers/cloud.ts new file mode 100644 index 000000000..9bf145bb4 --- /dev/null +++ b/spx-gui/src/model/project/drivers/cloud.ts @@ -0,0 +1,53 @@ +import { filename } from '@/util/path' +import { File, type Files } from '../../common/file' +import { type ReadableDriver, type Metadata, type WritableDriver } from './base' +import type { ProjectData, FileCollection } from '@/api/project' +import { getProject, updateProject } from '@/api/project' + +export class CloudDriver implements ReadableDriver, WritableDriver { + + constructor(private owner: string, private name: string) {} + + private projectDataPromise: Promise | undefined + + private fetchProjectData(): Promise { + if (this.projectDataPromise != null) return this.projectDataPromise + this.projectDataPromise = getProject(this.owner, this.name) + return this.projectDataPromise + } + + async getMetaData(): Promise { + const { owner, name, isPublic } = await this.fetchProjectData() + return { owner, name, isPublic } + } + + async listFiles(): Promise { + const { files } = await this.fetchProjectData() + return Object.keys(files) + } + + async readFile(path: string): Promise { + const { files } = await this.fetchProjectData() + const url = files[path] + return new File(filename(url), url) + } + + private async uploadFile(file: File) { + if (file.url != null) return file.url + return file.url = await upload(file) + } + + async write({ isPublic }: Metadata, files: Files) { + if (isPublic == null) throw new Error('isPublic required') + const fileUrls: FileCollection = {} + await Promise.all(Object.keys(files).map(async path => { + fileUrls[path] = await this.uploadFile(files[path]) + })) + await updateProject(this.owner, this.name, { isPublic, files: fileUrls }) + } + +} + +async function upload(file: File) { + return `https://TODO/${file.name}` +} diff --git a/spx-gui/src/model/project/drivers/local.ts b/spx-gui/src/model/project/drivers/local.ts new file mode 100644 index 000000000..3a6d715fd --- /dev/null +++ b/spx-gui/src/model/project/drivers/local.ts @@ -0,0 +1,89 @@ +import localforage from 'localforage' +import { File, type Files } from '../../common/file' +import { type ReadableDriver, type Metadata, type WritableDriver } from './base' + +const storage = localforage.createInstance({ + name: 'spx-gui', + storeName: 'project' +}) + +type MetadataEx = Metadata & { + files: string[] +} + +type RawFile = { + name: string + url: string | null + content: ArrayBuffer | null +} + +export class LocalDriver implements ReadableDriver, WritableDriver { + + constructor(private key: string) {} + + private async getMetadataEx() { + const metadataEx = await storage.getItem(this.key) + if (metadataEx == null) throw new Error('metadata not found in storage') + return metadataEx as MetadataEx + } + + private async setMetadataEx(metadataEx: MetadataEx) { + await storage.setItem(this.key, metadataEx) + } + + private async removeMetadataEx() { + await storage.removeItem(this.key) + } + + async getMetaData(): Promise { + return this.getMetadataEx() + } + + async listFiles(): Promise { + const { files } = await this.getMetadataEx() + return files + } + + async readFile(path: string): Promise { + const rawFile = await storage.getItem(`${this.key}/${path}`) + if (rawFile == null) throw new Error('file not found in storage') + const { name, url, content } = rawFile as RawFile + return new File(name, url, content) + } + + private async writeFile(path: string, file: File) { + const rawFile: RawFile = { + name: file.name, + url: file.url, + content: file.content + } + await storage.setItem(`${this.key}/${path}`, rawFile) + } + + private async removeFile(path: string) { + await storage.removeItem(`${this.key}/${path}`) + } + + private async clear() { + try { + const { files } = await this.getMetadataEx() + await Promise.all([ + this.removeMetadataEx(), + ...files.map(path => this.removeFile(path)) + ]) + } catch (e) { + // TODO: check error + } + } + + async write(metadata: Metadata, files: Files): Promise { + await this.clear() + const fileList = Object.keys(files) + const metadataEx = { ...metadata, files: fileList } + await Promise.all([ + this.setMetadataEx(metadataEx), + ...fileList.map(path => this.writeFile(path, files[path])) + ]) + } + +} diff --git a/spx-gui/src/model/project/drivers/zip.ts b/spx-gui/src/model/project/drivers/zip.ts new file mode 100644 index 000000000..4d355305e --- /dev/null +++ b/spx-gui/src/model/project/drivers/zip.ts @@ -0,0 +1,52 @@ +import JSZip from 'jszip' +import { File as LazyFile, type Files as LazyFiles } from '../../common/file' +import { type ReadableDriver, type Metadata, type WritableDriver } from './base' +import { filename, stripExt } from '@/util/path' + +export class ZipReader implements ReadableDriver { + + constructor(private zipFile: File) {} + + private jsZip: Promise | null = null + + private loadJSZip(): Promise { + if (this.jsZip != null) return this.jsZip + return JSZip.loadAsync(this.zipFile) + } + + async getMetaData(): Promise { + return { + owner: null, + name: stripExt(this.zipFile.name), + isPublic: null + } + } + + async listFiles(): Promise { + const jszip = await this.loadJSZip() + return Object.keys(jszip.files) + } + + async readFile(path: string): Promise { + const jszip = await this.loadJSZip() + const zipEntry = jszip.files[path] + const content = await zipEntry.async('arraybuffer') + return new LazyFile(filename(path), null, content) + } + +} + +export class ZipWriter implements WritableDriver { + + async write({ name }: Metadata, files: LazyFiles): Promise { + const zip = new JSZip() + await Promise.all(Object.keys(files).map(async path => { + const content = await files[path].arrayBuffer() + zip.file(path, content) + })) + + const blob = await zip.generateAsync({ type: 'blob' }) + return new File([blob], name + '.zip') + } + +} \ No newline at end of file diff --git a/spx-gui/src/model/project/index.ts b/spx-gui/src/model/project/index.ts new file mode 100644 index 000000000..b50a271b2 --- /dev/null +++ b/spx-gui/src/model/project/index.ts @@ -0,0 +1,108 @@ +/** + * @file class Project + * @desc Object-model definition for Project + */ + +import { reactive } from 'vue' +import { type ProjectData } from '../../api/project' +import { IsPublic } from '../../api/project' +import { Model } from '../common/model' + +import type { Metadata, ReadableDriver, WritableDriver } from './drivers/base' +import { CloudDriver } from './drivers/cloud' +import { LocalDriver } from './drivers/local' +import { ZipReader, ZipWriter } from './drivers/zip' +import type { Files } from '../common/file' + +const localSavedKey = 'TODO' + +export class Project extends Model { + + static + + id: string | null = null + name: string | null = null + owner: string | null = null + isPublic: IsPublic | null = null + version: number | null = null + cTime: string | null = null + uTime: string | null = null + + // get identifier() { + // return `${this.owner}/${this.name}` + // } + + constructor() { + super() + return reactive(this) as Project + } + + private applyMetadata({ owner, name, isPublic }: Metadata) { + this.owner = owner + this.name = name + this.isPublic = isPublic + } + + private async initFrom(readable: ReadableDriver) { + + const [metadata, filePaths] = await Promise.all([ + readable.getMetaData(), + readable.listFiles() + ]) + + this.applyMetadata(metadata) + + await Promise.all(filePaths.map(async filePath => { + const { content, url } = await readable.readFile(filePath) + // TODO + })) + } + + initBlank(owner: string, name = '') { + this.owner = owner + this.name = name + } + + initFromZip(zipFile: File) { + const zipStorage = new ZipReader(zipFile) + return this.initFrom(zipStorage) + } + + initFromLocal() { + const localStorage = new LocalDriver(localSavedKey) + return this.initFrom(localStorage) + } + + async initFromCloud(owner: string, name: string) { + const storage = new CloudDriver(owner, name) + return this.initFrom(storage) + } + + private getFiles(): Files { + // TODO + } + + private async writeTo(writable: WritableDriver): Promise { + return writable.write({ + owner: this.owner, + name: this.name, + isPublic: this.isPublic + }, this.getFiles()) + } + + saveToZip() { + return this.writeTo(new ZipWriter()) + } + + writeToLocal() { + const localWriter = new LocalDriver(localSavedKey) + return this.writeTo(localWriter) + } + + writeToCloud() { + if (this.owner == null || this.name == null) throw new Error('owner & name requried') + const cloudWriter = new CloudDriver(this.owner, this.name) + return this.writeTo(cloudWriter) + } + +} diff --git a/spx-gui/src/model/sound.ts b/spx-gui/src/model/sound.ts new file mode 100644 index 000000000..2e9eeecc6 --- /dev/null +++ b/spx-gui/src/model/sound.ts @@ -0,0 +1,26 @@ +import { reactive } from 'vue' +import { File, type ReadFile } from './common/file' +import { Model } from './common/model' + +export type CostumeConfig = { + path: string + rate: number + sampleCount: number +} + +export class Costume extends Model { + + path!: string + file!: File + rate!: number + sampleCount!: number + + constructor(public name: string, config: CostumeConfig, public _read: ReadFile) { + super(config) + return reactive(this) + } + + async init() { + this.file = await this._read(this.path) + } +} diff --git a/spx-gui/src/model/sprite.ts b/spx-gui/src/model/sprite.ts new file mode 100644 index 000000000..980eb8127 --- /dev/null +++ b/spx-gui/src/model/sprite.ts @@ -0,0 +1,89 @@ +/** + * @file class Sprite + * @desc Object-model definition for Sprite & Costume + */ + +import { reactive } from 'vue'; +import { File, type Files, type ReadFile } from './common/file' +import { Model } from './common/model' +import { join } from '@/util/path' +import { resolve } from 'path' +import { type CostumeConfig, Costume } from './Costume' + +export type SpriteConfig = { + heading: number + x: number + y: number + size: number + rotationStyle: string + costumes: CostumeConfig[] + costumeIndex: number + visible: boolean + isDraggable: boolean + // TODO: + // costumeSet: costumeSet + // costumeMPSet: costumeMPSet + // currentCostumeIndex: int + // fAnimations: map + // mAnimations: map + // tAnimations: map +} + +const spriteAssetPath = 'assets/sprites' + +export class Sprite extends Model { + + heading!: number + x!: number + y!: number + size!: number + rotationStyle!: string + costumes!: Costume[] + costumeIndex!: number + visible!: boolean + isDraggable!: boolean + + constructor( + public name: string, + { costumes: costumeConfigs, ...config }: SpriteConfig, + public code: string, + public _read: ReadFile, + ) { + super({ + ...config, + costumes: costumeConfigs.map( + c => new Costume(c, this._read) + ) + }) + return reactive(this) + } + + async init() { + await Promise.all(this.costumes.map(c => c.init())) + } + + getConfig(): SpriteConfig { + return { + heading: this.heading, + x: this.x, + y: this.y, + size: this.size, + rotationStyle: this.rotationStyle, + costumes: this.costumes.map(c => c.getConfig()), + costumeIndex: this.costumeIndex, + visible: this.visible, + isDraggable: this.isDraggable + } + } + + getFiles() { + const files: Files = {} + const assetPath = join(spriteAssetPath, this.name) + files[`${this.name}.spx`] = new File(`${this.name}.spx`, null, this.code) + files[`${assetPath}/index.json`] = new File('index.json', null, JSON.stringify(this.getConfig())) + for (const costume of this.costumes) { + files[resolve(assetPath, costume.path)] = costume.file + } + return files + } +} diff --git a/spx-gui/src/model/stage.ts b/spx-gui/src/model/stage.ts new file mode 100644 index 000000000..f5b77a155 --- /dev/null +++ b/spx-gui/src/model/stage.ts @@ -0,0 +1,64 @@ +/** + * @file class Stage + * @desc Object-model definition for Stage & Costume + */ + +import { reactive } from 'vue'; +import { File, type Files, type ReadFile } from './common/file' +import { Model } from './common/model' +import { resolve } from 'path' +import { Backdrop, type BackdropConfig } from './backdrop' + +export type StageConfig = { + scenes: BackdropConfig[] + sceneIndex: number + // TODO: + // costumes: CostumeConfig[] + // currentCostumeIndex: number +} + +const stageAssetPath = 'assets' +const stageCodeFileName = 'main.spx' + +export class Stage extends Model { + + backdrops!: Backdrop[] + backdropIndex!: number + + constructor( + public name: string, + { scenes: backdropConfigs, sceneIndex: backdropIndex, ...config }: StageConfig, + public code: string, + public _read: ReadFile, + ) { + super({ + ...config, + backdrops: backdropConfigs.map( + c => new Backdrop(c, this._read) + ), + backdropIndex + }) + return reactive(this) + } + + async init() { + await Promise.all(this.backdrops.map(c => c.init())) + } + + getConfig(): StageConfig { + return { + scenes: this.backdrops.map(b => b.getConfig()), + sceneIndex: this.backdropIndex + } + } + + getFiles() { + const files: Files = {} + files[stageCodeFileName] = new File(stageCodeFileName, null, this.code) + for (const backdrop of this.backdrops) { + files[resolve(stageAssetPath, backdrop.path)] = backdrop.file + } + // config file is handled by project + return files + } +} diff --git a/spx-gui/src/store/user.ts b/spx-gui/src/store/user.ts index 51240d148..ddd44fb0a 100644 --- a/spx-gui/src/store/user.ts +++ b/spx-gui/src/store/user.ts @@ -1,4 +1,5 @@ -import { casdoorSdk } from '@/util/casdoor' +import CasdoorSdk from '@/util/casdoor' +import { casdoorConfig } from '@/util/env' import type ITokenResponse from 'js-pkce/dist/ITokenResponse' import { defineStore } from 'pinia' @@ -28,6 +29,11 @@ export interface JwtPayload { phone: string } +const casdoorSdk = new CasdoorSdk({ + ...casdoorConfig, + redirectPath: '/callback' +}) + export const useUserStore = defineStore('spx-user', { state: () => ({ accessToken: null as string | null, diff --git a/spx-gui/src/util/casdoor.ts b/spx-gui/src/util/casdoor.ts index af4a3cd3a..8309c4609 100644 --- a/spx-gui/src/util/casdoor.ts +++ b/spx-gui/src/util/casdoor.ts @@ -1,11 +1,3 @@ -const config: SdkConfig = { - serverUrl: import.meta.env.VITE_CASDOOR_ENDPOINT, - clientId: import.meta.env.VITE_CASDOOR_CLIENT_ID, - organizationName: import.meta.env.VITE_CASDOOR_ORGANIZATION_NAME, - appName: import.meta.env.VITE_CASDOOR_APP_NAME, - redirectPath: '/callback' -} - // The following content is copied from the casdoor-sdk package, as // we need to modify some of the code. // Original source: @@ -155,4 +147,4 @@ class Sdk { } } -export const casdoorSdk = new Sdk(config) +export default Sdk diff --git a/spx-gui/src/util/env.ts b/spx-gui/src/util/env.ts new file mode 100644 index 000000000..eae460a9d --- /dev/null +++ b/spx-gui/src/util/env.ts @@ -0,0 +1,21 @@ +/** + * @file env file + * @desc Exports enviroment variables from `.env.*` file + */ + +/// + +// Base URL for spx-backend APIs, e.g. `/api` +export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL as string + +// Base URL for the application, e.g. `https://builder.goplus.org` +// TODO: what about import.meta.BASE_URL +export const publishBaseUrl = import.meta.env.VITE_PUBLISH_BASE_UR as string + +// Casdoor configurations +export const casdoorConfig = { + serverUrl: import.meta.env.VITE_CASDOOR_ENDPOINT as string, + clientId: import.meta.env.VITE_CASDOOR_CLIENT_ID as string, + organizationName: import.meta.env.VITE_CASDOOR_ORGANIZATION_NAME as string, + appName: import.meta.env.VITE_CASDOOR_APP_NAME as string, +} diff --git a/spx-gui/src/util/path.ts b/spx-gui/src/util/path.ts new file mode 100644 index 000000000..e172cdc89 --- /dev/null +++ b/spx-gui/src/util/path.ts @@ -0,0 +1,29 @@ + +export function join(base: string, ...paths: string[]) { + // TODO + return [base, ...paths].join('/') +} + +export function resolve(base: string, ...paths: string[]) { + // TODO + return [base, ...paths].join('/') +} + +export function filename(urlOrPath: string) { + // TODO + const slashPos = urlOrPath.lastIndexOf('/') + if (slashPos >= 0) urlOrPath = urlOrPath.slice(slashPos + 1) + return urlOrPath +} + +export function dirname(urlOrPath: string) { + // TODO + const slashPos = urlOrPath.lastIndexOf('/') + if (slashPos >= 0) urlOrPath = urlOrPath.slice(0, slashPos) + return urlOrPath +} + +export function stripExt(fileName: string) { + // TODO + return fileName +} diff --git a/spx-gui/vite.config.ts b/spx-gui/vite.config.ts index bd986e2dc..9e9853eaa 100644 --- a/spx-gui/vite.config.ts +++ b/spx-gui/vite.config.ts @@ -40,6 +40,15 @@ export default defineConfig(({ mode }) => { }, optimizeDeps: { include: [`monaco-editor/esm/vs/editor/editor.worker`] + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + } } } })