From afb6958995406966511433a61209cdb9d6bd1514 Mon Sep 17 00:00:00 2001 From: Jelle van Snik Date: Wed, 16 Feb 2022 21:30:12 +0100 Subject: [PATCH] storage api + error boundary --- package.json | 2 + src/components/layout/ErrorBoundary.tsx | 64 +++++++ src/components/layout/loading.css | 0 src/index.css | 2 +- src/index.tsx | 25 +-- src/state/watched/store.ts | 50 ++++++ src/utils/storage.ts | 226 ++++++++++++++++++++++++ src/views/MovieView.tsx | 2 +- src/views/SearchView.tsx | 2 +- yarn.lock | 20 ++- 10 files changed, 378 insertions(+), 15 deletions(-) create mode 100644 src/components/layout/ErrorBoundary.tsx delete mode 100644 src/components/layout/loading.css create mode 100644 src/state/watched/store.ts create mode 100644 src/utils/storage.ts diff --git a/package.json b/package.json index e14f5280..0050eeaf 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "react-helmet": "^6.1.0", "react-router-dom": "^5.2.0", "react-scripts": "^5.0.0", + "react-tracked": "^1.7.6", + "scheduler": "^0.20.2", "web-vitals": "^1.0.1" }, "scripts": { diff --git a/src/components/layout/ErrorBoundary.tsx b/src/components/layout/ErrorBoundary.tsx new file mode 100644 index 00000000..5af355e4 --- /dev/null +++ b/src/components/layout/ErrorBoundary.tsx @@ -0,0 +1,64 @@ +import { Title } from "components/Text/Title"; +import { Component } from "react"; + +interface ErrorBoundaryState { + hasError: boolean; + error?: { + name: string; + description: string; + path: string; + }; +} + +export class ErrorBoundary extends Component<{}, ErrorBoundaryState> { + state: ErrorBoundaryState = { + hasError: false, + }; + + static getDerivedStateFromError() { + return { + hasError: true, + }; + } + + componentDidCatch(error: any, errorInfo: any) { + console.error("Render error caught", error, errorInfo); + if (error instanceof Error) { + let realError: Error = error as Error; + this.setState((s) => ({ + ...s, + hasError: true, + error: { + name: realError.name, + description: realError.message, + path: errorInfo.componentStack.split("\n")[1], + }, + })); + } + } + + render() { + if (!this.state.hasError) return this.props.children; + + // TODO make pretty + return ( +
+
+ Whoops, it broke +

+ The app encountered an error and wasn't able to recover, please + report it to the discord server or on GitHub. +

+
+ {this.state.error ? ( +
+

+ {this.state.error.name} - {this.state.error.description} +

+

{this.state.error.path}

+
+ ) : null} +
+ ); + } +} diff --git a/src/components/layout/loading.css b/src/components/layout/loading.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/index.css b/src/index.css index 4775d9ef..a433960f 100644 --- a/src/index.css +++ b/src/index.css @@ -4,5 +4,5 @@ html, body { - @apply min-h-screen bg-denim-100 text-white font-open-sans; + @apply bg-denim-100 text-denim-700 font-open-sans min-h-screen; } diff --git a/src/index.tsx b/src/index.tsx index ef47def1..dc8f917a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,14 +1,17 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { HashRouter } from 'react-router-dom'; -import './index.css'; -import App from './App'; +import React from "react"; +import ReactDOM from "react-dom"; +import { HashRouter } from "react-router-dom"; +import "./index.css"; +import App from "./App"; +import { ErrorBoundary } from "components/layout/ErrorBoundary"; ReactDOM.render( - - - - - , - document.getElementById('root') + + + + + + + , + document.getElementById("root") ); diff --git a/src/state/watched/store.ts b/src/state/watched/store.ts new file mode 100644 index 00000000..30380675 --- /dev/null +++ b/src/state/watched/store.ts @@ -0,0 +1,50 @@ +import { versionedStoreBuilder } from 'utils/storage'; + +/* +version 0 +{ + [{scraperid}]: { + movie: { + [{movie-id}]: { + full: { + currentlyAt: number, + totalDuration: number, + updatedAt: number, // unix timestamp in ms + meta: FullMetaObject, // no idea whats in here + } + } + }, + show: { + [{show-id}]: { + [{season}-{episode}]: { + currentlyAt: number, + totalDuration: number, + updatedAt: number, // unix timestamp in ms + show: { + episode: string, + season: string, + }, + meta: FullMetaObject, // no idea whats in here + } + } + } + } +} +*/ + +export const VideoProgressStore = versionedStoreBuilder() + .setKey('video-progress') + .addVersion({ + version: 0, + migrate(data: any) { + // TODO migration + throw new Error("Migration not been written yet!!") + }, + }) + .addVersion({ + version: 1, + create() { + return {} + } + }) + .build() diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 00000000..e662e74b --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,226 @@ +// TODO make type and react safe!! +/* + it needs to be react-ified by having a save function not on the instance itself. + also type safety is important, this is all spaghetti with "any" everywhere +*/ + + +function buildStoreObject(d: any) { + const data: any = { + versions: d.versions, + currentVersion: d.maxVersion, + id: d.storageString, + }; + + function update(this: any, obj: any) { + if (!obj) throw new Error("object to update is not an object"); + + // repeat until object fully updated + if (obj["--version"] === undefined) obj["--version"] = 0; + while (obj["--version"] !== this.currentVersion) { + // get version + let version: any = obj["--version"] || 0; + if (version.constructor !== Number || version < 0) version = -42; + // invalid on purpose so it will reset + else { + version = (version as number + 1).toString(); + } + + // check if version exists + if (!this.versions[version]) { + console.error( + `Version not found for storage item in store ${this.id}, resetting` + ); + obj = null; + break; + } + + // update object + obj = this.versions[version].update(obj); + } + + // if resulting obj is null, use latest version as init object + if (obj === null) { + console.error( + `Storage item for store ${this.id} has been reset due to faulty updates` + ); + return this.versions[this.currentVersion.toString()].init(); + } + + // updates succesful, return + return obj; + } + + function get(this: any) { + // get from storage api + const store = this; + let data: any = localStorage.getItem(this.id); + + // parse json if item exists + if (data) { + try { + data = JSON.parse(data); + if (!data.constructor) { + console.error( + `Storage item for store ${this.id} has not constructor` + ); + throw new Error("storage item has no constructor"); + } + if (data.constructor !== Object) { + console.error(`Storage item for store ${this.id} is not an object`); + throw new Error("storage item is not an object"); + } + } catch (_) { + // if errored, set to null so it generates new one, see below + console.error(`Failed to parse storage item for store ${this.id}`); + data = null; + } + } + + // if item doesnt exist, generate from version init + if (!data) { + data = this.versions[this.currentVersion.toString()].init(); + } + + // update the data if needed + data = this.update(data); + + // add a save object to return value + data.save = function save() { + localStorage.setItem(store.id, JSON.stringify(data)); + }; + + // add instance helpers + Object.entries(d.instanceHelpers).forEach(([name, helper]: any) => { + if (data[name] !== undefined) + throw new Error( + `helper name: ${name} on instance of store ${this.id} is reserved` + ); + data[name] = helper.bind(data); + }); + + // return data + return data; + } + + // add functions to store + data.get = get.bind(data); + data.update = update.bind(data); + + // add static helpers + Object.entries(d.staticHelpers).forEach(([name, helper]: any) => { + if (data[name] !== undefined) + throw new Error(`helper name: ${name} on store ${data.id} is reserved`); + data[name] = helper.bind({}); + }); + + return data; +} + +/* + * Builds a versioned store + * + * manages versioning of localstorage items + */ +export function versionedStoreBuilder(): any { + return { + _data: { + versionList: [], + maxVersion: 0, + versions: {}, + storageString: undefined, + instanceHelpers: {}, + staticHelpers: {}, + }, + + setKey(str: string) { + this._data.storageString = str; + return this; + }, + + addVersion({ version, migrate, create }: any) { + // input checking + if (version < 0) throw new Error("Cannot add version below 0 in store"); + if (version > 0 && !migrate) + throw new Error( + `Missing migration on version ${version} (needed for any version above 0)` + ); + + // update max version list + if (version > this._data.maxVersion) this._data.maxVersion = version; + // add to version list + this._data.versionList.push(version); + + // register version + this._data.versions[version.toString()] = { + version: version, // version number + update: migrate + ? (data: any) => { + // update function, and increment version + migrate(data); + data["--version"] = version; + return data; + } + : undefined, + init: create + ? () => { + // return an initial object + const data = create(); + data["--version"] = version; + return data; + } + : undefined, + }; + return this; + }, + + registerHelper({ name, helper, type }: any) { + // type + if (!type) type = "instance"; + + // input checking + if (!name || name.constructor !== String) { + throw new Error("helper name is not a string"); + } + if (!helper || helper.constructor !== Function) { + throw new Error("helper function is not a function"); + } + if (!["instance", "static"].includes(type)) { + throw new Error("helper type must be either 'instance' or 'static'"); + } + + // register helper + if (type === "instance") this._data.instanceHelpers[name as string] = helper; + else if (type === "static") this._data.staticHelpers[name as string] = helper; + + return this; + }, + + build() { + // check if version list doesnt skip versions + const versionListSorted = this._data.versionList.sort((a: number, b: number) => a - b); + versionListSorted.forEach((v: any, i: number, arr: any[]) => { + if (i === 0) return; + if (v !== arr[i - 1] + 1) + throw new Error("Version list of store is not incremental"); + }); + + // version zero must exist + if (versionListSorted[0] !== 0) + throw new Error("Version 0 doesn't exist in version list of store"); + + // max version must have init function + if (!this._data.versions[this._data.maxVersion.toString()].init) + throw new Error( + `Missing create function on version ${this._data.maxVersion} (needed for latest version of store)` + ); + + // check storage string + if (!this._data.storageString) + throw new Error("storage key not set in store"); + + // build versioned store + return buildStoreObject(this._data); + }, + }; +} diff --git a/src/views/MovieView.tsx b/src/views/MovieView.tsx index 91e86511..d18f71eb 100644 --- a/src/views/MovieView.tsx +++ b/src/views/MovieView.tsx @@ -3,5 +3,5 @@ export function MovieView() {

Movie view here

- ) + ); } diff --git a/src/views/SearchView.tsx b/src/views/SearchView.tsx index 282ccda2..6ae0b228 100644 --- a/src/views/SearchView.tsx +++ b/src/views/SearchView.tsx @@ -50,7 +50,7 @@ export function SearchView() { ))} ) : null} - {search.searchQuery !== "" && results.length == 0 ? : null} + {search.searchQuery !== "" && results.length === 0 ? : null} ); } diff --git a/yarn.lock b/yarn.lock index f8dc0998..9cf54e9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7255,6 +7255,11 @@ proxy-addr@~2.0.5: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-compare@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/proxy-compare/-/proxy-compare-2.0.2.tgz#343e624d0ec399dfbe575f1d365d4fa042c9fc69" + integrity sha512-3qUXJBariEj3eO90M3Rgqq3+/P5Efl0t/dl9g/1uVzIQmO3M+ql4hvNH3mYdu8H+1zcKv07YvL55tsY74jmH1A== + psl@^1.1.33: version "1.8.0" resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz" @@ -7489,6 +7494,14 @@ react-side-effect@^2.1.0: resolved "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.1.tgz" integrity sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ== +react-tracked@^1.7.6: + version "1.7.6" + resolved "https://registry.yarnpkg.com/react-tracked/-/react-tracked-1.7.6.tgz#11bccec80acccdf5029db20171a887b8b16b7ae1" + integrity sha512-yqfkqj4UZpsadBLIHnPLrc8a0SLgjKoSQrdyipfWeXvLnPl+/AV8MrqRVbNogUJsqHOo+ojlWy2PMxuZPVcPnQ== + dependencies: + proxy-compare "2.0.2" + use-context-selector "1.3.9" + react@^17.0.2: version "17.0.2" resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz" @@ -7820,7 +7833,7 @@ saxes@^5.0.1: scheduler@^0.20.2: version "0.20.2" - resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== dependencies: loose-envify "^1.1.0" @@ -8719,6 +8732,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +use-context-selector@1.3.9: + version "1.3.9" + resolved "https://registry.yarnpkg.com/use-context-selector/-/use-context-selector-1.3.9.tgz#d1527393839f0d790ccdd52e28e8f353b8be6c2e" + integrity sha512-YgzRyeFjoJXwFn2qLVAuIbV6EQ8DOuzu3SS/eiCxyAyvBhcn02jYSz8c5v22QQU3LW6Ez/Iyo62kKvS7Kdqt3A== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"