|
| 1 | +<!-- |
| 2 | + SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
| 3 | + SPDX-License-Identifier: GPL-3.0-or-later |
| 4 | +--> |
| 5 | +<template> |
| 6 | + <NcDialog |
| 7 | + class="sharing-dialog" |
| 8 | + name="" |
| 9 | + size="normal" |
| 10 | + @closing="emit('close')"> |
| 11 | + <div class="sharing-dialog__header"> |
| 12 | + <!-- Back button, shown when in settings --> |
| 13 | + <NcButton |
| 14 | + v-if="inSettings" |
| 15 | + class="sharing-dialog__settings-back-btn" |
| 16 | + variant="tertiary" |
| 17 | + :aria-label="t('Back to sharing options')" |
| 18 | + @click="inSettings = false"> |
| 19 | + <template #icon> |
| 20 | + <NcIconSvgWrapper :svg="IconArrowLeft" directional /> |
| 21 | + </template> |
| 22 | + </NcButton> |
| 23 | + |
| 24 | + <!-- Dialog title and subtitle --> |
| 25 | + <span class="dialog__titles"> |
| 26 | + <h2 class="sharing-dialog__title"> |
| 27 | + {{ dialogTitle }} |
| 28 | + </h2> |
| 29 | + <h3 v-if="inSettings && nodeName" class="sharing-dialog__subtitle"> |
| 30 | + {{ nodeName }} |
| 31 | + </h3> |
| 32 | + </span> |
| 33 | + </div> |
| 34 | + |
| 35 | + <NcEmptyContent |
| 36 | + v-if="loading" |
| 37 | + class="sharing-dialog__loading" |
| 38 | + :name="t('Loading sharing options…')"> |
| 39 | + <template #icon> |
| 40 | + <NcLoadingIcon :size="44" /> |
| 41 | + </template> |
| 42 | + </NcEmptyContent> |
| 43 | + |
| 44 | + <NcEmptyContent |
| 45 | + v-else-if="error" |
| 46 | + class="sharing-dialog__error" |
| 47 | + :name="t('Failed to create share')" |
| 48 | + :description="error" /> |
| 49 | + |
| 50 | + <!-- Confirmation shown after the share is submitted --> |
| 51 | + <ShareConfirmation |
| 52 | + v-else-if="submitted" |
| 53 | + :link="submitResult?.link ?? null" |
| 54 | + :isPublic="submitResult?.isPublic ?? false" |
| 55 | + @close="emit('close')" /> |
| 56 | + |
| 57 | + <template v-else-if="share"> |
| 58 | + <SharePanel |
| 59 | + v-model:shareDialogTab="shareDialogTab" |
| 60 | + :inSettings="inSettings" |
| 61 | + :share="share" |
| 62 | + :folderName="folderName" |
| 63 | + @settingsWarning="settingsHasWarning = $event" |
| 64 | + @settingsAvailable="settingsAvailable = $event" |
| 65 | + @submitted="onSubmitted" /> |
| 66 | + </template> |
| 67 | + |
| 68 | + <!-- Settings toggle --> |
| 69 | + <NcButton |
| 70 | + v-if="!inSettings && !submitted && share && settingsAvailable" |
| 71 | + :aria-label="t('Additional sharing settings')" |
| 72 | + class="sharing-dialog__settings-toggle" |
| 73 | + :class="{ 'sharing-dialog__settings-toggle--warning': settingsHasWarning }" |
| 74 | + variant="tertiary" |
| 75 | + @click="inSettings = true"> |
| 76 | + <template #icon> |
| 77 | + <NcIconSvgWrapper :svg="IconCogOutline" /> |
| 78 | + </template> |
| 79 | + </NcButton> |
| 80 | + </NcDialog> |
| 81 | +</template> |
| 82 | + |
| 83 | +<script setup lang="ts"> |
| 84 | +import type { INode } from '@nextcloud/files' |
| 85 | +import type { Share } from './api/share.ts' |
| 86 | +import type { SharingCapabilities } from './types/api.ts' |
| 87 | +
|
| 88 | +import IconArrowLeft from '@mdi/svg/svg/arrow-left.svg?raw' |
| 89 | +import IconCogOutline from '@mdi/svg/svg/cog-outline.svg?raw' |
| 90 | +import { getCapabilities } from '@nextcloud/capabilities' |
| 91 | +import { FileType } from '@nextcloud/files' |
| 92 | +import { computed, onMounted, ref, shallowRef } from 'vue' |
| 93 | +import NcButton from '@nextcloud/vue/components/NcButton' |
| 94 | +import NcDialog from '@nextcloud/vue/components/NcDialog' |
| 95 | +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' |
| 96 | +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' |
| 97 | +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' |
| 98 | +import ShareConfirmation from './components/ShareConfirmation.vue' |
| 99 | +import SharePanel from './components/SharePanel.vue' |
| 100 | +import { createShare } from './api/share.ts' |
| 101 | +import { SOURCE_TYPE_NODE } from './constants.ts' |
| 102 | +import { ShareDialogTab } from './types/ui.ts' |
| 103 | +import { t } from './utils/l10n.ts' |
| 104 | +import { logger } from './utils/logger.ts' |
| 105 | +
|
| 106 | +const props = defineProps<{ |
| 107 | + /** An existing share to edit. Provide this or {@link node}. */ |
| 108 | + share?: Share |
| 109 | + /** The file or folder to share. A draft share is created for it. */ |
| 110 | + node?: INode |
| 111 | +}>() |
| 112 | +
|
| 113 | +const emit = defineEmits<{ |
| 114 | + (e: 'close'): void |
| 115 | +}>() |
| 116 | +
|
| 117 | +const { sharing: sharingCapabilities } = getCapabilities() as SharingCapabilities |
| 118 | +
|
| 119 | +const inSettings = ref(false) |
| 120 | +// Loading only while a draft is being created for a node; a passed-in share is ready. |
| 121 | +const loading = ref(!props.share) |
| 122 | +const error = ref<string | null>(null) |
| 123 | +const share = shallowRef<Share | null>(props.share ?? null) |
| 124 | +
|
| 125 | +// Display name for the title/subtitle, from the node if one was provided. |
| 126 | +const nodeName = computed(() => props.node?.displayname ?? null) |
| 127 | +
|
| 128 | +// Folder name when the shared node is a folder, used to hint that public |
| 129 | +// uploads land in it. Null for files or when opened without a node. |
| 130 | +const folderName = computed(() => props.node?.type === FileType.Folder ? props.node.displayname : null) |
| 131 | +
|
| 132 | +const dialogTitle = computed(() => { |
| 133 | + if (inSettings.value) { |
| 134 | + return t('Sharing settings') |
| 135 | + } |
| 136 | + return nodeName.value |
| 137 | + ? t('Share "{name}"', { name: nodeName.value }) |
| 138 | + : t('Share') |
| 139 | +}) |
| 140 | +
|
| 141 | +const shareDialogTab = ref<ShareDialogTab>(ShareDialogTab.InvitedPeople) |
| 142 | +const settingsHasWarning = ref(false) |
| 143 | +const settingsAvailable = ref(false) |
| 144 | +
|
| 145 | +// Once the share is submitted we swap the form for the confirmation view. |
| 146 | +const submitted = ref(false) |
| 147 | +const submitResult = ref<{ link: string | null, isPublic: boolean } | null>(null) |
| 148 | +
|
| 149 | +/** |
| 150 | + * Switch to the confirmation view with the submitted share's link. |
| 151 | + * |
| 152 | + * @param payload The resolved link and whether it is a public link |
| 153 | + * @param payload.link |
| 154 | + * @param payload.isPublic |
| 155 | + */ |
| 156 | +function onSubmitted(payload: { link: string | null, isPublic: boolean }) { |
| 157 | + submitResult.value = payload |
| 158 | + submitted.value = true |
| 159 | +} |
| 160 | +
|
| 161 | +onMounted(async () => { |
| 162 | + // A ready share was passed in; nothing to create. |
| 163 | + if (share.value) { |
| 164 | + return |
| 165 | + } |
| 166 | +
|
| 167 | + try { |
| 168 | + if (!props.node) { |
| 169 | + throw new Error('Either a share or a node must be provided') |
| 170 | + } |
| 171 | + // Validate source type is registered |
| 172 | + if (!sharingCapabilities.source_types.some((t) => t.class === SOURCE_TYPE_NODE)) { |
| 173 | + throw new Error('File source type not available') |
| 174 | + } |
| 175 | +
|
| 176 | + // Create a draft share and attach the file as source |
| 177 | + const draft = await createShare() |
| 178 | + await draft.addNode(props.node) |
| 179 | + share.value = draft |
| 180 | + } catch (e) { |
| 181 | + const message = e instanceof Error ? e.message : 'Unknown error' |
| 182 | + error.value = message |
| 183 | + logger.error('Failed to initialize share', { error: e }) |
| 184 | + } finally { |
| 185 | + loading.value = false |
| 186 | + } |
| 187 | +}) |
| 188 | +
|
| 189 | +</script> |
| 190 | + |
| 191 | +<style scoped lang="scss"> |
| 192 | +.sharing-dialog { |
| 193 | + // Hide the default dialog title, we use our own in the content instead |
| 194 | + :deep(.dialog__name) { |
| 195 | + display: none; |
| 196 | + } |
| 197 | +
|
| 198 | + // Consistent vertical rhythm between the header and the form. |
| 199 | + :deep(.dialog__content) { |
| 200 | + display: flex; |
| 201 | + flex-direction: column; |
| 202 | + gap: calc(var(--default-grid-baseline) * 3); |
| 203 | + } |
| 204 | +
|
| 205 | + // Scroll the form (everything but the fixed header/close) once it grows |
| 206 | + // tall, so the scrollbar never overlaps the header. A max-height rather than |
| 207 | + // flex is used so the dialog still sizes to its content when it is short. |
| 208 | + :deep(.share-panel) { |
| 209 | + max-height: 50vh; |
| 210 | + overflow-y: auto; |
| 211 | + // Match the dialog's inline padding at the bottom (its content has none), |
| 212 | + // so the form does not sit flush against the edge. |
| 213 | + padding-block-end: calc(var(--default-grid-baseline) * 3); |
| 214 | + } |
| 215 | +
|
| 216 | + &__loading, |
| 217 | + &__error { |
| 218 | + display: flex; |
| 219 | + justify-content: center; |
| 220 | + align-items: center; |
| 221 | + padding: calc(var(--default-grid-baseline) * 12); |
| 222 | + } |
| 223 | +
|
| 224 | + .sharing-dialog__settings-toggle { |
| 225 | + z-index: 1; |
| 226 | + position: absolute !important; |
| 227 | + top: var(--default-grid-baseline); |
| 228 | + inset-inline-end: var(--default-grid-baseline); |
| 229 | + margin-inline-end: calc(var(--button-size) + var(--default-grid-baseline)); |
| 230 | +
|
| 231 | + &--warning { |
| 232 | + &::after { |
| 233 | + content: ''; |
| 234 | + position: absolute; |
| 235 | + top: 2px; |
| 236 | + inset-inline-end: 2px; |
| 237 | + width: 10px; |
| 238 | + height: 10px; |
| 239 | + border-radius: 50%; |
| 240 | + border: 2px solid var(--color-main-background); |
| 241 | + background-color: var(--color-warning); |
| 242 | + pointer-events: none; |
| 243 | + } |
| 244 | + } |
| 245 | + } |
| 246 | + .sharing-dialog__header { |
| 247 | + display: flex; |
| 248 | + align-items: center; |
| 249 | + gap: calc(var(--default-grid-baseline) * 2); |
| 250 | + height: calc(var(--default-clickable-area) * 2); |
| 251 | + padding-inline-end: calc(var(--default-clickable-area) * 2); |
| 252 | + } |
| 253 | +
|
| 254 | + .dialog__titles { |
| 255 | + display: flex; |
| 256 | + flex-direction: column; |
| 257 | + overflow: hidden; |
| 258 | + text-overflow: ellipsis; |
| 259 | +
|
| 260 | + h2.sharing-dialog__title, |
| 261 | + h3.sharing-dialog__subtitle { |
| 262 | + margin-top: 2px; |
| 263 | + margin-bottom: 2px; |
| 264 | + line-height: 1.1em; |
| 265 | + font-size: 21px; |
| 266 | + } |
| 267 | +
|
| 268 | + h2.sharing-dialog__title { |
| 269 | + word-break: break-all; |
| 270 | + } |
| 271 | +
|
| 272 | + h3.sharing-dialog__subtitle { |
| 273 | + color: var(--color-text-maxcontrast); |
| 274 | + font-size: 1em; |
| 275 | + font-weight: normal; |
| 276 | + overflow: hidden; |
| 277 | + text-overflow: ellipsis; |
| 278 | + white-space: nowrap; |
| 279 | + } |
| 280 | + } |
| 281 | +} |
| 282 | +</style> |
0 commit comments