diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-08-28 13:10:25 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-09-03 16:07:49 +0200 |
commit | 04b25ba59d48ea3ab1d34d9f05ede33fc7ad7fbc (patch) | |
tree | d16c43eaeea478254009d7d2a87b75af0bc553ce /core | |
parent | 5118f6684bd2f4f8d693ada710e845b6af5c5395 (diff) | |
download | nextcloud-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')
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> |