diff --git a/webapp/src/main.js b/webapp/src/main.js index f7c6cbcb..797f9969 100644 --- a/webapp/src/main.js +++ b/webapp/src/main.js @@ -1,4 +1,6 @@ import { createApp } from "vue"; +import { createPinia } from "pinia"; +import piniaPluginPersistedstate from "pinia-plugin-persistedstate"; import App from "./App.vue"; import store from "./store"; import UIkit from "uikit"; @@ -21,14 +23,13 @@ UIkit.mixin( // Create Vue app const app = createApp(App); - -// Use visibility observer -//app.use(VueObserveVisibility); +const pinia = createPinia(); +pinia.use(piniaPluginPersistedstate); // Use global mixins app.mixin(modalMixin); app.mixin(labThingsMixins); // Use Vuex store -app.use(store); +app.use(pinia); app.mount("#app"); diff --git a/webapp/src/stores/settings.js b/webapp/src/stores/settings.js index 003a8142..9ebbe543 100644 --- a/webapp/src/stores/settings.js +++ b/webapp/src/stores/settings.js @@ -1,5 +1,5 @@ -import { createStore } from "vuex"; -import wotStoreModule from "./wot-client"; +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; function getOriginFromLocation() { // This will default to the same origin that's serving @@ -14,156 +14,98 @@ function getOriginFromLocation() { } } -/** - * Converts a Vuex state key (e.g. "appTheme") into a corresponding - * Vuex mutation name (e.g. "changeAppTheme") using the `change` - * convention. - * - * @param {string} key - The Vuex state key to convert. - * @returns {string} - The formatted mutation name. - */ -function keyToMutationName(key) { - return `change${key.charAt(0).toUpperCase() + key.slice(1)}`; -} +// Define Pinia store +export const useDefaultStore = defineStore( + "default", + () => { + // State + const origin = ref(getOriginFromLocation()); + const available = ref(false); + const waiting = ref(false); + const error = ref(""); + const trackWindow = ref(true); + const activeStreams = ref({}); + const microscopeHostname = ref(""); -/** - * Converts a Vuex mutation name (e.g. "changeAppTheme") back into - * the corresponding state key (e.g. "appTheme") using the - * `change` convention. - * - * @param {string} mutationName - The Vuex mutation name to reverse. - * @returns {string|null} - The derived state key, or null if the - * mutation name doesn't match the `change` convention. - */ -function mutationToKey(mutationName) { - const prefix = "change"; - if (!mutationName.startsWith(prefix)) { - return null; // Not a mutation we care about - } - const key = mutationName.slice(prefix.length); - return key.charAt(0).toLowerCase() + key.slice(1); -} - -const LOCALSTORAGE_KEYS = [ - "appTheme", - "disableStream", - "overrideOrigin", - "navigationStepSize", - "navigationInvert", -]; - -export default createStore({ - modules: { - wot: wotStoreModule, - }, - state: { - origin: getOriginFromLocation(), - available: false, - waiting: false, - error: "", - trackWindow: true, - activeStreams: {}, - microscopeHostname: "", // Persistent items: // The app theme (e.g. light/dark) - appTheme: "system", - disableStream: false, + const appTheme = ref("system"); + const disableStream = ref(false); // The origin to use if overriding with dev tools - overrideOrigin: "http://microscope.local:5000", + const overrideOrigin = ref("http://microscope.local:5000"); // The step sizes for navigation via control pane/keys presses - navigationStepSize: { + const navigationStepSize = ref({ x: 200, y: 200, z: 50, - }, + }); // The axis inversion for navigation via control pane/keys presses - navigationInvert: { + const navigationInvert = ref({ x: false, y: false, z: false, + }); + + // Actions + function resetState() { + waiting.value = false; + available.value = false; + error.value = ""; // TODO: verify that "" is needed to match initial state + } + + function setConnected() { + waiting.value = false; + available.value = true; + } + + function addStream(id) { + activeStreams.value[id] = true; + } + function removeStream(id) { + activeStreams.value[id] = false; + } + // TODO: Replace for direct access to state. + // Getters + const baseUri = computed(() => origin.value); + const ready = computed(() => available.value); + + // Export + return { + //State + origin, + available, + waiting, + error, + trackWindow, + activeStreams, + microscopeHostname, + appTheme, + disableStream, + overrideOrigin, + navigationStepSize, + navigationInvert, + + //Getters + baseUri, + ready, + + //Actions + resetState, + setConnected, + addStream, + removeStream, + }; + }, + { + // PiniaPluginPersistedState will now automatically persist ONLY these specific refs to localStorage + persist: { + paths: [ + "appTheme", + "disableStream", + "overrideOrigin", + "navigationStepSize", + "navigationInvert", + ], }, }, - - mutations: { - changeOrigin(state, origin) { - state.origin = origin; - }, - changeWaiting(state, waiting) { - state.waiting = waiting; - }, - changeDisableStream(state, disabled) { - state.disableStream = disabled; - }, - changeTrackWindow(state, enabled) { - state.trackWindow = enabled; - }, - changeAppTheme(state, theme) { - state.appTheme = theme; - }, - resetState(state) { - state.waiting = false; - state.available = false; - state.error = null; - }, - setConnected(state) { - state.waiting = false; - state.available = true; - }, - setErrorMessage(state, msg) { - state.error = msg; - }, - addStream(state, id) { - state.activeStreams[id] = true; - }, - removeStream(state, id) { - state.activeStreams[id] = false; - }, - changeMicroscopeHostname(state, value) { - state.microscopeHostname = value; - }, - changeOverrideOrigin(state, value) { - state.overrideOrigin = value; - }, - changeNavigationStepSize(state, value) { - state.navigationStepSize = value; - }, - changeNavigationInvert(state, value) { - state.navigationInvert = value; - }, - }, - - actions: {}, - - getters: { - baseUri: (state) => state.origin, - ready: (state) => state.available, - }, - - plugins: [ - (store) => { - // Load initial state from localStorage - LOCALSTORAGE_KEYS.forEach((key) => { - const saved = localStorage.getItem(key); - if (saved !== null) { - try { - const parsed = JSON.parse(saved); - const mutationName = keyToMutationName(key); - store.commit(mutationName, parsed); - } catch (e) { - console.warn(`Failed to parse localStorage key "${key}":`, e); - localStorage.removeItem(key); - } - } - }); - - // Subscribe to mutations - store.subscribe((mutation, state) => { - const key = mutationToKey(mutation.type); - // If the mutation is chacning a local storage key then update localStorage - if (key && LOCALSTORAGE_KEYS.includes(key)) { - localStorage.setItem(key, JSON.stringify(state[key])); - } - }); - }, - ], -}); +); diff --git a/webapp/src/stores/wot.js b/webapp/src/stores/wot.js index b1fd3050..637b0ce3 100644 --- a/webapp/src/stores/wot.js +++ b/webapp/src/stores/wot.js @@ -1,82 +1,65 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; import axios from "axios"; -export const wotStoreModule = { - namespaced: true, - state: () => ({ - thingDescriptions: {}, - servient: null, - helpers: null, - }), - mutations: { - addThingDescription(state, { thingName, thingDescription }) { - state.thingDescriptions[thingName] = thingDescription; - }, - removeThingDescription(state, thingName) { - delete state.thingDescriptions[thingName]; - }, - removeAllThingDescriptions(state) { - state.thingDescriptions = {}; - }, - }, - actions: { - async start() { - // Set up thing client - not currently used. - }, - async fetchThingDescription({ commit }, { uri, name = null }) { +export const useWotStore = defineStore("wot", () => { + // State + const thingDescriptions = ref({}); + const servient = ref(null); + const helpers = ref(null); + // Actions + function addThingDescription(thingName, thingDescription) { + thingDescriptions.value[thingName] = thingDescription; + } + + function removeThingDescription(thingName) { + delete thingDescriptions.value[thingName]; + } + + function removeAllThingDescriptions() { + thingDescriptions.value = {}; + } + + async function fetchThingDescription(uri, name = null) { // Fetch the thing description from the given URI and consume it // NB this should only be called once, or we'll duplicate effort. // Deduplication should be done elsewhere. - let response = await axios.get(uri); - let td = response.data; - let thing_name = name || uri.replace(/\/$/, "").split("/").pop(); - commit("addThingDescription", { - thingName: thing_name, - thingDescription: td, - }); - }, - async fetchThingDescriptions({ commit }, uri) { + const response = await axios.get(uri); + const td = response.data; + const thingName = name || uri.replace(/\/$/, "").split("/").pop(); + addThingDescription(thingName, td); + } + + async function fetchThingDescriptions(uri) { // Fetch thing descriptions from the given URI - let response = await axios.get(uri); + const response = await axios.get(uri); if (response.status !== 200) throw "Could not retrieve thing descriptions"; for (const k in response.data) { - let thing_name = k.replace(/\/$/, "").replace(/^\//, ""); - commit("addThingDescription", { - thingName: thing_name, - thingDescription: response.data[k], - }); + let thingName = k.replace(/\/$/, "").replace(/^\//, ""); + addThingDescription(thingName, response.data[k]); } - }, - }, - getters: { - thingDescriptions: (state) => { - return state.thingDescriptions; - }, - thingList: (state) => { - return Object.keys(state.thingDescriptions); - }, - thingDescription: (state) => (thingName) => { - return state.thingDescriptions[thingName]; - }, - thingAvailable: (state) => (thingName) => { - return thingName in state.thingDescriptions; - }, - thingAffordanceAvailable: (state) => (thing, affordanceType, affordance) => { - let td = state.thingDescriptions[thing]; - if (!td) { + } + + const thingList = computed(() => Object.keys(thingDescriptions.value)); + + const thingAvailable = (thingName) => thingName in thingDescriptions.value; + + const thingAffordanceAvailable = (thing, affordanceType, affordance) => { + const td = thingDescriptions.value[thing]; + if (!td || !td[affordanceType]) { return false; } return affordance in td[affordanceType]; - }, - thingFormUrl: - (state) => - (thing, affordanceType, affordance, op, allowUndefined = true) => { + }; + + const thingFormUrl = (thing, affordanceType, affordance, op, allowUndefined = true) => { // Find the URL for a particular operation - let td = state.thingDescriptions[thing]; + const td = thingDescriptions.value[thing]; if (!td) { if (allowUndefined) return undefined; throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`; } - let affordances = td[affordanceType]; + const affordances = td[affordanceType]; if (!affordances || !(affordance in affordances)) { if (allowUndefined) return undefined; @@ -97,17 +80,33 @@ export const wotStoreModule = { return base + href; } return href; - }, - thingPropertyUrl: (_state, getters) => (thing, property, op, allowUndefined) => { + }; + + const thingPropertyUrl = (thing, property, op, allowUndefined) => // Find the URL for a particular property - return getters.thingFormUrl(thing, "properties", property, op, allowUndefined); - }, - thingActionUrl: (_state, getters) => (thing, action, op, allowUndefined) => { + thingFormUrl(thing, "properties", property, op, allowUndefined); + + const thingActionUrl = (thing, action, op, allowUndefined) => // Find the URL for a particular action - return getters.thingFormUrl(thing, "actions", action, op, allowUndefined); - }, - }, + thingFormUrl(thing, "actions", action, op, allowUndefined); + + return { + thingDescriptions, + servient, + helpers, // State + thingList, + thingAvailable, // Helpers + thingFormUrl, + thingPropertyUrl, + thingActionUrl, + fetchThingDescription, + fetchThingDescriptions, // Actions + addThingDescription, + removeThingDescription, + removeAllThingDescriptions, + thingAffordanceAvailable, }; +}); export function findFormHref(affordance, op) { // Find the form in the affordance that matches the given operation type @@ -117,5 +116,3 @@ export function findFormHref(affordance, op) { if (matchingForm === undefined) return undefined; return matchingForm.href; } - -export default wotStoreModule;