Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

project save/load to browser #158

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
141 changes: 141 additions & 0 deletions gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
Original file line number Diff line number Diff line change
@@ -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<IDBDatabase>((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<string[]> {
const objectStore = await this.openObjectStore("readonly");
return new Promise<string[]>((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<string | null>((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<void>((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<void>((resolve, reject) => {
const deleteRequest = objectStore.delete(filename);
deleteRequest.onsuccess = () => {
resolve();
};
deleteRequest.onerror = () => {
reject(deleteRequest.error);
};
});
}
}

export default BrowserProjectsInterface;
84 changes: 82 additions & 2 deletions gui/src/app/pages/HomePage/LoadProjectWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Button from "@mui/material/Button";
import {
FieldsContentsMap,
FileNames,
Expand All @@ -8,13 +7,21 @@ 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,
useContext,
useEffect,
useState,
} from "react";
import BrowserProjectsInterface, {
BrowserProject,
} from "./BrowserProjectsInterface";
import timeAgoString from "@SpUtil/timeAgoString";

type LoadProjectWindowProps = {
onClose: () => void;
Expand Down Expand Up @@ -103,9 +110,38 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
}
}, [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 (
<div>
<h3>Load project</h3>
<h3>Upload project</h3>
<div>
You can upload:
<ul>
Expand Down Expand Up @@ -142,6 +178,50 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
</Button>
</div>
)}
<h3>Load from browser</h3>
{allBrowserProjects.length > 0 ? (
<table>
<tbody>
{allBrowserProjects.map((browserProject) => (
<tr key={browserProject.title}>
<td>
<SmallIconButton
icon={<Delete />}
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);
}}
/>
</td>
<td>
<Link
onClick={() => {
handleOpenBrowserProject(browserProject.title);
}}
component="button"
underline="none"
>
{browserProject.title}
</Link>
</td>
<td>
<span style={{ color: "gray" }}>
{timeAgoString(browserProject.timestamp)}
</span>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div>No projects found in browser storage</div>
)}
</div>
);
};
Expand Down
80 changes: 79 additions & 1 deletion gui/src/app/pages/HomePage/SaveProjectWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +20,7 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
const fileManifest = mapModelToFileManifest(data);

const [exportingToGist, setExportingToGist] = useState(false);
const [savingToBrowser, setSavingToBrowser] = useState(false);

return (
<div>
Expand Down Expand Up @@ -47,7 +50,7 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
</tbody>
</table>
<div>&nbsp;</div>
{!exportingToGist && (
{!exportingToGist && !savingToBrowser && (
<div>
<Button
onClick={async () => {
Expand All @@ -66,6 +69,14 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
>
Save to GitHub Gist
</Button>
&nbsp;
<Button
onClick={() => {
setSavingToBrowser(true);
}}
>
Save to Browser
</Button>
</div>
)}
{exportingToGist && (
Expand All @@ -75,6 +86,13 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
onClose={onClose}
/>
)}
{savingToBrowser && (
<SaveToBrowserView
fileManifest={fileManifest}
title={data.meta.title}
onCancel={onClose}
/>
)}
</div>
);
};
Expand Down Expand Up @@ -211,4 +229,64 @@ const makeSPShareableLinkFromGistUrl = (gistUrl: string) => {
return url;
};

type SaveToBrowserViewProps = {
fileManifest: Partial<FileRegistry>;
title: string;
onCancel: () => void;
};

const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
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 (
<div className="SaveToBrowserView">
<h3>Save to Browser</h3>
<p>
This project will be saved to your browser as &quot;{title}&quot;. It
will be available to you on this device until you clear your browser
cache, but not on other devices or browsers.
</p>
<div>
<Button
onClick={() => {
handleSave();
}}
>
Save to Browser
</Button>
&nbsp;
<Button onClick={onCancel}>Cancel</Button>
</div>
</div>
);
};

export default SaveProjectWindow;
Loading