feat(templates): add templates sidebar, template picker, save-as-template action, and i18n keys

This commit is contained in:
kolaente 2026-03-24 19:49:41 +01:00
parent 6b6ca25efa
commit 3de5206dd4
4 changed files with 89 additions and 3 deletions

View File

@ -108,6 +108,20 @@
:can-collapse="true" :can-collapse="true"
/> />
</nav> </nav>
<nav
v-if="templateProjects.length"
class="menu"
>
<span class="menu-label">
{{ $t('project.template.title') }}
</span>
<ProjectsNavigation
:model-value="templateProjects"
:can-edit-order="false"
:can-collapse="true"
/>
</nav>
</template> </template>
<PoweredByLink <PoweredByLink
@ -146,6 +160,7 @@ const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
const projects = computed(() => projectStore.notArchivedRootProjects as IProject[]) const projects = computed(() => projectStore.notArchivedRootProjects as IProject[])
const favoriteProjects = computed(() => projectStore.favoriteProjects as IProject[]) const favoriteProjects = computed(() => projectStore.favoriteProjects as IProject[])
const savedFilterProjects = computed(() => projectStore.savedFilterProjects as IProject[]) const savedFilterProjects = computed(() => projectStore.savedFilterProjects as IProject[])
const templateProjects = computed(() => projectStore.templateProjects as IProject[])
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -81,6 +81,12 @@
> >
{{ $t('menu.duplicate') }} {{ $t('menu.duplicate') }}
</DropdownItem> </DropdownItem>
<DropdownItem
icon="copy"
@click="saveAsTemplate"
>
{{ $t('project.template.saveAsTemplate') }}
</DropdownItem>
<DropdownItem <DropdownItem
v-tooltip="isDefaultProject ? $t('menu.cantArchiveIsDefault') : ''" v-tooltip="isDefaultProject ? $t('menu.cantArchiveIsDefault') : ''"
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }" :to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
@ -140,6 +146,9 @@ import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects' import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
import {PERMISSIONS} from '@/constants/permissions' import {PERMISSIONS} from '@/constants/permissions'
import AbstractService from '@/services/abstractService'
import {success} from '@/message'
import {useI18n} from 'vue-i18n'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
project: IProject project: IProject
@ -168,4 +177,18 @@ function setSubscriptionInStore(sub: ISubscription) {
const authStore = useAuthStore() const authStore = useAuthStore()
const isDefaultProject = computed(() => props.project?.id === authStore.settings.defaultProjectId) const isDefaultProject = computed(() => props.project?.id === authStore.settings.defaultProjectId)
const {t} = useI18n({useScope: 'global'})
async function saveAsTemplate() {
const templateService = new AbstractService({
create: '/projects/{projectId}/template',
})
const response = await templateService.create({projectId: props.project.id})
if (response.project) {
projectStore.setProject(response.project)
}
await projectStore.loadAllProjects()
success({message: t('project.template.saveAsTemplateSuccess')})
}
</script> </script>

View File

@ -394,6 +394,14 @@
"text": "Select a parent project which should hold the duplicated project:", "text": "Select a parent project which should hold the duplicated project:",
"success": "The project was successfully duplicated." "success": "The project was successfully duplicated."
}, },
"template": {
"title": "Templates",
"saveAsTemplate": "Save as Template",
"saveAsTemplateSuccess": "Project saved as template successfully.",
"useTemplate": "Use a template",
"selectTemplate": "Select a template...",
"createFromTemplate": "Creating project from template..."
},
"edit": { "edit": {
"header": "Edit This Project", "header": "Edit This Project",
"title": "Edit \"{project}\"", "title": "Edit \"{project}\"",

View File

@ -5,6 +5,18 @@
:primary-disabled="project.title === ''" :primary-disabled="project.title === ''"
@create="createProject()" @create="createProject()"
> >
<FormField
v-if="projectStore.hasTemplates"
:label="$t('project.template.useTemplate')"
>
<Multiselect
v-model="selectedTemplate"
:options="templateOptions"
:placeholder="$t('project.template.selectTemplate')"
label="title"
track-by="id"
/>
</FormField>
<FormField <FormField
v-model="project.title" v-model="project.title"
v-focus v-focus
@ -31,14 +43,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, reactive, shallowReactive, watch} from 'vue' import {ref, reactive, shallowReactive, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import ProjectService from '@/services/project' import ProjectService from '@/services/project'
import ProjectModel from '@/models/project' import ProjectModel from '@/models/project'
import ProjectDuplicateService from '@/services/projectDuplicateService'
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
import CreateEdit from '@/components/misc/CreateEdit.vue' import CreateEdit from '@/components/misc/CreateEdit.vue'
import ColorPicker from '@/components/input/ColorPicker.vue' import ColorPicker from '@/components/input/ColorPicker.vue'
import FormField from '@/components/input/FormField.vue' import FormField from '@/components/input/FormField.vue'
import Multiselect from '@/components/input/Multiselect.vue'
import {success} from '@/message' import {success} from '@/message'
import {useTitle} from '@/composables/useTitle' import {useTitle} from '@/composables/useTitle'
@ -51,6 +67,7 @@ const props = defineProps<{
}>() }>()
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
const router = useRouter()
useTitle(() => t('project.create.header')) useTitle(() => t('project.create.header'))
@ -60,6 +77,9 @@ const projectService = shallowReactive(new ProjectService())
const projectStore = useProjectStore() const projectStore = useProjectStore()
const parentProject = ref<IProject | null>(null) const parentProject = ref<IProject | null>(null)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const selectedTemplate = ref<IProject | null>(null)
const templateOptions = computed(() => projectStore.templateProjects as IProject[])
watch( watch(
() => props.parentProjectId, () => props.parentProjectId,
@ -85,8 +105,28 @@ async function createProject() {
} }
try { try {
await projectStore.createProject(project) if (selectedTemplate.value) {
success({message: t('project.create.createdSuccess')}) const duplicateService = new ProjectDuplicateService()
const duplicate = new ProjectDuplicateModel({
projectId: selectedTemplate.value.id,
parentProjectId: project.parentProjectId,
})
const response = await duplicateService.create(duplicate)
const newProject = response.duplicatedProject
if (newProject) {
if (project.title !== selectedTemplate.value.title) {
const updatedProject = await projectService.update({...newProject, title: project.title})
projectStore.setProject(updatedProject)
} else {
projectStore.setProject(newProject)
}
router.push({name: 'project.index', params: {projectId: newProject.id}})
}
success({message: t('project.create.createdSuccess')})
} else {
await projectStore.createProject(project)
success({message: t('project.create.createdSuccess')})
}
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
} }