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
@@ -100,6 +100,8 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
|||||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
||||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
||||||
| `GET` | `/projects/{id}/activity` | |
|
| `GET` | `/projects/{id}/activity` | |
|
||||||
|
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
||||||
|
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||||
|
|
||||||
### Views, filters and labels
|
### Views, filters and labels
|
||||||
|
|
||||||
|
|||||||
@@ -785,6 +785,42 @@ paths:
|
|||||||
"204": { description: Deleted. }
|
"204": { description: Deleted. }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/api/v1/trash/projects:
|
||||||
|
get:
|
||||||
|
tags: [projects]
|
||||||
|
summary: Projects you have deleted and can still bring back.
|
||||||
|
description: |
|
||||||
|
Only your own. Restoring has been possible since the trash existed, but
|
||||||
|
the endpoint takes an id — and the id is what you no longer have once the
|
||||||
|
project has gone from the interface.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
projects:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/Project"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
deleted_at: { type: string, format: date-time }
|
||||||
|
purge_after:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: |
|
||||||
|
When it stops being recoverable. Sent rather than
|
||||||
|
left for the client to work out, because the
|
||||||
|
retention window is the server's and a second
|
||||||
|
copy of it will one day disagree.
|
||||||
|
task_count:
|
||||||
|
type: integer
|
||||||
|
description: Tasks that went with it, and would come back with it.
|
||||||
|
|
||||||
/api/v1/trash/projects/{projectID}/restore:
|
/api/v1/trash/projects/{projectID}/restore:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -151,6 +152,38 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type trashedProjectJSON struct {
|
||||||
|
projectJSON
|
||||||
|
DeletedAt string `json:"deleted_at"`
|
||||||
|
// PurgeAfter is when it stops being recoverable. Sent rather than left for
|
||||||
|
// the frontend to work out, because the retention window is the server's and
|
||||||
|
// a copy of it in the interface is a copy that will one day disagree.
|
||||||
|
PurgeAfter string `json:"purge_after"`
|
||||||
|
TaskCount int `json:"task_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListTrashedProjects is what makes restoring reachable. The restore
|
||||||
|
// endpoint has always existed; without this, using it meant knowing the id of
|
||||||
|
// something that had already vanished from the interface.
|
||||||
|
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "list trashed projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]trashedProjectJSON, 0, len(trashed))
|
||||||
|
for _, t := range trashed {
|
||||||
|
out = append(out, trashedProjectJSON{
|
||||||
|
projectJSON: toProjectJSON(t.Project),
|
||||||
|
DeletedAt: t.DeletedAt.Format(time.RFC3339),
|
||||||
|
PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339),
|
||||||
|
TaskCount: t.TaskCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"projects": out})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
// Permission cannot be checked through ProjectRole here: a trashed project is
|
// Permission cannot be checked through ProjectRole here: a trashed project is
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restoring has been possible since the trash existed. Finding what to restore
|
||||||
|
// has not: the endpoint takes an id, and the id is exactly what you no longer
|
||||||
|
// have once the project has gone from the interface.
|
||||||
|
func TestTheTrashCanBeListedAndRestored(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ferie"})
|
||||||
|
id := created["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "book fly", "project_id": id})
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+id, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gone from the ordinary listing.
|
||||||
|
_, live := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
for _, raw := range live["projects"].([]any) {
|
||||||
|
if raw.(map[string]any)["id"] == id {
|
||||||
|
t.Fatal("a deleted project is still in the project list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And findable in the trash, with enough to decide whether to bring it back.
|
||||||
|
_, trash := ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||||
|
list, _ := trash["projects"].([]any)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("want one project in the trash, got %v", list)
|
||||||
|
}
|
||||||
|
entry := list[0].(map[string]any)
|
||||||
|
if entry["name"] != "Ferie" {
|
||||||
|
t.Errorf("name = %v", entry["name"])
|
||||||
|
}
|
||||||
|
if entry["task_count"] != float64(1) {
|
||||||
|
t.Errorf("task_count = %v, want 1 — the count is what says whether it matters",
|
||||||
|
entry["task_count"])
|
||||||
|
}
|
||||||
|
if entry["purge_after"] == nil || entry["deleted_at"] == nil {
|
||||||
|
t.Errorf("no deadline was reported: %v", entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("restore: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back, with its task.
|
||||||
|
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+id, nil)
|
||||||
|
if got, _ := tasks["tasks"].([]any); len(got) != 1 {
|
||||||
|
t.Errorf("the task did not come back with the project: %v", got)
|
||||||
|
}
|
||||||
|
_, trash = ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||||
|
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||||
|
t.Errorf("still in the trash after restoring: %v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's deleted project is not in your trash, and you cannot restore it.
|
||||||
|
func TestTheTrashIsPerOwner(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||||
|
id := created["id"].(string)
|
||||||
|
ts.do(t, "DELETE", "/api/v1/projects/"+id, nil)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
_, trash := other.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||||
|
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||||
|
t.Errorf("somebody else's deleted project is visible: %v", list)
|
||||||
|
}
|
||||||
|
resp, _ := other.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,9 +285,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Restoring is outside the block above: a trashed project is invisible
|
// The trash is outside the block above: a deleted project is invisible
|
||||||
// to the permission check by design, so the handler resolves ownership
|
// to the permission check by design, so these handlers resolve
|
||||||
// itself.
|
// ownership themselves.
|
||||||
|
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||||
|
|
||||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||||
|
|||||||
@@ -234,6 +234,55 @@ func (db *DB) DeleteProject(ctx context.Context, projectID string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrashedProject is a deleted project, with when it went and when it goes for good.
|
||||||
|
type TrashedProject struct {
|
||||||
|
Project
|
||||||
|
DeletedAt time.Time
|
||||||
|
TaskCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTrashedProjects returns what the caller owns and has deleted.
|
||||||
|
//
|
||||||
|
// Restoring has been possible since the trash existed, but only if you already
|
||||||
|
// knew the project's id — which is exactly what you no longer have once it has
|
||||||
|
// gone from the interface. A recovery window nobody can reach is not one.
|
||||||
|
//
|
||||||
|
// Owned, not shared: a member of a project somebody else deleted has no business
|
||||||
|
// bringing it back.
|
||||||
|
func (db *DB) ListTrashedProjects(ctx context.Context, userID string) ([]TrashedProject, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||||
|
p.archived, p.sort_order, p.created_at, p.updated_at, p.deleted_at,
|
||||||
|
(SELECT count(*) FROM tasks t
|
||||||
|
WHERE t.project_id = p.id AND t.deleted_at = p.deleted_at) AS task_count
|
||||||
|
FROM projects p
|
||||||
|
WHERE p.owner_id = ? AND p.deleted_at IS NOT NULL
|
||||||
|
ORDER BY p.deleted_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []TrashedProject{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t TrashedProject
|
||||||
|
var created, updated, deleted int64
|
||||||
|
var icon sql.NullString
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &icon, &t.ViewMode, &t.OwnerID,
|
||||||
|
&t.IsInbox, &t.Archived, &t.SortOrder, &created, &updated, &deleted,
|
||||||
|
&t.TaskCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Icon = icon.String
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
|
t.DeletedAt = time.Unix(deleted, 0).UTC()
|
||||||
|
t.Role = RoleOwner
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// RestoreProject brings a project back, along with the tasks that were deleted with
|
// RestoreProject brings a project back, along with the tasks that were deleted with
|
||||||
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
|
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
|
||||||
// and separately, and restoring a project should not undo that too.
|
// and separately, and restoring a project should not undo that too.
|
||||||
|
|||||||
@@ -180,6 +180,45 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
|||||||
expect(await worker.text()).toContain("addEventListener('push'");
|
expect(await worker.text()).toContain("addEventListener('push'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A project can be renamed, deleted and brought back.
|
||||||
|
*
|
||||||
|
* The round trip matters more than the three steps: deleting used to be a
|
||||||
|
* one-way door in the interface — the API could restore, but only from an id
|
||||||
|
* that had already disappeared from the screen.
|
||||||
|
*/
|
||||||
|
test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Ferie');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
|
||||||
|
const title = page.getByLabel('Projektets navn');
|
||||||
|
await expect(title).toHaveValue('Ferie');
|
||||||
|
|
||||||
|
await title.fill('Sommerferie');
|
||||||
|
await title.press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||||
|
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await page.getByRole('button', { name: 'Slet projektet' }).click();
|
||||||
|
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
|
||||||
|
|
||||||
|
// And it is reachable again, which is the half that did not exist.
|
||||||
|
await page.goto('/indstillinger/data');
|
||||||
|
const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' });
|
||||||
|
await expect(trash.getByText('Sommerferie')).toBeVisible();
|
||||||
|
|
||||||
|
await trash.getByRole('button', { name: 'Hent tilbage' }).click();
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The MCP connector address must not fall through to the app shell.
|
* The MCP connector address must not fall through to the app shell.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export const api = {
|
|||||||
createProject: (data) => post('/projects', data),
|
createProject: (data) => post('/projects', data),
|
||||||
updateProject: (id, data) => patch(`/projects/${id}`, data),
|
updateProject: (id, data) => patch(`/projects/${id}`, data),
|
||||||
deleteProject: (id) => del(`/projects/${id}`),
|
deleteProject: (id) => del(`/projects/${id}`),
|
||||||
|
listTrashedProjects: () => get('/trash/projects'),
|
||||||
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
|
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
|
||||||
|
|
||||||
listSections: (projectId) => get(`/projects/${projectId}/sections`),
|
listSections: (projectId) => get(`/projects/${projectId}/sections`),
|
||||||
|
|||||||
@@ -101,6 +101,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let exportProject = $state('');
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -146,6 +176,38 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<header>
|
<header>
|
||||||
<h2>Eksport</h2>
|
<h2>Eksport</h2>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
/** One project: its sections, its tasks, and who it is shared with. */
|
/** One project: its sections, its tasks, and who it is shared with. */
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import TaskList from '$lib/components/TaskList.svelte';
|
import TaskList from '$lib/components/TaskList.svelte';
|
||||||
@@ -65,6 +66,41 @@
|
|||||||
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
|
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
|
||||||
let unsectioned = $derived(open.filter((t) => !t.section_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) {
|
async function invite(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
inviteError = '';
|
inviteError = '';
|
||||||
@@ -87,7 +123,25 @@
|
|||||||
<div class="view">
|
<div class="view">
|
||||||
{#if project}
|
{#if project}
|
||||||
<header>
|
<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">
|
<div class="views" role="group" aria-label="Visning">
|
||||||
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
||||||
<button
|
<button
|
||||||
@@ -103,6 +157,14 @@
|
|||||||
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
|
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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>
|
</header>
|
||||||
|
|
||||||
{#if showShare}
|
{#if showShare}
|
||||||
@@ -225,6 +287,58 @@
|
|||||||
overflow-wrap: anywhere;
|
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 {
|
.views {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
|||||||
Reference in New Issue
Block a user