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
-1
@@ -2,7 +2,7 @@
|
|||||||
FROM node:20-alpine AS build-frontend
|
FROM node:20-alpine AS build-frontend
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY frontend/package.json frontend/package-lock.json* ./
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
RUN npm install
|
RUN npm install -g npm@latest --quiet && npm install
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
Generated
+1751
-107
File diff suppressed because it is too large
Load Diff
@@ -9,17 +9,19 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3": "^7",
|
|
||||||
"@tiptap/extension-link": "^2.11.0",
|
"@tiptap/extension-link": "^2.11.0",
|
||||||
"@tiptap/extension-placeholder": "^2.11.0",
|
"@tiptap/extension-placeholder": "^2.11.0",
|
||||||
|
"@tiptap/extension-task-item": "^2.27.2",
|
||||||
|
"@tiptap/extension-task-list": "^2.27.2",
|
||||||
"@tiptap/pm": "^2.11.0",
|
"@tiptap/pm": "^2.11.0",
|
||||||
"@tiptap/starter-kit": "^2.11.0",
|
"@tiptap/starter-kit": "^2.11.0",
|
||||||
"@tiptap/suggestion": "^2.11.0",
|
"@tiptap/suggestion": "^2.11.0",
|
||||||
"@tiptap/vue-3": "^2.11.0",
|
"@tiptap/vue-3": "^2.11.0",
|
||||||
|
"d3": "^7",
|
||||||
|
"dompurify": "^3.1.0",
|
||||||
|
"marked": "^15.0.0",
|
||||||
"pinia": "^2.2.0",
|
"pinia": "^2.2.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"marked": "^15.0.0",
|
|
||||||
"dompurify": "^3.1.0",
|
|
||||||
"vue-router": "^4.4.0"
|
"vue-router": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+20
-2
@@ -144,9 +144,10 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="authStore.isAuthenticated">
|
<template v-if="authStore.isAuthenticated">
|
||||||
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<div class="app-content">
|
<div id="main-content" class="app-content">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,7 +158,7 @@ onUnmounted(() => {
|
|||||||
<div class="shortcuts-panel">
|
<div class="shortcuts-panel">
|
||||||
<div class="shortcuts-header">
|
<div class="shortcuts-header">
|
||||||
<h3>Keyboard Shortcuts</h3>
|
<h3>Keyboard Shortcuts</h3>
|
||||||
<button class="shortcuts-close" @click="closeShortcuts">×</button>
|
<button class="shortcuts-close" aria-label="Close keyboard shortcuts" @click="closeShortcuts">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcuts-body">
|
<div class="shortcuts-body">
|
||||||
<div class="shortcuts-section">
|
<div class="shortcuts-section">
|
||||||
@@ -262,6 +263,23 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -100%;
|
||||||
|
left: 0.5rem;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: top 0.1s;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -144,6 +144,42 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Task list */
|
||||||
|
.prose ul[data-type="taskList"] {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul[data-type="taskList"] li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul[data-type="taskList"] li > label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
width: 0.95em;
|
||||||
|
height: 0.95em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul[data-type="taskList"] li > div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tiptap editor */
|
/* Tiptap editor */
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function promoteAutoNote(note: { id: number; title: string }) {
|
|||||||
<span v-if="contextNoteId" class="context-indicator">
|
<span v-if="contextNoteId" class="context-indicator">
|
||||||
Note #{{ contextNoteId }}
|
Note #{{ contextNoteId }}
|
||||||
</span>
|
</span>
|
||||||
<button class="btn-close" @click="emit('close')">×</button>
|
<button class="btn-close" aria-label="Close chat panel" @click="emit('close')">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="messagesEl" class="panel-messages">
|
<div ref="messagesEl" class="panel-messages">
|
||||||
@@ -224,7 +224,7 @@ function promoteAutoNote(note: { id: number; title: string }) {
|
|||||||
<div v-if="attachedNote" class="attached-note">
|
<div v-if="attachedNote" class="attached-note">
|
||||||
<span class="attached-note-pill">
|
<span class="attached-note-pill">
|
||||||
{{ attachedNote.title }}
|
{{ attachedNote.title }}
|
||||||
<button class="attached-note-remove" @click="removeAttachedNote">×</button>
|
<button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ defineExpose({ focus });
|
|||||||
<div v-if="attachedNote" class="attached-note">
|
<div v-if="attachedNote" class="attached-note">
|
||||||
<span class="attached-note-pill">
|
<span class="attached-note-pill">
|
||||||
{{ attachedNote.title }}
|
{{ attachedNote.title }}
|
||||||
<button class="attached-note-remove" @click="removeAttachedNote">
|
<button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ onMounted(loadVersions);
|
|||||||
<div class="modal-card history-modal">
|
<div class="modal-card history-modal">
|
||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
<h3 class="history-title">Version History</h3>
|
<h3 class="history-title">Version History</h3>
|
||||||
<button class="history-close" @click="emit('close')">×</button>
|
<button class="history-close" aria-label="Close version history" @click="emit('close')">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="history-body">
|
<div class="history-body">
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ const buttons = [
|
|||||||
command: () => props.editor?.chain().focus().toggleCodeBlock().run(),
|
command: () => props.editor?.chain().focus().toggleCodeBlock().run(),
|
||||||
isActive: () => props.editor?.isActive("codeBlock") ?? false,
|
isActive: () => props.editor?.isActive("codeBlock") ?? false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "☑",
|
||||||
|
title: "Task List",
|
||||||
|
command: () => props.editor?.chain().focus().toggleTaskList().run(),
|
||||||
|
isActive: () => props.editor?.isActive("taskList") ?? false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "[[",
|
label: "[[",
|
||||||
title: "Insert wikilink (type note title to search)",
|
title: "Insert wikilink (type note title to search)",
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ onMounted(loadLogs);
|
|||||||
</span>
|
</span>
|
||||||
<div class="log-entry-actions">
|
<div class="log-entry-actions">
|
||||||
<button class="btn-log-edit" @click="startEdit(log)" title="Edit">Edit</button>
|
<button class="btn-log-edit" @click="startEdit(log)" title="Edit">Edit</button>
|
||||||
<button class="btn-log-delete" @click="deleteLog(log)" title="Delete">×</button>
|
<button class="btn-log-delete" aria-label="Delete log entry" @click="deleteLog(log)">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-content prose" v-html="renderMarkdown(log.content)"></div>
|
<div class="log-content prose" v-html="renderMarkdown(log.content)"></div>
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import Placeholder from "@tiptap/extension-placeholder";
|
|||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { serializeToMarkdown } from "@/utils/markdownSerializer";
|
import { serializeToMarkdown } from "@/utils/markdownSerializer";
|
||||||
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import { TagDecoration } from "@/extensions/TagDecoration";
|
import { TagDecoration } from "@/extensions/TagDecoration";
|
||||||
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
|
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
|
||||||
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
|
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
|
||||||
|
import { SlashCommands } from "@/extensions/SlashCommands";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -76,9 +79,12 @@ try {
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
}),
|
}),
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({ nested: true }),
|
||||||
TagDecoration,
|
TagDecoration,
|
||||||
WikilinkDecoration,
|
WikilinkDecoration,
|
||||||
WikilinkSuggestion,
|
WikilinkSuggestion,
|
||||||
|
SlashCommands,
|
||||||
],
|
],
|
||||||
onUpdate({ editor: ed }) {
|
onUpdate({ editor: ed }) {
|
||||||
if (updatingFromProp) return;
|
if (updatingFromProp) return;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const toastStore = useToastStore();
|
|||||||
:class="`toast--${toast.type}`"
|
:class="`toast--${toast.type}`"
|
||||||
>
|
>
|
||||||
<span class="toast-msg">{{ toast.message }}</span>
|
<span class="toast-msg">{{ toast.message }}</span>
|
||||||
<button class="toast-close" @click="toastStore.dismiss(toast.id)">×</button>
|
<button class="toast-close" aria-label="Dismiss notification" @click="toastStore.dismiss(toast.id)">×</button>
|
||||||
</div>
|
</div>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ body: string }>();
|
||||||
|
|
||||||
|
const STORAGE_KEY = "word_count_mode";
|
||||||
|
const mode = ref<"words" | "time">(
|
||||||
|
(localStorage.getItem(STORAGE_KEY) as "words" | "time") ?? "words"
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
mode.value = mode.value === "words" ? "time" : "words";
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainText = computed(() =>
|
||||||
|
props.body
|
||||||
|
.replace(/```[\s\S]*?```/g, " ")
|
||||||
|
.replace(/`[^`]*`/g, " ")
|
||||||
|
.replace(/!?\[.*?\]\(.*?\)/g, " ")
|
||||||
|
.replace(/\[\[([^\]]*)\]\]/g, "$1")
|
||||||
|
.replace(/[#*_~>|\\]/g, " ")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
const wordCount = computed(() => {
|
||||||
|
if (!plainText.value) return 0;
|
||||||
|
return plainText.value.split(/\s+/).filter(Boolean).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const charCount = computed(() => props.body.length);
|
||||||
|
|
||||||
|
const readingTime = computed(() => Math.max(1, Math.ceil(wordCount.value / 200)));
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
if (!props.body.trim()) return "";
|
||||||
|
if (mode.value === "time") return `~${readingTime.value} min read`;
|
||||||
|
return `${wordCount.value.toLocaleString()} words · ${charCount.value.toLocaleString()} chars`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button v-if="label" class="word-count" @click="toggle" :title="mode === 'words' ? 'Switch to reading time' : 'Switch to word count'">
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.word-count {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.word-count:hover { color: var(--color-text); }
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 type { Editor } from "@tiptap/vue-3";
|
||||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
@@ -9,6 +9,7 @@ import { useNotesStore } from "@/stores/notes";
|
|||||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
|
import WordCount from "@/components/WordCount.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: number;
|
projectId: number;
|
||||||
@@ -35,6 +36,25 @@ const projectNotes = ref<NoteItem[]>([]);
|
|||||||
const listLoading = ref(false);
|
const listLoading = ref(false);
|
||||||
const deletingId = ref<number | null>(null); // confirming delete for this id
|
const deletingId = ref<number | null>(null); // confirming delete for this id
|
||||||
const pendingDelete = ref<number | null>(null); // mid-deletion
|
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
|
// Editor state
|
||||||
const editingId = ref<number | null>(null);
|
const editingId = ref<number | null>(null);
|
||||||
@@ -46,6 +66,44 @@ const saving = ref(false);
|
|||||||
|
|
||||||
const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; });
|
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)
|
// TipTap editor ref (for MarkdownToolbar)
|
||||||
const titleRef = ref<HTMLInputElement | null>(null);
|
const titleRef = ref<HTMLInputElement | null>(null);
|
||||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||||
@@ -168,6 +226,7 @@ async function saveNote() {
|
|||||||
function backToList() {
|
function backToList() {
|
||||||
view.value = "list";
|
view.value = "list";
|
||||||
deletingId.value = null;
|
deletingId.value = null;
|
||||||
|
searchQuery.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestDelete(id: number, e: Event) {
|
function requestDelete(id: number, e: Event) {
|
||||||
@@ -245,6 +304,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<button class="btn-back-arrow" @click="backToList">← Notes</button>
|
<button class="btn-back-arrow" @click="backToList">← Notes</button>
|
||||||
<div class="editor-header-right">
|
<div class="editor-header-right">
|
||||||
|
<WordCount :body="noteBody" />
|
||||||
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
||||||
<span v-if="saving" class="saving-txt">Saving...</span>
|
<span v-if="saving" class="saving-txt">Saving...</span>
|
||||||
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
<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) ? ' ✓' : '' }}
|
#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Formatting toolbar -->
|
<!-- Formatting toolbar -->
|
||||||
@@ -308,7 +368,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
|||||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||||
</span>
|
</span>
|
||||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
<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>
|
||||||
|
|
||||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
<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 ── -->
|
<!-- ── LIST VIEW ── -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="panel-header">
|
<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>
|
||||||
|
|
||||||
<div v-if="listLoading" class="state-msg">Loading...</div>
|
<div v-if="listLoading" class="state-msg">Loading...</div>
|
||||||
|
|
||||||
<ul v-else class="note-list">
|
<ul v-else class="note-list">
|
||||||
<li
|
<li
|
||||||
v-for="note in projectNotes"
|
v-for="note in filteredNotes"
|
||||||
:key="note.id"
|
:key="note.id"
|
||||||
class="note-row"
|
class="note-row"
|
||||||
:class="{ active: editingId === note.id }"
|
:class="{ active: editingId === note.id }"
|
||||||
@@ -335,7 +423,11 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
|||||||
<div class="note-row-main">
|
<div class="note-row-main">
|
||||||
<span class="note-row-title">{{ note.title }}</span>
|
<span class="note-row-title">{{ note.title }}</span>
|
||||||
<span v-if="note.tags?.length" class="note-row-tags">
|
<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 v-if="note.tags.length > 3" class="note-tag-more">+{{ note.tags.length - 3 }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
|
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
|
||||||
@@ -350,7 +442,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
|||||||
>
|
>
|
||||||
{{ pendingDelete === note.id ? '...' : 'Delete?' }}
|
{{ pendingDelete === note.id ? '...' : 'Delete?' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-cancel-delete" @click="cancelDelete($event)">✕</button>
|
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)">✕</button>
|
||||||
</template>
|
</template>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@@ -363,8 +455,8 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li v-if="projectNotes.length === 0" class="state-msg">
|
<li v-if="filteredNotes.length === 0" class="state-msg">
|
||||||
No notes yet — ask the agent to create one.
|
{{ searchQuery.trim() ? 'No notes match.' : 'No notes yet — ask the agent to create one.' }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
@@ -588,6 +680,41 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
|||||||
padding: 0.5rem 0.6rem;
|
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 */
|
/* List */
|
||||||
.note-list {
|
.note-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -646,6 +773,11 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
|||||||
max-width: 6rem;
|
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 {
|
.note-tag-more {
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -707,4 +839,52 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-muted);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||||
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
|
|
||||||
const props = defineProps<{ projectId: number }>();
|
const props = defineProps<{ projectId: number }>();
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ interface Task {
|
|||||||
milestone_id: number | null;
|
milestone_id: number | null;
|
||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
body?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks = ref<Task[]>([]);
|
const tasks = ref<Task[]>([]);
|
||||||
@@ -31,6 +34,8 @@ const loading = ref(false);
|
|||||||
|
|
||||||
// Detail slide-over
|
// Detail slide-over
|
||||||
const activeTask = ref<Task | null>(null);
|
const activeTask = ref<Task | null>(null);
|
||||||
|
const taskBody = ref("");
|
||||||
|
const loadingBody = ref(false);
|
||||||
|
|
||||||
// Collapsed milestone groups (set of milestone IDs, null = "No Milestone" group)
|
// Collapsed milestone groups (set of milestone IDs, null = "No Milestone" group)
|
||||||
const collapsedGroups = ref<Set<number | null>>(new Set());
|
const collapsedGroups = ref<Set<number | null>>(new Set());
|
||||||
@@ -97,12 +102,25 @@ function toggleGroup(key: number | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTask(task: Task) {
|
async function openTask(task: Task) {
|
||||||
activeTask.value = task;
|
activeTask.value = task;
|
||||||
|
taskBody.value = task.body ?? "";
|
||||||
|
loadingBody.value = true;
|
||||||
|
try {
|
||||||
|
const full = await apiGet<Task>(`/api/tasks/${task.id}`);
|
||||||
|
taskBody.value = full.body ?? "";
|
||||||
|
// Update cached task so re-opens don't re-fetch if unchanged
|
||||||
|
task.body = full.body;
|
||||||
|
} catch {
|
||||||
|
// Non-critical — body just won't show
|
||||||
|
} finally {
|
||||||
|
loadingBody.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTask() {
|
function closeTask() {
|
||||||
activeTask.value = null;
|
activeTask.value = null;
|
||||||
|
taskBody.value = "";
|
||||||
deleteConfirmPending.value = false;
|
deleteConfirmPending.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +212,12 @@ defineExpose({ reload: loadAll });
|
|||||||
<div v-if="activeTask" class="task-detail">
|
<div v-if="activeTask" class="task-detail">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<button class="btn-back-arrow" @click="closeTask">← Tasks</button>
|
<button class="btn-back-arrow" @click="closeTask">← Tasks</button>
|
||||||
|
<RouterLink
|
||||||
|
:to="`/tasks/${activeTask.id}/edit`"
|
||||||
|
target="_blank"
|
||||||
|
class="btn-edit-task"
|
||||||
|
title="Open full editor"
|
||||||
|
>Edit ↗</RouterLink>
|
||||||
<span
|
<span
|
||||||
:class="['status-badge', `status-${activeTask.status}`]"
|
:class="['status-badge', `status-${activeTask.status}`]"
|
||||||
@click="cycleStatus(activeTask, $event)"
|
@click="cycleStatus(activeTask, $event)"
|
||||||
@@ -206,7 +230,7 @@ defineExpose({ reload: loadAll });
|
|||||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">
|
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">
|
||||||
{{ deletingTask ? '...' : 'Delete?' }}
|
{{ deletingTask ? '...' : 'Delete?' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-delete-cancel" @click="cancelDeleteTask">✕</button>
|
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask">✕</button>
|
||||||
</template>
|
</template>
|
||||||
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||||
@@ -233,6 +257,11 @@ defineExpose({ reload: loadAll });
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="taskBody || loadingBody" class="detail-body">
|
||||||
|
<div v-if="loadingBody" class="body-loading">…</div>
|
||||||
|
<div v-else class="prose" v-html="renderMarkdown(taskBody)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="detail-log">
|
<div class="detail-log">
|
||||||
<TaskLogSection :task-id="activeTask.id" />
|
<TaskLogSection :task-id="activeTask.id" />
|
||||||
</div>
|
</div>
|
||||||
@@ -527,6 +556,38 @@ defineExpose({ reload: loadAll });
|
|||||||
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
|
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
|
||||||
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
|
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
|
||||||
|
|
||||||
|
.btn-edit-task {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-edit-task:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 40%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-loading {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body .prose {
|
||||||
|
font-size: 0.83rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-delete-task {
|
.btn-delete-task {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Extension } from "@tiptap/vue-3";
|
||||||
|
import { PluginKey } from "@tiptap/pm/state";
|
||||||
|
import Suggestion from "@tiptap/suggestion";
|
||||||
|
import { createSuggestionRenderer } from "./suggestionRenderer";
|
||||||
|
import type { SuggestionItem } from "@/components/SuggestionDropdown.vue";
|
||||||
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
|
|
||||||
|
const slashCommandsKey = new PluginKey("slashCommands");
|
||||||
|
|
||||||
|
interface SlashCommand {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMANDS: SlashCommand[] = [
|
||||||
|
{ label: "Heading 1", value: "h1", keywords: ["heading", "h1", "title"] },
|
||||||
|
{ label: "Heading 2", value: "h2", keywords: ["heading", "h2", "subtitle"] },
|
||||||
|
{ label: "Heading 3", value: "h3", keywords: ["heading", "h3"] },
|
||||||
|
{ label: "Bullet List", value: "ul", keywords: ["bullet", "list", "ul", "unordered"] },
|
||||||
|
{ label: "Numbered List", value: "ol", keywords: ["numbered", "ordered", "ol", "number"] },
|
||||||
|
{ label: "Task List", value: "task", keywords: ["task", "todo", "checkbox", "check"] },
|
||||||
|
{ label: "Code Block", value: "code", keywords: ["code", "block", "pre", "codeblock"] },
|
||||||
|
{ label: "Blockquote", value: "quote", keywords: ["quote", "blockquote"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function applyCommand(editor: Editor, value: string) {
|
||||||
|
const chain = editor.chain().focus();
|
||||||
|
switch (value) {
|
||||||
|
case "h1": chain.toggleHeading({ level: 1 }).run(); break;
|
||||||
|
case "h2": chain.toggleHeading({ level: 2 }).run(); break;
|
||||||
|
case "h3": chain.toggleHeading({ level: 3 }).run(); break;
|
||||||
|
case "ul": chain.toggleBulletList().run(); break;
|
||||||
|
case "ol": chain.toggleOrderedList().run(); break;
|
||||||
|
case "task": chain.toggleTaskList().run(); break;
|
||||||
|
case "code": chain.toggleCodeBlock().run(); break;
|
||||||
|
case "quote": chain.toggleBlockquote().run(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SlashCommands = Extension.create({
|
||||||
|
name: "slashCommands",
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion<SuggestionItem>({
|
||||||
|
editor: this.editor,
|
||||||
|
pluginKey: slashCommandsKey,
|
||||||
|
char: "/",
|
||||||
|
allowSpaces: false,
|
||||||
|
items: ({ query }): SuggestionItem[] => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return COMMANDS
|
||||||
|
.filter((cmd) =>
|
||||||
|
!q ||
|
||||||
|
cmd.label.toLowerCase().includes(q) ||
|
||||||
|
cmd.keywords.some((k) => k.includes(q))
|
||||||
|
)
|
||||||
|
.map((cmd) => ({ label: cmd.label, value: cmd.value }));
|
||||||
|
},
|
||||||
|
command: ({ editor, range, props: item }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
applyCommand(editor as Editor, item.value);
|
||||||
|
},
|
||||||
|
render: createSuggestionRenderer,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -115,6 +115,17 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bulkDeleteConversations(ids: number[]) {
|
||||||
|
if (!ids.length) return;
|
||||||
|
await apiPost("/api/chat/conversations/bulk-delete", { ids });
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
conversations.value = conversations.value.filter((c) => !idSet.has(c.id));
|
||||||
|
if (currentConversation.value && idSet.has(currentConversation.value.id)) {
|
||||||
|
currentConversation.value = null;
|
||||||
|
}
|
||||||
|
for (const id of ids) delete convStreams.value[id];
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteConversation(id: number) {
|
async function deleteConversation(id: number) {
|
||||||
try {
|
try {
|
||||||
await apiDelete(`/api/chat/conversations/${id}`);
|
await apiDelete(`/api/chat/conversations/${id}`);
|
||||||
@@ -494,6 +505,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
createConversation,
|
createConversation,
|
||||||
fetchConversation,
|
fetchConversation,
|
||||||
deleteConversation,
|
deleteConversation,
|
||||||
|
bulkDeleteConversations,
|
||||||
updateTitle,
|
updateTitle,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
reconnectIfGenerating,
|
reconnectIfGenerating,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface ToolCallRecord {
|
|||||||
function: string;
|
function: string;
|
||||||
arguments: Record<string, unknown>;
|
arguments: Record<string, unknown>;
|
||||||
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[] };
|
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[] };
|
||||||
status: "success" | "error" | "declined";
|
status: "running" | "success" | "error" | "declined";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolPendingRecord {
|
export interface ToolPendingRecord {
|
||||||
|
|||||||
@@ -36,8 +36,15 @@ function serializeNode(node: JSONContent, listIndent = 0): string {
|
|||||||
(listIndent === 0 ? "\n" : "")
|
(listIndent === 0 ? "\n" : "")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "taskList":
|
||||||
|
return (
|
||||||
|
serializeTaskList(node.content || [], listIndent) +
|
||||||
|
(listIndent === 0 ? "\n" : "")
|
||||||
|
);
|
||||||
|
|
||||||
case "listItem":
|
case "listItem":
|
||||||
// Handled by bulletList/orderedList
|
case "taskItem":
|
||||||
|
// Handled by their respective list serializers
|
||||||
return "";
|
return "";
|
||||||
|
|
||||||
case "codeBlock": {
|
case "codeBlock": {
|
||||||
@@ -106,6 +113,17 @@ function serializeOrderedList(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeTaskList(items: JSONContent[], indent: number): string {
|
||||||
|
let out = "";
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type !== "taskItem") continue;
|
||||||
|
const checked = item.attrs?.checked ? "x" : " ";
|
||||||
|
const prefix = " ".repeat(indent) + `- [${checked}] `;
|
||||||
|
out += serializeListItem(item.content || [], prefix, indent);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function serializeListItem(
|
function serializeListItem(
|
||||||
children: JSONContent[],
|
children: JSONContent[],
|
||||||
prefix: string,
|
prefix: string,
|
||||||
@@ -131,6 +149,8 @@ function serializeListItem(
|
|||||||
indent + 1,
|
indent + 1,
|
||||||
child.attrs?.start ?? 1
|
child.attrs?.start ?? 1
|
||||||
);
|
);
|
||||||
|
} else if (child.type === "taskList") {
|
||||||
|
out += serializeTaskList(child.content || [], indent + 1);
|
||||||
} else {
|
} else {
|
||||||
out += serializeNode(child, indent + 1);
|
out += serializeNode(child, indent + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,51 @@ async function newConversation() {
|
|||||||
await startNewConversation();
|
await startNewConversation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk selection state
|
||||||
|
const selectMode = ref(false);
|
||||||
|
const selectedIds = ref<Set<number>>(new Set());
|
||||||
|
const bulkConfirm = ref(false);
|
||||||
|
const bulkDeleting = ref(false);
|
||||||
|
|
||||||
|
function toggleSelectMode() {
|
||||||
|
selectMode.value = !selectMode.value;
|
||||||
|
selectedIds.value = new Set();
|
||||||
|
bulkConfirm.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectConv(id: number) {
|
||||||
|
if (selectedIds.value.has(id)) {
|
||||||
|
selectedIds.value.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedIds.value.add(id);
|
||||||
|
}
|
||||||
|
selectedIds.value = new Set(selectedIds.value); // trigger reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
selectedIds.value = new Set(store.conversations.map((c) => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedIds.value = new Set();
|
||||||
|
bulkConfirm.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (!bulkConfirm.value) { bulkConfirm.value = true; return; }
|
||||||
|
bulkDeleting.value = true;
|
||||||
|
try {
|
||||||
|
const ids = [...selectedIds.value];
|
||||||
|
await store.bulkDeleteConversations(ids);
|
||||||
|
if (ids.includes(convId.value ?? -1)) router.push("/chat");
|
||||||
|
selectedIds.value = new Set();
|
||||||
|
bulkConfirm.value = false;
|
||||||
|
selectMode.value = false;
|
||||||
|
} finally {
|
||||||
|
bulkDeleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeConversation(id: number) {
|
async function removeConversation(id: number) {
|
||||||
await store.deleteConversation(id);
|
await store.deleteConversation(id);
|
||||||
if (convId.value === id) {
|
if (convId.value === id) {
|
||||||
@@ -441,9 +486,31 @@ onUnmounted(() => {
|
|||||||
@click="sidebarOpen = false"
|
@click="sidebarOpen = false"
|
||||||
></div>
|
></div>
|
||||||
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
|
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
|
||||||
<button class="btn-new-conv" @click="newConversation">
|
<div class="sidebar-top-bar">
|
||||||
+ New Chat
|
<button class="btn-new-conv" @click="newConversation">+ New Chat</button>
|
||||||
</button>
|
<button
|
||||||
|
class="btn-select-mode"
|
||||||
|
:class="{ active: selectMode }"
|
||||||
|
@click="toggleSelectMode"
|
||||||
|
title="Select conversations"
|
||||||
|
>Select</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk action bar -->
|
||||||
|
<div v-if="selectMode" class="bulk-bar">
|
||||||
|
<span class="bulk-count">{{ selectedIds.size }} selected</span>
|
||||||
|
<button class="bulk-link" @click="selectAll">All</button>
|
||||||
|
<button class="bulk-link" @click="clearSelection">None</button>
|
||||||
|
<button
|
||||||
|
v-if="selectedIds.size"
|
||||||
|
class="bulk-delete-btn"
|
||||||
|
:class="{ confirm: bulkConfirm }"
|
||||||
|
:disabled="bulkDeleting"
|
||||||
|
@click="bulkDelete"
|
||||||
|
>
|
||||||
|
{{ bulkDeleting ? '...' : bulkConfirm ? `Delete ${selectedIds.size}?` : `Delete (${selectedIds.size})` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- RAG project scope -->
|
<!-- RAG project scope -->
|
||||||
<div class="rag-scope-section">
|
<div class="rag-scope-section">
|
||||||
@@ -459,14 +526,22 @@ onUnmounted(() => {
|
|||||||
v-for="conv in group.convs"
|
v-for="conv in group.convs"
|
||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
class="conv-item"
|
class="conv-item"
|
||||||
:class="{ active: convId === conv.id }"
|
:class="{ active: convId === conv.id, selected: selectedIds.has(conv.id) }"
|
||||||
@click="selectConversation(conv.id)"
|
@click="selectMode ? toggleSelectConv(conv.id) : selectConversation(conv.id)"
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
v-if="selectMode"
|
||||||
|
type="checkbox"
|
||||||
|
class="conv-checkbox"
|
||||||
|
:checked="selectedIds.has(conv.id)"
|
||||||
|
@click.stop="toggleSelectConv(conv.id)"
|
||||||
|
/>
|
||||||
<div class="conv-info">
|
<div class="conv-info">
|
||||||
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
|
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
|
||||||
<span class="conv-date">{{ formatConvDate(conv.updated_at) }}</span>
|
<span class="conv-date">{{ formatConvDate(conv.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="!selectMode"
|
||||||
class="btn-delete-conv"
|
class="btn-delete-conv"
|
||||||
@click.stop="removeConversation(conv.id)"
|
@click.stop="removeConversation(conv.id)"
|
||||||
title="Delete conversation"
|
title="Delete conversation"
|
||||||
@@ -484,7 +559,7 @@ onUnmounted(() => {
|
|||||||
<section class="chat-main">
|
<section class="chat-main">
|
||||||
<template v-if="store.currentConversation">
|
<template v-if="store.currentConversation">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<button class="btn-sidebar-toggle hide-desktop" @click="toggleSidebar">
|
<button class="btn-sidebar-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
@@ -726,7 +801,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="no-conversation">
|
<div v-else class="no-conversation">
|
||||||
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar">
|
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
@@ -758,8 +833,14 @@ onUnmounted(() => {
|
|||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-top-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-new-conv {
|
.btn-new-conv {
|
||||||
margin: 0.75rem;
|
flex: 1;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -772,6 +853,75 @@ onUnmounted(() => {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-select-mode {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-select-mode:hover,
|
||||||
|
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
|
||||||
|
.bulk-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-secondary));
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-delete-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-danger, #e74c3c);
|
||||||
|
color: var(--color-danger, #e74c3c);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bulk-delete-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.bulk-delete-btn.confirm {
|
||||||
|
background: var(--color-danger, #e74c3c);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item.selected {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
.rag-scope-section {
|
.rag-scope-section {
|
||||||
padding: 0.5rem 0.75rem 0.25rem;
|
padding: 0.5rem 0.75rem 0.25rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { zoom, zoomIdentity } from "d3-zoom";
|
|||||||
import { drag } from "d3-drag";
|
import { drag } from "d3-drag";
|
||||||
import { select } from "d3-selection";
|
import { select } from "d3-selection";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -62,6 +63,52 @@ const tooltip = ref<{ visible: boolean; x: number; y: number; node: GraphNode |
|
|||||||
node: null,
|
node: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// peek panel state
|
||||||
|
const peekNode = ref<GraphNode | null>(null);
|
||||||
|
const peekBody = ref("");
|
||||||
|
const peekLoading = ref(false);
|
||||||
|
|
||||||
|
const peekLinkedNodes = computed<GraphNode[]>(() => {
|
||||||
|
if (!peekNode.value) return [];
|
||||||
|
const pid = String(peekNode.value.id);
|
||||||
|
const linkedIds = new Set<string>();
|
||||||
|
for (const e of edges.value) {
|
||||||
|
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
|
||||||
|
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
|
||||||
|
if (sid === pid) linkedIds.add(tid);
|
||||||
|
if (tid === pid) linkedIds.add(sid);
|
||||||
|
}
|
||||||
|
return nodes.value.filter(
|
||||||
|
(n) => linkedIds.has(String(n.id)) && n.type !== "tag"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openPeek(node: GraphNode) {
|
||||||
|
peekNode.value = node;
|
||||||
|
peekBody.value = "";
|
||||||
|
peekLoading.value = true;
|
||||||
|
try {
|
||||||
|
const endpoint = node.type === "task" ? `/api/tasks/${node.id}` : `/api/notes/${node.id}`;
|
||||||
|
const data = await apiGet<{ body?: string }>(endpoint);
|
||||||
|
peekBody.value = data.body ?? "";
|
||||||
|
} catch {
|
||||||
|
peekBody.value = "";
|
||||||
|
} finally {
|
||||||
|
peekLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePeek() {
|
||||||
|
peekNode.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape" && peekNode.value) {
|
||||||
|
closePeek();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let simulation: Simulation<GraphNode, undefined> | null = null;
|
let simulation: Simulation<GraphNode, undefined> | null = null;
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
@@ -208,7 +255,7 @@ function initGraph() {
|
|||||||
if (d.type === "tag") {
|
if (d.type === "tag") {
|
||||||
router.push(`/notes?tags=${encodeURIComponent(d.title)}`);
|
router.push(`/notes?tags=${encodeURIComponent(d.title)}`);
|
||||||
} else {
|
} else {
|
||||||
router.push(d.type === "task" ? `/tasks/${d.id}` : `/notes/${d.id}`);
|
openPeek(d);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on("mouseover", (event: MouseEvent, d: GraphNode) => {
|
.on("mouseover", (event: MouseEvent, d: GraphNode) => {
|
||||||
@@ -391,11 +438,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
resizeObserver = new ResizeObserver(handleResize);
|
resizeObserver = new ResizeObserver(handleResize);
|
||||||
if (containerRef.value) resizeObserver.observe(containerRef.value);
|
if (containerRef.value) resizeObserver.observe(containerRef.value);
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
simulation?.stop();
|
simulation?.stop();
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -466,6 +515,57 @@ onUnmounted(() => {
|
|||||||
<p>No notes to display. Create some notes to see the graph.</p>
|
<p>No notes to display. Create some notes to see the graph.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Peek panel -->
|
||||||
|
<Transition name="peek-slide">
|
||||||
|
<div v-if="peekNode" class="graph-peek-panel">
|
||||||
|
<div class="peek-header">
|
||||||
|
<span :class="['peek-type-badge', `peek-type-${peekNode.type}`]">{{ peekNode.type }}</span>
|
||||||
|
<div class="peek-actions">
|
||||||
|
<router-link
|
||||||
|
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}` : `/notes/${peekNode.id}`"
|
||||||
|
class="peek-link"
|
||||||
|
>Open</router-link>
|
||||||
|
<router-link
|
||||||
|
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}/edit` : `/notes/${peekNode.id}/edit`"
|
||||||
|
class="peek-link"
|
||||||
|
>Edit</router-link>
|
||||||
|
<button class="peek-close" @click="closePeek" title="Close (Esc)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="peek-title">{{ peekNode.title }}</h2>
|
||||||
|
|
||||||
|
<div v-if="peekNode.tags?.length" class="peek-tags">
|
||||||
|
<span v-for="tag in peekNode.tags" :key="tag" class="tag-chip">#{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="peek-body-wrap">
|
||||||
|
<div v-if="peekLoading" class="peek-loading">Loading...</div>
|
||||||
|
<div
|
||||||
|
v-else-if="peekBody"
|
||||||
|
class="prose peek-prose"
|
||||||
|
v-html="renderMarkdown(peekBody)"
|
||||||
|
/>
|
||||||
|
<div v-else class="peek-empty">No content.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="peekLinkedNodes.length" class="peek-linked">
|
||||||
|
<div class="peek-linked-label">Linked notes</div>
|
||||||
|
<ul class="peek-linked-list">
|
||||||
|
<li
|
||||||
|
v-for="n in peekLinkedNodes"
|
||||||
|
:key="n.id"
|
||||||
|
class="peek-linked-item"
|
||||||
|
@click="openPeek(n)"
|
||||||
|
>
|
||||||
|
<span :class="['peek-linked-type', `peek-type-${n.type}`]">{{ n.type[0].toUpperCase() }}</span>
|
||||||
|
{{ n.title }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="tooltip.visible && tooltip.node"
|
v-if="tooltip.visible && tooltip.node"
|
||||||
class="graph-tooltip"
|
class="graph-tooltip"
|
||||||
@@ -669,4 +769,165 @@ onUnmounted(() => {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Peek panel ── */
|
||||||
|
.graph-peek-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 340px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-slide-enter-active,
|
||||||
|
.peek-slide-leave-active {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.peek-slide-enter-from,
|
||||||
|
.peek-slide-leave-to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-type-badge {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.peek-type-task { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
|
||||||
|
.peek-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-link {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.peek-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.peek-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.peek-close:hover { color: var(--color-text); }
|
||||||
|
|
||||||
|
.peek-title {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem 0.75rem 0.4rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0 0.75rem 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-body-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 0.75rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-prose {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-loading,
|
||||||
|
.peek-empty {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-linked {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.5rem 0.75rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-linked-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-linked-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-linked-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.peek-linked-item:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peek-linked-type {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { apiGet, apiPost } from "@/api/client";
|
|||||||
import type { Editor } from "@tiptap/vue-3";
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||||
|
import WordCount from "@/components/WordCount.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||||
@@ -147,6 +148,8 @@ watch(body, () => {
|
|||||||
if (projectId.value) scheduleLinkCheck();
|
if (projectId.value) scheduleLinkCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(noteId, () => { showPreview.value = false; });
|
||||||
|
|
||||||
watch(projectId, (pid) => {
|
watch(projectId, (pid) => {
|
||||||
if (pid) fetchLinkSuggestions();
|
if (pid) fetchLinkSuggestions();
|
||||||
else linkSuggestions.value = [];
|
else linkSuggestions.value = [];
|
||||||
@@ -324,6 +327,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
</button>
|
</button>
|
||||||
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
|
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
|
||||||
<button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button>
|
<button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button>
|
||||||
|
<WordCount :body="body" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref="titleRef"
|
ref="titleRef"
|
||||||
@@ -363,21 +367,23 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
|
|
||||||
<!-- Normal editor -->
|
<!-- Normal editor -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-show="!showPreview" class="body-editor-wrap">
|
<Transition name="tab-fade" mode="out-in">
|
||||||
<TiptapEditor
|
<div v-if="!showPreview" class="body-editor-wrap">
|
||||||
ref="editorRef"
|
<TiptapEditor
|
||||||
:modelValue="body"
|
ref="editorRef"
|
||||||
placeholder="Write your note in Markdown..."
|
:modelValue="body"
|
||||||
@update:modelValue="onBodyUpdate"
|
placeholder="Write your note in Markdown..."
|
||||||
@selectionChange="onSelectionChange"
|
@update:modelValue="onBodyUpdate"
|
||||||
@escape="titleRef?.focus()"
|
@selectionChange="onSelectionChange"
|
||||||
|
@escape="titleRef?.focus()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="preview-pane prose"
|
||||||
|
v-html="renderedPreview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Transition>
|
||||||
<div
|
|
||||||
v-show="showPreview"
|
|
||||||
class="preview-pane prose"
|
|
||||||
v-html="renderedPreview"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Error from assist -->
|
<!-- Error from assist -->
|
||||||
@@ -431,7 +437,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
#{{ tag }}
|
#{{ tag }}
|
||||||
<span v-if="appliedTags.has(tag)" class="tag-check">✓</span>
|
<span v-if="appliedTags.has(tag)" class="tag-check">✓</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">×</button>
|
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">×</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -573,6 +579,43 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px;
|
||||||
|
gap: 2px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.22rem 0.75rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--color-text); }
|
||||||
|
.tab.active {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-fade-enter-active,
|
||||||
|
.tab-fade-leave-active {
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
.tab-fade-enter-from,
|
||||||
|
.tab-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.body-editor-wrap {
|
.body-editor-wrap {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ const exporting = ref(false);
|
|||||||
const restoring = ref(false);
|
const restoring = ref(false);
|
||||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Chat retention
|
||||||
|
const chatRetentionDays = ref(90);
|
||||||
|
const savingRetention = ref(false);
|
||||||
|
|
||||||
|
async function saveRetention() {
|
||||||
|
savingRetention.value = true;
|
||||||
|
try {
|
||||||
|
await apiPut("/api/settings", { chat_retention_days: String(chatRetentionDays.value) });
|
||||||
|
} finally {
|
||||||
|
savingRetention.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notification preferences
|
// Notification preferences
|
||||||
const notifyTaskReminders = ref(true);
|
const notifyTaskReminders = ref(true);
|
||||||
const notifySecurityAlerts = ref(true);
|
const notifySecurityAlerts = ref(true);
|
||||||
@@ -92,6 +105,9 @@ onMounted(async () => {
|
|||||||
// Load notification preferences from user settings
|
// Load notification preferences from user settings
|
||||||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||||||
defaultModel.value = allSettings.default_model ?? "";
|
defaultModel.value = allSettings.default_model ?? "";
|
||||||
|
chatRetentionDays.value = allSettings.chat_retention_days !== undefined
|
||||||
|
? Number(allSettings.chat_retention_days)
|
||||||
|
: 90;
|
||||||
if (allSettings.notify_task_reminders !== undefined) {
|
if (allSettings.notify_task_reminders !== undefined) {
|
||||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||||
}
|
}
|
||||||
@@ -234,6 +250,29 @@ async function exportData(scope: "user" | "full") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exportingNotes = ref(false);
|
||||||
|
|
||||||
|
async function exportNotes(format: "markdown" | "json") {
|
||||||
|
exportingNotes.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/export?format=${format}`);
|
||||||
|
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const ext = format === "json" ? "json" : "zip";
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `fabledassistant-${stamp}.${ext}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
toastStore.show("Export downloaded");
|
||||||
|
} catch (e) {
|
||||||
|
toastStore.show("Export failed: " + (e as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
exportingNotes.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function triggerRestoreUpload() {
|
function triggerRestoreUpload() {
|
||||||
restoreFileInput.value?.click();
|
restoreFileInput.value?.click();
|
||||||
}
|
}
|
||||||
@@ -561,11 +600,41 @@ function hostname(url: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Chat Retention ── half width ── -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>Chat History</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
Conversations older than this many days are automatically deleted when you load the chat.
|
||||||
|
Set to <strong>0</strong> to keep conversations forever.
|
||||||
|
</p>
|
||||||
|
<div class="field retention-field">
|
||||||
|
<label class="field-label">Retention period (days)</label>
|
||||||
|
<div class="retention-row">
|
||||||
|
<input
|
||||||
|
v-model.number="chatRetentionDays"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="3650"
|
||||||
|
class="input retention-input"
|
||||||
|
/>
|
||||||
|
<button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
|
||||||
|
{{ savingRetention ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ── Data ── half width ── -->
|
<!-- ── Data ── half width ── -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2>Data</h2>
|
<h2>Data</h2>
|
||||||
<p class="section-desc">Export your data or restore from a backup.</p>
|
<p class="section-desc">Export your data or restore from a backup.</p>
|
||||||
<div class="data-actions">
|
<div class="data-actions">
|
||||||
|
<button class="btn-secondary" @click="exportNotes('markdown')" :disabled="exportingNotes">
|
||||||
|
{{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
|
||||||
|
{{ exportingNotes ? "Exporting..." : "Export as JSON" }}
|
||||||
|
</button>
|
||||||
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
|
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
|
||||||
{{ exporting ? "Exporting..." : "Export My Data" }}
|
{{ exporting ? "Exporting..." : "Export My Data" }}
|
||||||
</button>
|
</button>
|
||||||
@@ -960,6 +1029,17 @@ function hostname(url: string): string {
|
|||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.retention-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-input {
|
||||||
|
width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Data buttons */
|
/* Data buttons */
|
||||||
.data-actions {
|
.data-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { TaskStatus, TaskPriority } from "@/types/task";
|
|||||||
import type { Editor } from "@tiptap/vue-3";
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||||
|
import WordCount from "@/components/WordCount.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||||
@@ -388,6 +389,7 @@ useEditorGuards(dirty, save);
|
|||||||
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
|
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
|
||||||
✨ Assist
|
✨ Assist
|
||||||
</button>
|
</button>
|
||||||
|
<WordCount :body="body" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="title"
|
v-model="title"
|
||||||
@@ -579,7 +581,7 @@ useEditorGuards(dirty, save);
|
|||||||
#{{ tag }}
|
#{{ tag }}
|
||||||
<span v-if="appliedTags.has(tag)" class="tag-check">✓</span>
|
<span v-if="appliedTags.has(tag)" class="tag-check">✓</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">×</button>
|
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">×</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -591,7 +593,7 @@ useEditorGuards(dirty, save);
|
|||||||
<div class="assist-panel-header">
|
<div class="assist-panel-header">
|
||||||
<span class="assist-panel-title">✨ AI Assist</span>
|
<span class="assist-panel-title">✨ AI Assist</span>
|
||||||
<button class="btn-proofread" @click="assist.proofread()" :disabled="assist.state.value === 'streaming'">Proofread</button>
|
<button class="btn-proofread" @click="assist.proofread()" :disabled="assist.state.value === 'streaming'">Proofread</button>
|
||||||
<button class="btn-close-assist" @click="toggleAssist">×</button>
|
<button class="btn-close-assist" aria-label="Close assist panel" @click="toggleAssist">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="assist-panel-body">
|
<div class="assist-panel-body">
|
||||||
<div v-if="assist.state.value === 'idle'" class="assist-idle">
|
<div v-if="assist.state.value === 'idle'" class="assist-idle">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from fabledassistant.routes.admin import admin_bp
|
|||||||
from fabledassistant.routes.api import api
|
from fabledassistant.routes.api import api
|
||||||
from fabledassistant.routes.auth import auth_bp
|
from fabledassistant.routes.auth import auth_bp
|
||||||
from fabledassistant.routes.chat import chat_bp
|
from fabledassistant.routes.chat import chat_bp
|
||||||
|
from fabledassistant.routes.export import export_bp
|
||||||
from fabledassistant.routes.notes import notes_bp
|
from fabledassistant.routes.notes import notes_bp
|
||||||
from fabledassistant.routes.images import images_bp
|
from fabledassistant.routes.images import images_bp
|
||||||
from fabledassistant.routes.milestones import milestones_bp
|
from fabledassistant.routes.milestones import milestones_bp
|
||||||
@@ -56,6 +57,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(api)
|
app.register_blueprint(api)
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(chat_bp)
|
app.register_blueprint(chat_bp)
|
||||||
|
app.register_blueprint(export_bp)
|
||||||
app.register_blueprint(images_bp)
|
app.register_blueprint(images_bp)
|
||||||
app.register_blueprint(milestones_bp)
|
app.register_blueprint(milestones_bp)
|
||||||
app.register_blueprint(notes_bp)
|
app.register_blueprint(notes_bp)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from fabledassistant.routes.utils import not_found
|
|||||||
from fabledassistant.config import Config
|
from fabledassistant.config import Config
|
||||||
from fabledassistant.services.chat import (
|
from fabledassistant.services.chat import (
|
||||||
add_message,
|
add_message,
|
||||||
|
bulk_delete_conversations,
|
||||||
|
cleanup_old_conversations,
|
||||||
create_conversation,
|
create_conversation,
|
||||||
delete_conversation,
|
delete_conversation,
|
||||||
get_conversation,
|
get_conversation,
|
||||||
@@ -38,6 +40,14 @@ async def list_conversations_route():
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
limit = min(request.args.get("limit", 50, type=int), 500)
|
||||||
offset = request.args.get("offset", 0, type=int)
|
offset = request.args.get("offset", 0, type=int)
|
||||||
|
# Apply retention policy before returning list
|
||||||
|
retention_str = await get_setting(uid, "chat_retention_days", "90")
|
||||||
|
try:
|
||||||
|
retention_days = int(retention_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
retention_days = 90
|
||||||
|
if retention_days > 0:
|
||||||
|
await cleanup_old_conversations(uid, retention_days)
|
||||||
conversations, total = await list_conversations(uid, limit=limit, offset=offset)
|
conversations, total = await list_conversations(uid, limit=limit, offset=offset)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"conversations": conversations,
|
"conversations": conversations,
|
||||||
@@ -45,6 +55,18 @@ async def list_conversations_route():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/conversations/bulk-delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
async def bulk_delete_conversations_route():
|
||||||
|
uid = get_current_user_id()
|
||||||
|
data = await request.get_json()
|
||||||
|
ids = data.get("ids", []) if isinstance(data, dict) else []
|
||||||
|
if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
|
||||||
|
return jsonify({"error": "ids must be a list of integers"}), 400
|
||||||
|
count = await bulk_delete_conversations(uid, ids)
|
||||||
|
return jsonify({"deleted": count})
|
||||||
|
|
||||||
|
|
||||||
@chat_bp.route("/conversations", methods=["POST"])
|
@chat_bp.route("/conversations", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
async def create_conversation_route():
|
async def create_conversation_route():
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, request
|
||||||
|
|
||||||
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
|
from fabledassistant.models import async_session
|
||||||
|
from fabledassistant.models.note import Note
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
export_bp = Blueprint("export", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(title: str) -> str:
|
||||||
|
name = re.sub(r"[^\w\s-]", "", title or "untitled").strip()
|
||||||
|
name = re.sub(r"[\s]+", "_", name) or "untitled"
|
||||||
|
return name[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def _note_frontmatter(note: Note) -> str:
|
||||||
|
lines = ["---"]
|
||||||
|
lines.append(f"title: {json.dumps(note.title or '')}")
|
||||||
|
lines.append(f"type: {'task' if note.is_task else 'note'}")
|
||||||
|
if note.tags:
|
||||||
|
lines.append(f"tags: [{', '.join(note.tags)}]")
|
||||||
|
if note.is_task:
|
||||||
|
lines.append(f"status: {note.status or 'todo'}")
|
||||||
|
lines.append(f"priority: {note.priority or 'none'}")
|
||||||
|
if note.due_date:
|
||||||
|
lines.append(f"due_date: {note.due_date}")
|
||||||
|
if note.project_id:
|
||||||
|
lines.append(f"project_id: {note.project_id}")
|
||||||
|
lines.append(f"created_at: {note.created_at.isoformat() if note.created_at else ''}")
|
||||||
|
lines.append(f"updated_at: {note.updated_at.isoformat() if note.updated_at else ''}")
|
||||||
|
lines.append("---")
|
||||||
|
return "\n".join(lines) + "\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_notes(uid: int):
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Note)
|
||||||
|
.where(Note.user_id == uid)
|
||||||
|
.order_by(Note.updated_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@export_bp.get("/api/export")
|
||||||
|
@login_required
|
||||||
|
async def export_data():
|
||||||
|
uid = get_current_user_id()
|
||||||
|
fmt = request.args.get("format", "markdown")
|
||||||
|
|
||||||
|
notes = await _fetch_notes(uid)
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
payload = [
|
||||||
|
{
|
||||||
|
"id": n.id,
|
||||||
|
"title": n.title,
|
||||||
|
"body": n.body,
|
||||||
|
"is_task": n.is_task,
|
||||||
|
"status": n.status,
|
||||||
|
"priority": n.priority,
|
||||||
|
"tags": n.tags or [],
|
||||||
|
"due_date": str(n.due_date) if n.due_date else None,
|
||||||
|
"project_id": n.project_id,
|
||||||
|
"milestone_id": n.milestone_id,
|
||||||
|
"parent_id": n.parent_id,
|
||||||
|
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||||
|
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
||||||
|
}
|
||||||
|
for n in notes
|
||||||
|
]
|
||||||
|
data = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
status=200,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.json"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Markdown ZIP
|
||||||
|
buf = io.BytesIO()
|
||||||
|
used_names: dict[str, int] = {}
|
||||||
|
|
||||||
|
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
index_rows = []
|
||||||
|
for note in notes:
|
||||||
|
base = _safe_filename(note.title)
|
||||||
|
count = used_names.get(base, 0)
|
||||||
|
used_names[base] = count + 1
|
||||||
|
fname = f"{base}_{count}.md" if count else f"{base}.md"
|
||||||
|
|
||||||
|
content = _note_frontmatter(note) + (note.body or "")
|
||||||
|
zf.writestr(fname, content.encode("utf-8"))
|
||||||
|
index_rows.append({"file": fname, "title": note.title, "id": note.id})
|
||||||
|
|
||||||
|
zf.writestr("index.json", json.dumps(index_rows, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
buf.seek(0)
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||||
|
return Response(
|
||||||
|
buf.read(),
|
||||||
|
status=200,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
"Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.zip"',
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, delete as sa_delete
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from fabledassistant.models import async_session
|
from fabledassistant.models import async_session
|
||||||
@@ -103,6 +103,35 @@ async def delete_conversation(user_id: int, conversation_id: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_delete_conversations(user_id: int, ids: list[int]) -> int:
|
||||||
|
"""Delete multiple conversations by ID for a user. Returns count deleted."""
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
sa_delete(Conversation)
|
||||||
|
.where(Conversation.user_id == user_id, Conversation.id.in_(ids))
|
||||||
|
.returning(Conversation.id)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return len(result.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_old_conversations(user_id: int, days: int) -> int:
|
||||||
|
"""Delete conversations older than `days` days. Returns count deleted."""
|
||||||
|
if days <= 0:
|
||||||
|
return 0
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
sa_delete(Conversation)
|
||||||
|
.where(Conversation.user_id == user_id, Conversation.updated_at < cutoff)
|
||||||
|
.returning(Conversation.id)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return len(result.fetchall())
|
||||||
|
|
||||||
|
|
||||||
async def update_conversation(
|
async def update_conversation(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
conversation_id: int,
|
conversation_id: int,
|
||||||
|
|||||||
Reference in New Issue
Block a user