Files
FabledScribe/frontend/src/views/TaskEditorView.vue
T
bvandeusen 7b92d13863 Make Back and Edit navigation consistently styled as bordered buttons
Previously .btn-back and .btn-edit in editor/viewer toolbars were plain
primary-colored links while all other toolbar actions were proper buttons.
All four views now use the same bordered button style: subtle border,
secondary text color, hover highlights primary border/text — matching
the existing convert/assist-toggle button aesthetic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 17:22:31 -05:00

1046 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist";
import { apiPost } from "@/api/client";
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";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const notesStore = useNotesStore();
const toast = useToastStore();
const title = ref("");
const body = ref("");
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const dueDate = ref("");
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
return (editorRef.value?.editor as Editor | undefined) ?? null;
});
const taskId = computed(() =>
route.params.id ? Number(route.params.id) : null
);
const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist
const assist = useAssist(body);
const renderedStreaming = computed(() => renderMarkdown(assist.streamingText.value));
const renderedProposal = computed(() => renderMarkdown(assist.proposedText.value));
// Assist panel toggle (persisted)
const ASSIST_KEY = 'fa-assist-open';
const assistOpen = ref(localStorage.getItem(ASSIST_KEY) !== 'false');
function toggleAssist() {
assistOpen.value = !assistOpen.value;
localStorage.setItem(ASSIST_KEY, String(assistOpen.value));
}
// Floating inline assist button
const floatingAssist = ref({ show: false, top: 0, left: 0 });
const pendingSelection = ref<{ start: number; end: number } | null>(null);
const instructionRef = ref<HTMLTextAreaElement | null>(null);
function onSelectionChange(payload: { text: string; start: number; end: number }) {
if (payload.text.trim().length > 0) {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const rect = sel.getRangeAt(0).getBoundingClientRect();
floatingAssist.value = { show: true, top: rect.top - 44, left: rect.left + rect.width / 2 };
pendingSelection.value = { start: payload.start, end: payload.end };
}
} else {
floatingAssist.value.show = false;
pendingSelection.value = null;
}
}
function handleInlineAssist() {
if (!pendingSelection.value) return;
assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end);
assistOpen.value = true;
localStorage.setItem(ASSIST_KEY, 'true');
floatingAssist.value.show = false;
nextTick(() => instructionRef.value?.focus());
}
// Diff view toggle
const showFullProposed = ref(false);
function handleAssistAccept() {
const newBody = assist.accept();
if (newBody !== body.value) {
body.value = newBody;
markDirty();
toast.show("Section updated");
}
showFullProposed.value = false;
}
function truncateTarget(text: string, max = 60): string {
const first = text.split("\n")[0];
return first.length > max ? first.slice(0, max) + "..." : first;
}
// Tag suggestions
const suggestedTags = ref<string[]>([]);
const appliedTags = ref<Set<string>>(new Set());
const suggestingTags = ref(false);
async function fetchTagSuggestions() {
if (suggestingTags.value) return;
suggestingTags.value = true;
suggestedTags.value = [];
appliedTags.value = new Set();
try {
const res = await apiPost<{ suggested_tags: string[] }>("/api/notes/suggest-tags", {
title: title.value,
body: body.value,
});
suggestedTags.value = res.suggested_tags;
} catch {
toast.show("Failed to get tag suggestions", "error");
} finally {
suggestingTags.value = false;
}
}
function applyTagSuggestion(tag: string) {
if (appliedTags.value.has(tag)) return;
body.value = body.value.trimEnd() + `\n#${tag}`;
appliedTags.value.add(tag);
markDirty();
}
function dismissTagSuggestions() {
suggestedTags.value = [];
appliedTags.value = new Set();
}
let savedTitle = "";
let savedBody = "";
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
function markDirty() {
dirty.value =
title.value !== savedTitle ||
body.value !== savedBody ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate;
}
function onBodyUpdate(newVal: string) {
body.value = newVal;
markDirty();
}
onMounted(async () => {
if (taskId.value) {
await store.fetchTask(taskId.value);
if (store.currentTask) {
title.value = store.currentTask.title;
body.value = store.currentTask.body;
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;
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
}
}
});
async function save() {
if (saving.value) return;
saving.value = true;
try {
const data = {
title: title.value,
body: body.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
savedTitle = title.value;
savedBody = body.value;
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
dirty.value = false;
toast.show("Task saved");
} else {
const task = await store.createTask(data);
dirty.value = false;
toast.show("Task created");
router.push(`/tasks/${task.id}`);
}
} catch {
toast.show("Failed to save task", "error");
} finally {
saving.value = false;
}
}
const showDeleteConfirm = ref(false);
function remove() {
if (taskId.value) {
showDeleteConfirm.value = true;
}
}
async function confirmDelete() {
showDeleteConfirm.value = false;
if (!taskId.value) return;
try {
await store.deleteTask(taskId.value);
dirty.value = false;
toast.show("Task deleted");
router.push("/tasks");
} catch {
toast.show("Failed to delete task", "error");
}
}
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
onBeforeRouteLeave(() => {
if (dirty.value) {
return confirm("You have unsaved changes. Leave anyway?");
}
});
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) {
e.preventDefault();
}
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script>
<template>
<main class="editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</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>
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
Assist
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Task title"
class="title-input"
@input="markDirty"
/>
<div class="field-row">
<div class="field">
<label>Status</label>
<select v-model="status" @change="markDirty" class="field-select">
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
<div class="field">
<label>Priority</label>
<select v-model="priority" @change="markDirty" class="field-select">
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="field">
<label>Due Date</label>
<input
v-model="dueDate"
type="date"
class="field-input"
@input="markDirty"
/>
</div>
</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">&#10003;</span>
</button>
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">&times;</button>
</template>
</div>
<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" :editor="tiptapEditor" />
</div>
<div class="editor-body">
<div class="editor-main">
<div v-show="!showPreview">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Describe this task... Use #tags inline"
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
/>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
></div>
</div>
<aside v-if="assistOpen" class="assist-panel">
<!-- Panel header -->
<div class="assist-panel-header">
<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-close-assist" @click="toggleAssist">×</button>
</div>
<!-- Panel body -->
<div class="assist-panel-body">
<!-- IDLE -->
<div v-if="assist.state.value === 'idle'" class="assist-idle">
<div class="assist-sections-label">Sections</div>
<div class="assist-sections">
<div
v-for="(section, i) in assist.sections.value"
:key="i"
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
@click="assist.selectSection(section)"
>
{{ section.heading || '(preamble)' }}
</div>
<div v-if="!assist.sections.value.length" class="assist-empty">
Write some content to get started.
</div>
</div>
<template v-if="assist.target.value">
<div class="assist-target-preview">
Editing: <em>{{ truncateTarget(assist.target.value.text) }}</em>
</div>
<textarea
ref="instructionRef"
v-model="assist.instruction.value"
placeholder="What should I do with this section?"
class="assist-instruction"
rows="3"
></textarea>
<div class="assist-input-actions">
<button
class="btn-generate"
@click="assist.submit()"
:disabled="!assist.canSubmit.value"
>Generate</button>
<button class="btn-clear" @click="assist.clearSelection()">Clear</button>
</div>
</template>
<div v-else class="assist-hint">
Select a section above or highlight text in the editor.
</div>
</div>
<!-- ERROR -->
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
<!-- STREAMING -->
<div v-if="assist.state.value === 'streaming'" class="assist-streaming">
<div class="assist-streaming-label">
{{ assist.isProofreading.value
? 'Proofreading document...'
: `Revising: "${truncateTarget(assist.target.value?.text ?? '')}"` }}
</div>
<div class="assist-preview-box prose" v-html="renderedStreaming"></div>
<span class="typing-indicator">●●●</span>
</div>
<!-- REVIEW -->
<div v-if="assist.state.value === 'review'" class="assist-review">
<div class="assist-review-header">
<span>{{ assist.isProofreading.value ? 'Document proofread' : 'Proposed changes' }}</span>
<button class="btn-toggle-view" @click="showFullProposed = !showFullProposed">
{{ showFullProposed ? 'Show diff' : 'Show full text' }}
</button>
</div>
<div v-if="!showFullProposed" class="diff-view">
<div
v-for="(line, i) in assist.diff.value"
:key="i"
:class="['diff-line', `diff-${line.type}`]"
>
<span class="diff-marker">{{ line.type === 'delete' ? '' : line.type === 'insert' ? '+' : ' ' }}</span>
<span class="diff-text">{{ line.text }}</span>
</div>
<div v-if="!assist.diff.value.length" class="diff-empty">No changes.</div>
</div>
<div v-else class="assist-preview-box prose" v-html="renderedProposal"></div>
<div class="assist-actions">
<button class="btn-accept" @click="handleAssistAccept"> Accept</button>
<button class="btn-reject" @click="() => { assist.reject(); showFullProposed = false; }"> Reject</button>
</div>
</div>
</div>
</aside>
</div>
<!-- Floating inline assist button (teleported to body) -->
<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 -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
<div class="modal-card" @click.stop>
<h3 class="modal-title">Delete Task</h3>
<p class="modal-message">Are you sure you want to delete this task? This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
</main>
</template>
<style scoped>
/* ── Layout ── */
.editor-page {
max-width: 1400px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-header {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.5rem 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.editor-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.editor-main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 0.75rem 1.5rem 1.5rem;
}
/* ── Toolbar & inputs ── */
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
text-decoration: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn-back:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-save {
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: default;
}
.btn-delete {
padding: 0.45rem 1rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.btn-assist-toggle {
margin-left: auto;
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.85rem;
}
.btn-assist-toggle.active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.title-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 1.25rem;
font-weight: 600;
background: var(--color-bg-card);
color: var(--color-text);
}
.editor-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
}
.tab {
padding: 0.45rem 1rem;
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
}
.tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
min-height: 200px;
background: var(--color-bg-card);
}
.field-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
flex: 1;
}
.field label {
font-size: 0.85rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.field-select,
.field-input {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
}
/* ── Tag suggestions ── */
.tag-suggest-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.btn-suggest-tags {
padding: 0.3rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.8rem;
}
.btn-suggest-tags:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-suggest-tags:disabled {
opacity: 0.6;
cursor: wait;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem 0.55rem;
border: 1px solid var(--color-primary);
border-radius: 999px;
background: transparent;
color: var(--color-primary);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tag-pill:hover:not(:disabled) {
background: var(--color-primary);
color: #fff;
}
.tag-pill.applied {
background: var(--color-success, #2ecc71);
border-color: var(--color-success, #2ecc71);
color: #fff;
cursor: default;
}
.tag-check {
font-size: 0.7rem;
}
.btn-dismiss-tags {
padding: 0.1rem 0.4rem;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.btn-dismiss-tags:hover {
color: var(--color-text);
}
/* ── Assist panel ── */
.assist-panel {
width: 320px;
flex-shrink: 0;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
}
.assist-panel-header {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--color-border);
}
.assist-panel-title {
flex: 1;
font-size: 0.8rem;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-proofread {
padding: 0.3rem 0.65rem;
font-size: 0.78rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
.btn-proofread:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-proofread:disabled {
opacity: 0.5;
cursor: default;
}
.btn-close-assist {
padding: 0.1rem 0.4rem;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.assist-panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.75rem 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
/* Section list */
.assist-sections-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin-bottom: 0.2rem;
}
.assist-sections {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
max-height: 200px;
overflow-y: auto;
flex-shrink: 0;
}
.assist-section-item {
padding: 0.35rem 0.7rem;
cursor: pointer;
font-size: 0.82rem;
border-left: 3px solid transparent;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assist-section-item:hover {
background: var(--color-bg-secondary);
}
.assist-section-item.selected {
border-left-color: var(--color-primary);
background: var(--color-bg-secondary);
font-weight: 500;
}
.assist-empty,
.assist-hint {
padding: 0.6rem 0.7rem;
font-size: 0.82rem;
color: var(--color-text-muted);
}
.assist-target-preview {
font-size: 0.8rem;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-target-preview em {
font-style: normal;
color: var(--color-text);
}
.assist-instruction {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 0.88rem;
font-family: inherit;
resize: vertical;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
min-height: 3.5rem;
}
.assist-input-actions {
display: flex;
gap: 0.5rem;
}
.btn-generate {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-generate:disabled {
opacity: 0.5;
cursor: default;
}
.btn-clear {
padding: 0.4rem 0.9rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
/* Streaming */
.assist-streaming-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-preview-box {
padding: 0.65rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
font-size: 0.9rem;
max-height: 300px;
overflow-y: auto;
}
.typing-indicator {
color: var(--color-text-muted);
font-size: 0.75rem;
letter-spacing: 0.15em;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* Error */
.assist-error {
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid var(--color-danger);
border-radius: var(--radius-sm);
font-size: 0.85rem;
color: var(--color-danger);
}
/* Review / diff */
.assist-review-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.btn-toggle-view {
font-size: 0.75rem;
color: var(--color-primary);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.diff-view {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
font-size: 0.82rem;
font-family: monospace;
max-height: 340px;
overflow-y: auto;
padding: 0.4rem 0;
}
.diff-line {
display: flex;
align-items: baseline;
padding: 0.05rem 0.5rem;
line-height: 1.5;
}
.diff-delete {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
}
.diff-insert {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
}
.diff-equal {
color: var(--color-text-muted);
}
.diff-marker {
flex-shrink: 0;
width: 1rem;
text-align: center;
user-select: none;
font-weight: 700;
}
.diff-text {
flex: 1;
white-space: pre-wrap;
word-break: break-word;
}
.diff-empty {
padding: 0.5rem;
color: var(--color-text-muted);
font-style: italic;
font-size: 0.82rem;
}
.assist-actions {
display: flex;
gap: 0.5rem;
}
.btn-accept {
padding: 0.4rem 1rem;
background: var(--color-success);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-reject {
padding: 0.4rem 1rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: 1.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-btn {
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-danger {
background: var(--color-danger);
color: #fff;
border-color: var(--color-danger);
}
/* ── Mobile ── */
@media (max-width: 768px) {
.editor-body {
flex-direction: column;
}
.assist-panel {
width: auto;
flex: 0 0 45%;
border-left: none;
border-top: 1px solid var(--color-border);
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.editor-header {
padding: 0.75rem 1rem 0.5rem;
}
.editor-main {
padding: 0.5rem 1rem 1rem;
}
}
</style>