281 lines
8.2 KiB
Vue
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>
|