CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 47s
21 buttons filled with --color-primary, which resolves to Scribe's violet accent: every auth Submit (Login, Register, Invite, Forgot, Reset), plus Invite, Add, Confirm, Restore, Generate, Log-save, Subtask-confirm, Toggle-open, the modal primary, the version-restore, the inline-assist button, the milestone-plan actions, the task-advance hover, and both empty-state CTAs. The house style is explicit that the accent never appears on an action button: action colours are universal across the family precisely so a Save button looks identical in every app, while the accent carries identity. Doing both makes the accent mean two things and neither clearly. Operator's call, and the reasoning is worth keeping: the violet-on-Scribe- actions treatment was a deliberate early choice to give the web UI its own personality, made when much more of the app was user-facing. That is no longer true, so consistency is now worth more than the distinction it was buying. Found in two passes, which is the part worth noting. The first scan looked for `.btn-*` and found 13. Seven more were the same thing under different names — .modal-btn-primary, .vh-btn-restore, .inline-assist-btn, .empty-action, .task-advance-btn — plus .ms-plan-actions .btn-primary, a compound override flagged in the previous commit. Searching by naming convention finds what was named consistently, which is never the whole set. Deliberately NOT changed: progress-bar fills, active tab / page / selection states, tag-pill hover, the duration badge, the skip link, the assist pulse. Those are identity and active-state, which is exactly where the accent belongs. After this the accent appears only there, which is what makes it read as identity rather than as decoration. Colour swaps only — 25 lines changed, no geometry, no structure, no templates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
847 lines
26 KiB
Vue
847 lines
26 KiB
Vue
<script setup lang="ts">
|
||
import { ref, 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";
|
||
import { Trash2 } from "lucide-vue-next";
|
||
|
||
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 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 titlePlaceholder = computed(() => {
|
||
switch (noteType.value) {
|
||
case 'process': return 'Process name';
|
||
default: return 'Title';
|
||
}
|
||
});
|
||
|
||
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";
|
||
|
||
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;
|
||
}
|
||
|
||
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";
|
||
savedTitle = title.value;
|
||
savedBody = body.value;
|
||
savedTags = [...tags.value];
|
||
savedProjectId = projectId.value;
|
||
savedMilestoneId = milestoneId.value;
|
||
savedNoteType = noteType.value;
|
||
}
|
||
} else {
|
||
// New note: read type from query param
|
||
const qt = route.query.type as string | undefined;
|
||
if (qt && ["note", "process"].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
|
||
}
|
||
|
||
await nextTick();
|
||
titleRef.value?.focus();
|
||
|
||
// Initial link suggestions
|
||
fetchLinkSuggestions();
|
||
});
|
||
|
||
async function save() {
|
||
if (saving.value) return;
|
||
saving.value = true;
|
||
const finalBody = body.value;
|
||
try {
|
||
if (isEditing.value) {
|
||
await store.updateNote(noteId.value!, {
|
||
title: title.value,
|
||
body: finalBody,
|
||
tags: tags.value,
|
||
project_id: projectId.value,
|
||
milestone_id: milestoneId.value,
|
||
note_type: noteType.value,
|
||
});
|
||
savedTitle = title.value;
|
||
savedBody = body.value;
|
||
savedTags = [...tags.value];
|
||
savedProjectId = projectId.value;
|
||
savedMilestoneId = milestoneId.value;
|
||
savedNoteType = noteType.value;
|
||
dirty.value = false;
|
||
toast.show("Note saved");
|
||
} else {
|
||
const note = await store.createNote({
|
||
title: title.value,
|
||
body: finalBody,
|
||
tags: tags.value,
|
||
project_id: projectId.value,
|
||
milestone_id: milestoneId.value,
|
||
note_type: noteType.value,
|
||
});
|
||
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(projectId.value ? `/projects/${projectId.value}` : "/");
|
||
} 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;
|
||
const finalBody = body.value;
|
||
try {
|
||
await store.updateNote(noteId.value!, {
|
||
title: title.value, body: finalBody, tags: tags.value,
|
||
project_id: projectId.value, milestone_id: milestoneId.value,
|
||
note_type: noteType.value,
|
||
});
|
||
savedTitle = title.value;
|
||
savedBody = body.value;
|
||
savedTags = [...tags.value];
|
||
savedProjectId = projectId.value;
|
||
savedMilestoneId = milestoneId.value;
|
||
savedNoteType = noteType.value;
|
||
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="projectId ? `/projects/${projectId}` : '/'" class="btn-back">← {{ projectId ? 'Project' : 'Knowledge' }}</router-link>
|
||
<button class="btn-save" @click="save" :disabled="saving">
|
||
{{ saving ? "Saving..." : "Save" }}
|
||
</button>
|
||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||
<Trash2 :size="16" /> Delete
|
||
</button>
|
||
<WordCount :body="body" />
|
||
</div>
|
||
<input
|
||
ref="titleRef"
|
||
v-model="title"
|
||
type="text"
|
||
:placeholder="titlePlaceholder"
|
||
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()">
|
||
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
||
<template v-if="noteType === 'process'">
|
||
<label class="ef-label">Prompt</label>
|
||
<textarea
|
||
class="prompt-editor"
|
||
:value="body"
|
||
@input="onBodyUpdate(($event.target as HTMLTextAreaElement).value)"
|
||
placeholder="The prompt to run when this process is fired (markdown). If it needs inputs, instruct Claude to ask up front."
|
||
spellcheck="false"
|
||
></textarea>
|
||
</template>
|
||
|
||
<!-- ── Generic note editor ──────────────────────────────── -->
|
||
<template v-else>
|
||
<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>
|
||
</template>
|
||
</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="process">Process</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 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);
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
/* 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-action-primary); color: var(--fs-text-on-action); }
|
||
|
||
.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: 500;
|
||
color: var(--color-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
/* ── Process editor ─────────────────────────────────────── */
|
||
.ef-label {
|
||
font-family: 'Fraunces', Georgia, serif;
|
||
font-size: 0.92rem;
|
||
color: var(--color-primary);
|
||
}
|
||
.prompt-editor {
|
||
width: 100%;
|
||
min-height: 60vh;
|
||
margin-top: 8px;
|
||
padding: 14px 16px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
background: var(--color-surface);
|
||
color: var(--color-text);
|
||
/* Prompts are plain markdown — a code-style editor, not rich text. */
|
||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||
font-size: 0.88rem;
|
||
line-height: 1.55;
|
||
tab-size: 2;
|
||
resize: vertical;
|
||
outline: none;
|
||
}
|
||
.prompt-editor:focus {
|
||
border-color: var(--color-primary);
|
||
}
|
||
/* 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>
|