Move project renaming from the project view to configuration

- ProjectView: title is now a plain <h1>, no longer inline-editable
  (dropped titleDraft/saveTitle/patchProject and the input markup --
  nothing else used patchProject).
- ProjectConfigureView: new 'Project name' section above 'Statuses',
  a small PATCH /api/projects/:id form. On success it also calls the
  projects store's fetchProjects(), since the dashboard grid and the
  sidebar's project dropdown read from that store and otherwise
  wouldn't pick up the new name (or the project's new alphabetical
  position) until some unrelated reload.
- style.css: dropped the now-dead .project-head__title input rules
  (both views render a plain heading now); .project-head__title gains
  word-break so a long static title still wraps instead of overflowing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 21:01:27 +01:00
co-authored by Claude Sonnet 5
parent c5ffdae2cd
commit ea169ebed0
4 changed files with 71 additions and 67 deletions
+13 -5
View File
@@ -99,11 +99,11 @@ ghost. The same rule covers every kanban status column too (below).
`/projects/:id` shows one project. It renders on a **full-width** layout (the `/projects/:id` shows one project. It renders on a **full-width** layout (the
route sets `meta.wide`, which widens `.app__main` in `App.vue`), so the header route sets `meta.wide`, which widens `.app__main` in `App.vue`), so the header
spans the full width and the **Manage** menu sits top right. The title is spans the full width and the **Manage** menu sits top right. The title is a
inline-editable (saved on blur via `PATCH /api/projects/:id`). Manage plain heading here -- renaming lives on the configuration view (below). Manage
(`ProjectManageMenu.vue`) has a **Configure** link (to the status-management (`ProjectManageMenu.vue`) has a **Configure** link (to that view) and a
view below) and a **Delete project** action that opens a confirmation modal; **Delete project** action that opens a confirmation modal; confirming calls
confirming calls `DELETE /api/projects/:id` and returns to the dashboard. `DELETE /api/projects/:id` and returns to the dashboard.
Below the header are two tabs (local `activeTab` state, `v-show` so both stay Below the header are two tabs (local `activeTab` state, `v-show` so both stay
mounted). The tab order is fixed — **All tasks** first, **Kanban** second — but mounted). The tab order is fixed — **All tasks** first, **Kanban** second — but
@@ -139,6 +139,14 @@ statuses. The header mirrors the project view's — title, then a
alike (both `.menu__toggle`) (Manage's own Configure link is hidden here, alike (both `.menu__toggle`) (Manage's own Configure link is hidden here,
since it would just point at the current page). since it would just point at the current page).
A **Project name** section (a plain `PATCH /api/projects/:id` form,
`{ title }`) sits above **Statuses**. On success it also calls the
`projects` store's `fetchProjects()` -- the local `project` ref (and this
view's own header) update from the PATCH response directly, but the
dashboard grid and the sidebar's project dropdown read from that store, so
without the extra fetch the new name (and alphabetical position -- projects
are API-ordered by title) wouldn't show up there until some other reload.
The status list is a `vuedraggable` list (its own list, no shared drag group The status list is a `vuedraggable` list (its own list, no shared drag group
with the kanban board) bound directly to a local `statuses` ref; dragging with the kanban board) bound directly to a local `statuses` ref; dragging
mutates it in place, and `@change` persists the whole new order via mutates it in place, and `@change` persists the whole new order via
+1 -21
View File
@@ -575,27 +575,7 @@ h1 {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
} word-break: break-word;
.project-head__title input {
width: 100%;
font: inherit;
font-size: 1.4rem;
font-weight: 600;
color: var(--text);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
padding: 0.25rem 0.4rem;
}
.project-head__title input:hover {
border-color: var(--border);
}
.project-head__title input:focus {
color: var(--text);
background: var(--bg);
} }
.project-head__actions { .project-head__actions {
+56
View File
@@ -4,10 +4,12 @@ import { useRoute } from 'vue-router'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import ProjectManageMenu from '../components/ProjectManageMenu.vue' import ProjectManageMenu from '../components/ProjectManageMenu.vue'
import { ApiError, apiRequest } from '../lib/api' import { ApiError, apiRequest } from '../lib/api'
import { useProjectsStore } from '../stores/projects'
import type { CardStatus, Project } from '../types' import type { CardStatus, Project } from '../types'
const route = useRoute() const route = useRoute()
const projectId = Number(route.params.id) const projectId = Number(route.params.id)
const projects = useProjectsStore()
const project = ref<Project | null>(null) const project = ref<Project | null>(null)
const statuses = ref<CardStatus[]>([]) const statuses = ref<CardStatus[]>([])
@@ -25,6 +27,7 @@ async function load() {
]) ])
project.value = fetched project.value = fetched
statuses.value = fetchedStatuses statuses.value = fetchedStatuses
nameDraft.value = fetched.title
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 404) { if (e instanceof ApiError && e.status === 404) {
loadError.value = 'That project does not exist.' loadError.value = 'That project does not exist.'
@@ -34,6 +37,37 @@ async function load() {
} }
} }
// --- Project name ---------------------------------------------------------
const nameDraft = ref('')
const renaming = ref(false)
const renameError = ref<ApiError | null>(null)
async function onRenameProject() {
if (!project.value) return
const next = nameDraft.value.trim()
if (next === project.value.title) return
renaming.value = true
renameError.value = null
try {
const { project: updated } = await apiRequest<{ project: Project }>(`/projects/${projectId}`, {
method: 'PATCH',
auth: true,
body: { title: next },
})
project.value = updated
nameDraft.value = updated.title
// The dashboard grid and the sidebar's project dropdown both read from
// this store -- re-fetch so the new name (and alphabetical position)
// show up there too, not just in this view's own header.
await projects.fetchProjects()
} catch (e) {
renameError.value = e instanceof ApiError ? e : new ApiError('Could not save the name.', 0)
} finally {
renaming.value = false
}
}
// --- Reorder (drag-and-drop) ---------------------------------------------- // --- Reorder (drag-and-drop) ----------------------------------------------
// vuedraggable mutates `statuses` in place as the user drags; @change fires // vuedraggable mutates `statuses` in place as the user drags; @change fires
// once the drop lands, and we persist the whole new order. // once the drop lands, and we persist the whole new order.
@@ -161,6 +195,28 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</div> </div>
</div> </div>
<section class="config-section">
<h2>Project name</h2>
<form class="form" @submit.prevent="onRenameProject">
<label>
<span>Name</span>
<input v-model="nameDraft" type="text" maxlength="255" required />
<small v-if="renameError?.fieldError('title')" class="field-error">
{{ renameError.fieldError('title') }}
</small>
</label>
<p v-if="renameError && Object.keys(renameError.details).length === 0" class="form-error">
{{ renameError.message }}
</p>
<button type="submit" :disabled="renaming || nameDraft.trim() === project.title">
{{ renaming ? 'Saving…' : 'Save name' }}
</button>
</form>
</section>
<section class="config-section"> <section class="config-section">
<h2>Statuses</h2> <h2>Statuses</h2>
<p class="muted"> <p class="muted">
+1 -41
View File
@@ -28,8 +28,6 @@ const tabs = [
] as const ] as const
const activeTab = ref<(typeof tabs)[number]['key']>('kanban') const activeTab = ref<(typeof tabs)[number]['key']>('kanban')
const titleDraft = ref('')
const newText = ref('') const newText = ref('')
const submitting = ref(false) const submitting = ref(false)
@@ -96,10 +94,6 @@ async function onColumnChange(change: ColumnChange, column: Column) {
} }
} }
watch(project, (value) => {
if (value) titleDraft.value = value.title
})
onMounted(() => void load()) onMounted(() => void load())
async function load() { async function load() {
@@ -122,31 +116,6 @@ async function load() {
} }
} }
async function patchProject(fields: { title?: string }) {
actionError.value = null
try {
const { project: updated } = await apiRequest<{ project: Project }>(`/projects/${projectId}`, {
method: 'PATCH',
auth: true,
body: fields,
})
project.value = updated
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Could not save the change.'
if (project.value) titleDraft.value = project.value.title
}
}
function saveTitle() {
if (!project.value) return
const next = titleDraft.value.trim()
if (next === '') {
titleDraft.value = project.value.title // title is required
return
}
if (next !== project.value.title) void patchProject({ title: next })
}
/** Run a store mutation, surfacing failures and resyncing from the server. */ /** Run a store mutation, surfacing failures and resyncing from the server. */
async function run(op: Promise<unknown>) { async function run(op: Promise<unknown>) {
actionError.value = null actionError.value = null
@@ -178,16 +147,7 @@ async function onCreate() {
<template v-else-if="project"> <template v-else-if="project">
<div class="project-head"> <div class="project-head">
<h1 class="project-head__title"> <h1 class="project-head__title">{{ project.title }}</h1>
<input
v-model="titleDraft"
type="text"
maxlength="255"
aria-label="Project title"
@blur="saveTitle"
@keyup.enter="($event.target as HTMLInputElement).blur()"
/>
</h1>
<ProjectManageMenu <ProjectManageMenu
:project-id="projectId" :project-id="projectId"