aboutsummaryrefslogtreecommitdiffstats
path: root/core
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2024-08-28 13:10:25 +0200
committerFerdinand Thiessen <opensource@fthiessen.de>2024-09-03 16:07:49 +0200
commit04b25ba59d48ea3ab1d34d9f05ede33fc7ad7fbc (patch)
treed16c43eaeea478254009d7d2a87b75af0bc553ce /core
parent5118f6684bd2f4f8d693ada710e845b6af5c5395 (diff)
downloadnextcloud-server-04b25ba59d48ea3ab1d34d9f05ede33fc7ad7fbc.tar.gz
nextcloud-server-04b25ba59d48ea3ab1d34d9f05ede33fc7ad7fbc.zip
feat: Implement Vue UI for public page menu
This adds a Vue implementation of the public page menu, that is the menu that can be added using `PublicTemplateResponse::setHeaderActions`. Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de> Co-authored-by: Louis <louis@chmn.me> Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'core')
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue36
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuEntry.vue49
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue90
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue36
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue51
-rw-r--r--core/src/public-page-menu.ts15
-rw-r--r--core/src/views/PublicPageMenu.vue131
7 files changed, 408 insertions, 0 deletions
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue
new file mode 100644
index 00000000000..f3c57a12042
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue
@@ -0,0 +1,36 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <li ref="listItem" :role="itemRole" v-html="html" />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+
+defineProps<{
+ id: string
+ html: string
+}>()
+
+const listItem = ref<HTMLLIElement>()
+const itemRole = ref('presentation')
+
+onMounted(() => {
+ // check for proper roles
+ const menuitem = listItem.value?.querySelector('[role="menuitem"]')
+ if (menuitem) {
+ return
+ }
+ // check if a button is available
+ const button = listItem.value?.querySelector('button') ?? listItem.value?.querySelector('a')
+ if (button) {
+ button.role = 'menuitem'
+ } else {
+ // if nothing is available set role on `<li>`
+ itemRole.value = 'menuitem'
+ }
+})
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
new file mode 100644
index 00000000000..a5a1913ac2b
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
@@ -0,0 +1,49 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcListItem :anchor-id="`${id}--link`"
+ compact
+ :details="details"
+ :href="href"
+ :name="label"
+ role="presentation"
+ @click="$emit('click')">
+ <template #icon>
+ <div role="presentation" :class="['icon', icon, 'public-page-menu-entry__icon']" />
+ </template>
+ </NcListItem>
+</template>
+
+<script setup lang="ts">
+import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
+import { onMounted } from 'vue'
+
+const props = defineProps<{
+ /** Only emit click event but do not open href */
+ clickOnly?: boolean
+ // menu entry props
+ id: string
+ label: string
+ icon: string
+ href: string
+ details?: string
+}>()
+
+onMounted(() => {
+ const anchor = document.getElementById(`${props.id}--link`) as HTMLAnchorElement
+ // Make the `<a>` a menuitem
+ anchor.role = 'menuitem'
+ // Prevent native click handling if required
+ if (props.clickOnly) {
+ anchor.onclick = (event) => event.preventDefault()
+ }
+})
+</script>
+
+<style scoped>
+.public-page-menu-entry__icon {
+ padding-inline-start: var(--default-grid-baseline);
+}
+</style>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
new file mode 100644
index 00000000000..992ea631600
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
@@ -0,0 +1,90 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcDialog is-form
+ :name="label"
+ :open.sync="open"
+ @submit="createFederatedShare">
+ <NcTextField ref="input"
+ :label="t('core', 'Federated user')"
+ :placeholder="t('core', 'user@your-nextcloud.org')"
+ required
+ :value.sync="remoteUrl" />
+ <template #actions>
+ <NcButton :disabled="loading" type="primary" native-type="submit">
+ <template v-if="loading" #icon>
+ <NcLoadingIcon />
+ </template>
+ {{ t('core', 'Create share') }}
+ </NcButton>
+ </template>
+ </NcDialog>
+</template>
+
+<script setup lang="ts">
+import type Vue from 'vue'
+
+import { t } from '@nextcloud/l10n'
+import { showError } from '@nextcloud/dialogs'
+import { generateUrl } from '@nextcloud/router'
+import { getSharingToken } from '@nextcloud/sharing/public'
+import { nextTick, onMounted, ref, watch } from 'vue'
+import axios from '@nextcloud/axios'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import logger from '../../logger'
+
+defineProps<{
+ label: string
+}>()
+
+const loading = ref(false)
+const remoteUrl = ref('')
+// Todo: @nextcloud/vue should expose the types correctly
+const input = ref<Vue & { focus: () => void }>()
+const open = ref(true)
+
+// Focus when mounted
+onMounted(() => nextTick(() => input.value!.focus()))
+
+// Check validity
+watch(remoteUrl, () => {
+ let validity = ''
+ if (!remoteUrl.value.includes('@')) {
+ validity = t('core', 'The remote URL must include the user.')
+ } else if (!remoteUrl.value.match(/@(.+\..{2,}|localhost)(:\d\d+)?$/)) {
+ validity = t('core', 'Invalid remote URL.')
+ }
+ input.value!.$el.querySelector('input')!.setCustomValidity(validity)
+ input.value!.$el.querySelector('input')!.reportValidity()
+})
+
+/**
+ * Create a federated share for the current share
+ */
+async function createFederatedShare() {
+ loading.value = true
+
+ try {
+ const url = generateUrl('/apps/federatedfilesharing/createFederatedShare')
+ const { data } = await axios.post<{ remoteUrl: string }>(url, {
+ shareWith: remoteUrl.value,
+ token: getSharingToken(),
+ })
+ if (data.remoteUrl.includes('://')) {
+ window.location.href = data.remoteUrl
+ } else {
+ window.location.href = `${window.location.protocol}//${data.remoteUrl}`
+ }
+ } catch (error) {
+ logger.error('Failed to create federated share', { error })
+ showError(t('files_sharing', 'Failed to add the public link to your Nextcloud'))
+ } finally {
+ loading.value = false
+ }
+}
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue
new file mode 100644
index 00000000000..a4451a38bbe
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue
@@ -0,0 +1,36 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <PublicPageMenuEntry :id="id"
+ :icon="icon"
+ href="#"
+ :label="label"
+ @click="openDialog" />
+</template>
+
+<script setup lang="ts">
+import { spawnDialog } from '@nextcloud/dialogs'
+import PublicPageMenuEntry from './PublicPageMenuEntry.vue'
+import PublicPageMenuExternalDialog from './PublicPageMenuExternalDialog.vue'
+
+const props = defineProps<{
+ id: string
+ label: string
+ icon: string
+ href: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'click'): void
+}>()
+
+/**
+ * Open the "create federated share" dialog
+ */
+function openDialog() {
+ spawnDialog(PublicPageMenuExternalDialog, { label: props.label })
+ emit('click')
+}
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue
new file mode 100644
index 00000000000..54645e9ce48
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue
@@ -0,0 +1,51 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <PublicPageMenuEntry :id="id"
+ click-only
+ :icon="icon"
+ :href="href"
+ :label="label"
+ @click="onClick" />
+</template>
+
+<script setup lang="ts">
+import { showSuccess } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import PublicPageMenuEntry from './PublicPageMenuEntry.vue'
+
+const props = defineProps<{
+ id: string
+ label: string
+ icon: string
+ href: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'click'): void
+}>()
+
+/**
+ * Copy the href to the clipboard
+ */
+async function copyLink() {
+ try {
+ await window.navigator.clipboard.writeText(props.href)
+ showSuccess(t('core', 'Direct link copied to clipboard'))
+ } catch {
+ // No secure context -> fallback to dialog
+ window.prompt(t('core', 'Please copy the link manually:'), props.href)
+ }
+}
+
+/**
+ * onclick handler to trigger the "copy link" action
+ * and emit the event so the menu can be closed
+ */
+function onClick() {
+ copyLink()
+ emit('click')
+}
+</script>
diff --git a/core/src/public-page-menu.ts b/core/src/public-page-menu.ts
new file mode 100644
index 00000000000..b290d1d03e9
--- /dev/null
+++ b/core/src/public-page-menu.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import PublicPageMenu from './views/PublicPageMenu.vue'
+
+__webpack_nonce__ = getCSPNonce()
+
+const View = Vue.extend(PublicPageMenu)
+const instance = new View()
+instance.$mount('#public-page-menu')
diff --git a/core/src/views/PublicPageMenu.vue b/core/src/views/PublicPageMenu.vue
new file mode 100644
index 00000000000..a9ff78a7c5f
--- /dev/null
+++ b/core/src/views/PublicPageMenu.vue
@@ -0,0 +1,131 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <div class="public-page-menu__wrapper">
+ <NcButton v-if="primaryAction"
+ id="public-page-menu--primary"
+ class="public-page-menu__primary"
+ :href="primaryAction.href"
+ type="primary"
+ @click="openDialogIfNeeded">
+ <template v-if="primaryAction.icon" #icon>
+ <div :class="['icon', primaryAction.icon, 'public-page-menu__primary-icon']" />
+ </template>
+ {{ primaryAction.label }}
+ </NcButton>
+
+ <NcHeaderMenu v-if="secondaryActions.length > 0"
+ id="public-page-menu"
+ :aria-label="t('core', 'More actions')"
+ :open.sync="showMenu">
+ <template #trigger>
+ <IconMore :size="20" />
+ </template>
+ <ul :aria-label="t('core', 'More actions')"
+ class="public-page-menu"
+ role="menu">
+ <component :is="getComponent(entry)"
+ v-for="entry, index in secondaryActions"
+ :key="index"
+ v-bind="entry"
+ @click="showMenu = false" />
+ </ul>
+ </NcHeaderMenu>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { spawnDialog } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { useIsSmallMobile } from '@nextcloud/vue/dist/Composables/useIsMobile.js'
+import { computed, ref, type Ref } from 'vue'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
+import IconMore from 'vue-material-design-icons/DotsHorizontal.vue'
+import PublicPageMenuEntry from '../components/PublicPageMenu/PublicPageMenuEntry.vue'
+import PublicPageMenuCustomEntry from '../components/PublicPageMenu/PublicPageMenuCustomEntry.vue'
+import PublicPageMenuExternalEntry from '../components/PublicPageMenu/PublicPageMenuExternalEntry.vue'
+import PublicPageMenuExternalDialog from '../components/PublicPageMenu/PublicPageMenuExternalDialog.vue'
+import PublicPageMenuLinkEntry from '../components/PublicPageMenu/PublicPageMenuLinkEntry.vue'
+
+interface IPublicPageMenu {
+ id: string
+ label: string
+ href: string
+ icon?: string
+ html?: string
+ details?: string
+}
+
+const menuEntries = loadState<Array<IPublicPageMenu>>('core', 'public-page-menu')
+
+/** used to conditionally close the menu when clicking entry */
+const showMenu = ref(false)
+
+const isMobile = useIsSmallMobile() as Readonly<Ref<boolean>>
+/** The primary menu action - only showed when not on mobile */
+const primaryAction = computed(() => isMobile.value ? undefined : menuEntries[0])
+/** All other secondary actions (including primary action on mobile) */
+const secondaryActions = computed(() => isMobile.value ? menuEntries : menuEntries.slice(1))
+
+/**
+ * Get the render component for an entry
+ * @param entry The entry to get the component for
+ */
+function getComponent(entry: IPublicPageMenu) {
+ if ('html' in entry) {
+ return PublicPageMenuCustomEntry
+ }
+ switch (entry.id) {
+ case 'save':
+ return PublicPageMenuExternalEntry
+ case 'directLink':
+ return PublicPageMenuLinkEntry
+ default:
+ return PublicPageMenuEntry
+ }
+}
+
+/**
+ * Open the "federated share" dialog if needed
+ */
+function openDialogIfNeeded() {
+ if (primaryAction.value?.id !== 'save') {
+ return
+ }
+ spawnDialog(PublicPageMenuExternalDialog, { label: primaryAction.value.label })
+}
+</script>
+
+<style scoped lang="scss">
+.public-page-menu {
+ box-sizing: border-box;
+
+ > :deep(*) {
+ box-sizing: border-box;
+ }
+
+ &__wrapper {
+ display: flex;
+ flex-direction: row;
+ gap: var(--default-grid-baseline);
+ }
+
+ &__primary {
+ height: var(--default-clickable-area);
+ margin-block: calc((var(--header-height) - var(--default-clickable-area)) / 2);
+
+ // Ensure the correct focus-visible color is used (as this is rendered directly on the background(-image))
+ &:focus-visible {
+ border-color: var(--color-background-plain-text) !important;
+ }
+ }
+
+ &__primary-icon {
+ filter: var(--primary-invert-if-bright);
+ }
+}
+</style>