Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f4af6c0ab | ||
|
|
7ab68b0b58 | ||
|
|
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"}` |
|
||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -785,6 +785,42 @@ paths:
|
||||
"204": { description: Deleted. }
|
||||
"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:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
|
||||
@@ -929,3 +929,57 @@ func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
|
||||
t.Error("an SMTP host is set, so configured should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// A 200 with the app shell is an answer that says "yes, that exists" to anything
|
||||
// that asks. For /.well-known/ that is actively harmful: it is a reserved
|
||||
// namespace, and a client probing it is asking a yes-or-no question.
|
||||
//
|
||||
// Claude asks /.well-known/oauth-authorization-server before connecting to an
|
||||
// MCP server. It read the shell as "there is an authorization server here" and
|
||||
// then failed trying to register with it, reporting a broken sign-in service on
|
||||
// a server that has none and needs none.
|
||||
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, path := range []string{
|
||||
"/.well-known/oauth-authorization-server",
|
||||
"/.well-known/oauth-protected-resource",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/anything-at-all",
|
||||
} {
|
||||
resp, err := ts.client.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s: status %d, want 404", path, resp.StatusCode)
|
||||
}
|
||||
if strings.Contains(string(body), "<!doctype html") {
|
||||
t.Errorf("%s: answered with the app shell", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one well-known path that is real still works — it is a route, not a
|
||||
// fallthrough, so the rule above must not have swallowed it.
|
||||
func TestWellKnownCalDAVStillRedirects(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
resp, err := client.Get(ts.URL + "/.well-known/caldav")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
t.Error("CalDAV discovery was caught by the well-known rule")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -151,6 +152,38 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
// 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
|
||||
// to the permission check by design, so the handler resolves ownership
|
||||
// itself.
|
||||
// The trash is outside the block above: a deleted project is invisible
|
||||
// to the permission check by design, so these handlers resolve
|
||||
// ownership themselves.
|
||||
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
@@ -364,6 +365,21 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// /.well-known/ is reserved by RFC 8615 and is never a route in the app.
|
||||
// Handing back the shell there does not merely waste a response: it tells
|
||||
// whoever asked that the thing they were looking for exists.
|
||||
//
|
||||
// That is not hypothetical. Claude probes
|
||||
// /.well-known/oauth-authorization-server before connecting to an MCP server,
|
||||
// read 200 and a page of HTML as "there is an authorization server here", and
|
||||
// then failed trying to register with it — reporting a broken sign-in service
|
||||
// on a server that has none and needs none. A 404 means "no OAuth here", the
|
||||
// client stops asking, and the key in the URL is used as intended.
|
||||
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if s.web == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
|
||||
@@ -82,6 +82,12 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||
for (const href of hrefs) {
|
||||
await sidebar.locator(`a[href="${href}"]`).first().click();
|
||||
|
||||
// Wait for the navigation before looking at the page. Without this the
|
||||
// assertion can be satisfied by the heading of the page you just left —
|
||||
// which is not a hypothetical: a change that removed a project page's
|
||||
// heading entirely passed here on a fast machine and failed on CI.
|
||||
await page.waitForURL(`**${href}`);
|
||||
|
||||
// Something with a heading rendered. A route written outside the routes
|
||||
// tree lands on the SPA shell and renders nothing — which is exactly how
|
||||
// the label route broke, and it answered 200 the whole time.
|
||||
@@ -180,6 +186,47 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
||||
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');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible();
|
||||
|
||||
// The heading is the rename affordance: click it, and it becomes a field.
|
||||
await page.getByRole('button', { name: 'Ferie' }).click();
|
||||
const title = page.getByLabel('Projektets navn');
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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));
|
||||
|
||||
let editing = $state(false);
|
||||
|
||||
/** Renames on blur. A no-op when nothing changed, so tabbing through is free. */
|
||||
async function rename(input) {
|
||||
const name = input.value.trim();
|
||||
editing = false;
|
||||
if (!name || name === 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) {
|
||||
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,38 @@
|
||||
<div class="view">
|
||||
{#if project}
|
||||
<header>
|
||||
<h1>{project.name}</h1>
|
||||
<!-- A heading first, a field second. Swapping the h1 out for an input
|
||||
outright cost the page its heading — which the sidebar smoke test
|
||||
caught, and which is a real loss: every view here has one, and it is
|
||||
what a screen reader announces on arrival. So the heading stays and
|
||||
becomes editable on click. -->
|
||||
{#if editing}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
class="title"
|
||||
autofocus
|
||||
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;
|
||||
editing = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<h1 class:renameable={isOwner}>
|
||||
{#if isOwner}
|
||||
<button onclick={() => (editing = true)} title="Klik for at omdøbe">
|
||||
{project.name}
|
||||
</button>
|
||||
{:else}
|
||||
{project.name}
|
||||
{/if}
|
||||
</h1>
|
||||
{/if}
|
||||
<div class="views" role="group" aria-label="Visning">
|
||||
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
||||
<button
|
||||
@@ -103,6 +170,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 +300,72 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The heading's button carries no button-ness: it is the heading, and the only
|
||||
hint that it does anything is the underline on hover. */
|
||||
h1.renameable button {
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
h1.renameable button:hover {
|
||||
border-bottom-color: var(--line-strong);
|
||||
}
|
||||
|
||||
/* Matches the heading it stands in for, so the swap does not move the page. */
|
||||
.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