Mayank Chhabra
4 years ago
committed by
GitHub
16 changed files with 1059 additions and 51 deletions
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,93 @@ |
|||
<template> |
|||
<div class="pb-3 mb-2 installed-app d-flex flex-column align-items-center"> |
|||
<a |
|||
class="d-block mb-3 installed-app-link" |
|||
:href="url" |
|||
target="_blank" |
|||
:class="isUninstalling ? 'fade-in-out' : ''" |
|||
:disabled="isUninstalling" |
|||
><img |
|||
class="installed-app-icon app-icon" |
|||
:alt="name" |
|||
:src="`https://getumbrel.github.io/umbrel-apps-gallery/${id}/icon.svg`" |
|||
/></a> |
|||
<span class="text-center text-truncate mb-1">{{ |
|||
isUninstalling ? "Uninstalling..." : name |
|||
}}</span> |
|||
<b-button |
|||
class="uninstall-btn" |
|||
v-if="showUninstallButton && !isUninstalling" |
|||
variant="outline-danger" |
|||
size="sm" |
|||
@click="uninstall(name, id)" |
|||
><small><b-icon icon="trash"></b-icon> Uninstall</small></b-button |
|||
> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from "vuex"; |
|||
|
|||
export default { |
|||
props: { |
|||
id: String, |
|||
name: String, |
|||
hiddenService: String, |
|||
port: Number, |
|||
path: String, |
|||
showUninstallButton: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
isUninstalling: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
}, |
|||
data() { |
|||
return {}; |
|||
}, |
|||
computed: { |
|||
...mapState({ |
|||
installedApps: (state) => state.apps.installed, |
|||
}), |
|||
url: function () { |
|||
if (window.location.origin.indexOf(".onion") > 0) { |
|||
return `http://${this.hiddenService}${this.path}`; |
|||
} else { |
|||
return `http://${window.location.hostname}:${this.port}${this.path}`; |
|||
} |
|||
}, |
|||
}, |
|||
methods: { |
|||
uninstall(name, appId) { |
|||
if ( |
|||
!window.confirm( |
|||
`Are you sure you want to uninstall ${name}? This is will also delete all of its data.` |
|||
) |
|||
) { |
|||
return; |
|||
} |
|||
this.$store.dispatch("apps/uninstall", appId); |
|||
}, |
|||
}, |
|||
components: {}, |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
.installed-app { |
|||
width: 200px; |
|||
position: relative; |
|||
.installed-app-link { |
|||
text-decoration: none; |
|||
.installed-app-icon { |
|||
box-shadow: 0px 10px 30px rgba(209, 213, 223, 0.5); |
|||
} |
|||
} |
|||
.uninstall-btn { |
|||
position: absolute; |
|||
bottom: 0; |
|||
} |
|||
} |
|||
</style> |
@ -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 |
|||
}; |
@ -0,0 +1,127 @@ |
|||
<template> |
|||
<div class="p-sm-2"> |
|||
<div class="mt-3 mb-4"> |
|||
<div class=""> |
|||
<h1>app store</h1> |
|||
<p class="text-muted"> |
|||
Add super powers to your Umbrel with amazing self-hosted applications |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<div class="card-columns"> |
|||
<card-widget |
|||
v-for="categorizedApps in categorizedAppStore" |
|||
:key="categorizedApps[0].category" |
|||
class="pb-2 card-app-list" |
|||
:header="categorizedApps[0].category" |
|||
> |
|||
<router-link |
|||
:to="`/app-store/${app.id}`" |
|||
v-for="app in categorizedApps" |
|||
:key="app.id" |
|||
class="app-list-app d-flex justify-content-between align-items-center px-3 px-lg-4 py-3" |
|||
> |
|||
<div class="d-flex"> |
|||
<div class="d-block"> |
|||
<img |
|||
class="app-icon mr-2 mr-lg-3" |
|||
:src="`https://getumbrel.github.io/umbrel-apps-gallery/${app.id}/icon.svg`" |
|||
/> |
|||
</div> |
|||
<div class="d-flex justify-content-center flex-column"> |
|||
<h3 class="app-name font-weight-bolder text-dark mb-1"> |
|||
{{ app.name }} |
|||
</h3> |
|||
<p class="text-muted mb-0"> |
|||
{{ app.tagline }} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<svg |
|||
width="14" |
|||
height="25" |
|||
viewBox="0 0 14 25" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
class="ml-4 icon-arrow" |
|||
> |
|||
<path |
|||
d="M0.512563 3.0484C-0.170855 2.35104 -0.170855 1.22039 0.512563 0.523023C1.19598 -0.174341 2.30402 -0.174341 2.98744 0.523023L13.4874 11.2373C14.1499 11.9133 14.1731 13.0019 13.54 13.7066L3.91502 24.4209C3.26193 25.1479 2.15494 25.197 1.44248 24.5306C0.730023 23.8642 0.681893 22.7346 1.33498 22.0076L9.82776 12.5537L0.512563 3.0484Z" |
|||
fill="#C3C6D1" |
|||
/> |
|||
</svg> |
|||
<!-- Preload gallery images --> |
|||
<div class="d-none"> |
|||
<img |
|||
v-for="image in app.gallery" |
|||
class="d-none" |
|||
:key="app.id + image" |
|||
:src="`https://getumbrel.github.io/umbrel-apps-gallery/${app.id}/${image}`" |
|||
/> |
|||
</div> |
|||
</router-link> |
|||
</card-widget> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from "vuex"; |
|||
|
|||
import CardWidget from "@/components/CardWidget"; |
|||
|
|||
export default { |
|||
data() { |
|||
return {}; |
|||
}, |
|||
computed: { |
|||
...mapState({ |
|||
store: (state) => state.apps.store, |
|||
}), |
|||
categorizedAppStore: function () { |
|||
let group = this.store.reduce((r, a) => { |
|||
r[a.category] = [...(r[a.category] || []), a]; |
|||
return r; |
|||
}, {}); |
|||
return group; |
|||
}, |
|||
}, |
|||
created() { |
|||
this.$store.dispatch("apps/getAppStore"); |
|||
}, |
|||
components: { |
|||
CardWidget, |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
.app-list-app { |
|||
border-bottom: solid 1px #edf0f7; |
|||
&:first-child { |
|||
padding-top: 0 !important; |
|||
} |
|||
&:last-child { |
|||
border-bottom: none; |
|||
} |
|||
.icon-arrow { |
|||
will-change: tranform; |
|||
transform: translate3d(0, 0, 0); |
|||
transition: 0.8s cubic-bezier(0.2, 0.8, 0.2, 1); |
|||
} |
|||
&:hover { |
|||
.icon-arrow { |
|||
transform: translate3d(6px, 0, 0); |
|||
} |
|||
} |
|||
} |
|||
.card-app-list { |
|||
// https://stackoverflow.com/a/34115300 |
|||
box-shadow: 0 1px 10px rgba(209, 213, 223, 0.5), |
|||
0 1px 2px rgba(209, 213, 223, 0) !important; |
|||
margin-top: 16px !important; |
|||
margin-bottom: 16px !important; |
|||
display: inline-block; |
|||
width: 100%; |
|||
} |
|||
</style> |
@ -0,0 +1,268 @@ |
|||
<template> |
|||
<div class="p-sm-2"> |
|||
<div class="mt-3 mb-1 mb-sm-3 pb-lg-2"> |
|||
<router-link |
|||
to="/app-store" |
|||
class="card-link text-muted d-flex align-items-center mb-4" |
|||
><svg |
|||
width="7" |
|||
height="13" |
|||
viewBox="0 0 7 13" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
class="mr-1" |
|||
> |
|||
<path |
|||
d="M6.74372 11.4153C7.08543 11.7779 7.08543 12.3659 6.74372 12.7285C6.40201 13.0911 5.84799 13.0911 5.50628 12.7285L0.256283 7.15709C-0.0749738 6.80555 -0.0865638 6.23951 0.229991 5.87303L5.04249 0.301606C5.36903 -0.0764332 5.92253 -0.101971 6.27876 0.244565C6.63499 0.591101 6.65905 1.17848 6.33251 1.55652L2.08612 6.47256L6.74372 11.4153Z" |
|||
fill="#C3C6D1" |
|||
/> |
|||
</svg> |
|||
Back</router-link |
|||
> |
|||
<div |
|||
class="d-flex flex-column flex-sm-row justify-content-between align-items-center" |
|||
> |
|||
<div class="d-flex w-xs-100 justify-content-start pr-2"> |
|||
<div class="d-block"> |
|||
<img |
|||
class="app-icon app-icon-lg mr-2 mr-sm-3 align-self-top" |
|||
:src="`https://getumbrel.github.io/umbrel-apps-gallery/${app.id}/icon.svg`" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<h3 class="d-block font-weight-bold mb-1"> |
|||
{{ app.name }} |
|||
</h3> |
|||
<p class="text-muted">{{ app.tagline }}</p> |
|||
<p> |
|||
<small>{{ app.developer }}</small> |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<div |
|||
class="w-xs-100 d-flex flex-column align-items-sm-center" |
|||
v-if="isInstalled && !isUninstalling" |
|||
> |
|||
<b-button |
|||
variant="primary" |
|||
size="lg" |
|||
class="px-4" |
|||
:href="url" |
|||
target="_blank" |
|||
>Open</b-button |
|||
> |
|||
<div class="mt-2 text-center" v-if="app.defaultPassword"> |
|||
<small class="">The default password of this app is</small> |
|||
<input-copy |
|||
size="sm" |
|||
:value="app.defaultPassword" |
|||
class="mt-1" |
|||
></input-copy> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex flex-column align-items-sm-center w-xs-100" v-else> |
|||
<b-button |
|||
v-if="isInstalling" |
|||
variant="success" |
|||
size="lg" |
|||
class="px-4 fade-in-out" |
|||
disabled |
|||
>Installing...</b-button |
|||
> |
|||
<b-button |
|||
v-else-if="isUninstalling" |
|||
variant="warning" |
|||
size="lg" |
|||
class="px-4 fade-in-out" |
|||
disabled |
|||
>Uninstalling...</b-button |
|||
> |
|||
<b-button |
|||
v-else |
|||
variant="success" |
|||
size="lg" |
|||
class="px-4" |
|||
@click="installApp" |
|||
>Install</b-button |
|||
> |
|||
<small |
|||
:style="{ opacity: isInstalling || isUninstalling ? 1 : 0 }" |
|||
class="mt-1 d-block text-muted text-center" |
|||
>This may take a few minutes</small |
|||
> |
|||
<div |
|||
class="mt-2 text-center" |
|||
v-if="isInstalling && app.defaultPassword" |
|||
> |
|||
<small class="">The default password of this app is</small> |
|||
<input-copy |
|||
size="sm" |
|||
:value="app.defaultPassword" |
|||
class="mt-1" |
|||
></input-copy> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="app-gallery pt-3 pb-4 mb-2 mb-sm-3"> |
|||
<img |
|||
v-for="image in app.gallery" |
|||
class="app-gallery-screen mr-3" |
|||
:key="image" |
|||
:src="`https://getumbrel.github.io/umbrel-apps-gallery/${app.id}/${image}`" |
|||
/> |
|||
<div class="d-block" style="padding: 1px"></div> |
|||
</div> |
|||
<b-row> |
|||
<b-col col cols="12" lg="6" xl="8"> |
|||
<card-widget header="About this app"> |
|||
<div class="px-3 px-lg-4 pb-4"> |
|||
<p class="text-newlines">{{ app.description }}</p> |
|||
</div> |
|||
</card-widget> |
|||
</b-col> |
|||
<b-col col cols="12" lg="6" xl="4"> |
|||
<card-widget header="Information"> |
|||
<div class="px-3 px-lg-4 pb-4"> |
|||
<div class="d-flex justify-content-between mb-3"> |
|||
<span>Version</span> |
|||
<span>{{ app.version }}</span> |
|||
</div> |
|||
<div class="d-flex justify-content-between mb-3"> |
|||
<span>Source Code</span> |
|||
<a :href="app.repo" target="_blank">Open Source</a> |
|||
</div> |
|||
<div class="d-flex justify-content-between mb-3"> |
|||
<span>Developer</span> |
|||
<a :href="app.website" target="_blank">{{ app.developer }}</a> |
|||
</div> |
|||
<div class="d-flex justify-content-between mb-3"> |
|||
<span>Compatibility</span> |
|||
<span>Compatible</span> |
|||
</div> |
|||
<div class="mb-4"> |
|||
<span class="d-block mb-3">Requires</span> |
|||
<div |
|||
class="d-flex align-items-center justify-content-between mb-3" |
|||
v-for="dependency in app.dependencies" |
|||
:key="dependency" |
|||
> |
|||
<div class="d-flex align-items-center"> |
|||
<img |
|||
:src=" |
|||
require(`@/assets/app-store/dependencies/${dependency}.svg`) |
|||
" |
|||
style="width: 50px; height: 50px" |
|||
class="mr-2" |
|||
/> |
|||
<span class="text-muted my-0">{{ |
|||
formatDependency(dependency) |
|||
}}</span> |
|||
</div> |
|||
<div> |
|||
<svg |
|||
width="30" |
|||
height="30" |
|||
viewBox="0 0 30 30" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
d="M19.3035 10.7643C19.5718 10.4486 20.0451 10.4103 20.3607 10.6785C20.6763 10.9468 20.7147 11.4201 20.4464 11.7357L14.0714 19.2357C13.799 19.5563 13.3162 19.5901 13.0017 19.3105L9.62671 16.3105C9.31712 16.0354 9.28924 15.5613 9.56443 15.2517C9.83962 14.9421 10.3137 14.9142 10.6233 15.1894L13.4251 17.68L19.3035 10.7643Z" |
|||
fill="#00CD98" |
|||
/> |
|||
</svg> |
|||
<small class="text-success">Installed</small> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<b-link |
|||
:href="app.support" |
|||
target="_blank" |
|||
size="sm" |
|||
class="mb-2 py-1" |
|||
block |
|||
>Get support</b-link |
|||
> |
|||
</div> |
|||
</card-widget> |
|||
</b-col> |
|||
</b-row> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from "vuex"; |
|||
|
|||
import CardWidget from "@/components/CardWidget"; |
|||
import InputCopy from "@/components/Utility/InputCopy"; |
|||
|
|||
export default { |
|||
data() { |
|||
return {}; |
|||
}, |
|||
computed: { |
|||
...mapState({ |
|||
installedApps: (state) => state.apps.installed, |
|||
appStore: (state) => state.apps.store, |
|||
installing: (state) => state.apps.installing, |
|||
uninstalling: (state) => state.apps.uninstalling, |
|||
}), |
|||
app: function () { |
|||
return this.appStore.find((app) => app.id === this.$route.params.id); |
|||
}, |
|||
isInstalled: function () { |
|||
const installedAppIndex = this.installedApps.findIndex( |
|||
(app) => app.id === this.app.id |
|||
); |
|||
return installedAppIndex !== -1; |
|||
}, |
|||
isInstalling: function () { |
|||
const index = this.installing.findIndex((appId) => appId === this.app.id); |
|||
return index !== -1; |
|||
}, |
|||
isUninstalling: function () { |
|||
const index = this.uninstalling.findIndex( |
|||
(appId) => appId === this.app.id |
|||
); |
|||
return index !== -1; |
|||
}, |
|||
url: function () { |
|||
if (window.location.origin.indexOf(".onion") > 0) { |
|||
const installedApp = this.installedApps.find( |
|||
(app) => app.id === this.app.id |
|||
); |
|||
return `http://${installedApp.hiddenService}${this.app.path}`; |
|||
} else { |
|||
return `http://${window.location.hostname}:${this.app.port}${this.app.path}`; |
|||
} |
|||
}, |
|||
}, |
|||
methods: { |
|||
formatDependency(dependency) { |
|||
let name; |
|||
if (dependency === "bitcoind") { |
|||
name = "Bitcoin Core"; |
|||
} else if (dependency === "lnd") { |
|||
name = "LND"; |
|||
} else if (dependency === "electrum") { |
|||
name = "Electrum Server"; |
|||
} |
|||
return name; |
|||
}, |
|||
installApp() { |
|||
this.$store.dispatch("apps/install", this.app.id); |
|||
}, |
|||
}, |
|||
async created() { |
|||
await this.$store.dispatch("apps/getAppStore"); |
|||
}, |
|||
components: { |
|||
CardWidget, |
|||
InputCopy, |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
</style> |
@ -0,0 +1,80 @@ |
|||
<template> |
|||
<div class="p-sm-2"> |
|||
<div v-if="installedApps.length"> |
|||
<div class="my-3 pb-3"> |
|||
<div class="d-flex justify-content-between align-items-center"> |
|||
<h1>apps</h1> |
|||
<div> |
|||
<b-button variant="outline-primary" size="sm" @click="toggleEdit">{{ |
|||
isEditing ? "Done" : "Edit" |
|||
}}</b-button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex flex-wrap justify-content-start apps-container"> |
|||
<installed-app |
|||
v-for="app in installedApps" |
|||
:key="app.id" |
|||
:id="app.id" |
|||
:name="app.name" |
|||
:port="app.port" |
|||
:path="app.path" |
|||
:hiddenService="app.hiddenService" |
|||
:showUninstallButton="isEditing" |
|||
:isUninstalling="uninstallingApps.includes(app.id)" |
|||
> |
|||
</installed-app> |
|||
</div> |
|||
</div> |
|||
<div v-else> |
|||
<div class="my-3 pb-3"> |
|||
<h1>apps</h1> |
|||
<div |
|||
class="d-flex flex-column justify-content-center align-items-center py-5 mb-lg-5" |
|||
> |
|||
<p class="text-muted mb-2">You don't have any apps installed yet</p> |
|||
<b-button variant="success" class="px-4" :to="'app-store'" |
|||
>Go to App Store</b-button |
|||
> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { mapState } from "vuex"; |
|||
|
|||
import InstalledApp from "@/components/InstalledApp"; |
|||
|
|||
export default { |
|||
data() { |
|||
return { |
|||
isEditing: false, |
|||
}; |
|||
}, |
|||
computed: { |
|||
...mapState({ |
|||
installedApps: (state) => state.apps.installed, |
|||
uninstallingApps: (state) => state.apps.uninstalling, |
|||
}), |
|||
}, |
|||
created() { |
|||
this.$store.dispatch("apps/getInstalledApps"); |
|||
}, |
|||
methods: { |
|||
toggleEdit() { |
|||
this.isEditing = !this.isEditing; |
|||
}, |
|||
}, |
|||
components: { |
|||
InstalledApp, |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
.apps-container { |
|||
column-gap: 2rem; |
|||
} |
|||
</style> |
Loading…
Reference in new issue