diff --git a/src/elements/play-dialog/play-dialog.ts b/src/elements/play-dialog/play-dialog.ts index ccc13c0..6933a99 100644 --- a/src/elements/play-dialog/play-dialog.ts +++ b/src/elements/play-dialog/play-dialog.ts @@ -99,6 +99,9 @@ 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..f639426 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 {
-
-
- - - - - - - - + + this.dispatchEvent( + new CustomEvent('save-project', { + bubbles: true, + composed: true + }) + )} + > + + this.dispatchEvent( + new CustomEvent('load-project', { + bubbles: true, + composed: true + }) + )} + > { + const el = document.createElement('play-project-load-dialog') + assert.instanceOf(el, PlayProjectLoadDialog) +}) diff --git a/src/elements/play-project-load-dialog.ts b/src/elements/play-project-load-dialog.ts new file mode 100644 index 0000000..e4d99ff --- /dev/null +++ b/src/elements/play-project-load-dialog.ts @@ -0,0 +1,181 @@ +import { + css, + type CSSResultGroup, + html, + LitElement, + type TemplateResult +} from 'lit' +import {customElement, property, query, state} from 'lit/decorators.js' +import {PlayDialog} from './play-dialog/play-dialog.js' +import {cssReset} from '../utils/css-reset.js' + +import './play-button.js' +import './play-dialog/play-dialog.js' +import './play-toast.js' +import type {PlayProject} from '../storage/project-storage-client.js' +import {ProjectManager} from '../storage/project-manager.js' + +declare global { + interface HTMLElementTagNameMap { + 'play-project-load-dialog': PlayProjectLoadDialog + } +} + +@customElement('play-project-load-dialog') +export class PlayProjectLoadDialog extends LitElement { + static override readonly styles: CSSResultGroup = css` + ${cssReset} + + legend { + font-weight: bold; + } + + ol { + margin-bottom: 0; + padding-left: 0; + + /* Moves the numbers inside the element */ + list-style-position: inside; + } + + li { + margin-top: var(--space); + } + + pre { + overflow-y: auto; + word-break: break-all; + white-space: pre-line; + max-height: 100px; + font-family: var(--font-family-mono); + font-size: 14px; + line-height: 1.5; + background-color: var(--color-secondary-background); + color: inherit; + + border-radius: var(--radius); + padding: var(--space); + margin-top: var(--space); + margin-bottom: var(--space); + } + + p, + li { + color: inherit; + /* RPL/Body Regular/14-BodyReg */ + font-family: var(--font-family-sans); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + a, + a:visited { + color: var(--color-link); + } + + a:hover { + color: var(--color-link-hovered); + } + + select { + width: 100%; + font-family: var(--font-family-sans); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: -0.2px; + } + ` + + @property() src: string = '' + + @state() private _loading = false + @state() private _projects: PlayProject[] = [] + + @query('#project-select') + private _projectSelect!: HTMLSelectElement + + @query('play-dialog', true) + private _dialog!: PlayDialog + + @property({attribute: 'project-manager', type: ProjectManager}) + projectManager!: ProjectManager + + async open(): Promise { + this._loading = true + this._dialog.open() + this._projects = await this.projectManager.getProjectList() + this._loading = false + } + + close(): void { + this._dialog.close() + } + + async _load(): Promise { + const projectId = this._projectSelect.value + if (!projectId) return + const project = await this.projectManager.loadProject(projectId) + + // TODO: this assumes just one file for Play. + const fileContent = + (project.files || []).length > 0 + ? new TextDecoder().decode(project.files[0]?.content) + : '' + + this.dispatchEvent( + new CustomEvent('edit-src', { + detail: fileContent, + bubbles: true, + composed: true + }) + ) + this.dispatchEvent( + new CustomEvent('edit-name', { + detail: project.name, + bubbles: true, + composed: true + }) + ) + this.close() + } + + protected override render(): TemplateResult { + return html` + + ${this._loading + ? html`

Loading projects...

` + : html`

Choose a project to load:

+ ${this._projects.length > 0 + ? html` +
+ +
+ ` + : html`

No projects available

`}`} + +
+ this._load()} + /> +
+
+ ` + } +} diff --git a/src/elements/play-project-save-dialog.test.ts b/src/elements/play-project-save-dialog.test.ts new file mode 100644 index 0000000..ec06325 --- /dev/null +++ b/src/elements/play-project-save-dialog.test.ts @@ -0,0 +1,7 @@ +import {assert} from '@esm-bundle/chai' +import {PlayProjectSaveDialog} from './play-project-save-dialog.js' + +test('tag is defined', () => { + const el = document.createElement('play-project-save-dialog') + assert.instanceOf(el, PlayProjectSaveDialog) +}) diff --git a/src/elements/play-project-save-dialog.ts b/src/elements/play-project-save-dialog.ts new file mode 100644 index 0000000..49a678d --- /dev/null +++ b/src/elements/play-project-save-dialog.ts @@ -0,0 +1,140 @@ +import { + css, + type CSSResultGroup, + html, + LitElement, + type TemplateResult +} from 'lit' +import {customElement, property, query} from 'lit/decorators.js' +import {PlayDialog} from './play-dialog/play-dialog.js' +import {cssReset} from '../utils/css-reset.js' + +import './play-button.js' +import './play-dialog/play-dialog.js' +import './play-toast.js' +import {Bubble} from '../utils/bubble.js' + +declare global { + interface HTMLElementTagNameMap { + 'play-project-save-dialog': PlayProjectSaveDialog + } +} + +@customElement('play-project-save-dialog') +export class PlayProjectSaveDialog extends LitElement { + static override readonly styles: CSSResultGroup = css` + ${cssReset} + + legend { + font-weight: bold; + } + + ol { + margin-bottom: 0; + padding-left: 0; + + /* Moves the numbers inside the element */ + list-style-position: inside; + } + + li { + margin-top: var(--space); + } + + pre { + overflow-y: auto; + word-break: break-all; + white-space: pre-line; + max-height: 100px; + font-family: var(--font-family-mono); + font-size: 14px; + line-height: 1.5; + background-color: var(--color-secondary-background); + color: inherit; + + border-radius: var(--radius); + padding: var(--space); + margin-top: var(--space); + margin-bottom: var(--space); + } + + p, + li { + color: inherit; + /* RPL/Body Regular/14-BodyReg */ + font-family: var(--font-family-sans); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + a, + a:visited { + color: var(--color-link); + } + + a:hover { + color: var(--color-link-hovered); + } + ` + + @property() src: string = '' + + @query('#project-title', true) + private _nameInput!: HTMLInputElement + + @query('#save-button', true) + private _saveButton!: HTMLInputElement + + @query('play-dialog', true) + private _dialog!: PlayDialog + + open(name: string): void { + this._nameInput.value = name || '' + this._saveButton.disabled = this._nameInput.value === '' + this._dialog.open() + this._nameInput.focus() + } + + close(): void { + this._dialog.close() + } + + _save(): void { + if (!this._nameInput.value) { + return + } + this._saveButton.disabled = true + this.dispatchEvent( + Bubble('save-dialog-submit', this._nameInput.value) + ) + } + + protected override render(): TemplateResult { + return html` + + { + this._saveButton.disabled = !this._nameInput.value + if (e.key === 'Enter') { + this._save() + } + }} + /> + 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..de24da8 --- /dev/null +++ b/src/storage/local-project-storage-client.ts @@ -0,0 +1,118 @@ +// 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 { + PlayProject, + ProjectStorageClient +} from './project-storage-client.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-manager.ts b/src/storage/project-manager.ts new file mode 100644 index 0000000..daf39e3 --- /dev/null +++ b/src/storage/project-manager.ts @@ -0,0 +1,75 @@ +import type { + PlayProject, + 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 ProjectManager { + 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.name = name + project.updatedAt = new Date() + await this.projectStorageClient.UpdateProject(project) + + // 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 + } + + clearCurrentProject(): void { + this.setCurrentProject(undefined) + } + + private setCurrentProject(project: PlayProject | undefined): void { + this.currentProject = project + if (project) { + globalThis.sessionStorage.setItem( + SESSION_PROJECT_ID, + JSON.stringify(project) + ) + } else { + globalThis.sessionStorage.removeItem(SESSION_PROJECT_ID) + } + } +} diff --git a/src/storage/project-storage-client.ts b/src/storage/project-storage-client.ts new file mode 100644 index 0000000..0bc1713 --- /dev/null +++ b/src/storage/project-storage-client.ts @@ -0,0 +1,35 @@ +/** + * 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 +} + +export interface PlayProject { + /** readonly */ + id?: string | undefined + name: string + /** readonly */ + createdAt?: Date | undefined + /** readonly */ + updatedAt?: Date | undefined + /** t2_ id of the user who created a note */ + authorId?: string | undefined + files: PlayProjectFile[] +} + +export interface PlayProjectFile { + /** readonly */ + id?: string | undefined + name: string + content: Uint8Array + /** readonly */ + createdAt?: Date | undefined + /** readonly */ + updatedAt?: Date | undefined +}