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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user