diff --git a/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts b/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts new file mode 100644 index 00000000..3f173ed0 --- /dev/null +++ b/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts @@ -0,0 +1,141 @@ +import baseObjectCheck from "@SpUtil/baseObjectCheck"; + +export type BrowserProject = { + title: string; + timestamp: number; + fileManifest: { [name: string]: string }; +}; + +const isBrowserProject = (value: any): value is BrowserProject => { + if (!baseObjectCheck(value)) return false; + if (typeof value.timestamp !== "number") return false; + if (!baseObjectCheck(value.fileManifest)) return false; + for (const key in value.fileManifest) { + if (typeof key !== "string") return false; + if (typeof value.fileManifest[key] !== "string") return false; + } + return true; +}; + +export class BrowserProjectsInterface { + constructor( + private dbName: string = "stan-playground", + private dbVersion: number = 2, + private storeName: string = "browser-projects", + ) {} + async loadBrowserProject(title: string) { + const objectStore = await this.openObjectStore("readonly"); + const filename = `${title}.json`; + const content = await this.getTextFile(objectStore, filename); + if (!content) return null; + const bp = JSON.parse(content); + if (!isBrowserProject(bp)) { + console.warn(`Invalid browser project: ${title}`); + return null; + } + return bp; + } + async saveBrowserProject(title: string, browserProject: BrowserProject) { + const objectStore = await this.openObjectStore("readwrite"); + const filename = `${title}.json`; + return await this.setTextFile( + objectStore, + filename, + JSON.stringify(browserProject, null, 2), + ); + } + async getAllBrowserProjects() { + const titles = await this.getAllProjectTitles(); + const browserProjects = []; + for (const title of titles) { + const browserProject = await this.loadBrowserProject(title); + if (browserProject) { + browserProjects.push(browserProject); + } + } + return browserProjects; + } + async deleteProject(title: string) { + const objectStore = await this.openObjectStore("readwrite"); + const filename = `${title}.json`; + await this.deleteTextFile(objectStore, filename); + } + private async openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.dbVersion); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName, { keyPath: "name" }); + } + }; + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = () => { + reject(request.error); + }; + }); + } + private async openObjectStore(mode: IDBTransactionMode) { + const db = await this.openDatabase(); + const transaction = db.transaction(this.storeName, mode); + return transaction.objectStore(this.storeName); + } + private async getAllProjectTitles(): Promise { + const objectStore = await this.openObjectStore("readonly"); + return new Promise((resolve, reject) => { + const request = objectStore.getAllKeys(); + request.onsuccess = () => { + resolve( + request.result.map((key) => { + return key.toString().replace(/\.json$/, ""); + }), + ); + }; + request.onerror = () => { + reject(request.error); + }; + }); + } + private async getTextFile(objectStore: IDBObjectStore, filename: string) { + return new Promise((resolve, reject) => { + const getRequest = objectStore.get(filename); + getRequest.onsuccess = () => { + resolve(getRequest.result?.content || null); + }; + getRequest.onerror = () => { + reject(getRequest.error); + }; + }); + } + private async setTextFile( + objectStore: IDBObjectStore, + filename: string, + content: string, + ) { + return new Promise((resolve, reject) => { + const file = { name: filename, content: content }; + const putRequest = objectStore.put(file); + putRequest.onsuccess = () => { + resolve(); + }; + putRequest.onerror = () => { + reject(putRequest.error); + }; + }); + } + private async deleteTextFile(objectStore: IDBObjectStore, filename: string) { + return new Promise((resolve, reject) => { + const deleteRequest = objectStore.delete(filename); + deleteRequest.onsuccess = () => { + resolve(); + }; + deleteRequest.onerror = () => { + reject(deleteRequest.error); + }; + }); + } +} + +export default BrowserProjectsInterface; diff --git a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx index 27706868..df924aa5 100644 --- a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx +++ b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx @@ -1,4 +1,3 @@ -import Button from "@mui/material/Button"; import { FieldsContentsMap, FileNames, @@ -8,6 +7,10 @@ import { import { ProjectContext } from "@SpCore/ProjectContextProvider"; import { deserializeZipToFiles, parseFile } from "@SpCore/ProjectSerialization"; import UploadFilesArea from "@SpPages/UploadFilesArea"; +import { SmallIconButton } from "@fi-sci/misc"; +import { Delete } from "@mui/icons-material"; +import Link from "@mui/material/Link"; +import Button from "@mui/material/Button"; import { FunctionComponent, useCallback, @@ -15,6 +18,10 @@ import { useEffect, useState, } from "react"; +import BrowserProjectsInterface, { + BrowserProject, +} from "./BrowserProjectsInterface"; +import timeAgoString from "@SpUtil/timeAgoString"; type LoadProjectWindowProps = { onClose: () => void; @@ -103,9 +110,38 @@ const LoadProjectWindow: FunctionComponent = ({ } }, [filesUploaded, importUploadedFiles]); + const [allBrowserProjects, setAllBrowserProjects] = useState< + BrowserProject[] + >([]); + useEffect(() => { + const bpi = new BrowserProjectsInterface(); + bpi.getAllBrowserProjects().then((p) => { + setAllBrowserProjects(p); + }); + }, []); + + const handleOpenBrowserProject = useCallback( + async (title: string) => { + const bpi = new BrowserProjectsInterface(); + const browserProject = await bpi.loadBrowserProject(title); + if (!browserProject) { + alert("Failed to load project"); + return; + } + const { fileManifest } = browserProject; + update({ + type: "loadFiles", + files: mapFileContentsToModel(fileManifest), + clearExisting: true, + }); + onClose(); + }, + [update, onClose], + ); + return (
-

Load project

+

Upload project

You can upload:
    @@ -142,6 +178,50 @@ const LoadProjectWindow: FunctionComponent = ({
)} +

Load from browser

+ {allBrowserProjects.length > 0 ? ( + + + {allBrowserProjects.map((browserProject) => ( + + + + + + ))} + +
+ } + onClick={async () => { + const ok = window.confirm( + `Delete project "${browserProject.title}" from browser?`, + ); + if (!ok) return; + const bpi = new BrowserProjectsInterface(); + await bpi.deleteProject(browserProject.title); + const p = await bpi.getAllBrowserProjects(); + setAllBrowserProjects(p); + }} + /> + + { + handleOpenBrowserProject(browserProject.title); + }} + component="button" + underline="none" + > + {browserProject.title} + + + + {timeAgoString(browserProject.timestamp)} + +
+ ) : ( +
No projects found in browser storage
+ )}
); }; diff --git a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx index 42dcd0d4..b6542a69 100644 --- a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx +++ b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx @@ -6,6 +6,8 @@ import { ProjectContext } from "@SpCore/ProjectContextProvider"; import saveAsGitHubGist from "@SpCore/gists/saveAsGitHubGist"; import { triggerDownload } from "@SpUtil/triggerDownload"; import Button from "@mui/material/Button"; +import BrowserProjectsInterface from "./BrowserProjectsInterface"; +import timeAgoString from "@SpUtil/timeAgoString"; type SaveProjectWindowProps = { onClose: () => void; @@ -18,6 +20,7 @@ const SaveProjectWindow: FunctionComponent = ({ const fileManifest = mapModelToFileManifest(data); const [exportingToGist, setExportingToGist] = useState(false); + const [savingToBrowser, setSavingToBrowser] = useState(false); return (
@@ -47,7 +50,7 @@ const SaveProjectWindow: FunctionComponent = ({
 
- {!exportingToGist && ( + {!exportingToGist && !savingToBrowser && (
+   +
)} {exportingToGist && ( @@ -75,6 +86,13 @@ const SaveProjectWindow: FunctionComponent = ({ onClose={onClose} /> )} + {savingToBrowser && ( + + )}
); }; @@ -211,4 +229,64 @@ const makeSPShareableLinkFromGistUrl = (gistUrl: string) => { return url; }; +type SaveToBrowserViewProps = { + fileManifest: Partial; + title: string; + onCancel: () => void; +}; + +const SaveToBrowserView: FunctionComponent = ({ + fileManifest, + title, + onCancel, +}) => { + // use IndexedDB to save the project + // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB + + const handleSave = useCallback(async () => { + try { + const bpi = new BrowserProjectsInterface(); + const existingBrowserProject = await bpi.loadBrowserProject(title); + if (existingBrowserProject) { + const overwrite = window.confirm( + `A project with the title "${title}" already exists (modified ${timeAgoString(existingBrowserProject.timestamp)}). Do you want to overwrite it?`, + ); + if (!overwrite) { + return; + } + } + await bpi.saveBrowserProject(title, { + title, + timestamp: Date.now(), + fileManifest, + }); + } catch (err: any) { + alert(`Error saving to browser: ${err.message}`); + } + onCancel(); + }, [title, fileManifest, onCancel]); + + return ( +
+

Save to Browser

+

+ This project will be saved to your browser as "{title}". It + will be available to you on this device until you clear your browser + cache, but not on other devices or browsers. +

+
+ +   + +
+
+ ); +}; + export default SaveProjectWindow; diff --git a/gui/src/app/util/timeAgoString.ts b/gui/src/app/util/timeAgoString.ts new file mode 100644 index 00000000..af3afe26 --- /dev/null +++ b/gui/src/app/util/timeAgoString.ts @@ -0,0 +1,27 @@ +const timeAgoString = (timestampMsec?: number) => { + if (timestampMsec === undefined) return ""; + const timestampSeconds = Math.floor(timestampMsec / 1000); + const now = Date.now(); + const diff = now - timestampSeconds * 1000; + const diffSeconds = Math.floor(diff / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffYears = Math.floor(diffDays / 365); + if (diffYears > 0) { + return `${diffYears} yr${diffYears === 1 ? "" : "s"} ago`; + } else if (diffWeeks > 0) { + return `${diffWeeks} wk${diffWeeks === 1 ? "" : "s"} ago`; + } else if (diffDays > 0) { + return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; + } else if (diffHours > 0) { + return `${diffHours} hr${diffHours === 1 ? "" : "s"} ago`; + } else if (diffMinutes > 0) { + return `${diffMinutes} min ago`; + } else { + return `${diffSeconds} sec ago`; + } +}; + +export default timeAgoString;