feat(files_sharing): add `new file request` option
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>pull/46007/head
parent
b6f635f6f6
commit
443c48aefb
@ -0,0 +1,330 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<NcDialog can-close
|
||||
class="file-request-dialog"
|
||||
data-cy-file-request-dialog
|
||||
:close-on-click-outside="false"
|
||||
:name="currentStep !== STEP.LAST ? t('files_sharing', 'Create a file request') : t('files_sharing', 'File request created')"
|
||||
size="normal"
|
||||
@closing="onCancel">
|
||||
<!-- Header -->
|
||||
<NcNoteCard v-show="currentStep === STEP.FIRST" type="info" class="file-request-dialog__header">
|
||||
<p id="file-request-dialog-description" class="file-request-dialog__description">
|
||||
{{ t('files_sharing', 'Collect files from others even if they don\'t have an account.') }}
|
||||
{{ t('files_sharing', 'To ensure you can receive files, verify you have enough storage available.') }}
|
||||
</p>
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Main form -->
|
||||
<form ref="form"
|
||||
class="file-request-dialog__form"
|
||||
aria-labelledby="file-request-dialog-description"
|
||||
data-cy-file-request-dialog-form
|
||||
@submit.prevent.stop="onSubmit">
|
||||
<FileRequestIntro v-if="currentStep === STEP.FIRST"
|
||||
:context="context"
|
||||
:destination.sync="destination"
|
||||
:disabled="loading"
|
||||
:label.sync="label"
|
||||
:note.sync="note" />
|
||||
|
||||
<FileRequestDatePassword v-else-if="currentStep === STEP.SECOND"
|
||||
:deadline.sync="deadline"
|
||||
:disabled="loading"
|
||||
:password.sync="password" />
|
||||
|
||||
<FileRequestFinish v-else-if="share"
|
||||
:emails="emails"
|
||||
:share="share"
|
||||
@add-email="email => emails.push(email)"
|
||||
@remove-email="onRemoveEmail" />
|
||||
</form>
|
||||
|
||||
<!-- Controls -->
|
||||
<template #actions>
|
||||
<!-- Cancel the creation -->
|
||||
<NcButton :aria-label="t('files_sharing', 'Cancel')"
|
||||
:disabled="loading"
|
||||
:title="t('files_sharing', 'Cancel the file request creation')"
|
||||
data-cy-file-request-dialog-controls="cancel"
|
||||
type="tertiary"
|
||||
@click="onCancel">
|
||||
{{ t('files_sharing', 'Cancel') }}
|
||||
</NcButton>
|
||||
|
||||
<!-- Align right -->
|
||||
<span class="dialog__actions-separator" />
|
||||
|
||||
<!-- Back -->
|
||||
<NcButton v-show="currentStep === STEP.SECOND"
|
||||
:aria-label="t('files_sharing', 'Previous step')"
|
||||
:disabled="loading"
|
||||
data-cy-file-request-dialog-controls="back"
|
||||
type="tertiary"
|
||||
@click="currentStep = STEP.FIRST">
|
||||
{{ t('files_sharing', 'Previous') }}
|
||||
</NcButton>
|
||||
|
||||
<!-- Next -->
|
||||
<NcButton v-if="currentStep !== STEP.LAST"
|
||||
:aria-label="t('files_sharing', 'Continue')"
|
||||
:disabled="loading"
|
||||
data-cy-file-request-dialog-controls="next"
|
||||
@click="onPageNext">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="loading" />
|
||||
<IconNext v-else :size="20" />
|
||||
</template>
|
||||
{{ continueButtonLabel }}
|
||||
</NcButton>
|
||||
|
||||
<!-- Finish -->
|
||||
<NcButton v-else
|
||||
:aria-label="t('files_sharing', 'Close the creation dialog')"
|
||||
data-cy-file-request-dialog-controls="finish"
|
||||
type="primary"
|
||||
@click="$emit('close')">
|
||||
<template #icon>
|
||||
<IconCheck :size="20" />
|
||||
</template>
|
||||
{{ finishButtonLabel }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { AxiosError } from 'axios'
|
||||
import type { Folder, Node } from '@nextcloud/files'
|
||||
import type { OCSResponse } from '@nextcloud/typings/ocs'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate, translatePlural } from '@nextcloud/l10n'
|
||||
import { Type } from '@nextcloud/sharing'
|
||||
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 NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
|
||||
|
||||
import IconCheck from 'vue-material-design-icons/Check.vue'
|
||||
import IconNext from 'vue-material-design-icons/ArrowRight.vue'
|
||||
|
||||
import FileRequestDatePassword from './NewFileRequestDialog/FileRequestDatePassword.vue'
|
||||
import FileRequestFinish from './NewFileRequestDialog/FileRequestFinish.vue'
|
||||
import FileRequestIntro from './NewFileRequestDialog/FileRequestIntro.vue'
|
||||
import Share from '../models/Share'
|
||||
import logger from '../services/logger'
|
||||
|
||||
enum STEP {
|
||||
FIRST = 0,
|
||||
SECOND = 1,
|
||||
LAST = 2,
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NewFileRequestDialog',
|
||||
|
||||
components: {
|
||||
FileRequestDatePassword,
|
||||
FileRequestFinish,
|
||||
FileRequestIntro,
|
||||
IconCheck,
|
||||
IconNext,
|
||||
NcButton,
|
||||
NcDialog,
|
||||
NcLoadingIcon,
|
||||
NcNoteCard,
|
||||
},
|
||||
|
||||
props: {
|
||||
context: {
|
||||
type: Object as PropType<Folder>,
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: Array as PropType<Node[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
return {
|
||||
n: translatePlural,
|
||||
t: translate,
|
||||
STEP,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
currentStep: STEP.FIRST,
|
||||
loading: false,
|
||||
|
||||
destination: this.context.path || '/',
|
||||
label: '',
|
||||
note: '',
|
||||
|
||||
deadline: null as Date | null,
|
||||
password: null as string | null,
|
||||
|
||||
share: null as Share | null,
|
||||
emails: [] as string[],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
continueButtonLabel() {
|
||||
if (this.currentStep === STEP.LAST) {
|
||||
return this.t('files_sharing', 'Close')
|
||||
}
|
||||
return this.t('files_sharing', 'Continue')
|
||||
},
|
||||
|
||||
finishButtonLabel() {
|
||||
if (this.emails.length === 0) {
|
||||
return this.t('files_sharing', 'Close')
|
||||
}
|
||||
return this.n('files_sharing', 'Close and send email', 'Close and send {count} emails', this.emails.length, { count: this.emails.length })
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onPageNext() {
|
||||
const form = this.$refs.form as HTMLFormElement
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity()
|
||||
}
|
||||
|
||||
// custom destination validation
|
||||
// cannot share root
|
||||
if (this.destination === '/' || this.destination === '') {
|
||||
const destinationInput = form.querySelector('input[name="destination"]') as HTMLInputElement
|
||||
destinationInput?.setCustomValidity(this.t('files_sharing', 'Please select a folder, you cannot share the root directory.'))
|
||||
form.reportValidity()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.currentStep === STEP.FIRST) {
|
||||
this.currentStep = STEP.SECOND
|
||||
return
|
||||
}
|
||||
|
||||
this.createShare()
|
||||
},
|
||||
|
||||
onRemoveEmail(email: string) {
|
||||
const index = this.emails.indexOf(email)
|
||||
this.emails.splice(index, 1)
|
||||
},
|
||||
|
||||
onCancel() {
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
this.$emit('submit')
|
||||
},
|
||||
|
||||
async createShare() {
|
||||
this.loading = true
|
||||
|
||||
const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')
|
||||
// Format must be YYYY-MM-DD
|
||||
const expireDate = this.deadline ? this.deadline.toISOString().split('T')[0] : undefined
|
||||
try {
|
||||
const request = await axios.post(shareUrl, {
|
||||
path: this.destination,
|
||||
shareType: Type.SHARE_TYPE_LINK,
|
||||
publicUpload: 'true',
|
||||
password: this.password || undefined,
|
||||
expireDate,
|
||||
label: this.label,
|
||||
attributes: JSON.stringify({ is_file_request: true })
|
||||
})
|
||||
|
||||
// If not an ocs request
|
||||
if (!request?.data?.ocs) {
|
||||
throw request
|
||||
}
|
||||
|
||||
const share = new Share(request.data.ocs.data)
|
||||
this.share = share
|
||||
|
||||
logger.info('New file request created', { share })
|
||||
emit('files_sharing:share:created', { share })
|
||||
|
||||
// Move to the last page
|
||||
this.currentStep = STEP.LAST
|
||||
} catch (error) {
|
||||
const errorMessage = (error as AxiosError<OCSResponse>)?.response?.data?.ocs?.meta?.message
|
||||
showError(
|
||||
errorMessage
|
||||
? this.t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage })
|
||||
: this.t('files_sharing', 'Error creating the share'),
|
||||
)
|
||||
logger.error('Error while creating share', { error, errorMessage })
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-request-dialog {
|
||||
--margin: 36px;
|
||||
--secondary-margin: 18px;
|
||||
|
||||
&__header {
|
||||
margin: 0 var(--margin);
|
||||
}
|
||||
|
||||
&__form {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
padding: var(--secondary-margin) var(--margin);
|
||||
// overlap header bottom padding
|
||||
margin-top: calc(-1 * var(--secondary-margin));
|
||||
}
|
||||
|
||||
:deep(fieldset) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin-top: var(--secondary-margin);
|
||||
|
||||
:deep(legend) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.dialog__actions) {
|
||||
width: auto;
|
||||
margin-inline: 12px;
|
||||
// align left and remove margin
|
||||
margin-left: 0;
|
||||
span.dialog__actions-separator {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.input-field__helper-text-message) {
|
||||
// reduce helper text standing out
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,227 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Password and expiration summary -->
|
||||
<NcNoteCard v-if="passwordAndExpirationSummary" type="success">
|
||||
{{ passwordAndExpirationSummary }}
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Expiration date -->
|
||||
<fieldset class="file-request-dialog__expiration" data-cy-file-request-dialog-fieldset="expiration">
|
||||
<NcNoteCard v-if="defaultExpireDateEnforced" type="info">
|
||||
{{ t('files_sharing', 'Your administrator has enforced a default expiration date with a maximum {days} days.', { days: defaultExpireDate }) }}
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Enable expiration -->
|
||||
<legend>{{ t('files_sharing', 'When should the request expire ?') }}</legend>
|
||||
<NcCheckboxRadioSwitch v-show="!defaultExpireDateEnforced"
|
||||
:checked="defaultExpireDateEnforced || deadline !== null"
|
||||
:disabled="disabled || defaultExpireDateEnforced"
|
||||
@update:checked="onToggleDeadline">
|
||||
{{ t('files_sharing', 'Set a submission deadline') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<!-- Date picker -->
|
||||
<NcDateTimePickerNative v-if="deadline !== null"
|
||||
id="file-request-dialog-deadline"
|
||||
:disabled="disabled"
|
||||
:hide-label="true"
|
||||
:max="maxDate"
|
||||
:min="minDate"
|
||||
:placeholder="t('files_sharing', 'Select a date')"
|
||||
:required="defaultExpireDateEnforced"
|
||||
:value="deadline"
|
||||
name="deadline"
|
||||
type="date"
|
||||
@update:value="$emit('update:deadline', $event)"/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Password -->
|
||||
<fieldset class="file-request-dialog__password" data-cy-file-request-dialog-fieldset="password">
|
||||
<NcNoteCard v-if="enforcePasswordForPublicLink" type="info">
|
||||
{{ t('files_sharing', 'Your administrator has enforced a password protection.') }}
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Enable password -->
|
||||
<legend>{{ t('files_sharing', 'What password should be used for the request ?') }}</legend>
|
||||
<NcCheckboxRadioSwitch v-show="!enforcePasswordForPublicLink"
|
||||
:checked="enforcePasswordForPublicLink || password !== null"
|
||||
:disabled="disabled || enforcePasswordForPublicLink"
|
||||
@update:checked="onTogglePassword">
|
||||
{{ t('files_sharing', 'Set a password') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<div v-if="password !== null" class="file-request-dialog__password-field">
|
||||
<NcPasswordField ref="passwordField"
|
||||
:check-password-strength="true"
|
||||
:disabled="disabled"
|
||||
:label-outside="true"
|
||||
:placeholder="t('files_sharing', 'Enter a valid password')"
|
||||
:required="false"
|
||||
:value="password"
|
||||
name="password"
|
||||
@update:value="$emit('update:password', $event)" />
|
||||
<NcButton :aria-label="t('files_sharing', 'Generate a new password')"
|
||||
:title="t('files_sharing', 'Generate a new password')"
|
||||
type="tertiary-no-background"
|
||||
@click="generatePassword(); showPassword()">
|
||||
<template #icon>
|
||||
<IconPasswordGen :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
|
||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
|
||||
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
|
||||
import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
|
||||
|
||||
import IconPasswordGen from 'vue-material-design-icons/AutoFix.vue'
|
||||
|
||||
import GeneratePassword from '../../utils/GeneratePassword'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileRequestDatePassword',
|
||||
|
||||
components: {
|
||||
IconPasswordGen,
|
||||
NcButton,
|
||||
NcCheckboxRadioSwitch,
|
||||
NcDateTimePickerNative,
|
||||
NcNoteCard,
|
||||
NcPasswordField,
|
||||
},
|
||||
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
deadline: {
|
||||
type: Date as PropType<Date | null>,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
password: {
|
||||
type: String as PropType<string | null>,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
emits: [
|
||||
'update:deadline',
|
||||
'update:password',
|
||||
],
|
||||
|
||||
setup() {
|
||||
return {
|
||||
t: translate,
|
||||
|
||||
// Default expiration date if defaultExpireDateEnabled is true
|
||||
defaultExpireDate: window.OC.appConfig.core.defaultExpireDate as number,
|
||||
// Default expiration date is enabled for public links (can be disabled)
|
||||
defaultExpireDateEnabled: window.OC.appConfig.core.defaultExpireDateEnabled === true,
|
||||
// Default expiration date is enforced for public links (can't be disabled)
|
||||
defaultExpireDateEnforced: window.OC.appConfig.core.defaultExpireDateEnforced === true,
|
||||
|
||||
// Default password protection is enabled for public links (can be disabled)
|
||||
enableLinkPasswordByDefault: window.OC.appConfig.core.enableLinkPasswordByDefault === true,
|
||||
// Password protection is enforced for public links (can't be disabled)
|
||||
enforcePasswordForPublicLink: window.OC.appConfig.core.enforcePasswordForPublicLink === true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
maxDate: null as Date | null,
|
||||
minDate: new Date(new Date().setDate(new Date().getDate() + 1)),
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
passwordAndExpirationSummary(): string {
|
||||
if (this.deadline && this.password) {
|
||||
return this.t('files_sharing', 'The request will expire on {date} at midnight and will be password protected.', {
|
||||
date: this.deadline.toLocaleDateString(),
|
||||
})
|
||||
}
|
||||
|
||||
if (this.deadline) {
|
||||
return this.t('files_sharing', 'The request will expire on {date} at midnight.', {
|
||||
date: this.deadline.toLocaleDateString(),
|
||||
})
|
||||
}
|
||||
|
||||
if (this.password) {
|
||||
return this.t('files_sharing', 'The request will be password protected.')
|
||||
}
|
||||
|
||||
return ''
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// If defined, we set the default expiration date
|
||||
if (this.defaultExpireDate > 0) {
|
||||
this.$emit('update:deadline', new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate)))
|
||||
}
|
||||
|
||||
// If enforced, we cannot set a date before the default expiration days (see admin settings)
|
||||
if (this.defaultExpireDateEnforced) {
|
||||
this.maxDate = new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate))
|
||||
}
|
||||
|
||||
// If enabled by default, we generate a valid password
|
||||
if (this.enableLinkPasswordByDefault) {
|
||||
this.generatePassword()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onToggleDeadline(checked: boolean) {
|
||||
this.$emit('update:deadline', checked ? new Date() : null)
|
||||
},
|
||||
|
||||
async onTogglePassword(checked: boolean) {
|
||||
if (checked) {
|
||||
this.generatePassword()
|
||||
return
|
||||
}
|
||||
this.$emit('update:password', null)
|
||||
},
|
||||
|
||||
generatePassword() {
|
||||
GeneratePassword().then(password => {
|
||||
this.$emit('update:password', password)
|
||||
})
|
||||
},
|
||||
|
||||
showPassword() {
|
||||
// @ts-expect-error isPasswordHidden is private
|
||||
this.$refs.passwordField.isPasswordHidden = false
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-request-dialog__password-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,217 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Request note -->
|
||||
<NcNoteCard type="success">
|
||||
{{ t('files_sharing', 'You can now share the link below to allow others to upload files to your directory.') }}
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Copy share link -->
|
||||
<NcInputField ref="clipboard"
|
||||
:value="shareLink"
|
||||
:label="t('files_sharing', 'Share link')"
|
||||
:readonly="true"
|
||||
:show-trailing-button="true"
|
||||
:trailing-button-label="t('files_sharing', 'Copy to clipboard')"
|
||||
@click="copyShareLink"
|
||||
@click-trailing-button="copyShareLink">
|
||||
<template #trailing-button-icon>
|
||||
<IconCheck v-if="isCopied" :size="20" @click="isCopied = false" />
|
||||
<IconClipboard v-else :size="20" @click="copyShareLink" />
|
||||
</template>
|
||||
</NcInputField>
|
||||
|
||||
<template v-if="isShareByMailEnabled">
|
||||
<!-- Email share-->
|
||||
<NcTextField :value.sync="email"
|
||||
:label="t('files_sharing', 'Send link via email')"
|
||||
:placeholder="t('files_sharing', 'Enter an email address or paste a list')"
|
||||
type="email"
|
||||
@keypress.enter.stop="addNewEmail"
|
||||
@paste.stop.prevent="onPasteEmails" />
|
||||
|
||||
<!-- Email list -->
|
||||
<div v-if="emails.length > 0" class="file-request-dialog__emails">
|
||||
<NcChip v-for="mail in emails"
|
||||
:key="mail"
|
||||
:aria-label-close="t('files_sharing', 'Remove email')"
|
||||
:text="mail"
|
||||
@close="$emit('remove-email', mail)">
|
||||
<template #icon>
|
||||
<NcAvatar :disable-menu="true"
|
||||
:disable-tooltip="true"
|
||||
:is-guest="true"
|
||||
:size="24"
|
||||
:user="mail" />
|
||||
</template>
|
||||
</NcChip>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import Share from '../../models/Share'
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { translate, translatePlural } from '@nextcloud/l10n'
|
||||
|
||||
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
|
||||
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
|
||||
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
|
||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
||||
import NcChip from '@nextcloud/vue/dist/Components/NcChip.js'
|
||||
|
||||
import IconCheck from 'vue-material-design-icons/Check.vue'
|
||||
import IconClipboard from 'vue-material-design-icons/Clipboard.vue'
|
||||
import { getCapabilities } from '@nextcloud/capabilities'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileRequestFinish',
|
||||
|
||||
components: {
|
||||
IconCheck,
|
||||
IconClipboard,
|
||||
NcAvatar,
|
||||
NcInputField,
|
||||
NcNoteCard,
|
||||
NcTextField,
|
||||
NcChip,
|
||||
},
|
||||
|
||||
props: {
|
||||
share: {
|
||||
type: Object as PropType<Share>,
|
||||
required: true,
|
||||
},
|
||||
emails: {
|
||||
type: Array as PropType<string[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['add-email', 'remove-email'],
|
||||
|
||||
setup() {
|
||||
return {
|
||||
n: translatePlural,
|
||||
t: translate,
|
||||
isShareByMailEnabled: getCapabilities()?.files_sharing?.sharebymail?.enabled === true,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isCopied: false,
|
||||
email: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
shareLink() {
|
||||
return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async copyShareLink(event: MouseEvent) {
|
||||
if (!navigator.clipboard) {
|
||||
// Clipboard API not available
|
||||
showError(this.t('files_sharing', 'Clipboard is not available'))
|
||||
return
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(this.shareLink)
|
||||
|
||||
showSuccess(this.t('files_sharing', 'Link copied to clipboard'))
|
||||
this.isCopied = true
|
||||
event.target?.select?.()
|
||||
|
||||
setTimeout(() => {
|
||||
this.isCopied = false
|
||||
}, 3000)
|
||||
},
|
||||
|
||||
addNewEmail(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
if (e.target.checkValidity() === false) {
|
||||
e.target.reportValidity()
|
||||
return
|
||||
}
|
||||
|
||||
// The email is already in the list
|
||||
if (this.emails.includes(this.email.trim())) {
|
||||
e.target.setCustomValidity(this.t('files_sharing', 'Email already added'))
|
||||
e.target.reportValidity()
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.isValidEmail(this.email.trim())) {
|
||||
e.target.setCustomValidity(this.t('files_sharing', 'Invalid email address'))
|
||||
e.target.reportValidity()
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('add-email', this.email.trim())
|
||||
this.email = ''
|
||||
}
|
||||
},
|
||||
|
||||
// Handle dumping a list of emails
|
||||
onPasteEmails(e: ClipboardEvent) {
|
||||
const clipboardData = e.clipboardData
|
||||
if (!clipboardData) {
|
||||
return
|
||||
}
|
||||
|
||||
const pastedText = clipboardData.getData('text')
|
||||
const emails = pastedText.split(/[\s,;]+/).filter(Boolean).map((email) => email.trim())
|
||||
|
||||
const duplicateEmails = emails.filter((email) => this.emails.includes(email))
|
||||
const validEmails = emails.filter((email) => this.isValidEmail(email) && !duplicateEmails.includes(email))
|
||||
const invalidEmails = emails.filter((email) => !this.isValidEmail(email))
|
||||
validEmails.forEach((email) => this.$emit('add-email', email))
|
||||
|
||||
// Warn about invalid emails
|
||||
if (invalidEmails.length > 0) {
|
||||
showError(this.n('files_sharing', 'The following email address is not valid: {emails}', 'The following email addresses are not valid: {emails}', invalidEmails.length, { emails: invalidEmails.join(', ') }))
|
||||
}
|
||||
|
||||
// Warn about duplicate emails
|
||||
if (duplicateEmails.length > 0) {
|
||||
showError(this.n('files_sharing', '1 email address already added', '{count} email addresses already added', duplicateEmails.length, { count: duplicateEmails.length }))
|
||||
}
|
||||
|
||||
if (validEmails.length > 0) {
|
||||
showSuccess(this.n('files_sharing', '1 email address added', '{count} email addresses added', validEmails.length, { count: validEmails.length }))
|
||||
}
|
||||
|
||||
this.email = ''
|
||||
},
|
||||
|
||||
isValidEmail(email) {
|
||||
const regExpEmail = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
return regExpEmail.test(email)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.input-field,
|
||||
.file-request-dialog__emails {
|
||||
margin-top: var(--secondary-margin);
|
||||
}
|
||||
|
||||
.file-request-dialog__emails {
|
||||
display: flex;
|
||||
gap: var(--default-grid-baseline);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,153 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Request label -->
|
||||
<fieldset class="file-request-dialog__label" data-cy-file-request-dialog-fieldset="label">
|
||||
<legend>
|
||||
{{ t('files_sharing', 'What are you requesting ?') }}
|
||||
</legend>
|
||||
<NcTextField :value="label"
|
||||
:disabled="disabled"
|
||||
:label-outside="true"
|
||||
:placeholder="t('files_sharing', 'Birthday party photos, History assignment…')"
|
||||
:required="false"
|
||||
name="label"
|
||||
@update:value="$emit('update:label', $event)" />
|
||||
</fieldset>
|
||||
|
||||
<!-- Request destination -->
|
||||
<fieldset class="file-request-dialog__destination" data-cy-file-request-dialog-fieldset="destination">
|
||||
<legend>
|
||||
{{ t('files_sharing', 'Where should these files go ?') }}
|
||||
</legend>
|
||||
<NcTextField :value="destination"
|
||||
:disabled="disabled"
|
||||
:helper-text="t('files_sharing', 'The uploaded files are visible only to you unless you choose to share them.')"
|
||||
:label-outside="true"
|
||||
:minlength="2/* cannot share root */"
|
||||
:placeholder="t('files_sharing', 'Select a destination')"
|
||||
:readonly="false /* cannot validate a readonly input */"
|
||||
:required="true /* cannot be empty */"
|
||||
:show-trailing-button="destination !== context.path"
|
||||
:trailing-button-icon="'undo'"
|
||||
:trailing-button-label="t('files_sharing', 'Revert to default')"
|
||||
name="destination"
|
||||
@click="onPickDestination"
|
||||
@keypress.prevent.stop="/* prevent typing in the input, we use the picker */"
|
||||
@paste.prevent.stop="/* prevent pasting in the input, we use the picker */"
|
||||
@trailing-button-click="$emit('update:destination', '')">
|
||||
<IconFolder :size="18" />
|
||||
</NcTextField>
|
||||
</fieldset>
|
||||
|
||||
<!-- Request note -->
|
||||
<fieldset class="file-request-dialog__note" data-cy-file-request-dialog-fieldset="note">
|
||||
<legend>
|
||||
{{ t('files_sharing', 'Add a note') }}
|
||||
</legend>
|
||||
<NcTextArea :value="note"
|
||||
:disabled="disabled"
|
||||
:label-outside="true"
|
||||
:placeholder="t('files_sharing', 'Add a note to help people understand what you are requesting.')"
|
||||
:required="false"
|
||||
name="note"
|
||||
@update:value="$emit('update:note', $event)" />
|
||||
</fieldset>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { Folder, Node } from '@nextcloud/files'
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import { getFilePickerBuilder } from '@nextcloud/dialogs'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
|
||||
import IconFolder from 'vue-material-design-icons/Folder.vue'
|
||||
import NcTextArea from '@nextcloud/vue/dist/Components/NcTextArea.js'
|
||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileRequestIntro',
|
||||
|
||||
components: {
|
||||
IconFolder,
|
||||
NcTextArea,
|
||||
NcTextField,
|
||||
},
|
||||
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
context: {
|
||||
type: Object as PropType<Folder>,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
destination: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
note: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: [
|
||||
'update:destination',
|
||||
'update:label',
|
||||
'update:note',
|
||||
],
|
||||
|
||||
setup() {
|
||||
return {
|
||||
t: translate,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onPickDestination() {
|
||||
const filepicker = getFilePickerBuilder(this.t('files_sharing', 'Select a destination'))
|
||||
.addMimeTypeFilter('httpd/unix-directory')
|
||||
.allowDirectories(true)
|
||||
.addButton({
|
||||
label: this.t('files_sharing', 'Select'),
|
||||
callback: this.onPickedDestination,
|
||||
})
|
||||
.setFilter(node => node.path !== '/')
|
||||
.startAt(this.destination)
|
||||
.build()
|
||||
try {
|
||||
filepicker.pick()
|
||||
} catch (e) {
|
||||
// ignore cancel
|
||||
}
|
||||
},
|
||||
|
||||
onPickedDestination(nodes: Node[]) {
|
||||
const node = nodes[0]
|
||||
if (node) {
|
||||
this.$emit('update:destination', node.path)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.file-request-dialog__note :deep(textarea) {
|
||||
width: 100% !important;
|
||||
min-height: 80px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
import type { Entry, Folder, Node } from '@nextcloud/files'
|
||||
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import Vue, { defineAsyncComponent } from 'vue'
|
||||
import FileUploadSvg from '@mdi/svg/svg/file-upload.svg?raw'
|
||||
|
||||
const NewFileRequestDialogVue = defineAsyncComponent(() => import('../components/NewFileRequestDialog.vue'))
|
||||
|
||||
export const entry = {
|
||||
id: 'file-request',
|
||||
displayName: t('files', 'Create new file request'),
|
||||
iconSvgInline: FileUploadSvg,
|
||||
order: 30,
|
||||
enabled(): boolean {
|
||||
// determine requirements
|
||||
// 1. user can share the root folder
|
||||
// 2. OR user can create subfolders ?
|
||||
return true
|
||||
},
|
||||
async handler(context: Folder, content: Node[]) {
|
||||
// Create document root
|
||||
const mountingPoint = document.createElement('div')
|
||||
mountingPoint.id = 'file-request-dialog'
|
||||
document.body.appendChild(mountingPoint)
|
||||
|
||||
// Init vue app
|
||||
const NewFileRequestDialog = new Vue({
|
||||
name: 'NewFileRequestDialogRoot',
|
||||
render: (h) => h(
|
||||
NewFileRequestDialogVue,
|
||||
{
|
||||
props: {
|
||||
context,
|
||||
content,
|
||||
},
|
||||
on: {
|
||||
close: () => {
|
||||
NewFileRequestDialog.$destroy()
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
el: mountingPoint,
|
||||
})
|
||||
},
|
||||
} as Entry
|
||||
Loading…
Reference in New Issue