diff --git a/docs/load-save.md b/docs/load-save.md new file mode 100644 index 000000000..55e3d6519 --- /dev/null +++ b/docs/load-save.md @@ -0,0 +1,96 @@ +### Background + +#### Project Background + +In current software development practices, project management and file version control are core components. Projects involve a variety of file types, including code, images, audio, etc., requiring effective management of these files to support the development and maintenance of the project. + +#### Requirement Background + +Users need a convenient and efficient way to create, edit, save, and manage project files. Especially during file editing, the ability to perform incremental updates rather than just full updates is needed to improve efficiency and save resources. Additionally, the uniqueness and non-redundancy of project files need to be ensured. + +### Solution Overview + +**Project files are no longer stored in object storage on a project-by-project basis, but rather on a file-by-file basis**. + +#### Roles and Their Responsibilities + +- **Frontend Role**: Responsible for the design and implementation of the user interface, including the creation, editing, and display of project files. The frontend also needs to handle communication with the backend and interact directly with the object storage service to upload and delete files. +- **Backend Role**: Responsible for handling requests from the frontend, including providing complete project information and updating project information. Project information mainly includes the project's file structure, version number, `id`, `uid`, update time, etc. The backend does not directly deal with object storage. +- **Object Storage Service**: Provides storage capabilities for project files. Each file is accessed through a unique URL generated by a hash algorithm, ensuring the file's uniqueness and non-redundancy. + +#### How It Works + +1. **Project Loading**: The frontend makes an HTTP request to the backend, which retrieves the complete project information from the database and returns it to the frontend. The frontend parses and displays the project content. + + An example of the file structure is as follows (using MySQL database, which cannot store arrays, so JSON is serialized into a string for easy storage in the database): + + ```json + { + "index.json": "https://xxx.com/d2a6802666d0d9248393d0b02abc93ae.json", + "assets/demo.png":"https://xxx.com/f535a73a3dfe15ca7c623512f09d5ed4.png" + } + ``` + + The key corresponds to the file's relative path in the project, and the value corresponds to the file's online address (using object storage). The backend needs to deserialize the project file structure for the frontend, thus loading the entire project. + + After loading the entire project, the browser might have a data structure like this: + + ```json + const projectFiles = { + "index.json": { + onlineUrl: "https://xxx.com/123.json", + content: '...' + }, + // ... + } + + ``` + + The key corresponds to the file's relative path, and the value includes the online address, file content, etc. + + + +2. **Project Editing and Saving**: After the user edits a project file, the frontend interacts directly with the object storage service for file upload and deletion. After editing, the frontend sends the updated project file structure to the backend, which updates the database. + + ### Core Logic Detail Explanation + + #### Loading Project Files + + - **Frontend Request**: Carries the project ID to initiate a project loading request to the backend. + - **Backend Processing**: Queries the project file structure in the database based on the project ID, and returns the serialized JSON string to the frontend. + - **Frontend Parsing**: Converts the JSON string into a data structure of project files for editing and browsing by the user. + + #### Saving Edited Projects + + - **File Processing**: After editing a file, the frontend removes the file's `onlineUrl` and interacts with the object storage service to delete the old file, upload the new file, and obtain a new `url`. + - **Project File Structure Update**: The frontend collects all new `urls` of the files, forming an updated project file structure. + - **Backend Database Update**: The frontend sends the updated project file structure to the backend, which serializes it and updates the database, with the version number changing accordingly. + + ### Points to Note + + - Using a hash algorithm (e.g., MD5) to generate the file name for a file maintains the original file extension. This ensures the file's online address uniquely identifies the file, avoiding duplicate files and saving resources, as duplicate files will have the same `url`. If a file is changed, its `url` will change, providing a basis for a variant of incremental update between the client and server. + + - Users may need to download the entire project. This can be quickly accomplished directly with the object storage's packaging feature, meaning the frontend can directly interact with object storage without going through the backend. + + - Upon clicking the edit button in the UI, the frontend deletes the online address of the file, which is the `onlineUrl`. This is because once a file is edited, it is considered changed, and without a new online address, the file's old online address is removed. This approach allows for a kind of incremental update based on the presence or absence of each project file's online address in the browser. + + A possible logic for frontend project update: + + ```javascript + function saveProject() { + var fileTree = {} + for (const path of projectFiles) { + var url = projectFiles[path].onlineUrl + if (url == null) { + url = uploadFile(path, projectFiles[path].content) + } + fileTree[path] = url + } + postToServer(fileTree) + } + ``` + + - For a new project, since every file edited by the user does not have an online address, files without `urls` can be uploaded in the manner described above, and the new file structure can be sent to the backend. + - The initial version of the project is set to 1. When the user saves the project, the backend increases the version number as it updates the database. + - Throughout the process of loading and saving project files, the interaction between the frontend and backend only involves loading project file structures from the backend and updating the database by the backend, without the backend dealing with object storage. The frontend interacts directly with object storage. + diff --git a/spx-gui/.gitignore b/spx-gui/.gitignore index 06a167812..b548093a1 100644 --- a/spx-gui/.gitignore +++ b/spx-gui/.gitignore @@ -39,3 +39,5 @@ tsconfig.tsbuildinfo !.yarn/releases !.yarn/sdks !.yarn/versions + +.env.local diff --git a/spx-gui/env.d.ts b/spx-gui/env.d.ts index 11f02fe2a..91c147ac4 100644 --- a/spx-gui/env.d.ts +++ b/spx-gui/env.d.ts @@ -1 +1,14 @@ +/* eslint-disable @typescript-eslint/naming-convention */ /// + +interface ImportMetaEnv { + readonly VITE_API_BASE: 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/package-lock.json b/spx-gui/package-lock.json index 7df5eba1b..9c918340d 100644 --- a/spx-gui/package-lock.json +++ b/spx-gui/package-lock.json @@ -11,12 +11,14 @@ "@rollup/pluginutils": "^5.1.0", "axios": "^1.6.5", "file-saver": "^2.0.5", + "js-pkce": "^1.4.0", "jszip": "^3.10.1", "konva": "^9.3.1", "localforage": "^1.10.0", "monaco-editor": "^0.45.0", + "nanoid": "^5.0.5", "pinia": "^2.1.7", - "pinia-plugin-persist": "^1.0.0", + "pinia-plugin-persistedstate": "^3.2.1", "vicons": "^0.0.1", "vue": "^3.3.11", "vue-i18n": "^9.9.0", @@ -28,7 +30,6 @@ "@rushstack/eslint-patch": "^1.7.2", "@tsconfig/node20": "^20.1.2", "@types/file-saver": "^2.0.7", - "@types/golang-wasm-exec": "^1.15.2", "@types/node": "^20.11.10", "@types/wavesurfer.js": "^6.0.12", "@vicons/antd": "^0.12.0", @@ -1948,12 +1949,6 @@ "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", "dev": true }, - "node_modules/@types/golang-wasm-exec": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@types/golang-wasm-exec/-/golang-wasm-exec-1.15.2.tgz", - "integrity": "sha512-NA77toY4yOiiV5foDVT/rfxmtoox7ASHqGs4Eek8xTMcKWwAhZLOD3SYfLQKq4P2jtOLQQkeISq3zSuQ1Y+apg==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2913,6 +2908,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/css-render": { "version": "0.15.12", "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.12.tgz", @@ -3992,6 +3992,14 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/js-pkce": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/js-pkce/-/js-pkce-1.4.0.tgz", + "integrity": "sha512-ztPzIgjQ+hY2uvwsTi625Yt/123NODAInGg2Y7aQLlKpNpD16A3WePjKyY2+B8WCoZy1cGG4xC9C68Dt2ZB8iQ==", + "dependencies": { + "crypto-js": "^4.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4345,9 +4353,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.5.tgz", + "integrity": "sha512-/Veqm+QKsyMY3kqi4faWplnY1u+VuKO3dD2binyPIybP31DRO29bPF+1mszgLnrR2KqSLceFLBNw0zmvDzN1QQ==", "funding": [ { "type": "github", @@ -4355,10 +4363,10 @@ } ], "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -7377,47 +7385,12 @@ } } }, - "node_modules/pinia-plugin-persist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/pinia-plugin-persist/-/pinia-plugin-persist-1.0.0.tgz", - "integrity": "sha512-M4hBBd8fz/GgNmUPaaUsC29y1M09lqbXrMAHcusVoU8xlQi1TqgkWnnhvMikZwr7Le/hVyMx8KUcumGGrR6GVw==", - "dependencies": { - "vue-demi": "^0.12.1" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0", - "pinia": "^2.0.0", - "vue": "^2.0.0 || >=3.0.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, - "node_modules/pinia-plugin-persist/node_modules/vue-demi": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.12.5.tgz", - "integrity": "sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==", - "hasInstallScript": true, - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, + "node_modules/pinia-plugin-persistedstate": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.1.tgz", + "integrity": "sha512-MK++8LRUsGF7r45PjBFES82ISnPzyO6IZx3CH5vyPseFLZCk1g2kgx6l/nW8pEBKxxd4do0P6bJw+mUSZIEZUQ==", "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } + "pinia": "^2.0.0" } }, "node_modules/pinia/node_modules/vue-demi": { @@ -7485,6 +7458,23 @@ "node": ">=4" } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/spx-gui/package.json b/spx-gui/package.json index 89f024a5b..9daee4abf 100644 --- a/spx-gui/package.json +++ b/spx-gui/package.json @@ -14,12 +14,14 @@ "@rollup/pluginutils": "^5.1.0", "axios": "^1.6.5", "file-saver": "^2.0.5", + "js-pkce": "^1.4.0", "jszip": "^3.10.1", "konva": "^9.3.1", "localforage": "^1.10.0", "monaco-editor": "^0.45.0", + "nanoid": "^5.0.5", "pinia": "^2.1.7", - "pinia-plugin-persist": "^1.0.0", + "pinia-plugin-persistedstate": "^3.2.1", "vicons": "^0.0.1", "vue": "^3.3.11", "vue-i18n": "^9.9.0", @@ -31,7 +33,6 @@ "@rushstack/eslint-patch": "^1.7.2", "@tsconfig/node20": "^20.1.2", "@types/file-saver": "^2.0.7", - "@types/golang-wasm-exec": "^1.15.2", "@types/node": "^20.11.10", "@types/wavesurfer.js": "^6.0.12", "@vicons/antd": "^0.12.0", @@ -59,4 +60,4 @@ "vite-plugin-wasm": "^3.3.0", "vue-tsc": "^1.8.27" } -} \ No newline at end of file +} diff --git a/spx-gui/public/js/filesystem.js b/spx-gui/public/js/filesystem.js index fe0629db2..e063cedda 100644 --- a/spx-gui/public/js/filesystem.js +++ b/spx-gui/public/js/filesystem.js @@ -1,7 +1,7 @@ // configuration of IndexedDB -const dbName = 'project'; +const dbName = 'spx-gui'; const dbVersion = 2; -const storeName = 'dir'; +const storeName = 'project'; let db; let request = indexedDB.open(dbName, dbVersion); @@ -79,7 +79,7 @@ function getFilesStartingWith(dirname) { request.onsuccess = function (event) { const allFiles = event.target.result; allFiles.forEach(file => { - if (file.path.startsWith(dirname)) { + if (file.path?.startsWith(dirname)) { files.push(file.path); } }); diff --git a/spx-gui/src/axios/index.ts b/spx-gui/src/axios/index.ts index f6fa38f47..1c6676fda 100644 --- a/spx-gui/src/axios/index.ts +++ b/spx-gui/src/axios/index.ts @@ -6,38 +6,54 @@ * @FilePath: /builder/spx-gui/src/axios/index.ts * @Description: */ -import { createDiscreteApi } from "naive-ui"; -import axios, { type AxiosResponse } from "axios"; +import { createDiscreteApi } from 'naive-ui' +import axios, { type AxiosResponse } from 'axios' +import { useUserStore } from '@/store' -const baseURL = "http://116.62.66.126:8080"; +const baseURL = 'http://116.62.66.126:8080' const service = axios.create({ baseURL: baseURL, - timeout: 15000, -}); + timeout: 15000 +}) -const { message } = createDiscreteApi(["message"]); +const { message } = createDiscreteApi(['message']) export interface ResponseData { - code: number; - data: T; - msg: string; + code: number + data: T + msg: string } +service.interceptors.request.use( + async (config) => { + const userStore = useUserStore() + + const token = await userStore.getFreshAccessToken() + if (token) { + config.headers['Authorization'] = `Bearer ${token}` + } + return config + }, + (error) => { + message.error(error.message) + return Promise.reject(error) + } +) + // response interceptor service.interceptors.response.use( (response: AxiosResponse>) => { - console.log(response.data); - if (response.data.code === 200) { - return response; + if (response.data.code >= 200 && response.data.code < 300) { + return response } else { - message.error(response.data.msg); - return Promise.reject(new Error(response.data.msg || "Error")); + message.error(response.data.msg) + return Promise.reject(new Error(response.data.msg || 'Error')) } }, (error) => { - message.error(error.message); - return Promise.reject(error); - }, -); -export { service, baseURL }; + message.error(error.message) + return Promise.reject(error) + } +) +export { service, baseURL } diff --git a/spx-gui/src/class/asset-base.ts b/spx-gui/src/class/asset-base.ts index a817ad72e..b03e54e37 100644 --- a/spx-gui/src/class/asset-base.ts +++ b/spx-gui/src/class/asset-base.ts @@ -41,7 +41,7 @@ export abstract class AssetBase implements AssetBaseInterface { * @param file File */ addFile(...file: FileWithUrl[]): void { - let exist = []; + const exist = []; for (const f of file) { if (this._files.find(file => file.name === f.name)) { exist.push(f); @@ -71,11 +71,13 @@ export abstract class AssetBase implements AssetBaseInterface { */ loadFileFromURL(url: string) { // TODO + console.log('loadFileFromURL', url) } /** * Get the name of the asset. */ + // eslint-disable-next-line @typescript-eslint/naming-convention static NAME = "asset"; /** diff --git a/spx-gui/src/class/project.ts b/spx-gui/src/class/project.ts new file mode 100644 index 000000000..5f7c0b8d8 --- /dev/null +++ b/spx-gui/src/class/project.ts @@ -0,0 +1,246 @@ +import type { DirPath, RawDir } from '@/types/file' +import * as fs from '@/util/file-system' +import { nanoid } from 'nanoid' +import { + convertDirPathToProject, + convertRawDirToDirPath, + convertRawDirToZip, + getDirPathFromZip +} from '@/util/file' +import saveAs from 'file-saver' +import { SoundList, SpriteList } from '@/class/asset-list' +import { Backdrop } from '@/class/backdrop' + +export enum ProjectSource { + local, + cloud +} + +interface ProjectSummary { + // Temporary id when not uploaded to cloud, replace with real id after uploaded + id: string + // Project title + title: string + // version number + version: number + // Project source + source: ProjectSource +} + +interface ProjectDetail { + // Sprite list + sprite: SpriteList + // Sound list + sound: SoundList + // Backdrop + backdrop: Backdrop + // Entry code of the project + entryCode: string + // Documents not identified + unidentifiedFile: RawDir +} + +export class Project implements ProjectDetail, ProjectSummary { + id: string + version: number + source: ProjectSource + title: string + sprite: SpriteList + sound: SoundList + backdrop: Backdrop + entryCode: string + unidentifiedFile: RawDir + + // eslint-disable-next-line @typescript-eslint/naming-convention + static ENTRY_FILE_NAME = 'index.gmx' + + static fromRawData(data: ProjectDetail & ProjectSummary): Project { + const project = new Project() + Object.assign(project, data) + return project + } + + private static async getLocalProjects(): Promise { + const paths = await fs.readdir('summary/') + const projects: ProjectSummary[] = [] + for (const path of paths) { + const content = await fs.readFile(path) as ProjectSummary + projects.push(content) + } + return projects + } + + private static async getCloudProjects(): Promise { + // TODO + return [] + } + + /** + * Get the list of projects. + * @returns The list of local projects' summary + */ + static async getProjects(): Promise { + const localProjects = await Project.getLocalProjects() + const cloudProjects = await Project.getCloudProjects() + + const mergedProjects = localProjects + for (const cloudProject of cloudProjects) { + const local = mergedProjects.find((project) => project.id === cloudProject.id) + if (!local || local.version !== cloudProject.version) { + mergedProjects.push(cloudProject) + } + } + + return mergedProjects + } + + constructor() { + this.title = '' + this.sprite = new SpriteList() + this.sound = new SoundList() + this.backdrop = new Backdrop() + this.entryCode = '' + this.unidentifiedFile = {} + this.id = nanoid() + this.version = 1 + this.source = ProjectSource.local + } + + async load(id: string, source: ProjectSource = ProjectSource.cloud): Promise { + this.id = id + this.source = source + if (source === ProjectSource.local) { + const paths = (await fs.readdir(id)) as string[] + const dirPath: DirPath = {} + for (const path of paths) { + const content = await fs.readFile(path) + dirPath[path] = content + } + this._load(dirPath) + + const summary = await fs.readFile("summary/" + id) as ProjectSummary + Object.assign(this, summary) + } else { + // TODO: load from cloud + } + } + + /** + * Load project from directory. + * @param DirPath The directory + */ + private _load(dirPath: DirPath): void + + /** + * Load project. + * @param proj The project + */ + private _load(proj: Project): void + + private _load(arg: DirPath | Project): void { + if (typeof arg === 'object' && arg instanceof Project) { + this.id = arg.id + this.version = arg.version + this.source = arg.source + this.title = arg.title + this.sprite = arg.sprite + this.sound = arg.sound + this.backdrop = arg.backdrop + this.entryCode = arg.entryCode + this.unidentifiedFile = arg.unidentifiedFile + } else { + const proj = convertDirPathToProject(arg) + this._load(proj) + } + } + + async loadFromZip(file: File, title?: string) { + const dirPath = await getDirPathFromZip(file) + this._load(dirPath) + this.title = title || file.name.split('.')[0] || this.title + } + + /** + * Save project to storage. + */ + async saveLocal() { + const dirPath = await this.dirPath + for (const [key, value] of Object.entries(dirPath)) { + await fs.writeFile(key, value) + } + const summary: ProjectSummary = { + id: this.id, + title: this.title, + version: this.version, + source: this.source + } + fs.writeFile(this.summaryPath, summary) + } + + /** + * Remove project from storage. + */ + async removeLocal() { + await fs.rmdir(this.path) + await fs.unlink(this.summaryPath) + } + + /** + * Save project to Cloud. + */ + async save() { + // TODO: save to cloud + } + + /** + * Download project to computer. + */ + async download() { + const content = await this.zip + saveAs(content, `${this.title}.zip`) + } + + run() { + window.project_path = this.id + } + + async setId(id: string) { + await this.removeLocal() + this.id = id + await this.saveLocal() + } + + get path() { + return this.id + '/' + } + + get summaryPath() { + return 'summary/' + this.id + } + + get rawDir() { + const dir: RawDir = {} + const files: RawDir = Object.assign( + {}, + this.unidentifiedFile, + ...[this.backdrop, ...this.sprite.list, ...this.sound.list].map((item) => item.dir) + ) + files[Project.ENTRY_FILE_NAME] = this.entryCode + for (const [path, value] of Object.entries(files)) { + const fullPath = this.path + path + dir[fullPath] = value + } + return dir + } + + get dirPath(): Promise { + return convertRawDirToDirPath(this.rawDir) + } + + get zip(): Promise { + return (async () => { + const blob = await convertRawDirToZip(this.rawDir) + return new File([blob], `${this.title}.zip`, { type: blob.type }) + })() + } +} diff --git a/spx-gui/src/components/code-editor/CodeEditor.vue b/spx-gui/src/components/code-editor/CodeEditor.vue index 4e572d80e..e53f3dd57 100644 --- a/spx-gui/src/components/code-editor/CodeEditor.vue +++ b/spx-gui/src/components/code-editor/CodeEditor.vue @@ -7,68 +7,72 @@ * @Description: --> \ No newline at end of file + diff --git a/spx-gui/src/components/code-editor/register.ts b/spx-gui/src/components/code-editor/register.ts index c1923ee47..cecaf83f7 100644 --- a/spx-gui/src/components/code-editor/register.ts +++ b/spx-gui/src/components/code-editor/register.ts @@ -7,7 +7,7 @@ * @Description: */ import { keywords, typeKeywords, LanguageConfig, MonarchTokensProviderConfig } from './language' -import wasmModuleUrl from '/wasm/format.wasm?url&wasmModule'; +import wasmModuleUrl from '/wasm/format.wasm?url'; import function_completions from './snippet'; import { monaco } from '.'; function completionItem(range: monaco.IRange | monaco.languages.CompletionItemRanges): monaco.languages.CompletionItem[] { diff --git a/spx-gui/src/components/spx-stage/SpxStage.vue b/spx-gui/src/components/spx-stage/SpxStage.vue index a021db471..42fc47c4b 100644 --- a/spx-gui/src/components/spx-stage/SpxStage.vue +++ b/spx-gui/src/components/spx-stage/SpxStage.vue @@ -88,8 +88,11 @@ const run = async () => { show.value = false; // TODO: backdrop.config.zorder depend on sprites, entry code depend on sprites and other code (such as global variables). backdropStore.backdrop.config = backdropStore.backdrop.defaultConfig; - window.project_path = projectStore.project.title; - show.value = true; + projectStore.project.run() + // If you assign show to `true` directly in a block of code, it will result in the page view not being updated and the iframe will not be remounted, hence the 300ms delay! + setTimeout(() => { + show.value = true + }, 300) }; diff --git a/spx-gui/src/components/top-menu/TopMenu.vue b/spx-gui/src/components/top-menu/TopMenu.vue index 238ea1eed..435331a03 100644 --- a/spx-gui/src/components/top-menu/TopMenu.vue +++ b/spx-gui/src/components/top-menu/TopMenu.vue @@ -12,10 +12,9 @@ + + diff --git a/spx-gui/src/global.d.ts b/spx-gui/src/global.d.ts index 9d4259f29..1eeeef3ce 100644 --- a/spx-gui/src/global.d.ts +++ b/spx-gui/src/global.d.ts @@ -6,23 +6,39 @@ * @FilePath: /spx-gui/src/global.d.ts * @Description: The global declaration. */ -import { FormatResponse } from "./components/code-editor"; -/** - * Add global declaration for File with url property. - */ +import { FormatResponse } from './components/code-editor' + declare global { - interface File { - url: string; - } - + interface File { + url: string + } + + /** + * @description: format spx code power by gopfmt's wasm + * @param {string} spx-code + * @return {*} + * @Author: Zhang Zhi Yang + * @Date: 2024-02-02 14:54:14 + */ + function formatSPX(input: string): FormatResponse + + class Go { + argv: string[] + env: { [envKey: string]: string } + exit: (code: number) => void + importObject: WebAssembly.Imports + exited: boolean + mem: DataView + run(instance: WebAssembly.Instance): Promise + } + + interface Window { /** - * @description: format spx code power by gopfmt's wasm - * @param {string} spx-code - * @return {*} - * @Author: Zhang Zhi Yang - * @Date: 2024-02-02 14:54:14 + * Required to communicate with Go WASM instance. */ - function formatSPX(input: string): FormatResponse; + // eslint-disable-next-line @typescript-eslint/naming-convention + project_path: string + } } -export { File, formatSPX } \ No newline at end of file +export { File, formatSPX } diff --git a/spx-gui/src/globals.d.ts b/spx-gui/src/globals.d.ts deleted file mode 100644 index 5a853d843..000000000 --- a/spx-gui/src/globals.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * @Author: Zhang Zhi Yang - * @Date: 2024-01-25 14:19:31 - * @LastEditors: Zhang Zhi Yang - * @LastEditTime: 2024-02-02 14:54:55 - * @FilePath: /spx-gui/src/globals.d.ts - * @Description: - */ - -interface Window { - // Required to communicate with Go WASM. - project_path: string; -} - diff --git a/spx-gui/src/main.ts b/spx-gui/src/main.ts index 001195da5..758162646 100644 --- a/spx-gui/src/main.ts +++ b/spx-gui/src/main.ts @@ -6,35 +6,36 @@ * @FilePath: /builder/spx-gui/src/main.ts * @Description: */ -import { createApp } from "vue"; -import App from "./App.vue"; +import { createApp } from 'vue' +import App from './App.vue' -import Loading from "@/components/loading/Loading.vue" -import { initAssets, initCodeEditor } from './plugins'; -import { initRouter } from "@/router/index"; -import { initStore } from "./store"; -import { initI18n } from "@/language"; +import { initAssets, initCodeEditor } from './plugins' +import { initRouter } from '@/router/index' +import { initI18n } from '@/language' -import { addFileUrl } from "./util/file"; -import VueKonva from 'vue-konva'; - +import { addFileUrl } from './util/file' +import VueKonva from 'vue-konva' +import { initStore } from './store' async function initApp() { - // const loading = createApp(Loading); - // loading.mount('#appLoading'); - - // Give priority to loading css,js resources - initAssets() - addFileUrl() - - const app = createApp(App); - await initStore(app); - await initRouter(app); - await initCodeEditor(); - - await initI18n(app); - app.use(VueKonva); - // loading.unmount() - app.mount('#app') + // const loading = createApp(Loading); + // loading.mount('#appLoading'); + + // Give priority to loading css,js resources + initAssets() + addFileUrl() + + const app = createApp(App) + + initStore(app) + + await initRouter(app) + await initCodeEditor() + + await initI18n(app) + + app.use(VueKonva) + + app.mount('#app') } initApp() diff --git a/spx-gui/src/router/index.ts b/spx-gui/src/router/index.ts index df0b88ceb..1fcee4fc1 100644 --- a/spx-gui/src/router/index.ts +++ b/spx-gui/src/router/index.ts @@ -6,56 +6,53 @@ * @FilePath: /spx-gui/src/router/index.ts * @Description: */ -import type { App } from "vue"; -import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"; - +import type { App } from 'vue' +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' const routes: Array = [ - { path: '/', redirect: '/editor/homepage' }, - { - path: '/spx/home', - name: 'SpxHome', - component: () => - import("@/view/HomeView.vue"), - }, - { - path: '/sprite/list', - name: 'SpriteList', - component: () => - import("../components/sprite-list/SpriteList.vue"), - }, - { - path:"/code/editor", - name:'codeeditor', - component:()=> - import("../components/code-editor-demo/CodeEditorDemo.vue"), - }, - { - path:"/stage/viewer", - name:'StageViewer', - component:()=> - import("../components/stage-viewer-demo/StageViewerDemo.vue"), - }, - { - path: '/editor/homepage', - name: 'EditorHomepage', - component: () => - import("../view/EditorHomepage.vue"), - } -]; - + { path: '/', redirect: '/editor/homepage' }, + { + path: '/spx/home', + name: 'SpxHome', + component: () => import('@/view/HomeView.vue') + }, + { + path: '/sprite/list', + name: 'SpriteList', + component: () => import('../components/sprite-list/SpriteList.vue') + }, + { + path: '/code/editor', + name: 'codeeditor', + component: () => import('../components/code-editor-demo/CodeEditorDemo.vue') + }, + { + path: '/stage/viewer', + name: 'StageViewer', + component: () => import('../components/stage-viewer-demo/StageViewerDemo.vue') + }, + { + path: '/editor/homepage', + name: 'EditorHomepage', + component: () => import('../view/EditorHomepage.vue') + }, + { + path: '/callback', + name: 'Signin Callback', + component: () => import('@/view/SigninCallback.vue') + } +] const router = createRouter({ - history: createWebHistory(''), - routes, -}); + history: createWebHistory(''), + routes +}) -export const initRouter = async (app:App) => { +export const initRouter = async (app: App) => { app.use(router) // This is an example of a routing result that needs to be loaded. - await new Promise(resolve => { - setTimeout(() => { - resolve(true) - }, 0); + await new Promise((resolve) => { + setTimeout(() => { + resolve(true) + }, 0) }) } - diff --git a/spx-gui/src/store/index.ts b/spx-gui/src/store/index.ts index 687ad19f1..a59ca3130 100644 --- a/spx-gui/src/store/index.ts +++ b/spx-gui/src/store/index.ts @@ -4,17 +4,16 @@ * @LastEditors: Zhang Zhi Yang * @LastEditTime: 2024-01-18 08:53:10 * @FilePath: /builder/spx-gui/src/store/index.ts - * @Description: + * @Description: */ -import { createPinia, defineStore } from "pinia" -import { type App } from "vue"; -import piniaPluginPersist from "pinia-plugin-persist"; +import { createPinia } from 'pinia' +import { type App } from 'vue' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' -export const initStore = async (app: App)=> { - const store = createPinia(); - store.use(piniaPluginPersist) - app.use(store); +export const initStore = (app: App) => { + const store = createPinia() + store.use(piniaPluginPersistedstate) + app.use(store) } -export * from './modules'; - +export * from './modules' diff --git a/spx-gui/src/store/modules/language/index.ts b/spx-gui/src/store/modules/language/index.ts index cec435171..6765889a8 100644 --- a/spx-gui/src/store/modules/language/index.ts +++ b/spx-gui/src/store/modules/language/index.ts @@ -5,25 +5,27 @@ * @LastEditTime: 2024-01-18 01:29:52 * @FilePath: src/store/modules/language/index.ts * @Description: language config store -*/ -import { defineStore } from "pinia"; -import { ref } from "vue"; + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' -export const useLanguageStore = defineStore('language', () => { - const language = ref('en'); +export const useLanguageStore = defineStore( + 'language', + () => { + const language = ref('en') const setLanguage = (_language: string) => { - language.value = _language; - }; - const getLanguage = () => language.value; + language.value = _language + } + const getLanguage = () => language.value return { - // state - language: language, - // actions - setLanguage, - getLanguage - }; -}, { - persist: { - enabled: true, + // state + language: language, + // actions + setLanguage, + getLanguage } -}); + }, + { + persist: true + } +) diff --git a/spx-gui/src/store/modules/project/index.ts b/spx-gui/src/store/modules/project/index.ts index 30e606377..ef34c086b 100644 --- a/spx-gui/src/store/modules/project/index.ts +++ b/spx-gui/src/store/modules/project/index.ts @@ -9,183 +9,56 @@ import { ref, watch } from 'vue' import { defineStore } from 'pinia' -import * as fs from '@/util/file-system' -import type { FileType, DirPath, RawDir } from "@/types/file"; -import { convertDirPathToProject, convertRawDirToDirPath, convertRawDirToZip, getDirPathFromZip } from "@/util/file"; -import saveAs from "file-saver"; -import { SoundList, SpriteList } from "@/class/asset-list"; -import { Backdrop } from '@/class/backdrop'; - -const UNTITLED_NAME = 'Untitled' -interface ProjectData { - title: string - sprite: SpriteList - sound: SoundList - backdrop: Backdrop - entryCode: string - UnidentifiedFile: RawDir -} - -export class Project implements ProjectData { - title: string; - sprite: SpriteList; - sound: SoundList; - backdrop: Backdrop; - entryCode: string; - UnidentifiedFile: RawDir; - - static ENTRY_FILE_NAME = 'index.gmx' - - static fromRawData(data: ProjectData): Project { - return new Project(data.title, data.sprite, data.sound, data.backdrop, data.entryCode, data.UnidentifiedFile) - } - - constructor(title: string, sprite: SpriteList = new SpriteList(), sound: SoundList = new SoundList(), backdrop: Backdrop = new Backdrop(), entryCode: string = "", UnidentifiedFile: RawDir = {}) { - this.title = title - this.sprite = sprite - this.sound = sound - this.backdrop = backdrop - this.entryCode = entryCode - this.UnidentifiedFile = UnidentifiedFile - } - - /** - * Load project from storage. - * @param title The name of project - */ - async load(title: string): Promise; - - /** - * Load project from zip file. - * @param zipFile The zip file - */ - async load(zipFile: File, title?: string): Promise; - - async load(arg: string | File, title?: string): Promise; - - async load(arg: string | File, title?: string): Promise { - if (typeof arg === 'string') { - const paths = await fs.readdir(arg) as string[] - const dirPath: DirPath = {} - for (const path of paths) { - const content = await fs.readFile(path) as FileType - dirPath[path] = content - } - this._load(dirPath) - } else if (typeof arg === 'object' && arg instanceof File) { - const dirPath = await getDirPathFromZip(arg, title || UNTITLED_NAME) - this._load(dirPath) - } - } - - /** - * Load project from directory. - * @param DirPath The directory - */ - private _load(dirPath: DirPath): void; - - /** - * Load project. - * @param proj The project - */ - private _load(proj: Project): void; - - private _load(arg: DirPath | Project): void { - if (typeof arg === 'object' && arg instanceof Project) { - this.title = arg.title - this.sprite = arg.sprite - this.sound = arg.sound - this.backdrop = arg.backdrop - this.entryCode = arg.entryCode - this.UnidentifiedFile = arg.UnidentifiedFile - } else { - const proj = convertDirPathToProject(arg) - this._load(proj) - } - } - - async save() { - const dirPath = await this.dirPath - for (const [key, value] of Object.entries(dirPath)) { - await fs.writeFile(key, value) - } - } - - async saveToComputer() { - const content = await convertRawDirToZip(this.rawDir) - const title = this.title - saveAs(content, `${title}.zip`) - } - - async remove() { - await fs.rmdir(this.path) - } - - get path() { - return this.title + "/" - } - - get rawDir() { - const dir: RawDir = {} - const files: RawDir = Object.assign({}, this.UnidentifiedFile, ...[this.backdrop, ...this.sprite.list, ...this.sound.list].map(item => item.dir)) - files[Project.ENTRY_FILE_NAME] = this.entryCode - for (const [path, value] of Object.entries(files)) { - const fullPath = this.path + path - dir[fullPath] = value - } - return dir - } - - get dirPath(): Promise { - return convertRawDirToDirPath(this.rawDir) - } -} +import { Project, ProjectSource } from '@/class/project' +import { debounce } from '@/util/global' export const useProjectStore = defineStore('project', () => { - const project = ref(new Project(UNTITLED_NAME)) - - /** - * while project changed, save it to storage. - */ - watch(() => project.value, async () => { - console.log('project changed', project.value); - await project.value.remove() - await project.value.save() - }, { deep: true }) - - /** - * Load project. - */ - const loadProject = async (arg: string | File, title?: string) => { - const newProject = new Project(UNTITLED_NAME) - await newProject.load(arg, title) - project.value = newProject - } - - /** - * Get the list of local projects. - * @returns The list of local projects' name - * - * @example - * const projects = await getLocalProjects() // ['project1', 'project2'] - * project.load(projects[0]) - */ - const getLocalProjects = async (): Promise => { - const dir = await fs.readdir('') - const map = new Map() - for (const path of dir) { - const k = path.split('/').shift() - if (!k) continue - if (!map.has(k)) { - map.set(k, true) - } - } - return Array.from(map.keys()) - } - - return { - project, - getLocalProjects, - loadProject - } + /** + * The project. You can use `project.value` to get it. + */ + const project = ref(new Project()) + + // TODO: Consider moving the autosave behaviour into the class project in the future, when non-spx-gui scenarios like widgets are supported. + + /** + * Remove original project and save the project to storage. + */ + const saveLocal = debounce(async () => { + console.log('project changed', project.value) + await project.value.removeLocal() + await project.value.saveLocal() + }) + + /** + * while project changed, save it to storage automatically. + */ + watch(() => project.value, saveLocal, { deep: true }) + + /** + * Load project. + * @param id project id + * @param source project source, default is `ProjectSource.cloud` + */ + const loadProject = async (id: string, source: ProjectSource = ProjectSource.cloud) => { + const newProject = new Project() + await newProject.load(id, source) + project.value = newProject + } + + /** + * Load project from zip file. + * @param file the zip file + * @param title the title, default is `file.name` + */ + const loadFromZip = async (file: File, title?: string) => { + const newProject = new Project() + await newProject.loadFromZip(file, title || file.name.slice(0, file.name.lastIndexOf('.'))) + project.value = newProject + } + + return { + project, + loadProject, + loadFromZip + } }) diff --git a/spx-gui/src/store/modules/user/index.ts b/spx-gui/src/store/modules/user/index.ts index 7dc7866cb..28da40977 100644 --- a/spx-gui/src/store/modules/user/index.ts +++ b/spx-gui/src/store/modules/user/index.ts @@ -1,45 +1,103 @@ -/* - * @Author: Zhang Zhi Yang - * @Date: 2024-01-15 09:16:18 - * @LastEditors: Zhang Zhi Yang - * @LastEditTime: 2024-01-15 10:18:11 - * @FilePath: /builder/spx-gui/src/store/modules/user/index.ts - * @Description: - */ -import { defineStore } from "pinia" -import { ref, computed, readonly } from "vue" -// The returned value of `defineStore () `is named using the name of store -// This value needs to start with `use` and end with `Store`. -// (for example, `useAssetStore`, `useUserStore`, `useStyleStore`) +import { casdoorSdk } from '@/util/casdoor' +import type ITokenResponse from 'js-pkce/dist/ITokenResponse' +import { defineStore } from 'pinia' -// The first parameter is the unique ID of the Store in the application -export const useUserStore = defineStore( - 'user', - () => { - // ----------state------------------------------------ - const token = ref(""); - const username = ref(""); +// https://stackoverflow.com/questions/38552003/how-to-decode-jwt-token-in-javascript-without-using-a-library +const parseJwt = (token: string) => { + const base64Url = token.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + }) + .join('') + ) - // ----------getters------------------------------------ - const getFullToken = computed(() => "Bear " + token.value) + return JSON.parse(jsonPayload) +} + +export const useUserStore = defineStore('spx-user', { + state: () => ({ + accessToken: null as string | null, + refreshToken: null as string | null, - // ----------actions------------------------------------ - const setToken = (_token: string) => { - token.value = _token + // timestamp in milliseconds, null if never expires + accessTokenExpiresAt: null as number | null, + refreshTokenExpiresAt: null as number | null + }), + actions: { + async getFreshAccessToken(): Promise { + if (!this.accessTokenValid()) { + if (!this.refreshTokenValid()) { + this.signOut() + return null } - return { - // state - username: readonly(username), - token: readonly(token), - // getters - getFullToken, - // actions - setToken + + try { + const tokenResp = await casdoorSdk.pkce.refreshAccessToken(this.refreshToken!) + this.setToken(tokenResp) + } catch (error) { + // TODO: not to clear storage for network error + console.error('Failed to refresh access token', error) + this.signOut() + throw error } - }, { - persist: { - enabled: true, + } + return this.accessToken + }, + setToken(tokenResp: ITokenResponse) { + const accessTokenExpiresAt = tokenResp.expires_in + ? Date.now() + tokenResp.expires_in * 1000 + : null + const refreshTokenExpiresAt = tokenResp.refresh_expires_in + ? Date.now() + tokenResp.refresh_expires_in * 1000 + : null + this.accessToken = tokenResp.access_token + this.refreshToken = tokenResp.refresh_token + this.accessTokenExpiresAt = accessTokenExpiresAt + this.refreshTokenExpiresAt = refreshTokenExpiresAt + }, + signOut() { + this.accessToken = null + this.refreshToken = null + this.accessTokenExpiresAt = null + this.refreshTokenExpiresAt = null + }, + hasSignedIn() { + return this.accessTokenValid() || this.refreshTokenValid() + }, + accessTokenValid() { + const delta = 60 * 1000 // 1 minute + return !!( + this.accessToken && + (this.accessTokenExpiresAt === null || this.accessTokenExpiresAt - delta > Date.now()) + ) + }, + refreshTokenValid() { + const delta = 60 * 1000 // 1 minute + return !!( + this.refreshToken && + (this.refreshTokenExpiresAt === null || this.refreshTokenExpiresAt - delta > Date.now()) + ) + }, + async consumeCurrentUrl() { + const tokenResp = await casdoorSdk.pkce.exchangeForAccessToken(window.location.href) + this.setToken(tokenResp) + }, + signInWithRedirection() { + casdoorSdk.signinWithRedirection() } -} -) - + }, + getters: { + userInfo: (state) => { + if (!state.accessToken) return null + return parseJwt(state.accessToken) as { + displayName: string + avatar: string + } + } + }, + persist: true +}) diff --git a/spx-gui/src/util/casdoor.ts b/spx-gui/src/util/casdoor.ts new file mode 100644 index 000000000..10c7a574c --- /dev/null +++ b/spx-gui/src/util/casdoor.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +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: +// https://github.com/casdoor/casdoor-js-sdk/blob/master/src/sdk.ts +// The original source is a relatively simple SDK that wraps around +// the js-pkce package. We need to modify the code to expose the +// PKCE object, so that we can use it to refresh the access token. + +// Copyright 2021 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import PKCE from 'js-pkce' +import type IObject from 'js-pkce/dist/IObject' + +interface SdkConfig { + serverUrl: string // your Casdoor server URL, e.g., "https://door.casbin.com" for the official demo site + clientId: string // the Client ID of your Casdoor application, e.g., "014ae4bd048734ca2dea" + appName: string // the name of your Casdoor application, e.g., "app-casnode" + organizationName: string // the name of the Casdoor organization connected with your Casdoor application, e.g., "casbin" + redirectPath?: string // the path of the redirect URL for your Casdoor application, will be "/callback" if not provided + signinPath?: string // the path of the signin URL for your Casdoor applcation, will be "/api/signin" if not provided + scope?: string // apply for permission to obtain the user information, will be "profile" if not provided + storage?: Storage // the storage to store the state, will be sessionStorage if not provided +} + +// reference: https://github.com/casdoor/casdoor-go-sdk/blob/90fcd5646ec63d733472c5e7ce526f3447f99f1f/auth/jwt.go#L19-L32 +export interface CasdoorAccount { + organization: string + username: string + type: string + name: string + avatar: string + email: string + phone: string + affiliation: string + tag: string + language: string + score: number + isAdmin: boolean + accessToken: string +} + +class Sdk { + config: SdkConfig + pkce: PKCE + + constructor(config: SdkConfig) { + this.config = config + if (config.redirectPath === undefined || config.redirectPath === null) { + this.config.redirectPath = '/callback' + } + + if (config.scope === undefined || config.scope === null) { + this.config.scope = 'profile' + } + + this.pkce = new PKCE({ + client_id: this.config.clientId, + redirect_uri: `${window.location.origin}${this.config.redirectPath}`, + authorization_endpoint: `${this.config.serverUrl.trim()}/login/oauth/authorize`, + token_endpoint: `${this.config.serverUrl.trim()}/api/login/oauth/access_token`, + requested_scopes: this.config.scope || 'profile', + storage: this.config.storage + }) + } + + getOrSaveState(): string { + const state = sessionStorage.getItem('casdoor-state') + if (state !== null) { + return state + } else { + const state = Math.random().toString(36).slice(2) + sessionStorage.setItem('casdoor-state', state) + return state + } + } + + clearState() { + sessionStorage.removeItem('casdoor-state') + } + + public getSignupUrl(enablePassword: boolean = true): string { + if (enablePassword) { + sessionStorage.setItem('signinUrl', this.getSigninUrl()) + return `${this.config.serverUrl.trim()}/signup/${this.config.appName}` + } else { + return this.getSigninUrl().replace('/login/oauth/authorize', '/signup/oauth/authorize') + } + } + + public getSigninUrl(): string { + const redirectUri = + this.config.redirectPath && this.config.redirectPath.includes('://') + ? this.config.redirectPath + : `${window.location.origin}${this.config.redirectPath}` + const state = this.getOrSaveState() + return `${this.config.serverUrl.trim()}/login/oauth/authorize?client_id=${this.config.clientId}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${this.config.scope}&state=${state}` + } + + // TODO: Rewrite this function. Is access_token really need to be passed with the url? + public getUserProfileUrl(userName: string, account: CasdoorAccount): string { + let param = '' + if (account !== undefined && account !== null) { + param = `?access_token=${account.accessToken}` + } + return `${this.config.serverUrl.trim()}/users/${this.config.organizationName}/${userName}${param}` + } + + // TODO: The same as getUserProfileUrl + public getMyProfileUrl(account: CasdoorAccount, returnUrl: String = ''): string { + let params = '' + if (account !== undefined && account !== null) { + params = `?access_token=${account.accessToken}` + if (returnUrl !== '') { + params += `&returnUrl=${returnUrl}` + } + } else if (returnUrl !== '') { + params = `?returnUrl=${returnUrl}` + } + return `${this.config.serverUrl.trim()}/account${params}` + } + + public async signinWithRedirection(additionalParams?: IObject): Promise { + window.location.assign(this.pkce.authorizeUrl(additionalParams)) + } + + // TODO: move to axios service + public async getUserInfo(accessToken: string) { + return fetch(`${this.config.serverUrl.trim()}/api/userinfo`, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }).then((res) => res.json()) + } +} + +export const casdoorSdk = new Sdk(config) diff --git a/spx-gui/src/util/file-system.ts b/spx-gui/src/util/file-system.ts index 5ea9f82fd..ce737dfce 100644 --- a/spx-gui/src/util/file-system.ts +++ b/spx-gui/src/util/file-system.ts @@ -6,12 +6,11 @@ * @FilePath: /spx-gui/src/util/FileSystem.ts * @Description: */ -import type { FileType } from '@/types/file'; import localforage from 'localforage'; const storage = localforage.createInstance({ - name: 'file-system', - storeName: 'file' + name: 'spx-gui', + storeName: 'project' }) async function performAsyncOperation(operation: Promise, callback?: (err: any, data: any) => void) { @@ -25,7 +24,7 @@ async function performAsyncOperation(operation: Promise, callback?: (err: a } } -export function writeFile(filename: string, data: FileType, callback?: (err: any, data: any) => void): Promise { +export function writeFile(filename: string, data: T, callback?: (err: any, data: T) => void): Promise { return performAsyncOperation(storage.setItem(filename, data), callback); } @@ -33,8 +32,8 @@ export function unlink(filename: string, callback?: (err: any, data: any) => voi return performAsyncOperation(storage.removeItem(filename), callback); } -export function readFile(filename: string, callback?: (err: any, data: any) => void): Promise { - return performAsyncOperation(storage.getItem(filename) as Promise, callback); +export function readFile(filename: string, callback?: (err: any, data: any) => void) { + return performAsyncOperation(storage.getItem(filename), callback); } export function readdir(dirname: string, callback?: (err: any, data: any) => void): Promise { diff --git a/spx-gui/src/util/file.ts b/spx-gui/src/util/file.ts index 4923952c7..cfb9d9be1 100644 --- a/spx-gui/src/util/file.ts +++ b/spx-gui/src/util/file.ts @@ -73,13 +73,13 @@ export const arrayBuffer2Content = (arr: ArrayBuffer, type: string, name: string * @returns the array buffer */ export const content2ArrayBuffer = async (content: any, type: string): Promise => { + const reader = new FileReader() switch (type) { case 'application/json': return new TextEncoder().encode(JSON.stringify(content)) case 'text/plain': return new TextEncoder().encode(content) default: - const reader = new FileReader() reader.readAsArrayBuffer(content) await new Promise(resolve => reader.onload = resolve) return reader.result as ArrayBuffer @@ -92,7 +92,7 @@ export const content2ArrayBuffer = async (content: any, type: string): Promise) { - let keys = Object.keys(dir); + const keys = Object.keys(dir); let prefix = keys[0]; for (let i = 1; i < keys.length; i++) { while (!keys[i].startsWith(prefix)) { @@ -103,7 +103,7 @@ export function getPrefix(dir: Record) { return prefix.endsWith('/') ? prefix : prefix + '/'; } -import { Project } from "@/store/modules/project" +import { Project } from "@/class/project" import { Backdrop } from "@/class/backdrop" import { Sound } from "@/class/sound" import { Sprite } from "@/class/sprite" @@ -166,15 +166,14 @@ export async function convertRawDirToDirPath(dir: RawDir): Promise { * ├─ 5.png * └─ index.json */ -export async function getDirPathFromZip(zipFile: File, title?: string): Promise { +export async function getDirPathFromZip(zipFile: File): Promise { const zip = await JSZip.loadAsync(zipFile); - const projectName = title || zipFile.name.split('.')[0] || 'project'; const dir: DirPath = {}; const prefix = getPrefix(zip.files) - for (let [relativePath, zipEntry] of Object.entries(zip.files)) { - if (zipEntry.dir) continue + for (const [relativePath, zipEntry] of Object.entries(zip.files)) { + if (zipEntry.dir || relativePath.split('/').pop()?.startsWith(".")) continue + const path = relativePath.replace(prefix, '') const content = await zipEntry.async('arraybuffer') - const path = projectName + '/' + relativePath.replace(prefix, '') const type = getMimeFromExt(relativePath.split('.').pop()!) const size = content.byteLength const modifyTime = zipEntry.date || new Date(); @@ -189,8 +188,6 @@ export async function getDirPathFromZip(zipFile: File, title?: string): Promise< return dir } -const UNTITLED_NAME = 'untitled'; - /** * Parse directory to project. * @param {DirPath} dir @@ -217,11 +214,13 @@ export function convertDirPathToProject(dir: DirPath): Project { return item; } - const proj: Project = new Project(Object.keys(dir).pop()?.split('/').shift() || UNTITLED_NAME) + const proj: Project = new Project() + const prefix = getPrefix(dir) + // eslint-disable-next-line prefer-const for (let [path, file] of Object.entries(dir)) { const filename = file.path.split('/').pop()!; - path = path.replace(proj.title + '/', '') + path = path.replace(prefix, '') if (Sprite.REG_EXP.test(path)) { const spriteName = path.match(Sprite.REG_EXP)?.[1] || ''; const sprite: Sprite = findOrCreateItem(spriteName, proj.sprite.list, Sprite); @@ -244,7 +243,7 @@ export function convertDirPathToProject(dir: DirPath): Project { handleFile(file, filename, proj.backdrop); } else { - proj.UnidentifiedFile[path] = arrayBuffer2Content(file.content, file.type, filename) + proj.unidentifiedFile[path] = arrayBuffer2Content(file.content, file.type, filename) } } return proj @@ -267,8 +266,9 @@ export async function convertRawDirToZip(dir: RawDir): Promise { const zip = new JSZip(); const prefix = getPrefix(dir) + // eslint-disable-next-line prefer-const for (let [path, value] of Object.entries(dir)) { - prefix && (path = path.replace(prefix + '/', '')); + prefix && (path = path.replace(prefix, '')); zip.file(...zipFileValue(path, value)); } diff --git a/spx-gui/src/util/global.ts b/spx-gui/src/util/global.ts index 3b32c69af..b1ebb2947 100644 --- a/spx-gui/src/util/global.ts +++ b/spx-gui/src/util/global.ts @@ -4,15 +4,26 @@ * @LastEditors: TuGitee tgb@std.uestc.edu.cn * @LastEditTime: 2024-01-25 12:15:36 * @FilePath: /spx-gui-front-private/src/util/global.js - * @Description: + * @Description: */ /** * Check if an object is empty * @param obj an object - * @returns + * @returns */ export function isObjectEmpty(obj: any) { - if (!obj) return true - return Object.keys(obj).length === 0 + if (!obj) return true + return Object.keys(obj).length === 0 +} + +export function debounce any>(func: T, delay: number = 300) { + let timeoutId: ReturnType; + return function (this: ThisParameterType, ...args: Parameters) { + const context = this; + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + func.apply(context, args); + }, delay); + }; } \ No newline at end of file diff --git a/spx-gui/src/view/SigninCallback.vue b/spx-gui/src/view/SigninCallback.vue new file mode 100644 index 000000000..4aa88020d --- /dev/null +++ b/spx-gui/src/view/SigninCallback.vue @@ -0,0 +1,25 @@ + + + diff --git a/spx-gui/tsconfig.app.json b/spx-gui/tsconfig.app.json index fc4b7d906..d1ed12b75 100644 --- a/spx-gui/tsconfig.app.json +++ b/spx-gui/tsconfig.app.json @@ -27,4 +27,4 @@ ] } } -} \ No newline at end of file +}