mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
add AppCatalog view/route
This commit is contained in:
parent
2d262cae3b
commit
8f8b1cc8df
5 changed files with 289 additions and 10 deletions
|
@ -9,6 +9,7 @@
|
|||
"api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?",
|
||||
"app_change_label": "Change Label",
|
||||
"app_change_url": "Change URL",
|
||||
"app_choose_category": "Choose a category",
|
||||
"app_info_access_desc": "Groups / users currently allowed to access this app:",
|
||||
"app_info_changelabel_desc": "Change app label in the portal.",
|
||||
"app_info_default_desc": "Redirect domain root to this application ({domain}).",
|
||||
|
@ -19,14 +20,15 @@
|
|||
"app_install_custom_no_manifest": "No manifest.json file",
|
||||
"app_make_default": "Make default",
|
||||
"app_no_actions": "This application doesn't have any actions",
|
||||
"app_show_categories": "Show categories",
|
||||
"app_state_inprogress": "not yet working",
|
||||
"app_state_inprogress_explanation": "This maintainer of this app declared that this application is not ready yet for production use. BE CAREFUL!",
|
||||
"app_state_notworking": "not working",
|
||||
"app_state_notworking_explanation": "This maintainer of this app declared it as 'not working'. IT WILL BREAK YOUR SYSTEM!",
|
||||
"app_state_low_quality": "low quality",
|
||||
"app_state_low_quality_explanation": "This app may be functional, but may still contain issues, or is not fully integrated with YunoHost, or it does not respect the good practices.",
|
||||
"app_state_high-quality": "high quality",
|
||||
"app_state_high-quality_explanation": "This app is well-integrated with YunoHost. It has been (and is!) peer-reviewed by the YunoHost app team. It can be expected to be safe and maintained on the long-term.",
|
||||
"app_state_lowquality": "low quality",
|
||||
"app_state_lowquality_explanation": "This app may be functional, but may still contain issues, or is not fully integrated with YunoHost, or it does not respect the good practices.",
|
||||
"app_state_highquality": "high quality",
|
||||
"app_state_highquality_explanation": "This app is well-integrated with YunoHost. It has been (and is!) peer-reviewed by the YunoHost app team. It can be expected to be safe and maintained on the long-term.",
|
||||
"app_state_working": "working",
|
||||
"app_state_working_explanation": "The maintainer of this app declared it as 'working'. It means that it should be functional (c.f. application level) but is not necessarily peer-reviewed, it may still contain issues or is not fully integrated with YunoHost.",
|
||||
"applications": "Applications",
|
||||
|
@ -48,6 +50,7 @@
|
|||
"check": "Check",
|
||||
"close": "Close",
|
||||
"closed": "closed",
|
||||
"code": "Code",
|
||||
"common": {
|
||||
"firstname": "First name",
|
||||
"lastname": "Last name"
|
||||
|
@ -273,6 +276,7 @@
|
|||
"previous": "Previous",
|
||||
"protocol": "Protocol",
|
||||
"read_more": "Read more",
|
||||
"readme": "Readme",
|
||||
"rerun_diagnosis": "Rerun diagnosis",
|
||||
"request_adoption": "waiting adoption",
|
||||
"request_adoption_details": "The current maintainer would like to stop maintaining this app. Feel free to propose yourself as the new maintainer!",
|
||||
|
|
|
@ -167,7 +167,7 @@ const routes = [
|
|||
},
|
||||
{
|
||||
name: 'app-info',
|
||||
path: '/apps/:id',
|
||||
path: '/apps/info/:id',
|
||||
component: () => import(/* webpackChunkName: "views/apps" */ '@/views/app/AppInfo'),
|
||||
props: true,
|
||||
meta: {
|
||||
|
@ -177,6 +177,17 @@ const routes = [
|
|||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'app-catalog-home',
|
||||
path: '/apps/catalog',
|
||||
component: () => import(/* webpackChunkName: "views/apps-catalog" */ '@/views/app/AppCatalog'),
|
||||
meta: {
|
||||
breadcrumb: [
|
||||
{ name: 'app-list', trad: 'applications' },
|
||||
{ name: 'app-catalog-home', trad: 'catalog' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
/* ────────────────╮
|
||||
│ SYSTEM UPDATE │
|
||||
|
|
264
app/src/views/app/AppCatalog.vue
Normal file
264
app/src/views/app/AppCatalog.vue
Normal file
|
@ -0,0 +1,264 @@
|
|||
<template>
|
||||
<div class="app-catalog" v-if="apps">
|
||||
<!-- CATEGORY SELECT -->
|
||||
<b-input-group class="mb-3">
|
||||
<b-input-group-prepend is-text>
|
||||
<icon iname="filter" />
|
||||
</b-input-group-prepend>
|
||||
<b-select v-model="category" :options="categories" />
|
||||
<b-input-group-append>
|
||||
<b-button variant="primary" :disabled="category === null" @click="category = null">
|
||||
{{ $t('app_show_categories') }}
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
|
||||
<!-- APP SEARCH -->
|
||||
<b-input-group>
|
||||
<b-input-group-prepend is-text>
|
||||
<icon iname="search" />
|
||||
</b-input-group-prepend>
|
||||
<b-form-input
|
||||
id="search-input" :placeholder="$t('search_for_apps')"
|
||||
v-model="search" @input="onSearchInput"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-select v-model="quality" :options="qualityOptions" />
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
|
||||
<!-- CATEGORIES CARDS -->
|
||||
<b-card-group v-if="category === null" deck>
|
||||
<b-card
|
||||
v-for="cat in categories.slice(1)" :key="cat.value"
|
||||
class="category-card" no-body
|
||||
>
|
||||
<b-button variant="outline-dark" @click="category = cat.value">
|
||||
<b-card-title>
|
||||
<icon :iname="cat.icon" /> {{ cat.text }}
|
||||
</b-card-title>
|
||||
<b-card-text>{{ cat.description }}</b-card-text>
|
||||
</b-button>
|
||||
</b-card>
|
||||
</b-card-group>
|
||||
|
||||
<!-- APPS CARDS -->
|
||||
<b-card-group v-else deck>
|
||||
<b-card no-body v-for="app in filteredApps" :key="app.id">
|
||||
<b-card-body class="d-flex flex-column">
|
||||
<b-card-title class="d-flex">
|
||||
{{ app.manifest.name }}
|
||||
<small v-if="app.state !== 'working'" class="ml-2">
|
||||
<b-badge
|
||||
:variant="(app.color === 'danger' && app.state === 'lowquality') ? 'warning' : app.color"
|
||||
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
|
||||
>
|
||||
{{ $t('app_state_' + app.state) }}
|
||||
</b-badge>
|
||||
</small>
|
||||
</b-card-title>
|
||||
|
||||
<b-card-text>{{ app.manifest.description }}</b-card-text>
|
||||
|
||||
<b-card-text v-if="app.maintained === 'orphaned'" class="align-self-end mt-auto">
|
||||
<span v-if="app.maintained === 'orphaned'" class="alert-warning p-2" v-b-popover.hover.top="$t('orphaned_details')">
|
||||
<icon iname="warning" /> {{ $t(app.maintained) }}
|
||||
</span>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
|
||||
<!-- APP BUTTONS -->
|
||||
<b-button-group>
|
||||
<b-button :href="app.git.url" :variant="'outline-' + app.color" target="_blank">
|
||||
<icon iname="code" /> {{ $t('code') }}
|
||||
</b-button>
|
||||
|
||||
<b-button :href="app.git.url + '/blob/master/README.md'" :variant="'outline-' + app.color" target="_blank">
|
||||
<icon iname="book" /> {{ $t('readme') }}
|
||||
</b-button>
|
||||
|
||||
<b-button v-if="app.isInstallable" :variant="app.color">
|
||||
<icon iname="plus" /> {{ $t('install') }} <icon v-if="app.color === 'danger'" class="ml-1" iname="warning" />
|
||||
</b-button>
|
||||
<b-button v-else :variant="app.color" disabled>
|
||||
{{ $t('installed') }}
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
</b-card>
|
||||
</b-card-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from '@/helpers/api'
|
||||
|
||||
export default {
|
||||
name: 'AppCatalog',
|
||||
|
||||
data () {
|
||||
return {
|
||||
category: null,
|
||||
search: '',
|
||||
quality: 'all',
|
||||
searchAppsKeys: ['id', 'state', 'manifest.name'],
|
||||
qualityOptions: [
|
||||
{ value: 'isHighQuality', text: this.$i18n.t('only_highquality_apps') },
|
||||
{ value: 'isDecentQuality', text: this.$i18n.t('only_decent_quality_apps') },
|
||||
{ value: 'isWorking', text: this.$i18n.t('only_working_apps') },
|
||||
{ value: 'all', text: this.$i18n.t('all_apps') }
|
||||
],
|
||||
// computed/filled from api data
|
||||
categories: [
|
||||
{ text: this.$i18n.t('app_choose_category'), value: null },
|
||||
{ text: this.$i18n.t('all_apps'), value: 'all', icon: 'search' }
|
||||
],
|
||||
apps: undefined
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredApps () {
|
||||
const search = this.search.toLowerCase()
|
||||
function findValue (key, obj) {
|
||||
if (!key.includes('.')) return obj[key]
|
||||
// deep search in nested keys
|
||||
return key.split('.').reduce((obj, key) => obj[key], obj)
|
||||
}
|
||||
|
||||
if (this.quality === 'all' && this.category === 'all' && search === '') {
|
||||
return this.apps
|
||||
}
|
||||
return this.apps.filter(app => {
|
||||
if (this.quality !== 'all' && !app[this.quality]) return false
|
||||
if (this.category !== 'all' && app.category !== this.category) return false
|
||||
if (search === '') return true
|
||||
const searchMatchSome = this.searchAppsKeys.some(searchKey => {
|
||||
return findValue(searchKey, app).toLowerCase().includes(search)
|
||||
})
|
||||
if (searchMatchSome) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchData () {
|
||||
api.get('appscatalog?full&with_categories').then((data) => {
|
||||
// APPS
|
||||
const apps = []
|
||||
for (const key in data.apps) {
|
||||
const app = data.apps[key]
|
||||
if (app.state === 'notworking') continue
|
||||
|
||||
Object.assign(app, this.getQuality(app))
|
||||
app.isInstallable = !app.installed || app.manifest.multi_instance
|
||||
if (app.maintained !== 'request_adoption') {
|
||||
app.maintained = app.maintained ? 'maintained' : 'orphaned'
|
||||
}
|
||||
app.color = this.getColor(app)
|
||||
apps.push(app)
|
||||
}
|
||||
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
|
||||
|
||||
// CATEGORIES
|
||||
data.categories.forEach(({ title, id, icon, subTags, description }) => {
|
||||
this.categories.push({ text: title, value: id, icon, subTags, description })
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
getQuality (app) {
|
||||
const filters = {
|
||||
isHighQuality: false,
|
||||
isDecentQuality: false,
|
||||
isWorking: false,
|
||||
state: 'inprogress'
|
||||
}
|
||||
if (app.state === 'inprogress') return filters
|
||||
if (app.state === 'working' && app.level > 0) {
|
||||
filters.state = 'working'
|
||||
filters.isWorking = true
|
||||
}
|
||||
if (app.level <= 4) {
|
||||
filters.state = 'lowquality'
|
||||
return filters
|
||||
} else {
|
||||
filters.isDecentQuality = true
|
||||
}
|
||||
if (app.high_quality && app.level > 7) {
|
||||
filters.state = 'highquality'
|
||||
filters.isHighQuality = true
|
||||
}
|
||||
return filters
|
||||
},
|
||||
|
||||
getColor (app) {
|
||||
if (app.isHighQuality) return 'best'
|
||||
if (app.isDecentQuality) return 'success'
|
||||
if (app.isWorking) return 'warning'
|
||||
return 'danger'
|
||||
},
|
||||
|
||||
onSearchInput () {
|
||||
// allow search without selecting a category
|
||||
if (this.category === null) {
|
||||
this.category = 'all'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#search-input {
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
select {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-top: 2rem;
|
||||
flex-basis: 100%;
|
||||
min-height: 12rem;
|
||||
@include media-breakpoint-up(md) {
|
||||
flex-basis: 50%;
|
||||
max-width: calc(50% - 30px);
|
||||
}
|
||||
@include media-breakpoint-up(lg) {
|
||||
flex-basis: 33%;
|
||||
max-width: calc(33.3% - 30px);
|
||||
}
|
||||
|
||||
}
|
||||
.category-card {
|
||||
min-height: 10rem;
|
||||
border: 0;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
.btn {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom: 0;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.btn:first-of-type {
|
||||
border-left: 0
|
||||
}
|
||||
.btn:last-of-type {
|
||||
border-right: 0
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -60,16 +60,16 @@
|
|||
:label-cols-lg="app.supports_change_url ? 4 : 0"
|
||||
>
|
||||
<b-input-group v-if="app.supports_change_url">
|
||||
<b-input-group-prepend>
|
||||
<b-input-group-text>https://</b-input-group-text>
|
||||
<b-input-group-prepend is-text>
|
||||
https://
|
||||
</b-input-group-prepend>
|
||||
|
||||
<b-input-group-prepend class="flex-grow-1">
|
||||
<b-select v-model="form.url.domain" :options="domains" />
|
||||
</b-input-group-prepend>
|
||||
|
||||
<b-input-group-prepend>
|
||||
<b-input-group-text>/</b-input-group-text>
|
||||
<b-input-group-prepend is-text>
|
||||
/
|
||||
</b-input-group-prepend>
|
||||
|
||||
<b-input id="input-url" v-model="form.url.path" class="flex-grow-3" />
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
/>
|
||||
</b-input-group>
|
||||
<div class="buttons">
|
||||
<b-button variant="success" :to="{ name: 'app-catalog' }">
|
||||
<b-button variant="success" :to="{ name: 'app-catalog-home' }">
|
||||
<icon iname="plus" /> {{ $t('install') }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Reference in a new issue