Files
cloudron-box/dashboard/src/views/AppstoreView.vue
2025-09-29 22:50:38 +02:00

281 lines
8.2 KiB
Vue

<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import moment from 'moment';
import { ref, computed, useTemplateRef, onActivated, onMounted, onUnmounted, inject, watch, nextTick } from 'vue';
import { TextInput, ProgressBar, InputDialog, SingleSelect } from '@cloudron/pankow';
import AppsModel from '../models/AppsModel.js';
import AppstoreModel from '../models/AppstoreModel.js';
import AppInstallDialog from '../components/AppInstallDialog.vue';
import AppStoreItem from '../components/AppStoreItem.vue';
const appsModel = AppsModel.create();
const appstoreModel = AppstoreModel.create();
const ready = ref(false);
const apps = ref([]);
const search = ref('');
// clear category on search
watch(search, (newValue) => {
if (newValue) category.value ='';
});
function filterForNewApps(apps) {
var minApps = apps.length < 12 ? apps.length : 12; // prevent endless loop
var tmp = [];
var i = 0;
do {
var offset = moment().subtract(i++, 'days');
tmp = apps.filter(function (app) { return moment(app.publishedAt).isAfter(offset); });
} while(tmp.length < minApps);
return tmp;
}
const filteredApps = computed(() => {
if (category.value) {
if (category.value === 'new') {
return filterForNewApps(apps.value);
} else {
return apps.value.filter(a => {
if (a.manifest.tags.join().toLowerCase().indexOf(category.value) !== -1) return true;
return false;
});
}
}
if (!search.value) return apps.value;
const s = search.value.toLowerCase();
return apps.value.filter(a => {
if (a.manifest.title.toLowerCase().indexOf(s) !== -1) return true;
if (a.manifest.tagline.toLowerCase().indexOf(s) !== -1) return true;
if (a.manifest.tags.join().toLowerCase().indexOf(s) !== -1) return true;
return false;
});
});
const filteredAllApps = computed(() => {
return filteredApps.value.filter(a => !a.featured).sort((a, b) => { return a.manifest.title.localeCompare(b.manifest.title); });
});
const filteredPopularApps = computed(() => {
return filteredApps.value.filter(a => a.featured);
});
const appInstallDialog = useTemplateRef('appInstallDialog');
const searchInput = useTemplateRef('searchInput');
const inputDialog = useTemplateRef('inputDialog');
const features = inject('features');
const installedApps = ref([]);
const appstoreTokenError = ref(false);
const category = ref('');
const categories = [
{ id: '', label: t('appstore.category.all') },
{ id: 'new', label: t('appstore.category.newApps') },
{ id: 'analytics', label: 'Analytics'},
{ id: 'automation', label: 'Automation'},
{ id: 'blog', label: 'Blog'},
{ id: 'chat', label: 'Chat'},
{ id: 'crm', label: 'CRM'},
{ id: 'document', label: 'Documents'},
{ id: 'email', label: 'Email'},
{ id: 'federated', label: 'Federated'},
{ id: 'finance', label: 'Finance'},
{ id: 'forum', label: 'Forum'},
{ id: 'fun', label: 'Fun'},
{ id: 'gallery', label: 'Gallery'},
{ id: 'game', label: 'Games'},
{ id: 'git', label: 'Code Hosting'},
{ id: 'hosting', label: 'Web Hosting'},
{ id: 'learning', label: 'Learning'},
{ id: 'media', label: 'Media'},
{ id: 'no-code', label: 'No-code'},
{ id: 'notes', label: 'Notes'},
{ id: 'project', label: 'Project Management'},
{ id: 'sync', label: 'File Sync'},
{ id: 'voip', label: 'VoIP'},
{ id: 'vpn', label: 'VPN'},
{ id: 'wiki', label: 'Wiki'},
];
function onAppInstallDialogClose() {
window.location.href = '#/appstore';
}
function onInstall(app) {
window.location.href = `#/appstore/${app.manifest.id}?version=${app.manifest.version}`;
}
async function getAppList() {
const [error, result] = await appstoreModel.list();
if (error) {
if (error.status === 402) return appstoreTokenError.value = true;
return console.error(error);
}
apps.value = result;
}
async function getApp(id, version = '') {
const [error, result] = await appstoreModel.get(id, version);
if (error) {
console.error(error);
return null;
}
return result;
}
async function getInstalledApps() {
const [error, result] = await appsModel.list();
if (error) return console.error(error);
installedApps.value = result;
}
async function onHashChange() {
if (window.location.hash.indexOf('#/appstore') !== 0) return;
if (appInstallDialog.value) appInstallDialog.value.close();
const query = window.location.hash.slice('#/appstore/'.length);
if (query) {
const appId = query.split('?')[0];
const params = new URLSearchParams(window.location.hash.slice(window.location.hash.indexOf('?')));
const version = params.get('version') || 'latest';
const app = await getApp(appId, version);
if (app) {
appInstallDialog.value.open(app, installedApps.value.length >= features.value.appMaxCount);
} else {
inputDialog.value.info({
title: t('appstore.appNotFoundDialog.title'),
message: t('appstore.appNotFoundDialog.description', { appId, version }),
confirmLabel: t('main.dialog.close'),
});
}
} else {
await nextTick();
if (searchInput.value) searchInput.value.$el.focus();
}
}
const view = useTemplateRef('view');
const itemWidth = ref('unset');
function setItemWidth() {
const width = view.value.offsetWidth;
const gap = 20; // flexbox gap value
if (width <= 575) itemWidth.value = '100%';
else if (width <= 800) itemWidth.value = Number((width-gap*2)/2).toFixed() + 'px';
else if (width <= 1024) itemWidth.value = Number((width-gap*3)/3).toFixed() + 'px';
else itemWidth.value = Number((width-gap*4)/4).toFixed() + 'px';
}
async function onActivation() {
search.value = '';
category.value = '';
await getInstalledApps();
await getAppList();
// only deals with #/appstore/ hashes
window.addEventListener('hashchange', onHashChange);
onHashChange();
window.addEventListener('resize', setItemWidth);
setItemWidth();
}
onActivated(onActivation);
onMounted(async () => {
await onActivation();
ready.value = true;
});
onUnmounted(() => {
window.removeEventListener('hashchange', onHashChange);
window.removeEventListener('resize', setItemWidth);
});
</script>
<template>
<div ref="view" class="content-large" style="width: 100%; height: 100%;">
<InputDialog ref="inputDialog"/>
<AppInstallDialog ref="appInstallDialog" @close="onAppInstallDialogClose"/>
<div class="filter-bar">
<SingleSelect v-model="category" :options="categories" option-key="id" option-label="label" :disabled="!ready"/>
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;"/>
</div>
<div v-if="!ready" style="margin-top: 15px">
<ProgressBar mode="indeterminate" :show-label="false" :slim="true"/>
</div>
<div v-else-if="appstoreTokenError">
Cloudron not registered. Reset registration <a href="#/cloudron-account">here</a>.
</div>
<div v-else>
<div v-if="!search">
<h4 v-show="filteredPopularApps.length">{{ $t('appstore.category.popular') }}</h4>
<div class="grid">
<AppStoreItem :style="{ width: itemWidth }" v-for="app in filteredPopularApps" :app="app" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)"/>
</div>
<h4 v-show="filteredAllApps.length">{{ $t('appstore.category.all') }}</h4>
<div class="grid">
<AppStoreItem :style="{ width: itemWidth }" v-for="app in filteredAllApps" :app="app" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)"/>
</div>
</div>
<div v-else style="margin-top: 20px">
<div class="grid">
<AppStoreItem :style="{ width: itemWidth }" v-for="app in filteredApps" :app="app" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)"/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.grid-animation-move,
.grid-animation-enter-active,
.grid-animation-leave-active {
transition: all 0.2s ease;
}
.grid-animation-enter-from {
opacity: 0;
transform: scale(0);
}
.grid-animation-leave-to {
opacity: 0;
transform: scale(0);
}
.filter-bar {
width: 100%;
display: flex;
gap: 6px;
justify-content: space-between;
}
.grid {
position: relative;
display: flex;
gap: 20px;
flex-wrap: wrap;
}
</style>