From 71a6bf6109e185655abe752c4652c8ecdb26a11c Mon Sep 17 00:00:00 2001 From: Benedikt Kulmann Date: Tue, 30 Jul 2024 07:12:05 +0200 Subject: [PATCH 01/30] feat: bootstrap app store app --- dev/docker/ocis.web.config.json | 3 +- .../src/components/OcImage/OcImage.vue | 2 +- packages/web-app-app-store/package.json | 21 ++++ packages/web-app-app-store/src/appid.ts | 1 + .../src/components/AppListItem.vue | 75 ++++++++++++++ .../src/composables/actions/index.ts | 1 + .../actions/useAppActionsDownload.ts | 34 +++++++ .../src/composables/index.ts | 1 + packages/web-app-app-store/src/index.ts | 98 +++++++++++++++++++ .../web-app-app-store/src/piniaStores/apps.ts | 41 ++++++++ .../src/piniaStores/index.ts | 2 + .../src/piniaStores/repositories.ts | 17 ++++ packages/web-app-app-store/src/types.ts | 57 +++++++++++ .../web-app-app-store/src/views/AppList.vue | 43 ++++++++ .../web-pkg/src/components/BatchActions.vue | 2 +- .../ContextActions/ActionMenuItem.vue | 2 +- pnpm-lock.yaml | 36 ++++++- 17 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 packages/web-app-app-store/package.json create mode 100644 packages/web-app-app-store/src/appid.ts create mode 100644 packages/web-app-app-store/src/components/AppListItem.vue create mode 100644 packages/web-app-app-store/src/composables/actions/index.ts create mode 100644 packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts create mode 100644 packages/web-app-app-store/src/composables/index.ts create mode 100644 packages/web-app-app-store/src/index.ts create mode 100644 packages/web-app-app-store/src/piniaStores/apps.ts create mode 100644 packages/web-app-app-store/src/piniaStores/index.ts create mode 100644 packages/web-app-app-store/src/piniaStores/repositories.ts create mode 100644 packages/web-app-app-store/src/types.ts create mode 100644 packages/web-app-app-store/src/views/AppList.vue diff --git a/dev/docker/ocis.web.config.json b/dev/docker/ocis.web.config.json index 1bb559ba936..cfcc6cb58e0 100644 --- a/dev/docker/ocis.web.config.json +++ b/dev/docker/ocis.web.config.json @@ -20,7 +20,8 @@ "admin-settings", "ocm", "webfinger", - "epub-reader" + "epub-reader", + "app-store" ], "external_apps": [ { diff --git a/packages/design-system/src/components/OcImage/OcImage.vue b/packages/design-system/src/components/OcImage/OcImage.vue index 922f28a1553..2d87b771a20 100644 --- a/packages/design-system/src/components/OcImage/OcImage.vue +++ b/packages/design-system/src/components/OcImage/OcImage.vue @@ -1,5 +1,5 @@ + + diff --git a/packages/web-app-app-store/src/composables/actions/index.ts b/packages/web-app-app-store/src/composables/actions/index.ts new file mode 100644 index 00000000000..9158614212a --- /dev/null +++ b/packages/web-app-app-store/src/composables/actions/index.ts @@ -0,0 +1 @@ +export * from './useAppActionsDownload' diff --git a/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts b/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts new file mode 100644 index 00000000000..457a7e80b14 --- /dev/null +++ b/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts @@ -0,0 +1,34 @@ +import { Action, triggerDownloadWithFilename } from '@ownclouders/web-pkg' +import { useGettext } from 'vue3-gettext' +import { App } from '../../types' + +export type AppActionOptions = { + app: App +} + +export const useAppActionsDownload = () => { + const { $gettext } = useGettext() + + const downloadAppAction: Action = { + name: 'download-app', + icon: 'download', + label: () => { + return $gettext('Download') + }, + handler: (options?) => { + console.log('Download App', options?.app.name) + const version = options.app.versions[0] + const filename = version.filename || version.url.split('/').pop() + triggerDownloadWithFilename(version.url, filename) + }, + isVisible: () => { + return true + }, + componentType: 'button', + appearance: 'outline' + } + + return { + downloadAppAction + } +} diff --git a/packages/web-app-app-store/src/composables/index.ts b/packages/web-app-app-store/src/composables/index.ts new file mode 100644 index 00000000000..9fef387da76 --- /dev/null +++ b/packages/web-app-app-store/src/composables/index.ts @@ -0,0 +1 @@ +export * from './actions' diff --git a/packages/web-app-app-store/src/index.ts b/packages/web-app-app-store/src/index.ts new file mode 100644 index 00000000000..9b864ac62fc --- /dev/null +++ b/packages/web-app-app-store/src/index.ts @@ -0,0 +1,98 @@ +import { useGettext } from 'vue3-gettext' +import { computed, unref } from 'vue' +import { + AppMenuItemExtension, + defineWebApplication, + Extension, + useAbility, + useUserStore +} from '@ownclouders/web-pkg' +import { urlJoin } from '@ownclouders/web-client' +import { RouteRecordRaw } from 'vue-router' +import { useAppsStore, useRepositoriesStore } from './piniaStores' +import { AppStoreConfigSchema } from './types' +import { APPID } from './appid' +export default defineWebApplication({ + setup({ applicationConfig }) { + const { $gettext } = useGettext() + const { can } = useAbility() + const userStore = useUserStore() + const repositoryStore = useRepositoriesStore() + + const defaultRepositories = [ + { + name: 'awesome-ocis', + url: 'https://raw.githubusercontent.com/owncloud/awesome-ocis/main/webApps/apps.json' + } + ] + if (applicationConfig?.repositories) { + const { repositories } = AppStoreConfigSchema.parse(applicationConfig) + repositoryStore.setRepositories(repositories || defaultRepositories) + } else { + repositoryStore.setRepositories(defaultRepositories) + } + + const appInfo = { + name: $gettext('App Store'), + id: APPID, + icon: 'store', + color: '#ff6961' + } + + const hasPermission = computed(() => { + // TODO: which permission(s) do we need to check here? + return userStore.user && can('read-all', 'Setting') + }) + + const routes: RouteRecordRaw[] = [ + { + path: '/', + redirect: urlJoin(appInfo.id, 'list') + }, + { + path: '/list', + name: 'list', + component: () => import('./views/AppList.vue'), + beforeEnter: (to, from, next) => { + if (!unref(hasPermission)) { + return next({ path: '/' }) + } + next() + }, + meta: { + authContext: 'user', + title: $gettext('App Store') + } + } + ] + + const menuItemExtension: AppMenuItemExtension = { + id: `app.${appInfo.id}.menuItem`, + type: 'appMenuItem', + label: () => appInfo.name, + color: appInfo.color, + icon: appInfo.icon, + priority: 30, + path: urlJoin(appInfo.id) + } + const extensions = computed(() => { + const result: Extension[] = [] + + if (unref(hasPermission)) { + result.push(menuItemExtension) + } + + return result + }) + + return { + appInfo, + routes, + extensions, + ready: () => { + const appsStore = useAppsStore() + return appsStore.loadApps() + } + } + } +}) diff --git a/packages/web-app-app-store/src/piniaStores/apps.ts b/packages/web-app-app-store/src/piniaStores/apps.ts new file mode 100644 index 00000000000..aa22b3e1138 --- /dev/null +++ b/packages/web-app-app-store/src/piniaStores/apps.ts @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia' +import { App, AppStoreRepository, RawAppListSchema } from '../types' +import { ref } from 'vue' +import { APPID } from '../appid' +import { useRepositoriesStore } from './repositories' + +export const useAppsStore = defineStore(`${APPID}-apps`, () => { + const apps = ref([]) + + const repositoriesStore = useRepositoriesStore() + + const loadApps = async () => { + const loadAppsByRepo = async (repo: AppStoreRepository): Promise => { + try { + const data = await fetch(repo.url) + const appsListData = await data.json() + const appsList = RawAppListSchema.parse(appsListData) + return appsList.apps.map((app) => { + return { + ...app, + repository: repo + } + }) + } catch (e) { + console.error(e) + return [] + } + } + + const loadAppsPromises: Promise[] = [] + for (const repo of repositoriesStore.repositories) { + loadAppsPromises.push(loadAppsByRepo(repo)) + } + apps.value = (await Promise.all(loadAppsPromises)).flat() + } + + return { + apps, + loadApps + } +}) diff --git a/packages/web-app-app-store/src/piniaStores/index.ts b/packages/web-app-app-store/src/piniaStores/index.ts new file mode 100644 index 00000000000..996943c305d --- /dev/null +++ b/packages/web-app-app-store/src/piniaStores/index.ts @@ -0,0 +1,2 @@ +export * from './apps' +export * from './repositories' diff --git a/packages/web-app-app-store/src/piniaStores/repositories.ts b/packages/web-app-app-store/src/piniaStores/repositories.ts new file mode 100644 index 00000000000..71c259be80d --- /dev/null +++ b/packages/web-app-app-store/src/piniaStores/repositories.ts @@ -0,0 +1,17 @@ +import { defineStore } from 'pinia' +import { AppStoreRepository } from '../types' +import { ref } from 'vue' +import { APPID } from '../appid' + +export const useRepositoriesStore = defineStore(`${APPID}-repositories`, () => { + const repositories = ref([]) + + const setRepositories = (repos: AppStoreRepository[]) => { + repositories.value = repos + } + + return { + repositories, + setRepositories + } +}) diff --git a/packages/web-app-app-store/src/types.ts b/packages/web-app-app-store/src/types.ts new file mode 100644 index 00000000000..9a4587fe47a --- /dev/null +++ b/packages/web-app-app-store/src/types.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' + +export const AppStoreRepositorySchema = z.object({ + name: z.string(), + url: z.string() +}) +export type AppStoreRepository = z.infer + +export const AppStoreConfigSchema = z.object({ + repositories: z.array(AppStoreRepositorySchema) +}) + +export const AppVersionSchema = z.object({ + version: z.string(), + url: z.string(), + filename: z.string().optional() +}) + +export const AppAuthorSchema = z.object({ + name: z.string(), + email: z.string().optional(), + url: z.string().optional() +}) + +export const AppImageSchema = z.object({ + url: z.string(), + caption: z.string().optional() +}) + +export const AppResourceSchema = z.object({ + url: z.string(), + label: z.string(), + icon: z.string().optional() +}) + +export const RawAppSchema = z.object({ + id: z.string(), + name: z.string(), + subtitle: z.string(), + description: z.string().optional(), + license: z.string(), + versions: z.array(AppVersionSchema), + authors: z.array(AppAuthorSchema), + tags: z.array(z.string()), + coverImage: AppImageSchema.optional(), + screenshots: z.array(AppImageSchema).optional().default([]), + resources: z.array(AppResourceSchema).optional().default([]) // e.g. documentation, github, etc. +}) + +export const AppSchema = RawAppSchema.extend({ + repository: AppStoreRepositorySchema +}) +export type App = z.infer + +export const RawAppListSchema = z.object({ + apps: z.array(RawAppSchema) +}) diff --git a/packages/web-app-app-store/src/views/AppList.vue b/packages/web-app-app-store/src/views/AppList.vue new file mode 100644 index 00000000000..d8fcda318a1 --- /dev/null +++ b/packages/web-app-app-store/src/views/AppList.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/packages/web-pkg/src/components/BatchActions.vue b/packages/web-pkg/src/components/BatchActions.vue index 835c86f1a00..1fcdf6af6ff 100644 --- a/packages/web-pkg/src/components/BatchActions.vue +++ b/packages/web-pkg/src/components/BatchActions.vue @@ -21,7 +21,7 @@ - - diff --git a/packages/web-app-app-store/src/components/AppTile.vue b/packages/web-app-app-store/src/components/AppTile.vue new file mode 100644 index 00000000000..c4788b1f710 --- /dev/null +++ b/packages/web-app-app-store/src/components/AppTile.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts b/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts index 457a7e80b14..bac9c903fe9 100644 --- a/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts +++ b/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts @@ -16,7 +16,6 @@ export const useAppActionsDownload = () => { return $gettext('Download') }, handler: (options?) => { - console.log('Download App', options?.app.name) const version = options.app.versions[0] const filename = version.filename || version.url.split('/').pop() triggerDownloadWithFilename(version.url, filename) diff --git a/packages/web-app-app-store/src/views/AppList.vue b/packages/web-app-app-store/src/views/AppList.vue index d8fcda318a1..8a5cd6b8691 100644 --- a/packages/web-app-app-store/src/views/AppList.vue +++ b/packages/web-app-app-store/src/views/AppList.vue @@ -1,8 +1,8 @@ + + diff --git a/packages/web-app-app-store/src/components/AppCover.vue b/packages/web-app-app-store/src/components/AppCover.vue new file mode 100644 index 00000000000..36a7f64b99a --- /dev/null +++ b/packages/web-app-app-store/src/components/AppCover.vue @@ -0,0 +1,44 @@ + + + + diff --git a/packages/web-app-app-store/src/components/AppTags.vue b/packages/web-app-app-store/src/components/AppTags.vue new file mode 100644 index 00000000000..44f26aa47c1 --- /dev/null +++ b/packages/web-app-app-store/src/components/AppTags.vue @@ -0,0 +1,45 @@ + + + + diff --git a/packages/web-app-app-store/src/components/AppTile.vue b/packages/web-app-app-store/src/components/AppTile.vue index bf0aa6a5dba..66a41ac2ffd 100644 --- a/packages/web-app-app-store/src/components/AppTile.vue +++ b/packages/web-app-app-store/src/components/AppTile.vue @@ -1,54 +1,40 @@ diff --git a/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts b/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts index 237a3b5d97b..760cd777c4d 100644 --- a/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts +++ b/packages/web-app-app-store/src/composables/actions/useAppActionsDownload.ts @@ -1,9 +1,10 @@ import { Action, triggerDownloadWithFilename } from '@ownclouders/web-pkg' import { useGettext } from 'vue3-gettext' -import { App } from '../../types' +import { App, AppVersion } from '../../types' export type AppActionOptions = { app: App + version?: AppVersion } export const useAppActionsDownload = () => { @@ -16,7 +17,7 @@ export const useAppActionsDownload = () => { return $gettext('Download') }, handler: (options?) => { - const version = options.app.mostRecentVersion + const version = options.version || options.app.mostRecentVersion const filename = version.filename || version.url.split('/').pop() triggerDownloadWithFilename(version.url, filename) }, diff --git a/packages/web-app-app-store/src/index.ts b/packages/web-app-app-store/src/index.ts index 9b864ac62fc..9c4dc8582fe 100644 --- a/packages/web-app-app-store/src/index.ts +++ b/packages/web-app-app-store/src/index.ts @@ -63,6 +63,21 @@ export default defineWebApplication({ authContext: 'user', title: $gettext('App Store') } + }, + { + path: '/app/:appId', + name: 'details', + component: () => import('./views/AppDetails.vue'), + beforeEnter: (to, from, next) => { + if (!unref(hasPermission)) { + return next({ path: '/' }) + } + next() + }, + meta: { + authContext: 'user', + title: $gettext('App Details') + } } ] diff --git a/packages/web-app-app-store/src/piniaStores/apps.ts b/packages/web-app-app-store/src/piniaStores/apps.ts index ce07ab6e53b..befc224db1d 100644 --- a/packages/web-app-app-store/src/piniaStores/apps.ts +++ b/packages/web-app-app-store/src/piniaStores/apps.ts @@ -1,42 +1,49 @@ import { defineStore } from 'pinia' import { App, AppStoreRepository, RawAppListSchema } from '../types' -import { ref } from 'vue' +import { ref, unref } from 'vue' import { APPID } from '../appid' import { useRepositoriesStore } from './repositories' -export const useAppsStore = defineStore(`${APPID}-apps`, () => { - const apps = ref([]) - +export const useAppsStore = () => { const repositoriesStore = useRepositoriesStore() - const loadApps = async () => { - const loadAppsByRepo = async (repo: AppStoreRepository): Promise => { - try { - const data = await fetch(repo.url) - const appsListData = await data.json() - const appsList = RawAppListSchema.parse(appsListData) - return appsList.apps.map((app) => { - return { - ...app, - repository: repo, - mostRecentVersion: app.versions[0] - } - }) - } catch (e) { - console.error(e) - return [] - } + return defineStore(`${APPID}-apps`, () => { + const apps = ref([]) + + const getById = (id: string) => { + return unref(apps).find((app) => app.id === id) } - const loadAppsPromises: Promise[] = [] - for (const repo of repositoriesStore.repositories) { - loadAppsPromises.push(loadAppsByRepo(repo)) + const loadApps = async () => { + const loadAppsByRepo = async (repo: AppStoreRepository): Promise => { + try { + const data = await fetch(repo.url) + const appsListData = await data.json() + const appsList = RawAppListSchema.parse(appsListData) + return appsList.apps.map((app) => { + return { + ...app, + repository: repo, + mostRecentVersion: app.versions[0] + } + }) + } catch (e) { + console.error(e) + return [] + } + } + + const loadAppsPromises: Promise[] = [] + for (const repo of repositoriesStore.repositories) { + loadAppsPromises.push(loadAppsByRepo(repo)) + } + apps.value = (await Promise.all(loadAppsPromises)).flat() } - apps.value = (await Promise.all(loadAppsPromises)).flat() - } - return { - apps, - loadApps - } -}) + return { + apps, + getById, + loadApps + } + })() +} diff --git a/packages/web-app-app-store/src/types.ts b/packages/web-app-app-store/src/types.ts index ca3468f8249..e308ab52d7b 100644 --- a/packages/web-app-app-store/src/types.ts +++ b/packages/web-app-app-store/src/types.ts @@ -15,6 +15,7 @@ export const AppVersionSchema = z.object({ url: z.string(), filename: z.string().optional() }) +export type AppVersion = z.infer export const AppAuthorSchema = z.object({ name: z.string(), diff --git a/packages/web-app-app-store/src/views/AppDetails.vue b/packages/web-app-app-store/src/views/AppDetails.vue new file mode 100644 index 00000000000..669f0f3246f --- /dev/null +++ b/packages/web-app-app-store/src/views/AppDetails.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/packages/web-pkg/src/components/ContextActions/ActionMenuItem.vue b/packages/web-pkg/src/components/ContextActions/ActionMenuItem.vue index 46b4bdb1a6f..a474ac99083 100644 --- a/packages/web-pkg/src/components/ContextActions/ActionMenuItem.vue +++ b/packages/web-pkg/src/components/ContextActions/ActionMenuItem.vue @@ -7,7 +7,7 @@ :class="[action.class, 'action-menu-item', 'oc-py-s', 'oc-px-m', 'oc-width-1-1']" :aria-label="componentProps.disabled ? action.disabledTooltip?.(actionOptions) : ''" data-testid="action-handler" - size="small" + :size="size" justify-content="left" v-on="componentListeners" > @@ -30,7 +30,7 @@ data-testid="action-icon" :name="action.icon" :fill-type="action.iconFillType || 'line'" - size="medium" + :size="size" /> , required: true }, + size: { + type: String, + required: false, + default: 'medium' + }, appearance: { type: String, default: 'raw' From b6daee0b62ba8b5173979a2baa75cfc75b95763d Mon Sep 17 00:00:00 2001 From: Benedikt Kulmann Date: Fri, 2 Aug 2024 07:45:15 +0200 Subject: [PATCH 09/30] feat: introduce layout component which also loads all apps --- .../web-app-app-store/src/LayoutContainer.vue | 39 +++++++++++++ .../src/components/AppTile.vue | 8 ++- .../src/components/LoadingApps.vue | 50 +++++++++++++++++ packages/web-app-app-store/src/index.ts | 55 +++++++++---------- .../src/views/AppDetails.vue | 6 +- 5 files changed, 124 insertions(+), 34 deletions(-) create mode 100644 packages/web-app-app-store/src/LayoutContainer.vue create mode 100644 packages/web-app-app-store/src/components/LoadingApps.vue diff --git a/packages/web-app-app-store/src/LayoutContainer.vue b/packages/web-app-app-store/src/LayoutContainer.vue new file mode 100644 index 00000000000..cca17c66b51 --- /dev/null +++ b/packages/web-app-app-store/src/LayoutContainer.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/web-app-app-store/src/components/AppTile.vue b/packages/web-app-app-store/src/components/AppTile.vue index 66a41ac2ffd..416566e8656 100644 --- a/packages/web-app-app-store/src/components/AppTile.vue +++ b/packages/web-app-app-store/src/components/AppTile.vue @@ -1,6 +1,6 @@