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>
pull/47568/head
Ferdinand Thiessen 2024-08-28 13:10:25 +07:00
parent 5118f6684b
commit 04b25ba59d
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400
8 changed files with 409 additions and 0 deletions

@ -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>

@ -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>

@ -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>

@ -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>

@ -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>

@ -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')

@ -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>

@ -19,6 +19,7 @@ module.exports = {
main: path.join(__dirname, 'core/src', 'main.js'),
maintenance: path.join(__dirname, 'core/src', 'maintenance.js'),
profile: path.join(__dirname, 'core/src', 'profile.ts'),
'public-page-menu': path.resolve(__dirname, 'core/src', 'public-page-menu.ts'),
recommendedapps: path.join(__dirname, 'core/src', 'recommendedapps.js'),
systemtags: path.resolve(__dirname, 'core/src', 'systemtags/merged-systemtags.js'),
'unified-search': path.join(__dirname, 'core/src', 'unified-search.ts'),