UI polish pass: word count, slash commands, task lists, graph peek, bulk delete, export
- WordCount component (toggle words/chars vs read time, persisted mode) - TipTap: TaskList/TaskItem extensions, slash command menu (H1-H3, lists, code, quote, task) - Markdown serializer: task list → `- [ ]` / `- [x]` roundtrip - GraphView: slide-in peek panel for note/task nodes (body, tags, linked nodes); tag nodes still navigate - ChatView: bulk-select conversations with two-click confirm delete + chat retention policy (default 90d) - NoteEditorView: pill tab bar with animated edit/preview toggle + WordCount in toolbar - WorkspaceNoteEditor: inline search with tag matching, inline new-note creation, WordCount - WorkspaceTaskPanel: task body rendered in slide-over + Edit link - Settings: data export (Markdown ZIP / JSON) via GET /api/export - Accessibility: skip-to-content link, aria-labels on all icon-only buttons - Fix: export route used non-existent fabledassistant.database — corrected to async_session() - Fix: ToolCallRecord.status type now includes "running" (was causing TS build error) - Dockerfile: upgrade npm to latest before install to suppress major-version notice - npm audit fix: patched minimatch (ReDoS) and rollup (path traversal) — 0 vulnerabilities Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
@@ -9,6 +9,7 @@ import { useNotesStore } from "@/stores/notes";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import WordCount from "@/components/WordCount.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
@@ -35,6 +36,25 @@ const projectNotes = ref<NoteItem[]>([]);
|
||||
const listLoading = ref(false);
|
||||
const deletingId = ref<number | null>(null); // confirming delete for this id
|
||||
const pendingDelete = ref<number | null>(null); // mid-deletion
|
||||
const searchQuery = ref("");
|
||||
|
||||
const filteredNotes = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
if (!q) return projectNotes.value;
|
||||
return projectNotes.value.filter(
|
||||
(n) =>
|
||||
n.title.toLowerCase().includes(q) ||
|
||||
n.tags?.some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
function matchedTags(note: NoteItem): string[] {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return (note.tags ?? []).filter(
|
||||
(t) => t.toLowerCase().includes(q) && !note.title.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Editor state
|
||||
const editingId = ref<number | null>(null);
|
||||
@@ -46,6 +66,44 @@ const saving = ref(false);
|
||||
|
||||
const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; });
|
||||
|
||||
// New note inline creation
|
||||
const showNewNoteInput = ref(false);
|
||||
const newNoteTitle = ref("");
|
||||
const creatingNote = ref(false);
|
||||
const newNoteTitleRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
async function startNewNote() {
|
||||
showNewNoteInput.value = true;
|
||||
newNoteTitle.value = "";
|
||||
await nextTick();
|
||||
newNoteTitleRef.value?.focus();
|
||||
}
|
||||
|
||||
function cancelNewNote() {
|
||||
showNewNoteInput.value = false;
|
||||
newNoteTitle.value = "";
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
const title = newNoteTitle.value.trim();
|
||||
if (!title || creatingNote.value) return;
|
||||
creatingNote.value = true;
|
||||
try {
|
||||
const note = await apiPost<{ id: number; title: string; body: string; tags: string[]; updated_at: string }>(
|
||||
"/api/notes",
|
||||
{ title, project_id: props.projectId, body: "", is_task: false }
|
||||
);
|
||||
showNewNoteInput.value = false;
|
||||
newNoteTitle.value = "";
|
||||
await loadProjectNotes();
|
||||
await openNote(note.id);
|
||||
} catch {
|
||||
toast.show("Failed to create note", "error");
|
||||
} finally {
|
||||
creatingNote.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// TipTap editor ref (for MarkdownToolbar)
|
||||
const titleRef = ref<HTMLInputElement | null>(null);
|
||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
@@ -168,6 +226,7 @@ async function saveNote() {
|
||||
function backToList() {
|
||||
view.value = "list";
|
||||
deletingId.value = null;
|
||||
searchQuery.value = "";
|
||||
}
|
||||
|
||||
function requestDelete(id: number, e: Event) {
|
||||
@@ -245,6 +304,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
<div class="panel-header">
|
||||
<button class="btn-back-arrow" @click="backToList">← Notes</button>
|
||||
<div class="editor-header-right">
|
||||
<WordCount :body="noteBody" />
|
||||
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
||||
<span v-if="saving" class="saving-txt">Saving...</span>
|
||||
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
||||
@@ -288,7 +348,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
>
|
||||
#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}
|
||||
</button>
|
||||
<button class="btn-dismiss-suggestions" @click="tagSuggestions.dismissTagSuggestions()">✕</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
@@ -308,7 +368,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||
</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-dismiss-suggestions" @click="linkSuggestions = []">✕</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||
@@ -319,14 +379,42 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
<!-- ── LIST VIEW ── -->
|
||||
<template v-else>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Notes</span>
|
||||
<template v-if="showNewNoteInput">
|
||||
<input
|
||||
ref="newNoteTitleRef"
|
||||
v-model="newNoteTitle"
|
||||
class="new-note-input"
|
||||
placeholder="Note title..."
|
||||
:disabled="creatingNote"
|
||||
@keydown.enter="createNote"
|
||||
@keydown.escape="cancelNewNote"
|
||||
/>
|
||||
<button class="btn-new-note-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
{{ creatingNote ? '...' : '+' }}
|
||||
</button>
|
||||
<button class="btn-new-note-cancel" aria-label="Cancel new note" @click="cancelNewNote">✕</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="panel-title">Notes</span>
|
||||
<button class="btn-new-note" @click="startNewNote" title="New note">+ New</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="note-search-bar">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="note-search-input"
|
||||
placeholder="Search notes..."
|
||||
type="search"
|
||||
/>
|
||||
<button v-if="searchQuery" class="btn-search-clear" @click="searchQuery = ''" title="Clear search">✕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="listLoading" class="state-msg">Loading...</div>
|
||||
|
||||
<ul v-else class="note-list">
|
||||
<li
|
||||
v-for="note in projectNotes"
|
||||
v-for="note in filteredNotes"
|
||||
:key="note.id"
|
||||
class="note-row"
|
||||
:class="{ active: editingId === note.id }"
|
||||
@@ -335,7 +423,11 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
<div class="note-row-main">
|
||||
<span class="note-row-title">{{ note.title }}</span>
|
||||
<span v-if="note.tags?.length" class="note-row-tags">
|
||||
<span v-for="tag in note.tags.slice(0, 3)" :key="tag" class="note-tag-pill">#{{ tag }}</span>
|
||||
<span
|
||||
v-for="tag in note.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
:class="['note-tag-pill', { 'tag-match': matchedTags(note).includes(tag) }]"
|
||||
>#{{ tag }}</span>
|
||||
<span v-if="note.tags.length > 3" class="note-tag-more">+{{ note.tags.length - 3 }}</span>
|
||||
</span>
|
||||
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
|
||||
@@ -350,7 +442,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
>
|
||||
{{ pendingDelete === note.id ? '...' : 'Delete?' }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" @click="cancelDelete($event)">✕</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)">✕</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
@@ -363,8 +455,8 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li v-if="projectNotes.length === 0" class="state-msg">
|
||||
No notes yet — ask the agent to create one.
|
||||
<li v-if="filteredNotes.length === 0" class="state-msg">
|
||||
{{ searchQuery.trim() ? 'No notes match.' : 'No notes yet — ask the agent to create one.' }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -588,6 +680,41 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Search bar */
|
||||
.note-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-search-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.note-search-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.note-search-input::-webkit-search-cancel-button { display: none; }
|
||||
|
||||
.btn-search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.25rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.btn-search-clear:hover { color: var(--color-text); }
|
||||
|
||||
/* List */
|
||||
.note-list {
|
||||
list-style: none;
|
||||
@@ -646,6 +773,11 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
max-width: 6rem;
|
||||
}
|
||||
|
||||
.note-tag-pill.tag-match {
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
outline: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.note-tag-more {
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-text-muted);
|
||||
@@ -707,4 +839,52 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-new-note {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.new-note-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 5px;
|
||||
padding: 0.22rem 0.5rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.new-note-input:focus { outline: none; }
|
||||
|
||||
.btn-new-note-confirm {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.22rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-new-note-confirm:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.btn-new-note-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-new-note-cancel:hover { color: var(--color-text); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user