Phase 20: Dedicated tag field — chip input, explicit tags array
Tags are now a first-class field rather than being auto-extracted from the note body. A new TagInput.vue chip component handles tag entry in both editor views with autocomplete, Enter/comma/backspace UX, and space-to-hyphen sanitization. Backend: - routes/notes.py: create reads tags from JSON; update accepts explicit tags (omit = keep existing); append_tag writes to tags array with dedup; suggest-tags accepts current_tags filter; remove extract_tags - routes/tasks.py: same — explicit tags on create/update; remove extract_tags - services/tag_suggestions.py: current_tags param replaces body extraction - services/tools.py: create_note tool schema adds tags param; executor passes it - services/llm.py: system prompt tells LLM to use tags param, not embed #tag in body Frontend: - components/TagInput.vue: new chip-based tag input (autocomplete, keyboard UX) - NoteEditorView.vue / TaskEditorView.vue: tags ref loaded from note.tags; TagInput placed between title and body; save/autosave include tags; suggest now adds chips; fetchTagSuggestions passes current_tags; dirty tracks tags - TiptapEditor.vue: remove fetchTags prop and TagSuggestion extension; keep TagDecoration for legacy inline #tag highlighting No DB migration needed — tags column already correct. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[];
|
||||
fetchTags: (q: string) => Promise<string[]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string[]];
|
||||
}>();
|
||||
|
||||
const inputValue = ref("");
|
||||
const suggestions = ref<string[]>([]);
|
||||
const showSuggestions = ref(false);
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
let fetchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function sanitize(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, "-").replace(/^#+/, "");
|
||||
}
|
||||
|
||||
function addTag(raw: string) {
|
||||
const tag = sanitize(raw);
|
||||
if (!tag) return;
|
||||
if (!props.modelValue.includes(tag)) {
|
||||
emit("update:modelValue", [...props.modelValue, tag]);
|
||||
}
|
||||
inputValue.value = "";
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
emit("update:modelValue", props.modelValue.filter((t) => t !== tag));
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
const val = inputValue.value.replace(/,$/, "");
|
||||
if (val.trim()) {
|
||||
addTag(val);
|
||||
}
|
||||
} else if (e.key === "Backspace" && !inputValue.value) {
|
||||
const tags = props.modelValue;
|
||||
if (tags.length > 0) {
|
||||
emit("update:modelValue", tags.slice(0, -1));
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
showSuggestions.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
const q = inputValue.value.trim().replace(/^#+/, "");
|
||||
if (fetchDebounce !== null) clearTimeout(fetchDebounce);
|
||||
if (!q) {
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
return;
|
||||
}
|
||||
fetchDebounce = setTimeout(async () => {
|
||||
try {
|
||||
const results = await props.fetchTags(q);
|
||||
suggestions.value = results.filter((t) => !props.modelValue.includes(t));
|
||||
showSuggestions.value = suggestions.value.length > 0;
|
||||
} catch {
|
||||
suggestions.value = [];
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// Small delay to allow click on suggestion
|
||||
setTimeout(() => {
|
||||
showSuggestions.value = false;
|
||||
// Commit any pending text on blur
|
||||
if (inputValue.value.trim()) {
|
||||
addTag(inputValue.value);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
nextTick(() => inputRef.value?.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tag-input-wrapper" @click="focusInput">
|
||||
<span
|
||||
v-for="tag in modelValue"
|
||||
:key="tag"
|
||||
class="tag-chip"
|
||||
>
|
||||
#{{ tag }}
|
||||
<button
|
||||
type="button"
|
||||
class="tag-chip-remove"
|
||||
@click.stop="removeTag(tag)"
|
||||
tabindex="-1"
|
||||
>×</button>
|
||||
</span>
|
||||
<div class="tag-input-inner">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
class="tag-input-field"
|
||||
placeholder="Add tag..."
|
||||
@keydown="onKeydown"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<ul v-if="showSuggestions" class="tag-autocomplete">
|
||||
<li
|
||||
v-for="s in suggestions"
|
||||
:key="s"
|
||||
class="tag-autocomplete-item"
|
||||
@mousedown.prevent="addTag(s)"
|
||||
>
|
||||
#{{ s }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tag-input-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
cursor: text;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
border: 1px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-chip-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.tag-chip-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.tag-input-inner {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
.tag-input-field {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
.tag-autocomplete {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.tag-autocomplete-item {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.tag-autocomplete-item:hover {
|
||||
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -9,18 +9,15 @@ import DOMPurify from "dompurify";
|
||||
import { serializeToMarkdown } from "@/utils/markdownSerializer";
|
||||
import { TagDecoration } from "@/extensions/TagDecoration";
|
||||
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
|
||||
import { TagSuggestion } from "@/extensions/TagSuggestion";
|
||||
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
placeholder?: string;
|
||||
fetchTags?: (query: string) => Promise<string[]>;
|
||||
}>(),
|
||||
{
|
||||
placeholder: "",
|
||||
fetchTags: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -72,9 +69,6 @@ try {
|
||||
}),
|
||||
TagDecoration,
|
||||
WikilinkDecoration,
|
||||
TagSuggestion.configure({
|
||||
fetchTags: props.fetchTags ?? (async () => []),
|
||||
}),
|
||||
WikilinkSuggestion,
|
||||
],
|
||||
onUpdate({ editor: ed }) {
|
||||
|
||||
@@ -77,6 +77,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
async function createNote(data: {
|
||||
title: string;
|
||||
body: string;
|
||||
tags?: string[];
|
||||
}): Promise<Note> {
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
@@ -88,7 +89,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body">>
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags">>
|
||||
): Promise<Note> {
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
|
||||
@@ -65,6 +65,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
async function createTask(data: {
|
||||
title: string;
|
||||
body: string;
|
||||
tags?: string[];
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
due_date?: string | null;
|
||||
@@ -80,7 +81,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
async function updateTask(
|
||||
id: number,
|
||||
data: Partial<
|
||||
Pick<Task, "title" | "body" | "status" | "priority" | "due_date">
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date">
|
||||
>
|
||||
): Promise<Task> {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { apiPost } from "@/api/client";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -17,6 +18,7 @@ const toast = useToastStore();
|
||||
|
||||
const title = ref("");
|
||||
const body = ref("");
|
||||
const tags = ref<string[]>([]);
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
@@ -106,6 +108,7 @@ async function fetchTagSuggestions() {
|
||||
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
current_tags: tags.value,
|
||||
});
|
||||
suggestedTags.value = res.suggested_tags;
|
||||
} catch {
|
||||
@@ -117,7 +120,9 @@ async function fetchTagSuggestions() {
|
||||
|
||||
function applyTagSuggestion(tag: string) {
|
||||
if (appliedTags.value.has(tag)) return;
|
||||
body.value = body.value.trimEnd() + `\n#${tag}`;
|
||||
if (!tags.value.includes(tag)) {
|
||||
tags.value = [...tags.value, tag];
|
||||
}
|
||||
appliedTags.value.add(tag);
|
||||
markDirty();
|
||||
}
|
||||
@@ -130,9 +135,13 @@ function dismissTagSuggestions() {
|
||||
// Track saved state for dirty detection
|
||||
let savedTitle = "";
|
||||
let savedBody = "";
|
||||
let savedTags: string[] = [];
|
||||
|
||||
function markDirty() {
|
||||
dirty.value = title.value !== savedTitle || body.value !== savedBody;
|
||||
dirty.value =
|
||||
title.value !== savedTitle ||
|
||||
body.value !== savedBody ||
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags);
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -146,8 +155,10 @@ onMounted(async () => {
|
||||
if (store.currentNote) {
|
||||
title.value = store.currentNote.title;
|
||||
body.value = store.currentNote.body;
|
||||
tags.value = [...(store.currentNote.tags || [])];
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -160,15 +171,18 @@ async function save() {
|
||||
await store.updateNote(noteId.value!, {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
tags: tags.value,
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
dirty.value = false;
|
||||
toast.show("Note saved");
|
||||
} else {
|
||||
const note = await store.createNote({
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
tags: tags.value,
|
||||
});
|
||||
dirty.value = false;
|
||||
toast.show("Note created");
|
||||
@@ -209,9 +223,10 @@ async function autoSave() {
|
||||
if (!isEditing.value || !dirty.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await store.updateNote(noteId.value!, { title: title.value, body: body.value });
|
||||
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value });
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
dirty.value = false;
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
@@ -275,6 +290,12 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
@input="markDirty"
|
||||
/>
|
||||
|
||||
<TagInput
|
||||
v-model="tags"
|
||||
:fetchTags="(q: string) => store.fetchAllTags(q)"
|
||||
@update:modelValue="markDirty"
|
||||
/>
|
||||
|
||||
<div class="tag-suggest-row">
|
||||
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
|
||||
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
|
||||
@@ -318,8 +339,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Write your note in Markdown... Use #tags inline"
|
||||
:fetchTags="(q: string) => store.fetchAllTags(q)"
|
||||
placeholder="Write your note in Markdown..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -20,6 +21,7 @@ const toast = useToastStore();
|
||||
|
||||
const title = ref("");
|
||||
const body = ref("");
|
||||
const tags = ref<string[]>([]);
|
||||
const status = ref<TaskStatus>("todo");
|
||||
const priority = ref<TaskPriority>("none");
|
||||
const dueDate = ref("");
|
||||
@@ -112,6 +114,7 @@ async function fetchTagSuggestions() {
|
||||
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
current_tags: tags.value,
|
||||
});
|
||||
suggestedTags.value = res.suggested_tags;
|
||||
} catch {
|
||||
@@ -123,7 +126,9 @@ async function fetchTagSuggestions() {
|
||||
|
||||
function applyTagSuggestion(tag: string) {
|
||||
if (appliedTags.value.has(tag)) return;
|
||||
body.value = body.value.trimEnd() + `\n#${tag}`;
|
||||
if (!tags.value.includes(tag)) {
|
||||
tags.value = [...tags.value, tag];
|
||||
}
|
||||
appliedTags.value.add(tag);
|
||||
markDirty();
|
||||
}
|
||||
@@ -135,6 +140,7 @@ function dismissTagSuggestions() {
|
||||
|
||||
let savedTitle = "";
|
||||
let savedBody = "";
|
||||
let savedTags: string[] = [];
|
||||
let savedStatus: TaskStatus = "todo";
|
||||
let savedPriority: TaskPriority = "none";
|
||||
let savedDueDate = "";
|
||||
@@ -143,6 +149,7 @@ function markDirty() {
|
||||
dirty.value =
|
||||
title.value !== savedTitle ||
|
||||
body.value !== savedBody ||
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
status.value !== savedStatus ||
|
||||
priority.value !== savedPriority ||
|
||||
dueDate.value !== savedDueDate;
|
||||
@@ -159,11 +166,13 @@ onMounted(async () => {
|
||||
if (store.currentTask) {
|
||||
title.value = store.currentTask.title;
|
||||
body.value = store.currentTask.body;
|
||||
tags.value = [...(store.currentTask.tags || [])];
|
||||
status.value = store.currentTask.status as TaskStatus;
|
||||
priority.value = store.currentTask.priority as TaskPriority;
|
||||
dueDate.value = store.currentTask.due_date || "";
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
savedDueDate = dueDate.value;
|
||||
@@ -178,6 +187,7 @@ async function save() {
|
||||
const data = {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
tags: tags.value,
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
due_date: dueDate.value || null,
|
||||
@@ -186,6 +196,7 @@ async function save() {
|
||||
await store.updateTask(taskId.value!, data);
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
savedDueDate = dueDate.value;
|
||||
@@ -235,12 +246,14 @@ async function autoSave() {
|
||||
await store.updateTask(taskId.value!, {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
tags: tags.value,
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
due_date: dueDate.value || null,
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
savedDueDate = dueDate.value;
|
||||
@@ -333,6 +346,12 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TagInput
|
||||
v-model="tags"
|
||||
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
||||
@update:modelValue="markDirty"
|
||||
/>
|
||||
|
||||
<div class="tag-suggest-row">
|
||||
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
|
||||
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
|
||||
@@ -376,8 +395,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Describe this task... Use #tags inline"
|
||||
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
||||
placeholder="Describe this task..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user