ed715dcc23
- Add note_type (note/person/place/list) selector + entity metadata fields (relationship, email, phone / address, hours) to NoteEditorView - Pre-select type via ?type= query param from KnowledgeView new-note dropdown - KnowledgeView: add split "New note / ▾" button with type dropdown - KnowledgeView: show per-type counts on sidebar filter buttons (when > 1) - Fix: filter-btn now flex layout so count badge aligns to right edge - Fix: list_notes count_query was missing parent_id filter (inflated totals) - Fix: PATCH /api/notes/:id now fires upsert_note_embedding (workspace autosave) - Fix: get_knowledge_counts endpoint for per-type counts - Fix: get_knowledge_tags was silently discarding note_type filter (double-stmt bug) - Fix: NoteEditorView onMounted stray brace from edit session Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
834 lines
26 KiB
Vue
834 lines
26 KiB
Vue
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import type { NoteType } from "@/types/note";
|
||
import { useNotesStore } from "@/stores/notes";
|
||
import { useToastStore } from "@/stores/toast";
|
||
import { renderMarkdown } from "@/utils/markdown";
|
||
import { useAssist, type NoteDraft } from "@/composables/useAssist";
|
||
import { useAutoSave } from "@/composables/useAutoSave";
|
||
import { useEditorGuards } from "@/composables/useEditorGuards";
|
||
import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
||
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
||
import { apiGet, apiPost } from "@/api/client";
|
||
import type { Editor } from "@tiptap/vue-3";
|
||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||
import WordCount from "@/components/WordCount.vue";
|
||
import TagInput from "@/components/TagInput.vue";
|
||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||
import DiffView from "@/components/DiffView.vue";
|
||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const store = useNotesStore();
|
||
const toast = useToastStore();
|
||
|
||
const title = ref("");
|
||
const body = ref("");
|
||
const tags = ref<string[]>([]);
|
||
const projectId = ref<number | null>(null);
|
||
const milestoneId = ref<number | null>(null);
|
||
const noteType = ref<NoteType>("note");
|
||
const entityMeta = reactive<Record<string, string>>({});
|
||
const dirty = ref(false);
|
||
const saving = ref(false);
|
||
const showPreview = ref(false);
|
||
const sidebarOpen = ref(true);
|
||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||
const titleRef = ref<HTMLInputElement | null>(null);
|
||
const tiptapEditor = computed<Editor | null>(() => {
|
||
return (editorRef.value?.editor as Editor | undefined) ?? null;
|
||
});
|
||
|
||
const noteId = computed(() =>
|
||
route.params.id ? Number(route.params.id) : null
|
||
);
|
||
const isEditing = computed(() => noteId.value !== null);
|
||
|
||
const renderedPreview = computed(() => renderMarkdown(body.value));
|
||
|
||
// AI Assist — pass noteId for draft persistence
|
||
const assist = useAssist(body, noteId, projectId);
|
||
|
||
// Scope selector options: "Whole document" + one per section
|
||
const scopeOptions = computed(() => {
|
||
const opts: Array<{ value: string; label: string }> = [
|
||
{ value: '__document__', label: 'Whole document' },
|
||
];
|
||
for (const section of assist.sections.value) {
|
||
opts.push({
|
||
value: String(assist.sections.value.indexOf(section)),
|
||
label: section.heading || '(preamble)',
|
||
});
|
||
}
|
||
return opts;
|
||
});
|
||
|
||
const scopeSelectValue = computed({
|
||
get() {
|
||
if (assist.scopeMode.value === 'document') return '__document__';
|
||
if (assist.selectedSection.value) {
|
||
const idx = assist.sections.value.indexOf(assist.selectedSection.value);
|
||
return idx >= 0 ? String(idx) : '__document__';
|
||
}
|
||
return '__document__';
|
||
},
|
||
set(val: string) {
|
||
if (val === '__document__') {
|
||
assist.scopeMode.value = 'document';
|
||
assist.selectedSection.value = null;
|
||
} else {
|
||
const idx = Number(val);
|
||
const section = assist.sections.value[idx];
|
||
if (section) {
|
||
assist.scopeMode.value = 'section';
|
||
assist.selectSection(section);
|
||
}
|
||
}
|
||
},
|
||
});
|
||
|
||
// Floating inline assist button
|
||
const instructionRef = ref<HTMLTextAreaElement | null>(null);
|
||
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
||
({ start, end }) => {
|
||
assist.scopeMode.value = 'section';
|
||
assist.selectTextRange(start, end);
|
||
nextTick(() => instructionRef.value?.focus());
|
||
}
|
||
);
|
||
|
||
function handleAssistAccept() {
|
||
const newBody = assist.accept();
|
||
body.value = newBody;
|
||
markDirty();
|
||
toast.show("Document updated");
|
||
}
|
||
|
||
// Tag suggestions
|
||
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
||
useTagSuggestions(title, body, tags, markDirty);
|
||
|
||
// ── Link Suggestions ────────────────────────────────────────────────────────
|
||
|
||
interface LinkSuggestion {
|
||
note_id: number;
|
||
title: string;
|
||
count: number;
|
||
}
|
||
|
||
const linkSuggestions = ref<LinkSuggestion[]>([]);
|
||
let linkCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
async function fetchLinkSuggestions() {
|
||
if (!projectId.value || !body.value || !noteId.value) {
|
||
linkSuggestions.value = [];
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiPost<{ suggestions: LinkSuggestion[] }>('/api/notes/link-suggestions', {
|
||
body: body.value,
|
||
project_id: projectId.value,
|
||
exclude_note_id: noteId.value,
|
||
});
|
||
linkSuggestions.value = res.suggestions;
|
||
} catch {
|
||
linkSuggestions.value = [];
|
||
}
|
||
}
|
||
|
||
function scheduleLinkCheck() {
|
||
if (linkCheckTimer) clearTimeout(linkCheckTimer);
|
||
linkCheckTimer = setTimeout(fetchLinkSuggestions, 2000);
|
||
}
|
||
|
||
watch(body, () => {
|
||
if (projectId.value) scheduleLinkCheck();
|
||
});
|
||
|
||
watch(noteId, () => { showPreview.value = false; });
|
||
|
||
watch(projectId, (pid) => {
|
||
if (pid) fetchLinkSuggestions();
|
||
else linkSuggestions.value = [];
|
||
});
|
||
|
||
/** Replace unlinked occurrences of title in body with [[title]]. */
|
||
function applyWikilink(text: string, title: string): string {
|
||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const parts = text.split(/(\[\[[^\]]*\]\])/);
|
||
const pattern = new RegExp(`\\b${escaped}\\b`, 'gi');
|
||
return parts.map((part, i) =>
|
||
i % 2 === 0 ? part.replace(pattern, `[[${title}]]`) : part
|
||
).join('');
|
||
}
|
||
|
||
function applyLink(title: string) {
|
||
body.value = applyWikilink(body.value, title);
|
||
markDirty();
|
||
linkSuggestions.value = linkSuggestions.value.filter(s => s.title !== title);
|
||
}
|
||
|
||
function applyAllLinks() {
|
||
let text = body.value;
|
||
for (const s of linkSuggestions.value) {
|
||
text = applyWikilink(text, s.title);
|
||
}
|
||
body.value = text;
|
||
markDirty();
|
||
linkSuggestions.value = [];
|
||
}
|
||
|
||
// Track saved state for dirty detection
|
||
let savedTitle = "";
|
||
let savedBody = "";
|
||
let savedTags: string[] = [];
|
||
let savedProjectId: number | null = null;
|
||
let savedMilestoneId: number | null = null;
|
||
let savedNoteType: NoteType = "note";
|
||
let savedEntityMeta: Record<string, string> = {};
|
||
|
||
function markDirty() {
|
||
dirty.value =
|
||
title.value !== savedTitle ||
|
||
body.value !== savedBody ||
|
||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||
projectId.value !== savedProjectId ||
|
||
milestoneId.value !== savedMilestoneId ||
|
||
noteType.value !== savedNoteType ||
|
||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
|
||
}
|
||
|
||
function onBodyUpdate(newVal: string) {
|
||
body.value = newVal;
|
||
markDirty();
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (noteId.value) {
|
||
await store.fetchNote(noteId.value);
|
||
if (store.currentNote) {
|
||
title.value = store.currentNote.title;
|
||
body.value = store.currentNote.body;
|
||
tags.value = [...(store.currentNote.tags || [])];
|
||
projectId.value = store.currentNote.project_id ?? null;
|
||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||
Object.assign(entityMeta, store.currentNote.metadata || {});
|
||
savedTitle = title.value;
|
||
savedBody = body.value;
|
||
savedTags = [...tags.value];
|
||
savedProjectId = projectId.value;
|
||
savedMilestoneId = milestoneId.value;
|
||
savedNoteType = noteType.value;
|
||
savedEntityMeta = { ...entityMeta };
|
||
}
|
||
} else {
|
||
// New note: read type from query param
|
||
const qt = route.query.type as string | undefined;
|
||
if (qt && ["note", "person", "place", "list"].includes(qt)) {
|
||
noteType.value = qt as NoteType;
|
||
}
|
||
}
|
||
|
||
// Restore pending draft if any
|
||
try {
|
||
const draft = await apiGet<NoteDraft>(`/api/notes/${noteId.value}/draft`);
|
||
if (draft) assist.loadDraft(draft);
|
||
} catch {
|
||
// No draft — normal
|
||
}
|
||
|
||
// Initial link suggestions
|
||
fetchLinkSuggestions();
|
||
});
|
||
|
||
async function save() {
|
||
if (saving.value) return;
|
||
saving.value = true;
|
||
try {
|
||
if (isEditing.value) {
|
||
await store.updateNote(noteId.value!, {
|
||
title: title.value,
|
||
body: body.value,
|
||
tags: tags.value,
|
||
project_id: projectId.value,
|
||
milestone_id: milestoneId.value,
|
||
note_type: noteType.value,
|
||
metadata: { ...entityMeta },
|
||
});
|
||
savedTitle = title.value;
|
||
savedBody = body.value;
|
||
savedTags = [...tags.value];
|
||
savedProjectId = projectId.value;
|
||
savedMilestoneId = milestoneId.value;
|
||
savedNoteType = noteType.value;
|
||
savedEntityMeta = { ...entityMeta };
|
||
dirty.value = false;
|
||
toast.show("Note saved");
|
||
} else {
|
||
const note = await store.createNote({
|
||
title: title.value,
|
||
body: body.value,
|
||
tags: tags.value,
|
||
project_id: projectId.value,
|
||
milestone_id: milestoneId.value,
|
||
note_type: noteType.value,
|
||
metadata: { ...entityMeta },
|
||
});
|
||
dirty.value = false;
|
||
toast.show("Note created");
|
||
router.push(`/notes/${note.id}`);
|
||
}
|
||
} catch {
|
||
toast.show("Failed to save note", "error");
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
const showDeleteConfirm = ref(false);
|
||
|
||
function remove() {
|
||
if (noteId.value) showDeleteConfirm.value = true;
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
showDeleteConfirm.value = false;
|
||
if (!noteId.value) return;
|
||
try {
|
||
await store.deleteNote(noteId.value);
|
||
dirty.value = false;
|
||
toast.show("Note deleted");
|
||
router.push("/notes");
|
||
} catch {
|
||
toast.show("Failed to delete note", "error");
|
||
}
|
||
}
|
||
|
||
// Auto-save every 5 minutes
|
||
async function doAutoSave() {
|
||
if (!isEditing.value || saving.value) return;
|
||
saving.value = true;
|
||
try {
|
||
await store.updateNote(noteId.value!, {
|
||
title: title.value, body: body.value, tags: tags.value,
|
||
project_id: projectId.value, milestone_id: milestoneId.value,
|
||
note_type: noteType.value, metadata: { ...entityMeta },
|
||
});
|
||
savedTitle = title.value;
|
||
savedBody = body.value;
|
||
savedTags = [...tags.value];
|
||
savedProjectId = projectId.value;
|
||
savedMilestoneId = milestoneId.value;
|
||
savedNoteType = noteType.value;
|
||
savedEntityMeta = { ...entityMeta };
|
||
dirty.value = false;
|
||
toast.show("Auto-saved");
|
||
} catch {
|
||
// Silent
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
useAutoSave(dirty, saving, doAutoSave);
|
||
useEditorGuards(dirty, save);
|
||
|
||
// Close any in-flight assist SSE stream when navigating away
|
||
onUnmounted(() => assist.clearSelection());
|
||
</script>
|
||
|
||
<template>
|
||
<main class="editor-page note-editor-page">
|
||
<div class="editor-header">
|
||
<div class="toolbar">
|
||
<router-link to="/notes" class="btn-back">← Notes</router-link>
|
||
<button class="btn-save" @click="save" :disabled="saving">
|
||
{{ saving ? "Saving..." : "Save" }}
|
||
</button>
|
||
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
|
||
<WordCount :body="body" />
|
||
</div>
|
||
<input
|
||
ref="titleRef"
|
||
v-model="title"
|
||
type="text"
|
||
placeholder="Title"
|
||
class="title-input"
|
||
@input="markDirty"
|
||
@keydown.ctrl.s.prevent="save"
|
||
@keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Two-column body: main | sidebar -->
|
||
<div class="note-body">
|
||
|
||
<!-- ── Main column ────────────────────────────────────────── -->
|
||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||
<div class="body-tabs-row">
|
||
<div class="editor-tabs">
|
||
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
|
||
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
|
||
</div>
|
||
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
|
||
</div>
|
||
|
||
<!-- Streaming preview -->
|
||
<template v-if="assist.state.value === 'streaming'">
|
||
<div class="stream-label">Generating...</div>
|
||
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
|
||
</template>
|
||
|
||
<!-- Review: full-document diff -->
|
||
<template v-else-if="assist.state.value === 'review'">
|
||
<DiffView :diff="assist.diff.value" class="main-diff" />
|
||
</template>
|
||
|
||
<!-- Normal editor -->
|
||
<template v-else>
|
||
<Transition name="tab-fade" mode="out-in">
|
||
<div v-if="!showPreview" class="body-editor-wrap">
|
||
<TiptapEditor
|
||
ref="editorRef"
|
||
:modelValue="body"
|
||
placeholder="Write your note in Markdown..."
|
||
@update:modelValue="onBodyUpdate"
|
||
@selectionChange="onSelectionChange"
|
||
@escape="titleRef?.focus()"
|
||
/>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="preview-pane prose"
|
||
v-html="renderedPreview"
|
||
/>
|
||
</Transition>
|
||
</template>
|
||
|
||
<!-- Error from assist -->
|
||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||
</div>
|
||
|
||
<!-- ── Sidebar ──────────────────────────────────────────── -->
|
||
<aside class="note-sidebar">
|
||
<button class="sidebar-toggle" @click="sidebarOpen = !sidebarOpen">
|
||
Details {{ sidebarOpen ? '▴' : '▾' }}
|
||
</button>
|
||
|
||
<div :class="['sidebar-content', { 'sidebar-open': sidebarOpen }]">
|
||
|
||
<!-- Project / Milestone / Tags -->
|
||
<div class="sb-field">
|
||
<label class="sb-label">Project</label>
|
||
<ProjectSelector
|
||
v-model="projectId"
|
||
@update:modelValue="milestoneId = null; markDirty()"
|
||
/>
|
||
</div>
|
||
<div v-if="projectId" class="sb-field">
|
||
<label class="sb-label">Milestone</label>
|
||
<MilestoneSelector
|
||
:projectId="projectId"
|
||
v-model="milestoneId"
|
||
@update:modelValue="markDirty"
|
||
/>
|
||
</div>
|
||
<div class="sb-field">
|
||
<label class="sb-label">Tags</label>
|
||
<TagInput
|
||
v-model="tags"
|
||
:fetchTags="(q: string) => store.fetchAllTags(q)"
|
||
@update:modelValue="markDirty"
|
||
/>
|
||
</div>
|
||
<div class="tag-suggest-row">
|
||
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
|
||
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
|
||
</button>
|
||
<template v-if="suggestedTags.length > 0">
|
||
<button
|
||
v-for="tag in suggestedTags"
|
||
:key="tag"
|
||
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
|
||
:disabled="appliedTags.has(tag)"
|
||
@click="applyTagSuggestion(tag)"
|
||
>
|
||
#{{ tag }}
|
||
<span v-if="appliedTags.has(tag)" class="tag-check">✓</span>
|
||
</button>
|
||
<button class="btn-dismiss-tags" aria-label="Dismiss tag suggestions" @click="dismissTagSuggestions">×</button>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Note type -->
|
||
<div class="sb-field">
|
||
<label class="sb-label">Type</label>
|
||
<select v-model="noteType" class="sb-select" @change="markDirty">
|
||
<option value="note">Note</option>
|
||
<option value="person">Person</option>
|
||
<option value="place">Place</option>
|
||
<option value="list">List</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Person metadata -->
|
||
<template v-if="noteType === 'person'">
|
||
<div class="sb-field">
|
||
<label class="sb-label">Relationship</label>
|
||
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
|
||
</div>
|
||
<div class="sb-field">
|
||
<label class="sb-label">Email</label>
|
||
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
|
||
</div>
|
||
<div class="sb-field">
|
||
<label class="sb-label">Phone</label>
|
||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Place metadata -->
|
||
<template v-if="noteType === 'place'">
|
||
<div class="sb-field">
|
||
<label class="sb-label">Address</label>
|
||
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
|
||
</div>
|
||
<div class="sb-field">
|
||
<label class="sb-label">Phone</label>
|
||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||
</div>
|
||
<div class="sb-field">
|
||
<label class="sb-label">Hours</label>
|
||
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9–5" @input="markDirty" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Link Suggestions -->
|
||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||
<div class="sb-label-row">
|
||
<span class="sb-label">Link Suggestions</span>
|
||
<button class="btn-link-all" @click="applyAllLinks">Link all</button>
|
||
</div>
|
||
<div class="link-suggest-list">
|
||
<div v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-item">
|
||
<span class="link-suggest-title">[[{{ s.title }}]]</span>
|
||
<span class="link-suggest-count">×{{ s.count }}</span>
|
||
<button class="btn-apply-link" @click="applyLink(s.title)">Link</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sb-divider"></div>
|
||
|
||
<!-- Writing Assistant (always visible) -->
|
||
<div class="assist-section">
|
||
<div class="assist-section-title">✨ Writing Assistant</div>
|
||
|
||
<!-- Scope selector -->
|
||
<div class="sb-field">
|
||
<label class="sb-label">Scope</label>
|
||
<select v-model="scopeSelectValue" class="sb-select" :disabled="assist.state.value === 'streaming'">
|
||
<option
|
||
v-for="opt in scopeOptions"
|
||
:key="opt.value"
|
||
:value="opt.value"
|
||
>{{ opt.label }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Idle: instruction + buttons -->
|
||
<template v-if="assist.state.value === 'idle'">
|
||
<textarea
|
||
ref="instructionRef"
|
||
v-model="assist.instruction.value"
|
||
placeholder="What should I do?"
|
||
class="assist-instruction"
|
||
rows="3"
|
||
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
|
||
></textarea>
|
||
<div class="assist-input-actions">
|
||
<button
|
||
class="btn-generate"
|
||
@click="assist.submit()"
|
||
:disabled="!assist.canSubmit.value"
|
||
>Generate</button>
|
||
<button
|
||
class="btn-proofread"
|
||
@click="assist.proofread()"
|
||
>Proofread</button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Streaming: cancel -->
|
||
<template v-else-if="assist.state.value === 'streaming'">
|
||
<div class="assist-active-hint">Generating… see main area</div>
|
||
<button class="btn-clear" @click="assist.clearSelection()">Cancel</button>
|
||
</template>
|
||
|
||
<!-- Review: accept / reject -->
|
||
<template v-else-if="assist.state.value === 'review'">
|
||
<div class="assist-active-hint">Review the diff in the main area</div>
|
||
<div class="assist-actions">
|
||
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
|
||
<button class="btn-reject" @click="assist.reject()">Reject</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<VersionHistorySection
|
||
v-if="noteId"
|
||
:note-id="noteId"
|
||
:current-body="body"
|
||
@restore="(b, t) => { body = b; tags = t; markDirty(); }"
|
||
/>
|
||
|
||
</div><!-- /sidebar-content -->
|
||
</aside>
|
||
</div><!-- /note-body -->
|
||
|
||
<!-- Floating inline assist button -->
|
||
<teleport to="body">
|
||
<button
|
||
v-if="floatingAssist.show"
|
||
class="inline-assist-btn"
|
||
:style="{ top: floatingAssist.top + 'px', left: floatingAssist.left + 'px' }"
|
||
@mousedown.prevent="handleInlineAssist"
|
||
>✨ Assist</button>
|
||
</teleport>
|
||
|
||
<!-- Delete confirmation -->
|
||
<ConfirmDialog
|
||
v-if="showDeleteConfirm"
|
||
title="Delete Note"
|
||
message="Are you sure you want to delete this note? This cannot be undone."
|
||
@confirm="confirmDelete"
|
||
@cancel="showDeleteConfirm = false"
|
||
/>
|
||
</main>
|
||
</template>
|
||
|
||
<style src="@/assets/editor-shared.css" />
|
||
<style scoped>
|
||
/* ── Two-column note layout ──────────────────────────────────── */
|
||
.note-editor-page {
|
||
max-width: 1600px;
|
||
}
|
||
|
||
.note-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
/* Main column */
|
||
.note-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow-y: auto;
|
||
padding: 0.75rem 1.25rem 2rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.body-tabs-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
flex-wrap: wrap;
|
||
padding-bottom: 0.5rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.editor-tabs {
|
||
display: inline-flex;
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
padding: 2px;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.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 {
|
||
min-height: 200px;
|
||
}
|
||
|
||
.stream-label {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-muted);
|
||
font-style: italic;
|
||
}
|
||
|
||
.stream-preview {
|
||
border: 1px solid var(--color-input-border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 0.75rem;
|
||
background: var(--color-bg-card);
|
||
min-height: 200px;
|
||
}
|
||
|
||
.main-diff {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Right sidebar */
|
||
.note-sidebar {
|
||
width: 280px;
|
||
flex-shrink: 0;
|
||
border-left: 1px solid var(--color-border);
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.sb-select, .sb-input {
|
||
width: 100%;
|
||
padding: 5px 8px;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
|
||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
||
color: var(--color-text);
|
||
font-size: 0.82rem;
|
||
font-family: inherit;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.sb-select:focus, .sb-input:focus {
|
||
border-color: var(--color-primary, #6366f1);
|
||
}
|
||
|
||
/* Tag suggest row inside sidebar */
|
||
.tag-suggest-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.3rem;
|
||
align-items: center;
|
||
}
|
||
|
||
/* Link Suggestions */
|
||
.link-suggest-field { gap: 0.4rem; }
|
||
|
||
.sb-label-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.btn-link-all {
|
||
font-size: 0.72rem;
|
||
padding: 0.15rem 0.5rem;
|
||
background: none;
|
||
border: 1px solid var(--color-primary);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--color-primary);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
.btn-link-all:hover { background: var(--color-primary); color: #fff; }
|
||
|
||
.link-suggest-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.link-suggest-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: 0.78rem;
|
||
}
|
||
|
||
.link-suggest-title {
|
||
flex: 1;
|
||
font-family: monospace;
|
||
color: var(--color-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.link-suggest-count {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.72rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.btn-apply-link {
|
||
flex-shrink: 0;
|
||
font-size: 0.72rem;
|
||
padding: 0.1rem 0.4rem;
|
||
background: none;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.btn-apply-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||
|
||
/* Writing Assistant section */
|
||
.assist-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.assist-section-title {
|
||
font-size: 0.78rem;
|
||
font-weight: 700;
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
/* Narrow screen: sidebar collapses */
|
||
@media (max-width: 720px) {
|
||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||
.note-main { padding: 0.75rem 1rem 1rem; overflow-y: visible; }
|
||
.note-sidebar {
|
||
width: 100%;
|
||
border-left: none;
|
||
border-top: 1px solid var(--color-border);
|
||
overflow-y: visible;
|
||
}
|
||
}
|
||
</style>
|