diff --git a/src/assets/app-store/dependencies/bitcoind.svg b/src/assets/app-store/dependencies/bitcoind.svg new file mode 100644 index 0000000..10aaca1 --- /dev/null +++ b/src/assets/app-store/dependencies/bitcoind.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/assets/app-store/dependencies/electrum.svg b/src/assets/app-store/dependencies/electrum.svg new file mode 100644 index 0000000..8f0d37f --- /dev/null +++ b/src/assets/app-store/dependencies/electrum.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/app-store/dependencies/lnd.svg b/src/assets/app-store/dependencies/lnd.svg new file mode 100644 index 0000000..8178498 --- /dev/null +++ b/src/assets/app-store/dependencies/lnd.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/components/AuthenticatedVerticalNavbar.vue b/src/components/AuthenticatedVerticalNavbar.vue index 570040a..69b598a 100644 --- a/src/components/AuthenticatedVerticalNavbar.vue +++ b/src/components/AuthenticatedVerticalNavbar.vue @@ -4,7 +4,7 @@

Balance - +

@@ -15,7 +15,7 @@ @@ -34,7 +34,7 @@
- + Home - + Bitcoin - + + + + + + + + + Apps + + + + + + + + + + App Store + + Log out - + + +
+ +
@@ -172,15 +234,19 @@ export default { data() { return { state: { - showBalance: true - } + showBalance: true, + }, }; }, + props: { + isMobileMenu: Boolean, + }, computed: { ...mapState({ - btcBalance: state => state.bitcoin.balance.total, - lightningBalance: state => state.lightning.balance.total, - unit: state => state.system.unit + btcBalance: (state) => state.bitcoin.balance.total, + lightningBalance: (state) => state.lightning.balance.total, + unit: (state) => state.system.unit, + appStore: (state) => state.apps.store, }), walletBalance() { return this.unit === "sats" @@ -189,7 +255,7 @@ export default { }, balanceLoaded() { return this.btcBalance >= 0 && this.lightningBalance >= 0; - } + }, }, methods: { logout() { @@ -197,15 +263,16 @@ export default { }, toggleBalance() { return (this.state.showBalance = !this.state.showBalance); - } + }, }, - props: { - isMobileMenu: Boolean + created() { + this.$store.dispatch("apps/getInstalledApps"); + this.$store.dispatch("apps/getAppStore"); }, components: { CountUp, - SatsBtcSwitch - } + SatsBtcSwitch, + }, }; diff --git a/src/components/InstalledApp.vue b/src/components/InstalledApp.vue new file mode 100644 index 0000000..23c8f17 --- /dev/null +++ b/src/components/InstalledApp.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/src/global-styles/custom.scss b/src/global-styles/custom.scss index 709912a..6fcad2a 100644 --- a/src/global-styles/custom.scss +++ b/src/global-styles/custom.scss @@ -54,9 +54,12 @@ a { &.btn-danger,&.btn-warning { color: #fff; } - &.btn-outline-danger:hover { - color: #fff; + &.btn-outline-danger { + &:hover,&:active { + color: #fff !important; + } } + } .card { @@ -457,4 +460,53 @@ body { // Helper class to support text with "\n" newline characters .text-newlines { white-space: pre-line; -} \ No newline at end of file +} + +// Helper class to truncate text with "..." +.text-truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +// Helper class for fade in and out animation +.fade-in-out { + animation: fadeInOut 1s infinite linear; +} + +@keyframes fadeInOut { + 0%, + 100% { + opacity: 0.6; + } + 50% { + opacity: 0.3; + } +} + +.app-icon { + height: 100px; + width: 100px; + border-radius: 20px; + object-fit: contain; + align-items: flex-start; +} + +.app-gallery { + display: flex; + overflow-x: scroll; + -webkit-overflow-scrolling: touch; + margin-left: calc(-15px - 1.5rem); + margin-right: calc(-15px - 1.5rem); + padding-left: calc(30px + 0.75rem); + padding-right: calc(30px + 0.75rem); + .app-gallery-screen { + height: 350px; + width: auto; + display: block; + box-shadow: 0px 10px 30px rgba(209, 213, 223, 0.8); + border-radius: 5px; + object-fit: contain; + } + } \ No newline at end of file diff --git a/src/global-styles/responsive.scss b/src/global-styles/responsive.scss index 5dd77d5..c26f63d 100644 --- a/src/global-styles/responsive.scss +++ b/src/global-styles/responsive.scss @@ -3,4 +3,102 @@ font-size: $font-size-base * 4; line-height: $font-size-base * 4; } -} \ No newline at end of file +} + +.card-columns { + column-count: 1; + @include media-breakpoint-up(md) { + column-count: 2; + } + column-gap: 30px; +} + +.app-name { + @include media-breakpoint-down(md) { + font-size: 1.4rem; + } + @include media-breakpoint-only(lg) { + font-size: 1.6rem; + } +} + +@include media-breakpoint-only(xs) { + .app-icon { + height: 66px !important; + width: 66px !important; + border-radius: 12px !important; + } + .installed-app { + width: calc(50% - 1rem) !important; + } + .w-xs-100 { + width: 100%; + } + .app-gallery { + margin-left: -15px; + margin-right: -15px; + padding-left: 15px; + padding-right: 15px; + .app-gallery-screen { + height: auto; // so it doesn't break on Chrome (i.e. show white padding on top/bottom) + height: intrinsic; // so it doesn't break on Safari (i.e. show white padding on top/bottom) https://stackoverflow.com/a/64108625 + width: calc(100vw); + } + } +} + +@include media-breakpoint-only(sm) { + .app-icon { + height: 70px; + width: 70px; + border-radius: 14px; + } + .app-gallery { + margin-left: calc(-15px - 0.75rem); + margin-right: calc(-15px - 0.75rem); + padding-left: 30px; + padding-right: 30px; + .app-gallery-screen { + height: 280px; + } + } +} + +@include media-breakpoint-only(md) { + .app-icon { + height: 80px; + width: 80px; + border-radius: 16px; + } + .app-gallery { + margin-left: calc(-15px - 0.75rem); + margin-right: calc(-15px - 0.75rem); + padding-left: 30px; + padding-right: 30px; + .app-gallery-screen { + height: 300px; + } + } +} + +@include media-breakpoint-only(lg) { + .app-gallery { + margin-left: calc(-15px - 0.75rem); + margin-right: calc(-15px - 0.75rem); + padding-left: 30px; + padding-right: 30px; + } + .app-icon { + height: 80px; + width: 80px; + border-radius: 16px; + } +} + +@include media-breakpoint-up(lg) { + .app-icon.app-icon-lg { + height: 140px; + width: 140px; + border-radius: 24px; + } +} diff --git a/src/helpers/api.js b/src/helpers/api.js index 7efbb0b..b3a04e6 100644 --- a/src/helpers/api.js +++ b/src/helpers/api.js @@ -122,7 +122,7 @@ const API = { }, // Wrap a post call - async post(url, data, auth = true) { + async post(url, data = {}, auth = true) { const requestOptions = { method: "post", url, diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index 76a952c..2349100 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -149,7 +149,7 @@ -
+
Umbrel v{{ availableUpdate.version }} getumbrel.com + | + chat

@@ -207,21 +207,21 @@ import AuthenticatedVerticalNavbar from "@/components/AuthenticatedVerticalNavba export default { data() { return { - isUpdating: false + isUpdating: false, }; }, computed: { ...mapState({ - name: state => state.user.name, - chain: state => state.bitcoin.chain, - availableUpdate: state => state.system.availableUpdate, - updateStatus: state => state.system.updateStatus, - showUpdateConfirmationModal: state => - state.system.showUpdateConfirmationModal + name: (state) => state.user.name, + chain: (state) => state.bitcoin.chain, + availableUpdate: (state) => state.system.availableUpdate, + updateStatus: (state) => state.system.updateStatus, + showUpdateConfirmationModal: (state) => + state.system.showUpdateConfirmationModal, }), isMobileMenuOpen() { return this.$store.getters.isMobileMenuOpen; - } + }, }, methods: { logout() { @@ -280,10 +280,10 @@ export default { autoHideDelay: 3000, variant: "danger", solid: true, - toaster: "b-toaster-bottom-right" + toaster: "b-toaster-bottom-right", }); } - } + }, }, created() { //load this data once: @@ -301,8 +301,8 @@ export default { }, watch: {}, components: { - AuthenticatedVerticalNavbar - } + AuthenticatedVerticalNavbar, + }, }; @@ -317,6 +317,10 @@ export default { top: 0; } +.content-container { + min-height: calc(100vh - 150px); +} + .input-search-form { form { position: relative; diff --git a/src/router/index.js b/src/router/index.js index f391354..df9718e 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -12,6 +12,9 @@ import Login from "../views/Login.vue"; import Dashboard from "../views/Dashboard.vue"; import Bitcoin from "../views/Bitcoin.vue"; import Lightning from "../views/Lightning.vue"; +import Apps from "../views/Apps.vue"; +import AppStore from "../views/AppStore.vue"; +import AppStoreApp from "../views/AppStoreApp.vue"; import Settings from "../views/Settings.vue"; import Logout from "../views/Logout.vue"; @@ -82,6 +85,35 @@ const routes = [ } ] }, + { + path: "/apps", + component: DashboardLayout, + meta: { requiresAuth: true }, + children: [ + { + path: "", + name: "apps", + component: Apps + } + ] + }, + { + path: "/app-store", + component: DashboardLayout, + meta: { requiresAuth: true }, + children: [ + { + path: "", + name: "app-store", + component: AppStore + }, + { + path: ":id", + name: "app-store-app", + component: AppStoreApp + } + ] + }, { path: "/settings", component: DashboardLayout, @@ -114,9 +146,20 @@ const router = new VueRouter({ mode: "history", base: process.env.BASE_URL, routes, - scrollBehavior: () => { - return { x: 0, y: 0 }; - } //scroll to top on page changes + scrollBehavior: (to, from, savedPosition) => { + // Exists when Browser's back/forward pressed + if (savedPosition) { + return savedPosition + // For anchors + } else if (to.hash) { + return { selector: to.hash, behavior: 'smooth' } + // By changing queries we are still in the same component, so "from.path" === "to.path" (new query changes just "to.fullPath", but not "to.path"). + } else if (from.path === to.path) { + return {} + } + // Scroll to top + return { x: 0, y: 0 } + } }); //Fake for now diff --git a/src/store/index.js b/src/store/index.js index 4693e6d..9988d75 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -6,6 +6,7 @@ import user from "./modules/user"; import system from "./modules/system"; import bitcoin from "./modules/bitcoin"; import lightning from "./modules/lightning"; +import apps from "./modules/apps"; Vue.use(Vuex); @@ -51,6 +52,7 @@ export default new Vuex.Store({ user, system, bitcoin, - lightning + lightning, + apps } }); diff --git a/src/store/modules/apps.js b/src/store/modules/apps.js new file mode 100644 index 0000000..db0a608 --- /dev/null +++ b/src/store/modules/apps.js @@ -0,0 +1,126 @@ +import API from "@/helpers/api"; +// import Vue from "vue" + +// Initial state +const state = () => ({ + installed: [], + store: [], + installing: [], + uninstalling: [] +}); + +// Functions to update the state directly +const mutations = { + setInstalledApps(state, apps) { + const alphabeticallySortedApps = apps.sort((a, b) => a.name.localeCompare(b.name)); + state.installed = alphabeticallySortedApps; + }, + setAppStore(state, appStore) { + state.store = appStore; + }, + addInstallingApp(state, appId) { + if (!state.installing.includes(appId)) { + state.installing.push(appId); + } + }, + removeInstallingApp(state, appId) { + const index = state.installing.findIndex((id) => id === appId); + if (index !== -1) { + state.installing.splice(index, 1); + } + }, + addUninstallingApp(state, appId) { + if (!state.uninstalling.includes(appId)) { + state.uninstalling.push(appId); + } + }, + removeUninstallingApp(state, appId) { + const index = state.uninstalling.findIndex((id) => id === appId); + if (index !== -1) { + state.uninstalling.splice(index, 1); + } + } +}; + +// Functions to get data from the API +const actions = { + async getInstalledApps({ commit }) { + const installedApps = await API.get(`${process.env.VUE_APP_MANAGER_API_URL}/v1/apps?installed=1`); + if (installedApps) { + commit("setInstalledApps", installedApps); + } + }, + async getAppStore({ commit, dispatch }) { + dispatch("getInstalledApps"); + const appStore = await API.get(`${process.env.VUE_APP_MANAGER_API_URL}/v1/apps`); + if (appStore) { + commit("setAppStore", appStore); + } + }, + async uninstall({ state, commit, dispatch }, appId) { + commit("addUninstallingApp", appId); + try { + await API.post( + `${process.env.VUE_APP_MANAGER_API_URL}/v1/apps/${appId}/uninstall` + ); + } catch (error) { + if (error.response && error.response.data) { + commit("removeUninstallingApp", appId); + return this.$bvToast.toast(error.response.data, { + title: "Error", + autoHideDelay: 3000, + variant: "danger", + solid: true, + toaster: "b-toaster-bottom-right", + }); + } + } + + const poll = window.setInterval(async () => { + await dispatch("getInstalledApps"); + const index = state.installed.findIndex((app) => app.id === appId); + if (index === -1) { + commit("removeUninstallingApp", appId); + window.clearInterval(poll); + } + }, 5000); + }, + async install({ state, commit, dispatch }, appId) { + commit("addInstallingApp", appId); + try { + await API.post( + `${process.env.VUE_APP_MANAGER_API_URL}/v1/apps/${appId}/install` + ); + } catch (error) { + if (error.response && error.response.data) { + commit("removeInstallingApp", appId); + return this.$bvToast.toast(error.response.data, { + title: "Error", + autoHideDelay: 3000, + variant: "danger", + solid: true, + toaster: "b-toaster-bottom-right", + }); + } + } + + const poll = window.setInterval(async () => { + await dispatch("getInstalledApps"); + const index = state.installed.findIndex((app) => app.id === appId); + if (index !== -1) { + commit("removeInstallingApp", appId); + window.clearInterval(poll); + } + }, 5000); + } +}; + +const getters = {}; + +export default { + namespaced: true, + state, + actions, + getters, + mutations +}; diff --git a/src/store/modules/user.js b/src/store/modules/user.js index 28bec4b..13363ff 100644 --- a/src/store/modules/user.js +++ b/src/store/modules/user.js @@ -6,7 +6,8 @@ const state = () => ({ name: "", jwt: window.localStorage.getItem("jwt") || "", registered: true, - seed: [] + seed: [], + installedApps: [] }); // Functions to update the state directly @@ -21,6 +22,9 @@ const mutations = { setName(state, name) { state.name = name; }, + setInstalledApps(state, installedApps) { + state.installedApps = installedApps; + }, setSeed(state, seed) { state.seed = seed; } @@ -65,10 +69,11 @@ const actions = { }, async getInfo({ commit }) { - const { name } = await API.get( + const { name, installedApps } = await API.get( `${process.env.VUE_APP_MANAGER_API_URL}/v1/account/info` ); commit("setName", name); + commit("setInstalledApps", installedApps); }, async getSeed({ commit, state, dispatch }, plainTextPassword) { diff --git a/src/views/AppStore.vue b/src/views/AppStore.vue new file mode 100644 index 0000000..c3af520 --- /dev/null +++ b/src/views/AppStore.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/views/AppStoreApp.vue b/src/views/AppStoreApp.vue new file mode 100644 index 0000000..f00b7a4 --- /dev/null +++ b/src/views/AppStoreApp.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/src/views/Apps.vue b/src/views/Apps.vue new file mode 100644 index 0000000..61fd868 --- /dev/null +++ b/src/views/Apps.vue @@ -0,0 +1,80 @@ + + + + +