diff --git a/studio/package-lock.json b/studio/package-lock.json index 01b49e488..408c2bf3d 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -1856,6 +1856,12 @@ "integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==", "dev": true }, + "@types/wicg-native-file-system": { + "version": "2020.6.0", + "resolved": "https://registry.npmjs.org/@types/wicg-native-file-system/-/wicg-native-file-system-2020.6.0.tgz", + "integrity": "sha512-M7n6jvHfUzUXDtf6UGpL6rVIddV7UzEYrvwZPORApeHvDGQnZJ79fXorLlDj8xJKyUemnEBohRd8yx09k9NBUw==", + "dev": true + }, "abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", diff --git a/studio/package.json b/studio/package.json index 85ed873f6..1e7102da8 100644 --- a/studio/package.json +++ b/studio/package.json @@ -64,6 +64,7 @@ "@stencil/store": "^1.3.0", "@types/socket.io-client": "^1.4.34", "@types/uuid": "^8.3.0", + "@types/wicg-native-file-system": "^2020.6.0", "autoprefixer": "^9.8.6", "husky": "^4.3.0", "prettier": "2.2.0", diff --git a/studio/src/app/components/editor/actions/deck/app-actions-deck/app-actions-deck.tsx b/studio/src/app/components/editor/actions/deck/app-actions-deck/app-actions-deck.tsx index 09fef9f58..059a0607d 100644 --- a/studio/src/app/components/editor/actions/deck/app-actions-deck/app-actions-deck.tsx +++ b/studio/src/app/components/editor/actions/deck/app-actions-deck/app-actions-deck.tsx @@ -8,9 +8,12 @@ import remoteStore from '../../../../../stores/remote.store'; import deckStore from '../../../../../stores/deck.store'; import userStore from '../../../../../stores/user.store'; import shareStore from '../../../../../stores/share.store'; +import errorStore from '../../../../../stores/error.store'; import {MoreAction} from '../../../../../utils/editor/more-action'; +import {BackupOfflineService} from '../../../../../services/editor/backup/backup.offline.service'; + @Component({ tag: 'app-actions-deck', shadow: false, @@ -226,6 +229,14 @@ export class AppActionsDeck { } } + private async backupOfflineData() { + try { + await BackupOfflineService.getInstance().backup(); + } catch (err) { + errorStore.state.error = `Something went wrong. ${err}.`; + } + } + render() { return ( @@ -300,6 +311,8 @@ export class AppActionsDeck { {offlineStore.state.offline ? : } + {this.renderBackup()} + + ); + } } diff --git a/studio/src/app/services/editor/backup/backup.offline.service.tsx b/studio/src/app/services/editor/backup/backup.offline.service.tsx new file mode 100644 index 000000000..528eab62d --- /dev/null +++ b/studio/src/app/services/editor/backup/backup.offline.service.tsx @@ -0,0 +1,168 @@ +import firebase from 'firebase/app'; + +import {get} from 'idb-keyval'; + +import deckStore from '../../../stores/deck.store'; + +import {Deck, DeckAttributes} from '../../../models/data/deck'; +import {Slide} from '../../../models/data/slide'; + +import {FirestoreUtils} from '../../../utils/editor/firestore.utils'; + +interface DeckBackupData { + name: string; + + attributes?: DeckAttributes; + background?: string; + header?: string; + footer?: string; + + owner_id: string; + + slides?: Slide[]; + + api_id?: string; + + created_at?: firebase.firestore.Timestamp; + updated_at?: firebase.firestore.Timestamp; +} + +interface DeckBackup { + id: string; + data: DeckBackupData; +} + +export class BackupOfflineService { + private static instance: BackupOfflineService; + + static getInstance(): BackupOfflineService { + if (!BackupOfflineService.instance) { + BackupOfflineService.instance = new BackupOfflineService(); + } + return BackupOfflineService.instance; + } + + async backup() { + if (!deckStore.state.deck || !deckStore.state.deck.id || !deckStore.state.deck.data) { + throw new Error('No deck found'); + } + + const slides: Slide[] = await this.getSlides(deckStore.state.deck); + + // We select what we want to backup and add the slides (not their id) in these data + const backupDeckData: DeckBackupData = FirestoreUtils.filterDelete(this.prepareDeckBackupData(slides), true); + + await this.save({ + id: deckStore.state.deck.id, + data: backupDeckData, + }); + } + + private prepareDeckBackupData(slides: Slide[]): DeckBackupData { + return { + name: deckStore.state.deck.data.name, + + attributes: deckStore.state.deck.data.attributes, + background: deckStore.state.deck.data.background, + header: deckStore.state.deck.data.header, + footer: deckStore.state.deck.data.footer, + + owner_id: deckStore.state.deck.data.owner_id, + + slides, + + api_id: deckStore.state.deck.data.api_id, + + created_at: deckStore.state.deck.data.created_at, + updated_at: deckStore.state.deck.data.updated_at, + }; + } + + private async getSlides(deck: Deck): Promise { + if (!deck.data.slides || deck.data.slides.length <= 0) { + return []; + } + + try { + const promises: Promise[] = []; + + for (let i: number = 0; i < deck.data.slides.length; i++) { + const slideId: string = deck.data.slides[i]; + + promises.push(get(`/decks/${deck.id}/slides/${slideId}`)); + } + + if (!promises || promises.length <= 0) { + return []; + } + + const slides: Slide[] = await Promise.all(promises); + + return slides; + } catch (err) { + throw new Error('Error while fetching slides'); + } + } + + private save(deck: DeckBackup): Promise { + if ('showSaveFilePicker' in window) { + return this.exportNativeFileSystem(deck); + } + + return this.exportDownload(deck); + } + + private async exportNativeFileSystem(deck: DeckBackup) { + const fileHandle: FileSystemFileHandle = await this.getNewFileHandle(); + + if (!fileHandle) { + throw new Error('Cannot access filesystem'); + } + + await this.writeFile(fileHandle, JSON.stringify(deck)); + } + + private async getNewFileHandle(): Promise { + const opts: SaveFilePickerOptions = { + types: [ + { + description: 'JSON Files', + accept: { + 'application/json': ['.json'], + }, + }, + ], + }; + + return showSaveFilePicker(opts); + } + + private async writeFile(fileHandle: FileSystemFileHandle, contents: string | BufferSource | Blob) { + // Create a writer (request permission if necessary). + const writer = await fileHandle.createWritable(); + // Write the full length of the contents + await writer.write(contents); + // Close the file and write the contents to disk + await writer.close(); + } + + private async exportDownload(deck: DeckBackup) { + const a: HTMLAnchorElement = document.createElement('a'); + a.style.display = 'none'; + document.body.appendChild(a); + + const blob: Blob = new Blob([JSON.stringify(deck)], {type: 'octet/stream'}); + const url: string = window.URL.createObjectURL(blob); + + a.href = url; + a.download = `${deck.data.name}.json`; + + a.click(); + + window.URL.revokeObjectURL(url); + + if (a && a.parentElement) { + a.parentElement.removeChild(a); + } + } +} diff --git a/studio/src/assets/assets.json b/studio/src/assets/assets.json index 0573489ff..92d51e32c 100644 --- a/studio/src/assets/assets.json +++ b/studio/src/assets/assets.json @@ -237,7 +237,11 @@ {"src": "/icons/word-cloud.svg", "ariaLabel": "Word Cloud"}, {"src": "/icons/ionicons/color-wand.svg", "ariaLabel": "Transform element"}, {"src": "/icons/ionicons/chevron-down.svg", "ariaLabel": "Chevron down"}, + {"src": "/icons/ionicons/chevron-back.svg", "ariaLabel": "Chevron back"}, + {"src": "/icons/ionicons/chevron-forward.svg", "ariaLabel": "Chevron forward"}, {"src": "/icons/ionicons/settings.svg", "ariaLabel": "Settings"}, - {"src": "/icons/text.svg", "ariaLabel": "Text"} + {"src": "/icons/text.svg", "ariaLabel": "Text"}, + {"src": "/icons/ionicons/play.svg", "ariaLabel": "Present"}, + {"src": "/icons/ionicons/download.svg", "ariaLabel": "Backup"} ] } diff --git a/studio/src/assets/icons/ionicons/download.svg b/studio/src/assets/icons/ionicons/download.svg new file mode 100644 index 000000000..977a88a66 --- /dev/null +++ b/studio/src/assets/icons/ionicons/download.svg @@ -0,0 +1 @@ +Download \ No newline at end of file