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