feat(frontend): add soft-delete UI with Bin page and undo support

- Add deletedAt field to IProject interface and ProjectModel
- Add restore() and getDeletedProjects() to ProjectService
- Add restoreProject() and fetchDeletedProjects() to project store
- Create ProjectsBin.vue page showing deleted projects with restore
- Add Bin link to sidebar navigation
- Update delete modal text to softer language (move to bin)
- Add undo toast action after deleting a project
- Add route for /projects/bin
This commit is contained in:
kolaente 2026-03-26 16:14:50 +01:00
parent 86cabee5c6
commit 1dcd27e566
9 changed files with 212 additions and 11 deletions

View File

@ -71,6 +71,16 @@
{{ $t('team.title') }}
</RouterLink>
</li>
<li>
<RouterLink
:to="{ name: 'projects.bin'}"
>
<span class="menu-item-icon icon">
<Icon icon="trash-alt" />
</span>
{{ $t('project.bin.title') }}
</RouterLink>
</li>
</menu>
</nav>

View File

@ -322,12 +322,22 @@
"delete": {
"title": "Delete \"{project}\"",
"header": "Delete this project",
"text1": "Are you sure you want to delete this project and all of its contents?",
"text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The project was successfully deleted.",
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
"tasksAndChildProjectsToDelete": "This will irrevocably remove approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete."
"text1": "Are you sure you want to move this project to the bin?",
"text2": "It will be permanently deleted after 30 days.",
"success": "The project was moved to the bin.",
"tasksToDelete": "This will affect approx. {count} tasks.",
"tasksAndChildProjectsToDelete": "This will affect approx. {tasks} tasks and {projects} projects.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete.",
"undoSuccess": "The project was restored.",
"undo": "Undo"
},
"bin": {
"title": "Bin",
"empty": "No deleted projects.",
"daysRemaining": "{days} days remaining",
"deletedOn": "Deleted on {date}",
"restore": "Restore",
"restoreSuccess": "The project was restored successfully."
},
"duplicate": {
"title": "Duplicate this project",

View File

@ -21,7 +21,8 @@ export interface IProject extends IAbstract {
backgroundBlurHash: string
parentProjectId: number
views: IProjectView[]
deletedAt: Date | null
created: Date
updated: Date
}

View File

@ -26,7 +26,8 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
backgroundBlurHash = ''
parentProjectId = 0
views: IProjectView[] = []
deletedAt: Date | null = null
created: Date = null
updated: Date = null
@ -51,6 +52,10 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
this.views = this.views.map(v => new ProjectViewModel(v))
if (this.deletedAt) {
this.deletedAt = new Date(this.deletedAt)
}
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}

View File

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

View File

@ -52,6 +52,28 @@ export default class ProjectService extends AbstractService<IProject> {
return window.URL.createObjectURL(new Blob([response.data]))
}
async restore(projectId: IProject['id']): Promise<IProject> {
const cancel = this.setLoading()
try {
const response = await this.http.post(`/projects/${projectId}/restore`)
return this.modelFactory(response.data)
} finally {
cancel()
}
}
async getDeletedProjects(): Promise<IProject[]> {
const cancel = this.setLoading()
try {
const response = await this.http.get('/projects/deleted')
return response.data.map((p: IProject) => this.modelFactory(p))
} finally {
cancel()
}
}
async removeBackground(project: IProject) {
const cancel = this.setLoading()

View File

@ -257,6 +257,30 @@ export const useProjectStore = defineStore('project', () => {
}
}
async function restoreProject(projectId: IProject['id']) {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
const restoredProject = await projectService.restore(projectId)
setProject(restoredProject)
return restoredProject
} finally {
cancel()
}
}
async function fetchDeletedProjects(): Promise<IProject[]> {
const cancel = setModuleLoading(setIsLoading)
const projectService = new ProjectService()
try {
return await projectService.getDeletedProjects()
} finally {
cancel()
}
}
async function loadAllProjects() {
const cancel = setModuleLoading(setIsLoading)
@ -353,6 +377,8 @@ export const useProjectStore = defineStore('project', () => {
createProject,
updateProject,
deleteProject,
restoreProject,
fetchDeletedProjects,
getAncestors,
setProjectView,
removeProjectView,

View File

@ -0,0 +1,113 @@
<template>
<div
class="content loader-container"
:class="{'is-loading': isLoading}"
>
<h1>{{ $t('project.bin.title') }}</h1>
<p v-if="deletedProjects.length === 0 && !isLoading">
{{ $t('project.bin.empty') }}
</p>
<div
v-for="project in deletedProjects"
:key="project.id"
class="deleted-project"
>
<div class="deleted-project-info">
<span class="deleted-project-title">{{ project.title }}</span>
<span class="deleted-project-meta">
{{ $t('project.bin.deletedOn', {date: formatDateShort(project.deletedAt)}) }}
&mdash;
{{ $t('project.bin.daysRemaining', {days: daysRemaining(project.deletedAt)}) }}
</span>
</div>
<XButton
variant="secondary"
:loading="restoring === project.id"
@click="restoreProject(project)"
>
{{ $t('project.bin.restore') }}
</XButton>
</div>
</div>
</template>
<script setup lang="ts">
import {ref, onMounted} from 'vue'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {useProjectStore} from '@/stores/projects'
import {formatDateShort} from '@/helpers/time/formatDate'
import type {IProject} from '@/modelTypes/IProject'
const SOFT_DELETE_RETENTION_DAYS = 30
const {t} = useI18n({useScope: 'global'})
const projectStore = useProjectStore()
useTitle(() => t('project.bin.title'))
const deletedProjects = ref<IProject[]>([])
const isLoading = ref(false)
const restoring = ref<number | null>(null)
onMounted(async () => {
isLoading.value = true
try {
deletedProjects.value = await projectStore.fetchDeletedProjects()
} finally {
isLoading.value = false
}
})
function daysRemaining(deletedAt: Date | null): number {
if (!deletedAt) return 0
const deleted = new Date(deletedAt)
const purgeDate = new Date(deleted.getTime() + SOFT_DELETE_RETENTION_DAYS * 24 * 60 * 60 * 1000)
const remaining = Math.ceil((purgeDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
return Math.max(0, remaining)
}
async function restoreProject(project: IProject) {
restoring.value = project.id
try {
await projectStore.restoreProject(project.id)
deletedProjects.value = deletedProjects.value.filter(p => p.id !== project.id)
success({message: t('project.bin.restoreSuccess')})
} finally {
restoring.value = null
}
}
</script>
<style lang="scss" scoped>
.deleted-project {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-block-end: 1px solid var(--grey-200);
&:last-child {
border-block-end: none;
}
}
.deleted-project-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.deleted-project-title {
font-weight: bold;
}
.deleted-project-meta {
font-size: 0.875rem;
color: var(--grey-500);
}
</style>

View File

@ -25,7 +25,7 @@
/>
<p>
{{ $t('misc.cannotBeUndone') }}
{{ $t('project.delete.text2') }}
</p>
</template>
</Modal>
@ -88,8 +88,17 @@ async function deleteProject() {
return
}
await projectStore.deleteProject(project.value)
success({message: t('project.delete.success')})
const deletedProject = project.value
await projectStore.deleteProject(deletedProject)
success({message: t('project.delete.success')}, [
{
title: t('project.delete.undo'),
callback: async () => {
await projectStore.restoreProject(deletedProject.id)
success({message: t('project.delete.undoSuccess')})
},
},
])
router.push({name: 'home'})
}
</script>