feat(projects): rename, delete, and a trash you can actually reach
A project could be created and shared and never renamed or deleted. Both have worked through the API since the beginning; there was simply nothing to click. The heading is now the field — a heading that happens to be editable rather than a form that looks like one — and saves on blur, with Escape putting the old name back. Owner only, and the Inbox keeps its plain heading because it cannot be either renamed away or deleted. Deleting needed more than a button. It is soft, with thirty days to change your mind, and restoring has been possible the whole time — from an id, which is exactly what you no longer have once the project has gone from the screen. A recovery window nobody can reach is not one. So GET /trash/projects lists what you have deleted, with how many tasks went with it and how long is left, and Settings → Data has the button. Owned, not shared: a member of a project somebody else deleted has no business bringing it back, and there is a test that says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
0dbea0ea4b
commit
8a6e1bc39e
@@ -176,6 +176,7 @@ export const api = {
|
||||
createProject: (data) => post('/projects', data),
|
||||
updateProject: (id, data) => patch(`/projects/${id}`, data),
|
||||
deleteProject: (id) => del(`/projects/${id}`),
|
||||
listTrashedProjects: () => get('/trash/projects'),
|
||||
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
|
||||
|
||||
listSections: (projectId) => get(`/projects/${projectId}/sections`),
|
||||
|
||||
@@ -101,6 +101,36 @@
|
||||
}
|
||||
|
||||
let exportProject = $state('');
|
||||
|
||||
// --- the trash -------------------------------------------------------------
|
||||
|
||||
let trashed = $state([]);
|
||||
let loadingTrash = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.listTrashedProjects()
|
||||
.then((r) => (trashed = r.projects))
|
||||
.catch((e) => app.toast(humanMessage(e)))
|
||||
.finally(() => (loadingTrash = false));
|
||||
});
|
||||
|
||||
async function restore(project) {
|
||||
const previous = trashed;
|
||||
trashed = trashed.filter((p) => p.id !== project.id);
|
||||
try {
|
||||
await api.restoreProject(project.id);
|
||||
await app.refreshProjects();
|
||||
} catch (e) {
|
||||
trashed = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole days left, rounded down — "0 dage tilbage" is the truthful last day. */
|
||||
function daysLeft(iso) {
|
||||
return Math.max(0, Math.floor((new Date(iso) - Date.now()) / 86400000));
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
@@ -146,6 +176,38 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Papirkurv</h2>
|
||||
<p class="hint">
|
||||
Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som
|
||||
de var — bortset fra opgaver, du havde slettet hver for sig først; dem var
|
||||
der en grund til.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loadingTrash}
|
||||
<p class="empty">…</p>
|
||||
{:else if trashed.length === 0}
|
||||
<p class="empty">Papirkurven er tom.</p>
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each trashed as project (project.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="title">{project.name}</span>
|
||||
<span class="meta">
|
||||
{project.task_count} opgave{project.task_count === 1 ? '' : 'r'} ·
|
||||
{daysLeft(project.purge_after)} dage tilbage
|
||||
</span>
|
||||
</div>
|
||||
<button class="use" onclick={() => restore(project)}>Hent tilbage</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Eksport</h2>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
/** One project: its sections, its tasks, and who it is shared with. */
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
@@ -65,6 +66,41 @@
|
||||
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
|
||||
let unsectioned = $derived(open.filter((t) => !t.section_id));
|
||||
|
||||
/** Renames on blur. A no-op when nothing changed, so tabbing through is free. */
|
||||
async function rename(input) {
|
||||
const name = input.value.trim();
|
||||
if (!name || name === project.name) {
|
||||
input.value = project.name;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
project = await api.updateProject(project.id, { name });
|
||||
// The sidebar reads its own copy, so it has to be told.
|
||||
await app.refreshProjects();
|
||||
} catch (e) {
|
||||
input.value = project.name;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const days = 30;
|
||||
const confirmed = confirm(
|
||||
`Slet "${project.name}" med alle dets opgaver?\n\n` +
|
||||
`Det havner i papirkurven og kan hentes tilbage under ` +
|
||||
`Indstillinger → Data og skabeloner i ${days} dage.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await api.deleteProject(project.id);
|
||||
await app.refreshProjects();
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function invite(event) {
|
||||
event.preventDefault();
|
||||
inviteError = '';
|
||||
@@ -87,7 +123,25 @@
|
||||
<div class="view">
|
||||
{#if project}
|
||||
<header>
|
||||
<h1>{project.name}</h1>
|
||||
{#if isOwner}
|
||||
<!-- A heading that happens to be editable, rather than a field that
|
||||
looks like one. Saves on blur; Escape puts the old name back. -->
|
||||
<input
|
||||
class="title"
|
||||
value={project.name}
|
||||
aria-label="Projektets navn"
|
||||
onblur={(e) => rename(e.currentTarget)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') {
|
||||
e.currentTarget.value = project.name;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<h1>{project.name}</h1>
|
||||
{/if}
|
||||
<div class="views" role="group" aria-label="Visning">
|
||||
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
||||
<button
|
||||
@@ -103,6 +157,14 @@
|
||||
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button class="remove" onclick={remove} aria-label="Slet projektet">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if showShare}
|
||||
@@ -225,6 +287,58 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Reads as the heading it replaces until you put the cursor in it. */
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.015em;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--s1) var(--s2);
|
||||
margin-left: calc(var(--s2) * -1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.title:focus {
|
||||
background: var(--surface);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
transition:
|
||||
color var(--fast) var(--ease),
|
||||
background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.views {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
|
||||
Reference in New Issue
Block a user