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:
@@ -263,6 +263,7 @@ export interface NotePage {
|
||||
title: string
|
||||
content: string
|
||||
position: number
|
||||
section: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -271,18 +272,18 @@ export async function fetchNotePages(): Promise<NotePage[]> {
|
||||
return parseJson<NotePage[]>(res, 'ノート取得')
|
||||
}
|
||||
|
||||
export async function createNotePage(title: string): Promise<NotePage> {
|
||||
export async function createNotePage(title: string, section?: string | null): Promise<NotePage> {
|
||||
const res = await fetch(`${BASE}/api/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
body: JSON.stringify({ title, section: section ?? null }),
|
||||
})
|
||||
return parseJson<NotePage>(res, 'ページ作成')
|
||||
}
|
||||
|
||||
export async function updateNotePage(
|
||||
pageId: number,
|
||||
patch: { title?: string; content?: string },
|
||||
patch: { title?: string; content?: string; section?: string | null },
|
||||
): Promise<NotePage> {
|
||||
const res = await fetch(`${BASE}/api/notes/${pageId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -295,3 +296,13 @@ export async function updateNotePage(
|
||||
export async function deleteNotePage(pageId: number): Promise<void> {
|
||||
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */
|
||||
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
|
||||
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ old_name: oldName, new_name: newName }),
|
||||
})
|
||||
return parseJson<{ renamed: number }>(res, 'セクション名変更')
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createNotePage,
|
||||
updateNotePage,
|
||||
deleteNotePage,
|
||||
renameNoteSection,
|
||||
type NotePage,
|
||||
} from '../api/client'
|
||||
|
||||
@@ -44,17 +45,42 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
activeId.value = id
|
||||
}
|
||||
|
||||
async function addPage(title = '新しいページ'): Promise<void> {
|
||||
async function addPage(title = '新しいページ', section: string | null = null): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
const page = await createNotePage(title)
|
||||
pages.value.push(page)
|
||||
const page = await createNotePage(title, section)
|
||||
// サーバー側でセクションの並びに合わせて再配置されている可能性があるため一覧を取り直す
|
||||
await load()
|
||||
activeId.value = page.id
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
/** ページの所属セクションを変更する。section が空/nullならグループ解除。並び順もサーバー側で調整されるため再取得する。 */
|
||||
async function setSection(id: number, section: string | null): Promise<void> {
|
||||
error.value = null
|
||||
flush()
|
||||
try {
|
||||
await updateNotePage(id, { section })
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'セクションの変更に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
/** oldName セクションに属する全ページを newName セクションへ一括で付け替える。newName が空ならグループ解除。 */
|
||||
async function renameSection(oldName: string, newName: string | null): Promise<void> {
|
||||
error.value = null
|
||||
flush()
|
||||
try {
|
||||
await renameNoteSection(oldName, newName)
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'セクション名の変更に失敗しました'
|
||||
}
|
||||
}
|
||||
|
||||
async function removePage(id: number): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
@@ -118,6 +144,8 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
addPage,
|
||||
removePage,
|
||||
edit,
|
||||
setSection,
|
||||
renameSection,
|
||||
flush,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user