mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
refactor: rework async AppCatalog
This commit is contained in:
parent
7f867e38e9
commit
38859f5afc
4 changed files with 259 additions and 191 deletions
54
app/src/components/globals/skeletons/AppCatalogSkeleton.vue
Normal file
54
app/src/components/globals/skeletons/AppCatalogSkeleton.vue
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BInputGroup class="w-100 mb-4">
|
||||||
|
<BInputGroupText>
|
||||||
|
<YIcon iname="search" />
|
||||||
|
</BInputGroupText>
|
||||||
|
|
||||||
|
<BFormInput :disabled="true" />
|
||||||
|
</BInputGroup>
|
||||||
|
|
||||||
|
<BCardGroup deck>
|
||||||
|
<BCard v-for="i in 15" :key="i" no-body>
|
||||||
|
<div class="d-flex w-100 mt-auto">
|
||||||
|
<BSkeleton width="30px" height="30px" class="me-2 ms-auto" />
|
||||||
|
<BSkeleton
|
||||||
|
:width="randint(30, 70) + '%'"
|
||||||
|
height="30px"
|
||||||
|
class="me-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<BSkeleton
|
||||||
|
v-if="randint(0, 1)"
|
||||||
|
:width="randint(30, 85) + '%'"
|
||||||
|
height="24px"
|
||||||
|
class="mx-auto"
|
||||||
|
/>
|
||||||
|
<BSkeleton
|
||||||
|
:width="randint(30, 85) + '%'"
|
||||||
|
height="24px"
|
||||||
|
class="mx-auto mb-auto"
|
||||||
|
/>
|
||||||
|
</BCard>
|
||||||
|
</BCardGroup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card {
|
||||||
|
min-height: 10rem;
|
||||||
|
flex-basis: 100% !important;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md) {
|
||||||
|
flex-basis: 50% !important;
|
||||||
|
max-width: calc(50% - 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg) {
|
||||||
|
flex-basis: 33% !important;
|
||||||
|
max-width: calc(33.3% - 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
73
app/src/types/core/api.ts
Normal file
73
app/src/types/core/api.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import type { Obj, Translation } from '@/types/commons'
|
||||||
|
|
||||||
|
// APPS
|
||||||
|
|
||||||
|
export type AppLevel = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||||
|
export type AppState = 'working' | 'inprogress' | 'thirdparty'
|
||||||
|
export type AppUpstream = {
|
||||||
|
license?: string | null
|
||||||
|
website?: string | null
|
||||||
|
demo?: string | null
|
||||||
|
admindoc?: string | null
|
||||||
|
userdoc?: string | null
|
||||||
|
code?: string | null
|
||||||
|
cpe?: string | null
|
||||||
|
}
|
||||||
|
export type AppMinManifest = {
|
||||||
|
packaging_format: 1 | 2
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: Translation
|
||||||
|
version: string
|
||||||
|
maintainers: string[]
|
||||||
|
integration: {
|
||||||
|
architectures: string | string[]
|
||||||
|
ldap: 'not_relevant' | boolean
|
||||||
|
sso: 'not_relevant' | boolean
|
||||||
|
multi_instance: boolean
|
||||||
|
disk: string
|
||||||
|
ram: { build: string; runtime: string }
|
||||||
|
yunohost: string
|
||||||
|
}
|
||||||
|
upstream: AppUpstream
|
||||||
|
}
|
||||||
|
type CatalogApp = {
|
||||||
|
added_in_catalog: number
|
||||||
|
antifeatures: string[]
|
||||||
|
category: string
|
||||||
|
featured: boolean
|
||||||
|
git: {
|
||||||
|
branch: string
|
||||||
|
revision: string
|
||||||
|
url: string | null
|
||||||
|
} | null
|
||||||
|
high_quality: boolean
|
||||||
|
id: string
|
||||||
|
lastUpdate: number
|
||||||
|
level: AppLevel
|
||||||
|
logo_hash: string | null
|
||||||
|
maintained: boolean
|
||||||
|
manifest: AppMinManifest
|
||||||
|
potential_alternative_to: string[]
|
||||||
|
state: AppState
|
||||||
|
subtags: string[]
|
||||||
|
repository: string
|
||||||
|
installed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Catalog = {
|
||||||
|
apps: Obj<CatalogApp>
|
||||||
|
categories: {
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
id: string
|
||||||
|
subtags: { id: string; title: string }[]
|
||||||
|
title: string
|
||||||
|
}[]
|
||||||
|
antifeatures: {
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}[]
|
||||||
|
}
|
|
@ -1,28 +1,29 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
import CardDeckFeed from '@/components/CardDeckFeed.vue'
|
import CardDeckFeed from '@/components/CardDeckFeed.vue'
|
||||||
import { useForm } from '@/composables/form'
|
import { useForm, useFormQuery } from '@/composables/form'
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
import { useInitialQueries } from '@/composables/useInitialQueries'
|
|
||||||
import { useSearch } from '@/composables/useSearch'
|
import { useSearch } from '@/composables/useSearch'
|
||||||
import { randint } from '@/helpers/commons'
|
import { pick } from '@/helpers/commons'
|
||||||
import { appRepoUrl, required } from '@/helpers/validators'
|
import { appRepoUrl, required } from '@/helpers/validators'
|
||||||
import type { Obj } from '@/types/commons'
|
import type { Catalog } from '@/types/core/api'
|
||||||
import type { FieldProps, FormFieldDict } from '@/types/form'
|
import type { FieldProps, FormFieldDict } from '@/types/form'
|
||||||
|
import { formatAppQuality } from './appData'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
search?: string
|
search?: string
|
||||||
quality?: string
|
quality?: 'all' | 'highQuality' | 'decentQuality' | 'working'
|
||||||
category?: string | null
|
category?: 'all' | string | null
|
||||||
subtag?: string
|
subtag?: 'all' | 'others' | string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
search: '',
|
search: '',
|
||||||
quality: 'decent_quality',
|
quality: 'decentQuality',
|
||||||
category: null,
|
category: null,
|
||||||
subtag: 'all',
|
subtag: 'all',
|
||||||
},
|
},
|
||||||
|
@ -30,17 +31,59 @@ const props = withDefaults(
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
|
||||||
const modalConfirm = useAutoModal()
|
const modalConfirm = useAutoModal()
|
||||||
const { loading } = useInitialQueries(
|
|
||||||
[{ uri: 'apps/catalog?full&with_categories&with_antifeatures' }],
|
|
||||||
{ onQueriesResponse },
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectedApp = ref()
|
const [apps, categories] = await api
|
||||||
const antifeatures = ref()
|
.get<Catalog>({
|
||||||
|
uri: 'apps/catalog?full&with_categories&with_antifeatures',
|
||||||
|
initial: true,
|
||||||
|
})
|
||||||
|
.then((catalog) => {
|
||||||
|
const apps = Object.values(catalog.apps)
|
||||||
|
.map((app) => {
|
||||||
|
const working = app.state === 'working'
|
||||||
|
return {
|
||||||
|
...pick(app, ['id', 'category', 'subtags', 'maintained']),
|
||||||
|
...pick(app.manifest, ['name', 'description']),
|
||||||
|
quality: formatAppQuality({ level: app.level, state: app.state }),
|
||||||
|
working,
|
||||||
|
decentQuality: working && app.level > 4,
|
||||||
|
highQuality: working && app.level >= 8,
|
||||||
|
logoHash: app.logo_hash,
|
||||||
|
searchValues: [
|
||||||
|
app.id,
|
||||||
|
app.state,
|
||||||
|
app.manifest.name,
|
||||||
|
app.manifest.description,
|
||||||
|
app.potential_alternative_to.join(' '),
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||||
|
|
||||||
|
// CATEGORIES
|
||||||
|
const categories = [
|
||||||
|
{ text: t('app_choose_category'), value: null, subtags: [] },
|
||||||
|
{ text: t('all_apps'), value: 'all', icon: 'search', subtags: [] },
|
||||||
|
...catalog.categories.map(({ title, id, ...rest }) => {
|
||||||
|
return { text: title, value: id, ...rest }
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
return [apps, categories] as const
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
quality,
|
||||||
|
category,
|
||||||
|
subtag,
|
||||||
|
search: externalSearch,
|
||||||
|
} = useFormQuery(props, () => {
|
||||||
|
if (props.category === null) return { ...props, category: 'all' }
|
||||||
|
})
|
||||||
|
|
||||||
const apps = ref<Obj[] | undefined>()
|
|
||||||
const [search, filteredApps] = useSearch(
|
const [search, filteredApps] = useSearch(
|
||||||
apps,
|
apps,
|
||||||
(s, app) => {
|
(s, app) => {
|
||||||
|
@ -58,11 +101,11 @@ const [search, filteredApps] = useSearch(
|
||||||
if (!appMatchSubtag) return false
|
if (!appMatchSubtag) return false
|
||||||
}
|
}
|
||||||
if (s === '') return true
|
if (s === '') return true
|
||||||
if (app.searchValues.includes(search)) return true
|
if (app.searchValues.includes(s)) return true
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
externalSearch: () => props.search,
|
externalSearch,
|
||||||
filterIfNoSearch: true,
|
filterIfNoSearch: true,
|
||||||
filterAllFn(s) {
|
filterAllFn(s) {
|
||||||
if (props.category === null) return false
|
if (props.category === null) return false
|
||||||
|
@ -79,7 +122,7 @@ const fields = {
|
||||||
component: 'InputItem',
|
component: 'InputItem',
|
||||||
label: t('url'),
|
label: t('url'),
|
||||||
rules: { required, appRepoUrl },
|
rules: { required, appRepoUrl },
|
||||||
props: {
|
cProps: {
|
||||||
id: 'custom-install',
|
id: 'custom-install',
|
||||||
placeholder: 'https://some.git.forge.tld/USER/REPOSITORY',
|
placeholder: 'https://some.git.forge.tld/USER/REPOSITORY',
|
||||||
},
|
},
|
||||||
|
@ -88,25 +131,20 @@ const fields = {
|
||||||
const { v, onSubmit } = useForm(form, fields)
|
const { v, onSubmit } = useForm(form, fields)
|
||||||
|
|
||||||
const qualityOptions = [
|
const qualityOptions = [
|
||||||
{ value: 'high_quality', text: t('only_highquality_apps') },
|
{ value: 'highQuality', text: t('only_highquality_apps') },
|
||||||
{
|
{
|
||||||
value: 'decent_quality',
|
value: 'decentQuality',
|
||||||
text: t('only_decent_quality_apps'),
|
text: t('only_decent_quality_apps'),
|
||||||
},
|
},
|
||||||
{ value: 'working', text: t('only_working_apps') },
|
{ value: 'working', text: t('only_working_apps') },
|
||||||
{ value: 'all', text: t('all_apps') },
|
{ value: 'all', text: t('all_apps') },
|
||||||
]
|
]
|
||||||
const categories = reactive([
|
|
||||||
{ text: t('app_choose_category'), value: null },
|
|
||||||
{ text: t('all_apps'), value: 'all', icon: 'search' },
|
|
||||||
// The rest is filled from api data
|
|
||||||
])
|
|
||||||
|
|
||||||
const subtags = computed(() => {
|
const subtags = computed(() => {
|
||||||
// build an options array for subtags v-model/options
|
// build an options array for subtags v-model/options
|
||||||
if (props.category && categories.length > 2) {
|
if (props.category && categories.length > 2) {
|
||||||
const category = categories.find((cat) => cat.value === props.category)
|
const category = categories.find((cat) => cat.value === props.category)!
|
||||||
if (category.subtags) {
|
if (category.subtags.length) {
|
||||||
const subtags = [{ text: t('all'), value: 'all' }]
|
const subtags = [{ text: t('all'), value: 'all' }]
|
||||||
category.subtags.forEach((subtag) => {
|
category.subtags.forEach((subtag) => {
|
||||||
subtags.push({ text: subtag.title, value: subtag.id })
|
subtags.push({ text: subtag.title, value: subtag.id })
|
||||||
|
@ -118,75 +156,6 @@ const subtags = computed(() => {
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
function onQueriesResponse(catalog: any) {
|
|
||||||
const apps_ = []
|
|
||||||
for (const key in catalog.apps) {
|
|
||||||
const app = catalog.apps[key]
|
|
||||||
app.isInstallable =
|
|
||||||
!app.installed || app.manifest.integration.multi_instance
|
|
||||||
app.working = app.state === 'working'
|
|
||||||
app.decent_quality = app.working && app.level > 4
|
|
||||||
app.high_quality = app.working && app.level >= 8
|
|
||||||
app.color = 'danger'
|
|
||||||
if (app.working && app.level <= 0) {
|
|
||||||
app.state = 'broken'
|
|
||||||
app.color = 'danger'
|
|
||||||
} else if (app.working && app.level <= 4) {
|
|
||||||
app.state = 'lowquality'
|
|
||||||
app.color = 'warning'
|
|
||||||
} else if (app.working) {
|
|
||||||
app.color = 'success'
|
|
||||||
}
|
|
||||||
app.searchValues = [
|
|
||||||
app.id,
|
|
||||||
app.state,
|
|
||||||
app.manifest.name,
|
|
||||||
app.manifest.description,
|
|
||||||
app.potential_alternative_to.join(' '),
|
|
||||||
]
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
apps_.push(app)
|
|
||||||
}
|
|
||||||
apps.value = apps_.sort((a, b) => (a.id > b.id ? 1 : -1))
|
|
||||||
|
|
||||||
// CATEGORIES
|
|
||||||
catalog.categories.forEach(({ title, id, icon, subtags, description }) => {
|
|
||||||
categories.push({
|
|
||||||
text: title,
|
|
||||||
value: id,
|
|
||||||
icon,
|
|
||||||
subtags,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
antifeatures.value = Object.fromEntries(
|
|
||||||
catalog.antifeatures.map((af) => [af.id, af]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateQuery(key, value) {
|
|
||||||
// Update the query string without reloading the page
|
|
||||||
router.replace({
|
|
||||||
query: {
|
|
||||||
...route.query,
|
|
||||||
// allow search without selecting a category
|
|
||||||
category: route.query.category || 'all',
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// INSTALL APP
|
|
||||||
async function onInstallClick(appId: string) {
|
|
||||||
const app = apps.value.find((app) => app.id === appId)
|
|
||||||
if (!app.decent_quality) {
|
|
||||||
const confirmed = await modalConfirm(t('confirm_install_app_' + app.state))
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
router.push({ name: 'app-install', params: { id: app.id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// INSTALL CUSTOM APP
|
// INSTALL CUSTOM APP
|
||||||
const onCustomInstallClick = onSubmit(async () => {
|
const onCustomInstallClick = onSubmit(async () => {
|
||||||
const confirmed = await modalConfirm(t('confirm_install_custom_app'))
|
const confirmed = await modalConfirm(t('confirm_install_custom_app'))
|
||||||
|
@ -201,12 +170,7 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewSearch
|
<ViewSearch :items="filteredApps" items-name="apps">
|
||||||
v-model="search"
|
|
||||||
:items="filteredApps"
|
|
||||||
items-name="apps"
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
<template #top-bar>
|
<template #top-bar>
|
||||||
<div id="view-top-bar">
|
<div id="view-top-bar">
|
||||||
<!-- APP SEARCH -->
|
<!-- APP SEARCH -->
|
||||||
|
@ -217,16 +181,11 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
|
|
||||||
<BFormInput
|
<BFormInput
|
||||||
id="search-input"
|
id="search-input"
|
||||||
:model-value="search"
|
v-model="search"
|
||||||
:placeholder="$t('search.for', { items: $t('items.apps', 2) })"
|
:placeholder="$t('search.for', { items: $t('items.apps', 2) })"
|
||||||
@update:model-value="updateQuery('search', $event)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BFormSelect
|
<BFormSelect v-model="quality" :options="qualityOptions" />
|
||||||
:model-value="quality"
|
|
||||||
:options="qualityOptions"
|
|
||||||
@update:model-value="updateQuery('quality', $event)"
|
|
||||||
/>
|
|
||||||
</BInputGroup>
|
</BInputGroup>
|
||||||
|
|
||||||
<!-- CATEGORY SELECT -->
|
<!-- CATEGORY SELECT -->
|
||||||
|
@ -235,16 +194,12 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
<YIcon iname="filter" />
|
<YIcon iname="filter" />
|
||||||
</BInputGroupText>
|
</BInputGroupText>
|
||||||
|
|
||||||
<BFormSelect
|
<BFormSelect v-model="category" :options="categories" />
|
||||||
:model-value="category"
|
|
||||||
:options="categories"
|
|
||||||
@update:model-value="updateQuery('category', $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BButton
|
<BButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:disabled="category === null"
|
:disabled="category === null"
|
||||||
@click="updateQuery('category', null)"
|
@click="category = null"
|
||||||
>
|
>
|
||||||
{{ $t('app_show_categories') }}
|
{{ $t('app_show_categories') }}
|
||||||
</BButton>
|
</BButton>
|
||||||
|
@ -256,46 +211,44 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
|
|
||||||
<BFormRadioGroup
|
<BFormRadioGroup
|
||||||
id="subtags-radio"
|
id="subtags-radio"
|
||||||
|
v-model="subtag"
|
||||||
name="subtags"
|
name="subtags"
|
||||||
:checked="subtag"
|
|
||||||
:options="subtags"
|
:options="subtags"
|
||||||
@change="updateQuery('subtag', $event)"
|
|
||||||
buttons
|
buttons
|
||||||
button-variant="outline-secondary"
|
button-variant="outline-secondary"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BFormSelect
|
<BFormSelect
|
||||||
id="subtags-select"
|
id="subtags-select"
|
||||||
:model-value="subtag"
|
v-model="subtag"
|
||||||
:options="subtags"
|
:options="subtags"
|
||||||
@update:model-value="updateQuery('subtag', $event)"
|
|
||||||
/>
|
/>
|
||||||
</BInputGroup>
|
</BInputGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- CATEGORIES CARDS -->
|
<!-- CATEGORIES CARDS -->
|
||||||
<BCardGroup v-if="category === null" deck tag="ul">
|
<template v-if="category === null" #forced-default>
|
||||||
|
<BCardGroup deck tag="ul" class="p-0 m-0">
|
||||||
<BCard
|
<BCard
|
||||||
v-for="cat in categories.slice(1)"
|
v-for="cat in categories.slice(1)"
|
||||||
:key="cat.value"
|
:key="cat.text"
|
||||||
tag="li"
|
tag="li"
|
||||||
class="category-card"
|
class="category-card"
|
||||||
>
|
>
|
||||||
<BCardTitle>
|
<BCardTitle>
|
||||||
<BLink
|
<BLink class="card-link" @click.prevent="category = cat.value">
|
||||||
@click.prevent="updateQuery('category', cat.value)"
|
<YIcon v-if="cat.icon" :iname="cat.icon" /> {{ cat.text }}
|
||||||
class="card-link"
|
|
||||||
>
|
|
||||||
<YIcon :iname="cat.icon" /> {{ cat.text }}
|
|
||||||
</BLink>
|
</BLink>
|
||||||
</BCardTitle>
|
</BCardTitle>
|
||||||
<BCardText>{{ cat.description }}</BCardText>
|
<BCardText v-if="'description' in cat">{{
|
||||||
|
cat.description
|
||||||
|
}}</BCardText>
|
||||||
</BCard>
|
</BCard>
|
||||||
</BCardGroup>
|
</BCardGroup>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- APPS CARDS -->
|
<CardDeckFeed v-if="filteredApps">
|
||||||
<CardDeckFeed v-else>
|
|
||||||
<BCard
|
<BCard
|
||||||
v-for="(app, i) in filteredApps"
|
v-for="(app, i) in filteredApps"
|
||||||
:key="app.id"
|
:key="app.id"
|
||||||
|
@ -310,9 +263,9 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
>
|
>
|
||||||
<BCardBody class="d-flex">
|
<BCardBody class="d-flex">
|
||||||
<BImg
|
<BImg
|
||||||
v-if="app.logo_hash"
|
v-if="app.logoHash"
|
||||||
class="app-logo rounded"
|
class="app-logo rounded"
|
||||||
:src="`./applogos/${app.logo_hash}.png`"
|
:src="`./applogos/${app.logoHash}.png`"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -321,37 +274,36 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
:to="{ name: 'app-install', params: { id: app.id } }"
|
:to="{ name: 'app-install', params: { id: app.id } }"
|
||||||
class="card-link"
|
class="card-link"
|
||||||
>
|
>
|
||||||
{{ app.manifest.name }}
|
{{ app.name }}
|
||||||
</BLink>
|
</BLink>
|
||||||
|
|
||||||
<small
|
<small
|
||||||
v-if="app.state !== 'working' || app.high_quality"
|
v-if="app.quality.state !== 'working' || app.highQuality"
|
||||||
class="d-flex align-items-center ms-2 position-relative"
|
class="d-flex align-items-center ms-2 position-relative"
|
||||||
>
|
>
|
||||||
<BBadge
|
<BBadge
|
||||||
v-if="app.state !== 'working'"
|
v-if="app.quality.state !== 'working'"
|
||||||
:variant="app.color"
|
|
||||||
v-b-popover.hover.bottom="
|
v-b-popover.hover.bottom="
|
||||||
$t(`app_state_${app.state}_explanation`)
|
$t(`app_state_${app.quality.state}_explanation`)
|
||||||
"
|
"
|
||||||
|
:variant="app.quality.variant"
|
||||||
>
|
>
|
||||||
<!-- app.state can be 'lowquality' or 'inprogress' -->
|
{{ $t(`app_state_${app.quality.state}`) }}
|
||||||
{{ $t('app_state_' + app.state) }}
|
|
||||||
</BBadge>
|
</BBadge>
|
||||||
|
|
||||||
<YIcon
|
<YIcon
|
||||||
v-if="app.high_quality"
|
v-if="app.highQuality"
|
||||||
iname="star"
|
|
||||||
class="star"
|
|
||||||
v-b-popover.hover.bottom="
|
v-b-popover.hover.bottom="
|
||||||
$t(`app_state_highquality_explanation`)
|
$t(`app_state_highquality_explanation`)
|
||||||
"
|
"
|
||||||
|
iname="star"
|
||||||
|
class="star"
|
||||||
/>
|
/>
|
||||||
</small>
|
</small>
|
||||||
</BCardTitle>
|
</BCardTitle>
|
||||||
|
|
||||||
<BCardText :id="`${app.id}-desc`">
|
<BCardText :id="`${app.id}-desc`">
|
||||||
{{ app.manifest.description }}
|
{{ app.description }}
|
||||||
</BCardText>
|
</BCardText>
|
||||||
|
|
||||||
<BCardText
|
<BCardText
|
||||||
|
@ -359,8 +311,8 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
class="align-self-end position-relative mt-auto"
|
class="align-self-end position-relative mt-auto"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="alert-warning p-1"
|
|
||||||
v-b-popover.hover.top="$t('orphaned_details')"
|
v-b-popover.hover.top="$t('orphaned_details')"
|
||||||
|
class="alert-warning p-1"
|
||||||
>
|
>
|
||||||
<YIcon iname="warning" /> {{ $t('orphaned') }}
|
<YIcon iname="warning" /> {{ $t('orphaned') }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -390,33 +342,6 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
</template>
|
</template>
|
||||||
</CardForm>
|
</CardForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- CUSTOM SKELETON -->
|
|
||||||
<template #skeleton>
|
|
||||||
<BCardGroup deck>
|
|
||||||
<BCard v-for="i in 15" :key="i" no-body style="min-height: 10rem">
|
|
||||||
<div class="d-flex w-100 mt-auto">
|
|
||||||
<BSkeleton width="30px" height="30px" class="me-2 ms-auto" />
|
|
||||||
<BSkeleton
|
|
||||||
:width="randint(30, 70) + '%'"
|
|
||||||
height="30px"
|
|
||||||
class="me-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<BSkeleton
|
|
||||||
v-if="randint(0, 1)"
|
|
||||||
:width="randint(30, 85) + '%'"
|
|
||||||
height="24px"
|
|
||||||
class="mx-auto"
|
|
||||||
/>
|
|
||||||
<BSkeleton
|
|
||||||
:width="randint(30, 85) + '%'"
|
|
||||||
height="24px"
|
|
||||||
class="mx-auto mb-auto"
|
|
||||||
/>
|
|
||||||
</BCard>
|
|
||||||
</BCardGroup>
|
|
||||||
</template>
|
|
||||||
</ViewSearch>
|
</ViewSearch>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -449,13 +374,9 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-deck {
|
.card-deck {
|
||||||
padding: 0;
|
.card {
|
||||||
margin-bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
flex-basis: 50%;
|
flex-basis: 50%;
|
||||||
|
@ -466,14 +387,15 @@ const onCustomInstallClick = onSubmit(async () => {
|
||||||
flex-basis: 33%;
|
flex-basis: 33%;
|
||||||
max-width: calc(33.3% - 1rem);
|
max-width: calc(33.3% - 1rem);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $white;
|
color: $white;
|
||||||
background-color: $dark;
|
background-color: $dark;
|
||||||
border-color: $dark;
|
border-color: $dark;
|
||||||
}
|
}
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 $btn-focus-width rgba($dark, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.card-link) {
|
:deep(.card-link) {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
19
app/src/views/app/appData.ts
Normal file
19
app/src/views/app/appData.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import type { AppLevel, AppState } from '@/types/core/api'
|
||||||
|
|
||||||
|
export function formatAppQuality(app: { state: AppState; level: AppLevel }) {
|
||||||
|
const variants = {
|
||||||
|
working: 'success',
|
||||||
|
lowquality: 'warning',
|
||||||
|
inprogress: 'danger',
|
||||||
|
broken: 'danger',
|
||||||
|
thirdparty: 'danger',
|
||||||
|
} as const
|
||||||
|
const working = app.state === 'working'
|
||||||
|
const state: keyof typeof variants =
|
||||||
|
working && app.level <= 0
|
||||||
|
? 'broken'
|
||||||
|
: working && app.level <= 4
|
||||||
|
? 'lowquality'
|
||||||
|
: app.state
|
||||||
|
return { state, variant: variants[state] }
|
||||||
|
}
|
Loading…
Reference in a new issue