Add section grouping to shared note tabs

Notes can now be clustered into named sections in the tab bar: each tab
gets a 🏷 button to assign it to a section, and clicking a section's
label renames (or, given an empty name, ungroups) every page in it.

note_pages gains a `section` column. Since the app has no migration
framework, database.py adds the column to existing SQLite files at
startup via PRAGMA table_info + ALTER TABLE. core/notes.set_section()
keeps same-section pages contiguous by renumbering position whenever a
page's section changes, so the tab bar and the AI-facing title list
(now grouped under "## section" headings) can both treat "adjacent" as
"same group" without a separate ordering field.

Also fixes a bug in the tab rename input: ref="renameInput" sits inside
a nested v-for, which Vue 3 binds as an array rather than a single
element, so renameInput.value?.select() was throwing on every rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 00:28:36 +09:00
co-authored by Claude Opus 5
parent b1350eb8bc
commit 1c21fc3406
10 changed files with 388 additions and 68 deletions
+105 -27
View File
@@ -1,15 +1,36 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import { onMounted, onUnmounted, ref, nextTick, computed } from 'vue'
import { useNotesStore } from '../stores/notes'
import type { NotePage } from '../api/client'
interface TabGroup {
section: string | null
pages: NotePage[]
}
const store = useNotesStore()
const renamingId = ref<number | null>(null)
const renameInput = ref<HTMLInputElement | null>(null)
// ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる
const renameInput = ref<HTMLInputElement[]>([])
/** 隣接して同じsectionが続く区間を1グループとして表示する(並び順はサーバー側が保証) */
const groups = computed<TabGroup[]>(() => {
const result: TabGroup[] = []
for (const page of store.pages) {
const last = result[result.length - 1]
if (last && last.section === page.section) {
last.pages.push(page)
} else {
result.push({ section: page.section, pages: [page] })
}
}
return result
})
async function startRename(id: number) {
renamingId.value = id
await nextTick()
renameInput.value?.select()
renameInput.value[0]?.select()
}
function onTitleInput(id: number, e: Event) {
@@ -31,6 +52,18 @@ async function confirmRemove(id: number, title: string) {
await store.removePage(id)
}
async function assignSection(page: NotePage) {
const input = window.prompt('このページが属するセクション名(空にするとグループ解除)', page.section ?? '')
if (input === null) return
await store.setSection(page.id, input.trim())
}
async function renameSectionGroup(currentName: string) {
const input = window.prompt('セクション名を変更(空にするとグループを解除)', currentName)
if (input === null) return
await store.renameSection(currentName, input.trim())
}
onMounted(() => {
store.load()
})
@@ -43,29 +76,43 @@ onUnmounted(() => {
<div class="note-panel">
<div class="tabs">
<div
v-for="page in store.pages"
:key="page.id"
class="tab"
:class="{ active: page.id === store.activeId }"
@click="store.selectPage(page.id)"
@dblclick="startRename(page.id)"
v-for="group in groups"
:key="`${group.section ?? '_'}-${group.pages[0].id}`"
class="tab-group"
:class="{ named: group.section }"
>
<input
v-if="renamingId === page.id"
ref="renameInput"
class="tab-rename"
:value="page.title"
@input="onTitleInput(page.id, $event)"
@blur="finishRename"
@keydown.enter="finishRename"
@keydown.esc="finishRename"
@click.stop
<button
v-if="group.section"
class="section-label"
:title="`「${group.section}」セクション。クリックで名前変更・解除`"
@click="renameSectionGroup(group.section)"
>{{ group.section }}</button>
<div
v-for="page in group.pages"
:key="page.id"
class="tab"
:class="{ active: page.id === store.activeId }"
@click="store.selectPage(page.id)"
@dblclick="startRename(page.id)"
>
<template v-else>
<span class="tab-title" :title="page.title">{{ page.title }}</span>
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template>
<input
v-if="renamingId === page.id"
ref="renameInput"
class="tab-rename"
:value="page.title"
@input="onTitleInput(page.id, $event)"
@blur="finishRename"
@keydown.enter="finishRename"
@keydown.esc="finishRename"
@click.stop
>
<template v-else>
<span class="tab-title" :title="page.title">{{ page.title }}</span>
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
<button class="tab-tag" title="セクションに割り当て" @click.stop="assignSection(page)">🏷</button>
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template>
</div>
</div>
<button class="tab-add" title="ページを追加" @click="store.addPage()">+</button>
</div>
@@ -88,6 +135,7 @@ onUnmounted(() => {
<div class="hint">
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
</div>
</div>
</template>
@@ -101,13 +149,40 @@ onUnmounted(() => {
}
.tabs {
display: flex;
align-items: stretch;
align-items: flex-end;
gap: 2px;
padding: 6px 6px 0;
overflow-x: auto;
flex-shrink: 0;
border-bottom: 1px solid #333;
}
.tab-group {
display: flex;
align-items: flex-end;
gap: 2px;
}
.tab-group.named {
background: #161d2e;
border: 1px solid #29314a;
border-bottom: none;
border-radius: 8px 8px 0 0;
padding: 3px 3px 0;
margin-right: 4px;
}
.section-label {
background: none;
border: none;
color: #7fa8e0;
font-size: 10px;
font-weight: 600;
cursor: pointer;
padding: 2px 5px 4px;
white-space: nowrap;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
}
.section-label:hover { color: #a8c8f0; text-decoration: underline; }
.tab {
display: flex;
align-items: center;
@@ -126,7 +201,7 @@ onUnmounted(() => {
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
.tab-title { overflow: hidden; text-overflow: ellipsis; }
.dot { color: #eab308; font-size: 8px; }
.tab-del, .tab-add {
.tab-tag, .tab-del, .tab-add {
background: none;
border: none;
color: #666;
@@ -135,8 +210,11 @@ onUnmounted(() => {
padding: 0 2px;
line-height: 1;
}
.tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
.tab:hover .tab-tag, .tab.active .tab-tag { opacity: 0.8; }
.tab-tag:hover { opacity: 1 !important; }
.tab-del:hover, .tab-add:hover { color: #f87171; }
.tab-add { color: #888; padding: 0 8px; }
.tab-add { color: #888; padding: 0 8px; align-self: center; }
.tab-add:hover { color: #93c5fd; }
.tab-rename {
background: #111;