chore: move frontend files
This commit is contained in:
51
frontend/src/views/project/settings/archive.vue
Normal file
51
frontend/src/views/project/settings/archive.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<modal
|
||||
@close="$router.back()"
|
||||
@submit="archiveProject()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ project.isArchived ? $t('project.archive.unarchive') : $t('project.archive.archive') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>{{ project.isArchived ? $t('project.archive.unarchiveText') : $t('project.archive.archiveText') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'ProjectSettingArchive'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {useRouter, useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const projectStore = useProjectStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const project = computed(() => projectStore.projects[route.params.projectId])
|
||||
useTitle(() => t('project.archive.title', {project: project.value.title}))
|
||||
|
||||
async function archiveProject() {
|
||||
try {
|
||||
const newProject = await projectStore.updateProject({
|
||||
...project.value,
|
||||
isArchived: !project.value.isArchived,
|
||||
})
|
||||
useBaseStore().setCurrentProject(newProject)
|
||||
success({message: t('project.archive.success')})
|
||||
} finally {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
</script>
|
305
frontend/src/views/project/settings/background.vue
Normal file
305
frontend/src/views/project/settings/background.vue
Normal file
@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
v-if="uploadBackgroundEnabled || unsplashBackgroundEnabled"
|
||||
:title="$t('project.background.title')"
|
||||
:loading="backgroundService.loading"
|
||||
class="project-background-setting"
|
||||
:wide="true"
|
||||
>
|
||||
<div
|
||||
v-if="uploadBackgroundEnabled"
|
||||
class="mb-4"
|
||||
>
|
||||
<input
|
||||
ref="backgroundUploadInput"
|
||||
accept="image/*"
|
||||
class="is-hidden"
|
||||
type="file"
|
||||
@change="uploadBackground"
|
||||
>
|
||||
<x-button
|
||||
:loading="backgroundUploadService.loading"
|
||||
variant="primary"
|
||||
@click="backgroundUploadInput?.click()"
|
||||
>
|
||||
{{ $t('project.background.upload') }}
|
||||
</x-button>
|
||||
</div>
|
||||
<template v-if="unsplashBackgroundEnabled">
|
||||
<input
|
||||
v-model="backgroundSearchTerm"
|
||||
:class="{'is-loading': backgroundService.loading}"
|
||||
class="input is-expanded"
|
||||
:placeholder="$t('project.background.searchPlaceholder')"
|
||||
type="text"
|
||||
@keyup="debounceNewBackgroundSearch()"
|
||||
>
|
||||
|
||||
<p class="unsplash-credit">
|
||||
<BaseButton
|
||||
class="unsplash-credit__link"
|
||||
href="https://unsplash.com"
|
||||
>
|
||||
{{ $t('project.background.poweredByUnsplash') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
|
||||
<ul class="image-search__result-list">
|
||||
<li
|
||||
v-for="im in backgroundSearchResult"
|
||||
:key="im.id"
|
||||
class="image-search__result-item"
|
||||
:style="{'background-image': `url(${backgroundBlurHashes[im.id]})`}"
|
||||
>
|
||||
<CustomTransition name="fade">
|
||||
<BaseButton
|
||||
v-if="backgroundThumbs[im.id]"
|
||||
class="image-search__image-button"
|
||||
@click="setBackground(im.id)"
|
||||
>
|
||||
<img
|
||||
class="image-search__image"
|
||||
:src="backgroundThumbs[im.id]"
|
||||
alt=""
|
||||
>
|
||||
</BaseButton>
|
||||
</CustomTransition>
|
||||
|
||||
<BaseButton
|
||||
:href="`https://unsplash.com/@${im.info.author}`"
|
||||
class="image-search__info"
|
||||
>
|
||||
{{ im.info.authorName }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<x-button
|
||||
v-if="backgroundSearchResult.length > 0"
|
||||
:disabled="backgroundService.loading"
|
||||
class="is-load-more-button mt-4"
|
||||
:shadow="false"
|
||||
variant="secondary"
|
||||
@click="searchBackgrounds(currentPage + 1)"
|
||||
>
|
||||
{{ backgroundService.loading ? $t('misc.loading') : $t('project.background.loadMore') }}
|
||||
</x-button>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<x-button
|
||||
v-if="hasBackground"
|
||||
:shadow="false"
|
||||
variant="tertiary"
|
||||
class="is-danger"
|
||||
@click.prevent.stop="removeBackground"
|
||||
>
|
||||
{{ $t('project.background.remove') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
@click.prevent.stop="$router.back()"
|
||||
>
|
||||
{{ $t('misc.close') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'ProjectSettingBackground' }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
|
||||
import BackgroundUploadService from '@/services/backgroundUpload'
|
||||
import ProjectService from '@/services/project'
|
||||
import type BackgroundImageModel from '@/models/backgroundImage'
|
||||
|
||||
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import {success} from '@/message'
|
||||
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const baseStore = useBaseStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTitle(() => t('project.background.title'))
|
||||
|
||||
const backgroundService = shallowReactive(new BackgroundUnsplashService())
|
||||
const backgroundSearchTerm = ref('')
|
||||
const backgroundSearchResult = ref([])
|
||||
const backgroundThumbs = ref<Record<string, string>>({})
|
||||
const backgroundBlurHashes = ref<Record<string, string>>({})
|
||||
const currentPage = ref(1)
|
||||
|
||||
// We're using debounce to not search on every keypress but with a delay.
|
||||
const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNCE, {
|
||||
trailing: true,
|
||||
})
|
||||
|
||||
const backgroundUploadService = ref(new BackgroundUploadService())
|
||||
const projectService = ref(new ProjectService())
|
||||
const projectStore = useProjectStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const unsplashBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('unsplash'))
|
||||
const uploadBackgroundEnabled = computed(() => configStore.enabledBackgroundProviders.includes('upload'))
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const hasBackground = computed(() => baseStore.background !== null)
|
||||
|
||||
// Show the default collection of backgrounds
|
||||
newBackgroundSearch()
|
||||
|
||||
function newBackgroundSearch() {
|
||||
if (!unsplashBackgroundEnabled.value) {
|
||||
return
|
||||
}
|
||||
// This is an extra method to reset a few things when searching to not break loading more photos.
|
||||
backgroundSearchResult.value = []
|
||||
backgroundThumbs.value = {}
|
||||
searchBackgrounds()
|
||||
}
|
||||
|
||||
async function searchBackgrounds(page = 1) {
|
||||
currentPage.value = page
|
||||
const result = await backgroundService.getAll({}, {s: backgroundSearchTerm.value, p: page})
|
||||
backgroundSearchResult.value = backgroundSearchResult.value.concat(result)
|
||||
result.forEach((background: BackgroundImageModel) => {
|
||||
getBlobFromBlurHash(background.blurHash)
|
||||
.then((b) => {
|
||||
backgroundBlurHashes.value[background.id] = window.URL.createObjectURL(b)
|
||||
})
|
||||
|
||||
backgroundService.thumb(background).then(b => {
|
||||
backgroundThumbs.value[background.id] = b
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
async function setBackground(backgroundId: string) {
|
||||
// Don't set a background if we're in the process of setting one
|
||||
if (backgroundService.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
const project = await backgroundService.update({
|
||||
id: backgroundId,
|
||||
projectId: route.params.projectId,
|
||||
})
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.success')})
|
||||
}
|
||||
|
||||
const backgroundUploadInput = ref<HTMLInputElement | null>(null)
|
||||
async function uploadBackground() {
|
||||
if (backgroundUploadInput.value?.files?.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const project = await backgroundUploadService.value.create(
|
||||
route.params.projectId,
|
||||
backgroundUploadInput.value?.files[0],
|
||||
)
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.success')})
|
||||
}
|
||||
|
||||
async function removeBackground() {
|
||||
const project = await projectService.value.removeBackground(currentProject.value)
|
||||
await baseStore.handleSetCurrentProject({project, forceUpdate: true})
|
||||
projectStore.setProject(project)
|
||||
success({message: t('project.background.removeSuccess')})
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.unsplash-credit {
|
||||
text-align: right;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.unsplash-credit__link {
|
||||
color: var(--grey-800);
|
||||
}
|
||||
|
||||
.image-search__result-list {
|
||||
--items-per-row: 1;
|
||||
margin: 1rem 0 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(var(--items-per-row), 1fr);
|
||||
|
||||
@media screen and (min-width: $mobile) {
|
||||
--items-per-row: 2;
|
||||
}
|
||||
@media screen and (min-width: $tablet) {
|
||||
--items-per-row: 4;
|
||||
}
|
||||
@media screen and (min-width: $tablet) {
|
||||
--items-per-row: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.image-search__result-item {
|
||||
margin-top: 0; // FIXME: removes padding from .content
|
||||
aspect-ratio: 16 / 10;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-search__image-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-search__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-search__info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: .25rem 0;
|
||||
opacity: 0;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
font-size: .75rem;
|
||||
font-weight: bold;
|
||||
color: $white;
|
||||
transition: opacity $transition;
|
||||
}
|
||||
.image-search__result-item:hover .image-search__info {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.is-load-more-button {
|
||||
margin: 1rem auto 0 !important;
|
||||
display: block;
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
79
frontend/src/views/project/settings/delete.vue
Normal file
79
frontend/src/views/project/settings/delete.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<modal
|
||||
@close="$router.back()"
|
||||
@submit="deleteProject()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('project.delete.header') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('project.delete.text1') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="totalTasks !== null"
|
||||
class="has-text-weight-bold"
|
||||
>
|
||||
{{
|
||||
totalTasks > 0 ? $t('project.delete.tasksToDelete', {count: totalTasks}) : $t('project.delete.noTasksToDelete')
|
||||
}}
|
||||
</p>
|
||||
<Loading
|
||||
v-else
|
||||
class="is-loading-small"
|
||||
variant="default"
|
||||
/>
|
||||
|
||||
<p>
|
||||
{{ $t('misc.cannotBeUndone') }}
|
||||
</p>
|
||||
</template>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watchEffect} from 'vue'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {success} from '@/message'
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const projectStore = useProjectStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const totalTasks = ref<number | null>(null)
|
||||
|
||||
const project = computed(() => projectStore.projects[route.params.projectId])
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (!route.params.projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskCollectionService = new TaskCollectionService()
|
||||
taskCollectionService.getAll({projectId: route.params.projectId}).then(() => {
|
||||
totalTasks.value = taskCollectionService.totalPages * taskCollectionService.resultCount
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
useTitle(() => t('project.delete.title', {project: project?.value?.title}))
|
||||
|
||||
async function deleteProject() {
|
||||
if (!project.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await projectStore.deleteProject(project.value)
|
||||
success({message: t('project.delete.success')})
|
||||
router.push({name: 'home'})
|
||||
}
|
||||
</script>
|
48
frontend/src/views/project/settings/duplicate.vue
Normal file
48
frontend/src/views/project/settings/duplicate.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.duplicate.title')"
|
||||
primary-icon="paste"
|
||||
:primary-label="$t('project.duplicate.label')"
|
||||
:loading="isLoading"
|
||||
@primary="duplicate"
|
||||
>
|
||||
<p>{{ $t('project.duplicate.text') }}</p>
|
||||
<ProjectSearch v-model="parentProject" />
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
|
||||
import {success} from '@/message'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useProject, useProjectStore} from '@/stores/projects'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => t('project.duplicate.title'))
|
||||
|
||||
const route = useRoute()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
|
||||
|
||||
const parentProject = ref<IProject | null>(null)
|
||||
watch(
|
||||
() => project.parentProjectId,
|
||||
parentProjectId => {
|
||||
parentProject.value = projectStore.projects[parentProjectId]
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
async function duplicate() {
|
||||
await duplicateProject(parentProject.value?.id ?? 0)
|
||||
success({message: t('project.duplicate.success')})
|
||||
}
|
||||
</script>
|
136
frontend/src/views/project/settings/edit.vue
Normal file
136
frontend/src/views/project/settings/edit.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.edit.header')"
|
||||
primary-icon=""
|
||||
:primary-label="$t('misc.save')"
|
||||
:tertiary="$t('misc.delete')"
|
||||
@primary="save"
|
||||
@tertiary="$router.push({ name: 'project.settings.delete', params: { id: projectId } })"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="title"
|
||||
>{{ $t('project.title') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="title"
|
||||
v-model="project.title"
|
||||
v-focus
|
||||
:class="{ 'disabled': isLoading}"
|
||||
:disabled="isLoading || undefined"
|
||||
class="input"
|
||||
:placeholder="$t('project.edit.titlePlaceholder')"
|
||||
type="text"
|
||||
@keyup.enter="save"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
v-tooltip="$t('project.edit.identifierTooltip')"
|
||||
class="label"
|
||||
for="identifier"
|
||||
>
|
||||
{{ $t('project.edit.identifier') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="identifier"
|
||||
v-model="project.identifier"
|
||||
v-focus
|
||||
:class="{ 'disabled': isLoading}"
|
||||
:disabled="isLoading || undefined"
|
||||
class="input"
|
||||
:placeholder="$t('project.edit.identifierPlaceholder')"
|
||||
type="text"
|
||||
@keyup.enter="save"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.parent') }}</label>
|
||||
<div class="control">
|
||||
<ProjectSearch v-model="parentProject" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="projectdescription"
|
||||
>{{ $t('project.edit.description') }}</label>
|
||||
<div class="control">
|
||||
<Editor
|
||||
id="projectdescription"
|
||||
v-model="project.description"
|
||||
:class="{ 'disabled': isLoading}"
|
||||
:disabled="isLoading"
|
||||
:placeholder="$t('project.edit.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.edit.color') }}</label>
|
||||
<div class="control">
|
||||
<ColorPicker v-model="project.hexColor" />
|
||||
</div>
|
||||
</div>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'ProjectSettingEdit'}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {watch, ref, type PropType} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Editor from '@/components/input/AsyncEditor'
|
||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/projectSearch.vue'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useProject} from '@/stores/projects'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
const props = defineProps({
|
||||
projectId: {
|
||||
type: Number as PropType<IProject['id']>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const {project, save: saveProject, isLoading} = useProject(props.projectId)
|
||||
|
||||
const parentProject = ref<IProject | null>(null)
|
||||
watch(
|
||||
() => project.parentProjectId,
|
||||
projectId => {
|
||||
if (project.parentProjectId) {
|
||||
parentProject.value = projectStore.projects[project.parentProjectId]
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
useTitle(() => project?.title ? t('project.edit.title', {project: project.title}) : '')
|
||||
|
||||
async function save() {
|
||||
project.parentProjectId = parentProject.value?.id ?? project.parentProjectId
|
||||
await saveProject()
|
||||
await useBaseStore().handleSetCurrentProject({project})
|
||||
router.back()
|
||||
}
|
||||
</script>
|
78
frontend/src/views/project/settings/share.vue
Normal file
78
frontend/src/views/project/settings/share.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.share.header')"
|
||||
:has-primary-action="false"
|
||||
>
|
||||
<template v-if="project">
|
||||
<userTeam
|
||||
:id="project.id"
|
||||
:user-is-admin="userIsAdmin"
|
||||
share-type="user"
|
||||
type="project"
|
||||
/>
|
||||
<userTeam
|
||||
:id="project.id"
|
||||
:user-is-admin="userIsAdmin"
|
||||
share-type="team"
|
||||
type="project"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<LinkSharing
|
||||
v-if="linkSharingEnabled"
|
||||
:project-id="projectId"
|
||||
class="mt-4"
|
||||
/>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {name: 'ProjectSettingShare'}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, watchEffect} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useTitle} from '@vueuse/core'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import ProjectModel from '@/models/project'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {RIGHTS} from '@/constants/rights'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
import LinkSharing from '@/components/sharing/linkSharing.vue'
|
||||
import userTeam from '@/components/sharing/userTeam.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const project = ref<IProject>()
|
||||
const title = computed(() => project.value?.title
|
||||
? t('project.share.title', {project: project.value.title})
|
||||
: '',
|
||||
)
|
||||
useTitle(title)
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const linkSharingEnabled = computed(() => configStore.linkSharingEnabled)
|
||||
const userIsAdmin = computed(() => project?.value?.maxRight === RIGHTS.ADMIN)
|
||||
|
||||
async function loadProject(projectId: number) {
|
||||
const projectService = new ProjectService()
|
||||
const newProject = await projectService.get(new ProjectModel({id: projectId}))
|
||||
await useBaseStore().handleSetCurrentProject({project: newProject})
|
||||
project.value = newProject
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.projectId !== undefined
|
||||
? parseInt(route.params.projectId as string)
|
||||
: undefined,
|
||||
)
|
||||
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
|
||||
</script>
|
280
frontend/src/views/project/settings/webhooks.vue
Normal file
280
frontend/src/views/project/settings/webhooks.vue
Normal file
@ -0,0 +1,280 @@
|
||||
<script lang="ts">
|
||||
export default {name: 'ProjectSettingWebhooks'}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed, watchEffect} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useTitle} from '@vueuse/core'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import ProjectModel from '@/models/project'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import CreateEdit from '@/components/misc/create-edit.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import type {IWebhook} from '@/modelTypes/IWebhook'
|
||||
import WebhookService from '@/services/webhook'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
import User from '@/components/misc/user.vue'
|
||||
import WebhookModel from '@/models/webhook'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import {success} from '@/message'
|
||||
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const project = ref<IProject>()
|
||||
useTitle(t('project.webhooks.title'))
|
||||
|
||||
const showNewForm = ref(false)
|
||||
|
||||
async function loadProject(projectId: number) {
|
||||
const projectService = new ProjectService()
|
||||
const newProject = await projectService.get(new ProjectModel({id: projectId}))
|
||||
await useBaseStore().handleSetCurrentProject({project: newProject})
|
||||
project.value = newProject
|
||||
await loadWebhooks()
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.projectId !== undefined
|
||||
? parseInt(route.params.projectId as string)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
|
||||
|
||||
const webhooks = ref<IWebhook[]>()
|
||||
const webhookService = new WebhookService()
|
||||
const availableEvents = ref<string[]>()
|
||||
|
||||
async function loadWebhooks() {
|
||||
webhooks.value = await webhookService.getAll({projectId: project.value.id})
|
||||
availableEvents.value = await webhookService.getAvailableEvents()
|
||||
}
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const webhookIdToDelete = ref<number>()
|
||||
|
||||
async function deleteWebhook() {
|
||||
await webhookService.delete({
|
||||
id: webhookIdToDelete.value,
|
||||
projectId: project.value.id,
|
||||
})
|
||||
showDeleteModal.value = false
|
||||
success({message: t('project.webhooks.deleteSuccess')})
|
||||
await loadWebhooks()
|
||||
}
|
||||
|
||||
const newWebhook = ref(new WebhookModel())
|
||||
const newWebhookEvents = ref({})
|
||||
|
||||
async function create() {
|
||||
|
||||
validateTargetUrl()
|
||||
if (!webhookTargetUrlValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedEvents = getSelectedEventsArray()
|
||||
newWebhook.value.events = selectedEvents
|
||||
|
||||
validateSelectedEvents()
|
||||
if (!selectedEventsValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
newWebhook.value.projectId = project.value.id
|
||||
const created = await webhookService.create(newWebhook.value)
|
||||
webhooks.value.push(created)
|
||||
newWebhook.value = new WebhookModel()
|
||||
showNewForm.value = false
|
||||
}
|
||||
|
||||
const webhookTargetUrlValid = ref(true)
|
||||
|
||||
function validateTargetUrl() {
|
||||
webhookTargetUrlValid.value = isValidHttpUrl(newWebhook.value.targetUrl)
|
||||
}
|
||||
|
||||
const selectedEventsValid = ref(true)
|
||||
|
||||
function getSelectedEventsArray() {
|
||||
return Object.entries(newWebhookEvents.value)
|
||||
.filter(([_, use]) => use)
|
||||
.map(([event]) => event)
|
||||
}
|
||||
|
||||
function validateSelectedEvents() {
|
||||
const events = getSelectedEventsArray()
|
||||
if (events.length === 0) {
|
||||
selectedEventsValid.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreateEdit
|
||||
:title="$t('project.webhooks.title')"
|
||||
:has-primary-action="false"
|
||||
:wide="true"
|
||||
>
|
||||
<x-button
|
||||
v-if="!(webhooks?.length === 0 || showNewForm)"
|
||||
icon="plus"
|
||||
class="mb-4"
|
||||
@click="showNewForm = true"
|
||||
>
|
||||
{{ $t('project.webhooks.create') }}
|
||||
</x-button>
|
||||
|
||||
<div
|
||||
v-if="webhooks?.length === 0 || showNewForm"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="targetUrl"
|
||||
>
|
||||
{{ $t('project.webhooks.targetUrl') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="targetUrl"
|
||||
v-model="newWebhook.targetUrl"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="$t('project.webhooks.targetUrl')"
|
||||
@focusout="validateTargetUrl"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="!webhookTargetUrlValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('project.webhooks.targetUrlInvalid') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="secret"
|
||||
>
|
||||
{{ $t('project.webhooks.secret') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="secret"
|
||||
v-model="newWebhook.secret"
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
<p class="help">
|
||||
{{ $t('project.webhooks.secretHint') }}
|
||||
<BaseButton href="https://vikunja.io/docs/webhooks/">
|
||||
{{ $t('project.webhooks.secretDocs') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
class="label"
|
||||
for="secret"
|
||||
>
|
||||
{{ $t('project.webhooks.events') }}
|
||||
</label>
|
||||
<p class="help">
|
||||
{{ $t('project.webhooks.eventsHint') }}
|
||||
</p>
|
||||
<div class="control">
|
||||
<Fancycheckbox
|
||||
v-for="event in availableEvents"
|
||||
:key="event"
|
||||
v-model="newWebhookEvents[event]"
|
||||
class="available-events-check"
|
||||
@update:modelValue="validateSelectedEvents"
|
||||
>
|
||||
{{ event }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
<p
|
||||
v-if="!selectedEventsValid"
|
||||
class="help is-danger"
|
||||
>
|
||||
{{ $t('project.webhooks.mustSelectEvents') }}
|
||||
</p>
|
||||
</div>
|
||||
<x-button
|
||||
icon="plus"
|
||||
@click="create"
|
||||
>
|
||||
{{ $t('project.webhooks.create') }}
|
||||
</x-button>
|
||||
</div>
|
||||
|
||||
<table
|
||||
v-if="webhooks?.length > 0"
|
||||
class="table has-actions is-striped is-hoverable is-fullwidth"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('project.webhooks.targetUrl') }}</th>
|
||||
<th>{{ $t('project.webhooks.events') }}</th>
|
||||
<th>{{ $t('misc.created') }}</th>
|
||||
<th>{{ $t('misc.createdBy') }}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="w in webhooks"
|
||||
:key="w.id"
|
||||
>
|
||||
<td>{{ w.targetUrl }}</td>
|
||||
<td>{{ w.events.join(', ') }}</td>
|
||||
<td>{{ formatDateShort(w.created) }}</td>
|
||||
<td>
|
||||
<User
|
||||
:avatar-size="25"
|
||||
:user="w.createdBy"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td class="actions">
|
||||
<x-button
|
||||
class="is-danger"
|
||||
icon="trash-alt"
|
||||
@click="() => {showDeleteModal = true;webhookIdToDelete = w.id}"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteWebhook()"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ $t('project.webhooks.delete') }}</span>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>{{ $t('project.webhooks.deleteText') }}</p>
|
||||
</template>
|
||||
</modal>
|
||||
</CreateEdit>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.available-events-check {
|
||||
margin-right: .5rem;
|
||||
width: 12.5rem;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user