From 0957e54be0876f8a7c289ad60835c98ee95a63a9 Mon Sep 17 00:00:00 2001 From: Andrew Gunsch Date: Wed, 21 Aug 2024 16:34:13 -0700 Subject: [PATCH 1/6] checkpoint, saving and loading --- not quite working --- src/elements/play-pen-header.ts | 39 +++++-- src/elements/play-pen/play-pen.ts | 21 +++- src/elements/play-project-button.ts | 30 +++-- src/elements/play-save-dialog.ts | 118 ++++++++++++++++++++ src/storage/local-project-storage-client.ts | 117 +++++++++++++++++++ src/storage/project-save.ts | 59 ++++++++++ src/storage/project-storage-client.ts | 13 +++ 7 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 src/elements/play-save-dialog.ts create mode 100644 src/storage/local-project-storage-client.ts create mode 100644 src/storage/project-save.ts create mode 100644 src/storage/project-storage-client.ts diff --git a/src/elements/play-pen-header.ts b/src/elements/play-pen-header.ts index 9fe3b15..a01cb33 100644 --- a/src/elements/play-pen-header.ts +++ b/src/elements/play-pen-header.ts @@ -6,13 +6,18 @@ import { type TemplateResult } from 'lit' import {customElement, property, query} from 'lit/decorators.js' +import {ifDefined} from 'lit/directives/if-defined.js' import {defaultSettings} from '../storage/settings-save.js' import {Bubble} from '../utils/bubble.js' import {cssReset} from '../utils/css-reset.js' import {openURL} from '../utils/open-url.js' -import type {PlaySettingsDialog} from './play-settings-dialog.js' +import {type AssetsState, emptyAssetsState} from './play-assets/play-assets.js' import type {PlayAssetsDialog} from './play-assets-dialog.js' +import type {PlayExportDialog} from './play-export-dialog.js' +import type {PlaySaveDialog} from './play-save-dialog.js' +import type {PlaySettingsDialog} from './play-settings-dialog.js' +import './play-assets-dialog.js' import './play-button.js' import './play-export-dialog.js' import './play-icon/play-icon.js' @@ -20,10 +25,9 @@ import './play-logo/play-logo.js' import './play-new-pen-button.js' import './play-project-button.js' import './play-resizable-text-input.js' +import './play-save-dialog.js' import './play-settings-dialog.js' -import './play-assets-dialog.js' -import {type AssetsState, emptyAssetsState} from './play-assets/play-assets.js' -import type {PlayExportDialog} from './play-export-dialog.js' +import { ProjectSave } from '../storage/project-save.js' declare global { interface HTMLElementEventMap { @@ -98,6 +102,9 @@ export class PlayPenHeader extends LitElement { @property({attribute: 'sandbox-app', type: Boolean}) sandboxApp: boolean = false + @property() + src: string = '' + @property() url: string = '' @@ -122,13 +129,25 @@ export class PlayPenHeader extends LitElement { @query('play-export-dialog') private _export!: PlayExportDialog + @query('play-save-dialog') + private _save!: PlaySaveDialog + @query('play-settings-dialog') private _settings!: PlaySettingsDialog - protected override firstUpdated(): void { - this.addEventListener('open-export-dialog', () => { - this._export.open() - }) + @property({attribute: 'project-save', type: ProjectSave}) + private projectSave!: ProjectSave; + + private saveProject(): void { + console.log('save project') + if (this.projectSave.getCurrentProject() === undefined) { + console.log('open') + this._save.open() + } + } + + private loadProject(): void { + console.log('load project') } protected override render(): TemplateResult { @@ -159,6 +178,9 @@ export class PlayPenHeader extends LitElement { > this._export.open()} + @save-project=${() => this.saveProject()} + @load-project=${() => this.loadProject()} > + | undefined @@ -169,6 +175,7 @@ export class PlayPen extends LitElement { @query('play-editor') private _editor!: PlayEditor @query('play-toast') private _toast!: PlayToast #bundleStore?: BundleStore | undefined + #projectSave?: ProjectSave | undefined readonly #env: VirtualTypeScriptEnvironment = newTSEnv() @state() _uploaded: Promise = Promise.resolve({}) /** Try to ensure the bundle hostname is unique. See compute-util. */ @@ -206,6 +213,10 @@ export class PlayPen extends LitElement { // bundle is loaded. } + if (!this.#projectSave) { + this.#projectSave = new ProjectSave(this.storageClient); + } + let pen if (this.allowURL) pen = loadPen(location) if (this.allowStorage) pen ??= loadPen(localStorage) @@ -235,9 +246,11 @@ export class PlayPen extends LitElement { >
- - - - - - - - + + this.dispatchEvent( + new CustomEvent('save-project', { + bubbles: true, + composed: true + }) + )} + > + + this.dispatchEvent( + new CustomEvent('load-project', { + bubbles: true, + composed: true + }) + )} + > + + this._save()} /> + + ` + } +} diff --git a/src/storage/local-project-storage-client.ts b/src/storage/local-project-storage-client.ts new file mode 100644 index 0000000..e61e347 --- /dev/null +++ b/src/storage/local-project-storage-client.ts @@ -0,0 +1,117 @@ +// Implementation of ProjectStorageClient backed by IndexedDB, +// for storing play project data and files locally in the browser. +// +// This is fairly primitive, and doesn't bother with relational data --- +// it simply stores the entire Project objects, ProjectFiles attached. + +import type { ProjectStorageClient } from './project-storage-client.js'; + +import { PlayProject } from '@devvit/protos/community.js'; + +const DB_NAME = 'PlayProjectDB'; +const DB_VERSION = 1; +const PROJECT_STORE = 'projects'; + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(PROJECT_STORE)) { + db.createObjectStore(PROJECT_STORE, { keyPath: 'id' }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** Browser-local implementation of ProjectStorageClient. */ +export class LocalProjectStorageClient implements ProjectStorageClient { + async CreateProject(name: string): Promise { + const db = await openDB(); + const transaction = db.transaction([PROJECT_STORE], 'readwrite'); + const store = transaction.objectStore(PROJECT_STORE); + + const id = crypto.randomUUID(); + const project: PlayProject = { + id, + name, + createdAt: new Date(), + updatedAt: new Date(), + authorId: '', + files: [], + }; + + return new Promise((resolve, reject) => { + const request = store.add(project); + request.onsuccess = () => { + resolve(project); + }; + request.onerror = () => { + reject(request.error); + }; + }); + } + + async UpdateProject(project: PlayProject): Promise { + const db = await openDB(); + const transaction = db.transaction([PROJECT_STORE], 'readwrite'); + const store = transaction.objectStore(PROJECT_STORE); + + project.updatedAt = new Date(); + + return new Promise((resolve, reject) => { + const request = store.put(project); + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + reject(request.error); + }; + }); + } + + async GetProject(id: string): Promise { + const db = await openDB(); + const transaction = db.transaction([PROJECT_STORE], 'readonly'); + const store = transaction.objectStore(PROJECT_STORE); + + return new Promise((resolve, reject) => { + const request = store.get(id); + request.onsuccess = () => { + if (request.result) { + resolve(request.result); + } else { + reject(new Error('Project not found')); + } + }; + request.onerror = () => { + reject(request.error); + }; + }); + } + + async ListProjects(): Promise { + const db = await openDB(); + const transaction = db.transaction([PROJECT_STORE], 'readonly'); + const store = transaction.objectStore(PROJECT_STORE); + + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = () => { + reject(request.error); + }; + }); + } +} diff --git a/src/storage/project-save.ts b/src/storage/project-save.ts new file mode 100644 index 0000000..03fdd67 --- /dev/null +++ b/src/storage/project-save.ts @@ -0,0 +1,59 @@ +import type { PlayProject } from "@devvit/protos/community.js"; +import type { ProjectStorageClient } from "./project-storage-client.js"; + +const SESSION_PROJECT_ID = 'SESSION_PROJECT_ID'; + +/** + * Operator for saving and loading projects. Handles logic for when and how to save. + * + * The underlying storage mechanism is abstracted away by the injected storage client. + */ +export class ProjectSave { + private projectStorageClient: ProjectStorageClient; + private currentProject: PlayProject | undefined; + + constructor(projectStorageClient: ProjectStorageClient) { + this.projectStorageClient = projectStorageClient; + + const restoredProjectStr = globalThis.sessionStorage.getItem(SESSION_PROJECT_ID); + if (restoredProjectStr) { + try { + this.currentProject = JSON.parse(restoredProjectStr); + } catch (e) { + // fall-through --- invalid data, just ignore it. + } + } + } + + getCurrentProject(): PlayProject | undefined { + return this.currentProject; + } + + async saveProject(name: string, src: string): Promise { + let project = this.getCurrentProject(); + if (project === undefined) { + project = await this.projectStorageClient.CreateProject(name); + } + + project.files = [{name: 'main.tsx', content: new TextEncoder().encode(src)}]; + project.updatedAt = new Date(); + + // Store the project in memory and in sessionStorage + this.setCurrentProject(project) + } + + async getProjectList(): Promise { + return this.projectStorageClient.ListProjects(); + } + + async loadProject(id: string): Promise { + const project = await this.projectStorageClient.GetProject(id); + this.setCurrentProject(project) + return project; + } + + private setCurrentProject(project: PlayProject): void { + this.currentProject = project; + globalThis.sessionStorage.setItem(SESSION_PROJECT_ID, JSON.stringify(project)); + } +} diff --git a/src/storage/project-storage-client.ts b/src/storage/project-storage-client.ts new file mode 100644 index 0000000..c9a800c --- /dev/null +++ b/src/storage/project-storage-client.ts @@ -0,0 +1,13 @@ +import { PlayProject } from '@devvit/protos/community.js' + +/** + * Interface for a client that can store and retrieve PlayProjects. + * + * This can be injected into play-pen to provide a different implementation. + */ +export interface ProjectStorageClient { + CreateProject(name: string): Promise; + UpdateProject(project: PlayProject): Promise; + GetProject(id: string): Promise; + ListProjects(): Promise; +} From cb7fe5ad359ec0d5e1fe5e8357b9f647707aa5e6 Mon Sep 17 00:00:00 2001 From: Andrew Gunsch Date: Wed, 21 Aug 2024 20:11:59 -0700 Subject: [PATCH 2/6] save flow works! --- src/elements/play-dialog/play-dialog.ts | 1 + src/elements/play-new-pen-button.ts | 5 ++- src/elements/play-pen-header.ts | 30 ++++++++++++-- src/elements/play-pen/play-pen.ts | 8 ++-- src/elements/play-project-button.ts | 54 +++++-------------------- src/elements/play-save-dialog.ts | 50 +++++++++++++++++++++-- src/storage/project-save.ts | 13 +++++- 7 files changed, 104 insertions(+), 57 deletions(-) diff --git a/src/elements/play-dialog/play-dialog.ts b/src/elements/play-dialog/play-dialog.ts index ccc13c0..32b4b56 100644 --- a/src/elements/play-dialog/play-dialog.ts +++ b/src/elements/play-dialog/play-dialog.ts @@ -99,6 +99,7 @@ export class PlayDialog extends LitElement implements PlayDialogLike { close(): void { this._dialog.close() + this.dispatchEvent(new CustomEvent('closed', {bubbles: true, composed: true})) } protected override render(): TemplateResult { diff --git a/src/elements/play-new-pen-button.ts b/src/elements/play-new-pen-button.ts index d36cd1b..15fa8ee 100644 --- a/src/elements/play-new-pen-button.ts +++ b/src/elements/play-new-pen-button.ts @@ -150,10 +150,12 @@ export class PlayNewPenButton extends LitElement {
-
-