diff --git a/package.json b/package.json index 8cbc113..7feb650 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,8 @@ "gzip": "3.5 KB" }, "dist/play-pen.js": { - "none": "31900 KB", - "gzip": "5150 KB" + "none": "32100 KB", + "gzip": "5190 KB" } }, "typesVersions": { diff --git a/src/elements/play-icon/icons/upload-outline.svg b/src/elements/play-icon/icons/upload-outline.svg new file mode 100644 index 0000000..8691190 --- /dev/null +++ b/src/elements/play-icon/icons/upload-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/elements/play-icon/play-icon.ts b/src/elements/play-icon/play-icon.ts index 2b25704..8fd63e0 100644 --- a/src/elements/play-icon/play-icon.ts +++ b/src/elements/play-icon/play-icon.ts @@ -35,6 +35,7 @@ import resizeHorizontalOutline from './icons/resize-horizontal-outline.svg' import restartOutline from './icons/restart-outline.svg' import shareNewOutline from './icons/share-new-outline.svg' import unmountOutline from './icons/unmount-outline.svg' +import uploadOutline from './icons/upload-outline.svg' export type PlayIconSVG = keyof typeof icons @@ -62,7 +63,8 @@ const icons = { 'resize-horizontal-outline': resizeHorizontalOutline, 'restart-outline': restartOutline, 'share-ios-outline': unmountOutline, - 'share-new-outline': shareNewOutline + 'share-new-outline': shareNewOutline, + 'upload-outline': uploadOutline } declare global { 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.saveProject(ev.detail)}> + +
` } } diff --git a/src/elements/play-pen/play-pen.ts b/src/elements/play-pen/play-pen.ts index 6600315..a5d0713 100644 --- a/src/elements/play-pen/play-pen.ts +++ b/src/elements/play-pen/play-pen.ts @@ -58,6 +58,9 @@ import '../play-pen-header.js' import '../play-preview-controls.js' import '../play-preview.js' import '../play-toast.js' +import type {ProjectStorageClient} from '../../storage/project-storage-client.js' +import {LocalProjectStorageClient} from '../../storage/local-project-storage-client.js' +import {ProjectManager} from '../../storage/project-manager.js' declare global { interface HTMLElementTagNameMap { @@ -147,6 +150,9 @@ export class PlayPen extends LitElement { SVG: svg } + @property() + projectStorageClient: ProjectStorageClient = new LocalProjectStorageClient() + /** Program executable. */ @state() private _assetsFilesystemType: AssetsFilesystemType = 'virtual' @state() private _assetsState: AssetsState = emptyAssetsState() @@ -169,6 +175,7 @@ export class PlayPen extends LitElement { @query('play-editor') private _editor!: PlayEditor @query('play-toast') private _toast!: PlayToast #bundleStore?: BundleStore | undefined + _projectManager?: ProjectManager | 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._projectManager) { + this._projectManager = new ProjectManager(this.projectStorageClient) + } + let pen if (this.allowURL) pen = loadPen(location) if (this.allowStorage) pen ??= loadPen(localStorage) @@ -235,9 +246,11 @@ export class PlayPen extends LitElement { > ) => this._assets.onVirtualFileChange(ev.detail)} @share=${this.#onShare} + >
{ + const el = document.createElement('play-project-button') + assert.instanceOf(el, PlayProjectButton) +}) diff --git a/src/elements/play-project-button.ts b/src/elements/play-project-button.ts new file mode 100644 index 0000000..2c992ad --- /dev/null +++ b/src/elements/play-project-button.ts @@ -0,0 +1,162 @@ +import { + LitElement, + css, + html, + type CSSResultGroup, + type TemplateResult +} from 'lit' +import {customElement, property} from 'lit/decorators.js' + +import {cssReset} from '../utils/css-reset.js' +import './play-dropdown-menu.js' +import './play-icon/play-icon.js' +import './play-list-item.js' + +export type SizeOptions = 'small' | 'medium' +const iconSizes: {[key in SizeOptions]: string} = { + small: '16px', + medium: '20px' +} + +declare global { + interface HTMLElementEventMap { + 'edit-src': CustomEvent + } + interface HTMLElementTagNameMap { + 'play-project-button': PlayProjectButton + } +} + +@customElement('play-project-button') +export class PlayProjectButton extends LitElement { + static override readonly styles: CSSResultGroup = css` + ${cssReset} + + .container { + display: flex; + flex-direction: row; + column-gap: 0; + justify-content: space-between; + align-items: center; + box-shadow: inset 0px 0px 0px var(--border-width) + var(--color-secondary-border); + border-radius: var(--radius-full); + } + + button { + font-family: var(--font-family-sans); + color: var(--color-secondary-plain); + background-color: transparent; + border: none; + cursor: pointer; + } + + :host([size='small']) button { + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 16px; + letter-spacing: -0.1px; + } + + :host([size='medium']) button { + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 20px; + letter-spacing: -0.3px; + } + + .project-pen { + display: flex; + flex-direction: row; + } + + :host([size='small']) .project-pen { + column-gap: 8px; + padding-top: 8px; + padding-right: 8px; + padding-bottom: 8px; + padding-left: 12px; + border-top-left-radius: 16px; + border-bottom-left-radius: 16px; + } + + :host([size='medium']) .project-pen { + column-gap: 8px; + padding-top: 10px; + padding-right: 12px; + padding-bottom: 10px; + padding-left: 16px; + border-top-left-radius: 20px; + border-bottom-left-radius: 20px; + } + + .project-pen:active { + background-color: var(--color-secondary-background-active); + } + + .project-pen:focus { + outline-color: var(--color-brand-background); + } + ` + @property({attribute: false}) srcByLabel?: {readonly [key: string]: string} + @property() size: SizeOptions = 'medium' + + protected override render(): TemplateResult { + return html` +
+ +
+ +
+
+ + this.dispatchEvent( + new CustomEvent('save-project', { + bubbles: true, + composed: true + }) + )} + > + + this.dispatchEvent( + new CustomEvent('load-project', { + bubbles: true, + composed: true + }) + )} + > + + this.dispatchEvent( + new CustomEvent('open-export-dialog', { + bubbles: true, + composed: true + }) + )} + > +
+
+
+ ` + } +} diff --git a/src/elements/play-project-load-dialog.test.ts b/src/elements/play-project-load-dialog.test.ts new file mode 100644 index 0000000..404d2b8 --- /dev/null +++ b/src/elements/play-project-load-dialog.test.ts @@ -0,0 +1,7 @@ +import {assert} from '@esm-bundle/chai' +import {PlayProjectLoadDialog} from './play-project-load-dialog.js' + +test('tag is defined', () => { + 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..ab0ae79 --- /dev/null +++ b/src/elements/play-project-load-dialog.ts @@ -0,0 +1,137 @@ +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} + + p { + 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; + } + + 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..a28417c --- /dev/null +++ b/src/elements/play-project-save-dialog.ts @@ -0,0 +1,87 @@ +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} + ` + + @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 +}