Skip to content

Commit

Permalink
timestamps for saved browser projects
Browse files Browse the repository at this point in the history
Add browser project functions and time ago string utility
  • Loading branch information
magland committed Jul 29, 2024
1 parent e8fe625 commit 041176a
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 43 deletions.
78 changes: 57 additions & 21 deletions gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,59 @@
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 storeName: string = "projects",
private dbVersion: number = 2,
private storeName: string = "browser-projects",
) {}
async loadProject(title: string) {
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;
return JSON.parse(content);
const bp = JSON.parse(content);
if (!isBrowserProject(bp)) {
console.warn(`Invalid browser project: ${title}`);
return null;
}
return bp;
}
async saveProject(title: string, fileManifest: { [name: string]: string }) {
async saveBrowserProject(title: string, browserProject: BrowserProject) {
const objectStore = await this.openObjectStore("readwrite");
const filename = `${title}.json`;
return await this.setTextFile(
objectStore,
filename,
JSON.stringify(fileManifest, null, 2),
JSON.stringify(browserProject, null, 2),
);
}
async listProjects(): 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);
};
});
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");
Expand All @@ -42,7 +62,7 @@ export class BrowserProjectsInterface {
}
private async openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.dbName);
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
Expand All @@ -62,6 +82,22 @@ export class BrowserProjectsInterface {
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);
Expand Down
45 changes: 27 additions & 18 deletions gui/src/app/pages/HomePage/LoadProjectWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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 Link from "@mui/material/Link";
import Button from "@mui/material/Button";
import {
FunctionComponent,
Expand All @@ -18,7 +18,10 @@ import {
useEffect,
useState,
} from "react";
import BrowserProjectsInterface from "./BrowserProjectsInterface";
import BrowserProjectsInterface, {
BrowserProject,
} from "./BrowserProjectsInterface";
import timeAgoString from "@SpUtil/timeAgoString";

type LoadProjectWindowProps = {
onClose: () => void;
Expand Down Expand Up @@ -107,24 +110,25 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
}
}, [filesUploaded, importUploadedFiles]);

const [browserProjectTitles, setBrowserProjectTitles] = useState<string[]>(
[],
);
const [allBrowserProjects, setAllBrowserProjects] = useState<
BrowserProject[]
>([]);
useEffect(() => {
const bpi = new BrowserProjectsInterface();
bpi.listProjects().then((titles) => {
setBrowserProjectTitles(titles);
bpi.getAllBrowserProjects().then((p) => {
setAllBrowserProjects(p);
});
}, []);

const handleOpenBrowserProject = useCallback(
async (title: string) => {
const bpi = new BrowserProjectsInterface();
const fileManifest = await bpi.loadProject(title);
if (!fileManifest) {
const browserProject = await bpi.loadBrowserProject(title);
if (!browserProject) {
alert("Failed to load project");
return;
}
const { fileManifest } = browserProject;
update({
type: "loadFiles",
files: mapFileContentsToModel(fileManifest),
Expand Down Expand Up @@ -175,37 +179,42 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
</div>
)}
<h3>Load from browser</h3>
{browserProjectTitles.length > 0 ? (
{allBrowserProjects.length > 0 ? (
<table>
<tbody>
{browserProjectTitles.map((title) => (
<tr key={title}>
{allBrowserProjects.map((browserProject) => (
<tr key={browserProject.title}>
<td>
<SmallIconButton
icon={<Delete />}
onClick={async () => {
const ok = window.confirm(
`Delete project "${title}" from browser?`,
`Delete project "${browserProject.title}" from browser?`,
);
if (!ok) return;
const bpi = new BrowserProjectsInterface();
await bpi.deleteProject(title);
const titles = await bpi.listProjects();
setBrowserProjectTitles(titles);
await bpi.deleteProject(browserProject.title);
const p = await bpi.getAllBrowserProjects();
setAllBrowserProjects(p);
}}
/>
</td>
<td>
<Link
onClick={() => {
handleOpenBrowserProject(title);
handleOpenBrowserProject(browserProject.title);
}}
component="button"
underline="none"
>
{title}
{browserProject.title}
</Link>
</td>
<td>
<span style={{ color: "gray" }}>
{timeAgoString(browserProject.timestamp)}
</span>
</td>
</tr>
))}
</tbody>
Expand Down
13 changes: 9 additions & 4 deletions gui/src/app/pages/HomePage/SaveProjectWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 Down Expand Up @@ -245,16 +246,20 @@ const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
const handleSave = useCallback(async () => {
try {
const bpi = new BrowserProjectsInterface();
const existingProject = await bpi.loadProject(title);
if (existingProject) {
const existingBrowserProject = await bpi.loadBrowserProject(title);
if (existingBrowserProject) {
const overwrite = window.confirm(
`A project with the title "${title}" already exists. Do you want to overwrite it?`,
`A project with the title "${title}" already exists (modified ${timeAgoString(existingBrowserProject.timestamp)}). Do you want to overwrite it?`,
);
if (!overwrite) {
return;
}
}
await bpi.saveProject(title, fileManifest);
await bpi.saveBrowserProject(title, {
title,
timestamp: Date.now(),
fileManifest,
});
} catch (err: any) {
alert(`Error saving to browser: ${err.message}`);
}
Expand Down
28 changes: 28 additions & 0 deletions gui/src/app/util/timeAgoString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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 diffMonths = Math.floor(diffWeeks / 4);
const diffYears = Math.floor(diffMonths / 12);
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;

0 comments on commit 041176a

Please sign in to comment.