refactor: rework async AppCatalog

This commit is contained in:
axolotle 2024-08-13 00:06:36 +02:00
parent 7f867e38e9
commit 38859f5afc
4 changed files with 259 additions and 191 deletions

View 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
View 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
}[]
}

View file

@ -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;

View 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] }
}