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
parent
5118f6684b
commit
04b25ba59d
@ -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>
|
||||||
Loading…
Reference in New Issue