fix(templates): address review feedback — templates page, proper service, fetch together with archived

This commit is contained in:
kolaente 2026-03-24 20:26:33 +01:00
parent 00645f50b6
commit b3d5eb01dc
8 changed files with 81 additions and 32 deletions

View File

@ -49,6 +49,16 @@
{{ $t('project.projects') }}
</RouterLink>
</li>
<li>
<RouterLink
:to="{ name: 'templates.index'}"
>
<span class="menu-item-icon icon">
<Icon icon="copy" />
</span>
{{ $t('project.template.title') }}
</RouterLink>
</li>
<li>
<RouterLink
v-shortcut="'KeyG KeyA'"
@ -108,20 +118,6 @@
:can-collapse="true"
/>
</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>
<PoweredByLink
@ -160,7 +156,6 @@ const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
const projects = computed(() => projectStore.notArchivedRootProjects as IProject[])
const favoriteProjects = computed(() => projectStore.favoriteProjects as IProject[])
const savedFilterProjects = computed(() => projectStore.savedFilterProjects as IProject[])
const templateProjects = computed(() => projectStore.templateProjects as IProject[])
</script>
<style lang="scss" scoped>

View File

@ -82,6 +82,7 @@
{{ $t('menu.duplicate') }}
</DropdownItem>
<DropdownItem
v-if="!project.isTemplate"
icon="copy"
@click="saveAsTemplate"
>
@ -146,7 +147,7 @@ import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {PERMISSIONS} from '@/constants/permissions'
import AbstractService from '@/services/abstractService'
import ProjectTemplateService from '@/services/projectTemplateService'
import {success} from '@/message'
import {useI18n} from 'vue-i18n'
@ -181,9 +182,7 @@ const isDefaultProject = computed(() => props.project?.id === authStore.settings
const {t} = useI18n({useScope: 'global'})
async function saveAsTemplate() {
const templateService = new AbstractService({
create: '/projects/{projectId}/template',
})
const templateService = new ProjectTemplateService()
const response = await templateService.create({projectId: props.project.id})
if (response.project) {
projectStore.setProject(response.project)

View File

@ -399,8 +399,9 @@
"saveAsTemplate": "Save as Template",
"saveAsTemplateSuccess": "Project saved as template successfully.",
"useTemplate": "Use a template",
"selectTemplate": "Select a template...",
"createFromTemplate": "Creating project from template..."
"selectTemplate": "Select a template\u2026",
"createFromTemplate": "Creating project from template\u2026",
"none": "You don't have any templates yet. Save a project as a template to get started."
},
"edit": {
"header": "Edit This Project",

View File

@ -241,6 +241,11 @@ const router = createRouter({
name: 'projects.index',
component: () => import('@/views/project/ListProjects.vue'),
},
{
path: '/templates',
name: 'templates.index',
component: () => import('@/views/project/ListTemplates.vue'),
},
{
path: '/projects/new',
name: 'project.create',

View File

@ -0,0 +1,9 @@
import AbstractService from './abstractService'
export default class ProjectTemplateService extends AbstractService {
constructor() {
super({
create: '/projects/{projectId}/template',
})
}
}

View File

@ -264,19 +264,11 @@ export const useProjectStore = defineStore('project', () => {
let page = 1
try {
do {
const newProjects = await projectService.getAll({}, {is_archived: true, expand: 'permissions'}, page) as IProject[]
const newProjects = await projectService.getAll({}, {is_archived: true, is_template: true, expand: 'permissions'}, page) as IProject[]
loadedProjects.push(...newProjects)
page++
} while (page <= projectService.totalPages)
// Fetch templates separately
let templatePage = 1
do {
const newTemplates = await projectService.getAll({}, {is_template: true, expand: 'permissions'}, templatePage) as IProject[]
loadedProjects.push(...newTemplates)
templatePage++
} while (templatePage <= projectService.totalPages)
} finally {
cancel()
}

View File

@ -0,0 +1,48 @@
<template>
<div
class="content loader-container"
:class="{'is-loading': loading}"
>
<header class="project-header">
<h3>{{ $t('project.template.title') }}</h3>
</header>
<p v-if="!loading && templates.length === 0">
{{ $t('project.template.none') }}
</p>
<ProjectCardGrid
v-else
:projects="templates"
:show-archived="false"
/>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import ProjectCardGrid from '@/components/project/partials/ProjectCardGrid.vue'
import {useTitle} from '@/composables/useTitle'
import {useProjectStore} from '@/stores/projects'
import type {IProject} from '@/modelTypes/IProject'
const {t} = useI18n()
const projectStore = useProjectStore()
useTitle(() => t('project.template.title'))
const loading = computed(() => projectStore.isLoading)
const templates = computed(() => projectStore.templateProjects as IProject[])
</script>
<style lang="scss" scoped>
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-block-end: 1rem;
}
</style>

View File

@ -593,7 +593,7 @@ func getUserProjectsStatement(userID int64, search string) *builder.Builder {
}
return builder.
Select("l.id, l.title, l.description, l.identifier, l.hex_color, l.owner_id, l.parent_project_id, l.is_archived, l.background_file_id, l.background_blur_hash, l.position, l.created, l.updated").
Select("l.id, l.title, l.description, l.identifier, l.hex_color, l.owner_id, l.parent_project_id, l.is_archived, l.is_template, l.background_file_id, l.background_blur_hash, l.position, l.created, l.updated").
From("projects", "l").
Join("LEFT", "team_projects tl", "tl.project_id = l.id").
Join("LEFT", "team_members tm2", "tm2.team_id = tl.team_id").
@ -658,7 +658,7 @@ func getAllProjectsForUser(s *xorm.Session, userID int64, opts *projectOptions)
baseQuery := querySQLString + `
UNION ALL
SELECT p.id, p.title, p.description, p.identifier, p.hex_color, p.owner_id, p.parent_project_id, (ap.is_archived OR p.is_archived) AS is_archived, p.background_file_id, p.background_blur_hash, p.position, p.created, p.updated FROM projects p
SELECT p.id, p.title, p.description, p.identifier, p.hex_color, p.owner_id, p.parent_project_id, (ap.is_archived OR p.is_archived) AS is_archived, p.is_template, p.background_file_id, p.background_blur_hash, p.position, p.created, p.updated FROM projects p
INNER JOIN all_projects ap ON p.parent_project_id = ap.id`
columnStr := strings.Join([]string{