CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 30s
CI & Build / TypeScript typecheck (push) Successful in 45s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Python tests (push) Successful in 2m13s
CI & Build / Build & push image (push) Successful in 42s
Twelve more files onto the shared buttons. What the pass turned up: DEAD, verified not merely unnamed: - .btn-reconsolidate (TaskEditorView). A comment eleven hundred lines up in the same file says the feature was removed in Phase 8. The CSS outlived it. - .btn-remove-slot (SettingsView), style rules only, no template anywhere. OFF-PALETTE, the #2319 shape: TrashView's restore and purge hovers used `var(--color-primary, #6366f1)` and `var(--color-danger, #ef4444)` — Tailwind indigo and Tailwind red, from no palette in this system. The fallback is what renders if the token is ever absent, and it renders something plausible forever. Now the action and destructive colours, no fallback. A REAL BREAKAGE MY OWN CHECK COULD NOT SEE, worth recording. Deleting a rule whose selector was part of a comma-separated group left the leading selectors behind: .btn-log-edit, <nothing> .log-textarea { … } which silently swallows the next rule. Brace counting passed — there are no braces in a dangling fragment. Found by scanning for selector lines ending in `,` not followed by another selector; three instances across two files, one of them interleaved with comments so the first sweep missed it. The sweep is now part of the verification, not a one-off. Kept bespoke, deliberately: .btn-pin/.btn-unpin (pill-shaped history badges), .btn-add-share and .btn-new-note (gradient CTAs — brand moments, which the house style does sanction), .btn-icon/.btn-bell (icon buttons, a different component), .btn-add-system/.btn-add-milestone (dashed "add" affordances). These are not drift; they are other things wearing a btn- prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
472 lines
13 KiB
Vue
472 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, computed, ref, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import { useNotesStore } from "@/stores/notes";
|
|
import { renderMarkdown } from "@/utils/markdown";
|
|
import { relativeTime } from "@/composables/useRelativeTime";
|
|
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
|
import type { Note } from "@/types/note";
|
|
import TagPill from "@/components/TagPill.vue";
|
|
import TableOfContents from "@/components/TableOfContents.vue";
|
|
import ShareDialog from "@/components/ShareDialog.vue";
|
|
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const store = useNotesStore();
|
|
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
|
|
const converting = ref(false);
|
|
const showShare = ref(false);
|
|
|
|
// Context enrichment
|
|
const projectTitle = ref<string | null>(null);
|
|
const milestoneName = ref<string | null>(null);
|
|
const parentNoteTitle = ref<string | null>(null);
|
|
|
|
const noteId = computed(() => Number(route.params.id));
|
|
|
|
async function loadContext(note: Note) {
|
|
projectTitle.value = null;
|
|
milestoneName.value = null;
|
|
parentNoteTitle.value = null;
|
|
|
|
const promises: Promise<void>[] = [];
|
|
|
|
if (note.project_id) {
|
|
promises.push(
|
|
apiGet<any>(`/api/projects/${note.project_id}`).then((data) => {
|
|
projectTitle.value = data.title ?? null;
|
|
if (note.milestone_id && data.summary?.milestone_summary) {
|
|
const ms = (data.summary.milestone_summary as Array<{ id: number; title: string }>)
|
|
.find((m) => m.id === note.milestone_id);
|
|
if (ms) milestoneName.value = ms.title;
|
|
}
|
|
}).catch(() => {})
|
|
);
|
|
}
|
|
|
|
if (note.parent_id) {
|
|
promises.push(
|
|
apiGet<Note>(`/api/notes/${note.parent_id}`).then((parent) => {
|
|
parentNoteTitle.value = parent.title || "Untitled";
|
|
}).catch(() => {})
|
|
);
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
async function loadNote(id: number) {
|
|
backlinks.value = [];
|
|
await store.fetchNote(id);
|
|
|
|
// If note is empty (no body), redirect to editor
|
|
if (store.currentNote && !store.currentNote.body.trim()) {
|
|
router.replace(`/notes/${id}/edit`);
|
|
return;
|
|
}
|
|
|
|
const [bl] = await Promise.allSettled([
|
|
store.fetchBacklinks(id),
|
|
loadContext(store.currentNote!),
|
|
]);
|
|
if (bl.status === "fulfilled") backlinks.value = bl.value;
|
|
}
|
|
|
|
onMounted(() => loadNote(noteId.value));
|
|
|
|
// Re-fetch when navigating between notes (Vue reuses the component)
|
|
watch(() => route.params.id, (newId) => {
|
|
if (newId) loadNote(Number(newId));
|
|
});
|
|
|
|
const isListNote = computed(() => {
|
|
const body = store.currentNote?.body ?? "";
|
|
return /^- \[[ xX]\] /m.test(body);
|
|
});
|
|
|
|
const renderedBody = computed(() => {
|
|
if (!store.currentNote) return "";
|
|
return renderMarkdown(store.currentNote.body, { interactiveCheckboxes: isListNote.value });
|
|
});
|
|
|
|
async function onBodyChange(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
if (target.type !== "checkbox" || !store.currentNote) return;
|
|
|
|
const index = parseInt(target.dataset.taskIndex ?? "", 10);
|
|
if (isNaN(index)) return;
|
|
|
|
let taskIdx = 0;
|
|
const newBody = store.currentNote.body.split("\n").map(line => {
|
|
const stripped = line.trimStart();
|
|
if (stripped.startsWith("- [ ] ") || stripped.startsWith("- [x] ") || stripped.startsWith("- [X] ")) {
|
|
if (taskIdx === index) {
|
|
const indent = line.length - stripped.length;
|
|
const wasChecked = !stripped.startsWith("- [ ] ");
|
|
taskIdx++;
|
|
return " ".repeat(indent) + (wasChecked ? "- [ ] " : "- [x] ") + stripped.slice(6);
|
|
}
|
|
taskIdx++;
|
|
}
|
|
return line;
|
|
}).join("\n");
|
|
|
|
// Optimistic update so the checkbox state doesn't snap back
|
|
store.currentNote.body = newBody;
|
|
|
|
try {
|
|
await apiPatch(`/api/notes/${store.currentNote.id}`, { body: newBody });
|
|
} catch {
|
|
await store.fetchNote(store.currentNote.id);
|
|
}
|
|
}
|
|
|
|
async function onBodyClick(e: MouseEvent) {
|
|
const target = e.target as HTMLElement;
|
|
|
|
const tagLink = target.closest(".inline-tag") as HTMLAnchorElement | null;
|
|
if (tagLink) {
|
|
e.preventDefault();
|
|
const tag = tagLink.dataset.tag;
|
|
if (tag) {
|
|
router.push({ path: "/notes", query: { tag } });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const wikilink = target.closest(".wikilink") as HTMLAnchorElement | null;
|
|
if (wikilink) {
|
|
e.preventDefault();
|
|
const title = wikilink.dataset.title;
|
|
if (title) {
|
|
try {
|
|
const note = await apiPost<Note>(
|
|
"/api/notes/resolve-title",
|
|
{ title }
|
|
);
|
|
router.push(`/notes/${note.id}`);
|
|
} catch {
|
|
const { useToastStore } = await import("@/stores/toast");
|
|
useToastStore().show(`Failed to resolve note "${title}"`, "error");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function onTagClick(tag: string) {
|
|
router.push({ path: "/notes", query: { tag } });
|
|
}
|
|
|
|
async function convertToTask() {
|
|
if (converting.value) return;
|
|
converting.value = true;
|
|
try {
|
|
await store.convertToTask(noteId.value);
|
|
const { useToastStore } = await import("@/stores/toast");
|
|
useToastStore().show("Converted to task");
|
|
router.push(`/tasks/${noteId.value}`);
|
|
} catch {
|
|
const { useToastStore } = await import("@/stores/toast");
|
|
useToastStore().show("Failed to convert note", "error");
|
|
} finally {
|
|
converting.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="viewer-layout">
|
|
<main class="viewer">
|
|
<div v-if="store.loading" class="viewer-skeleton" aria-label="Loading note">
|
|
<div class="skel-toolbar">
|
|
<div class="skel-btn"></div>
|
|
<div class="skel-btn skel-btn--wide"></div>
|
|
</div>
|
|
<div class="skel-title"></div>
|
|
<div class="skel-meta"></div>
|
|
<div class="skel-line"></div>
|
|
<div class="skel-line skel-line--short"></div>
|
|
<div class="skel-line"></div>
|
|
<div class="skel-line skel-line--medium"></div>
|
|
<div class="skel-line"></div>
|
|
<div class="skel-line skel-line--short"></div>
|
|
</div>
|
|
<template v-else-if="store.currentNote">
|
|
<div class="toolbar">
|
|
<router-link to="/notes" class="btn-ghost">← Notes</router-link>
|
|
<router-link
|
|
:to="`/notes/${store.currentNote.id}/edit`"
|
|
class="btn-primary"
|
|
>
|
|
Edit
|
|
</router-link>
|
|
<button
|
|
v-if="!store.currentNote.is_task"
|
|
class="btn-secondary btn-compact"
|
|
@click="convertToTask"
|
|
:disabled="converting"
|
|
>
|
|
{{ converting ? "Converting..." : "Convert to Task" }}
|
|
</button>
|
|
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
|
</div>
|
|
|
|
<!-- Breadcrumb: parent → project → milestone -->
|
|
<div
|
|
v-if="store.currentNote.parent_id || store.currentNote.project_id"
|
|
class="context-bar"
|
|
>
|
|
<router-link
|
|
v-if="store.currentNote.parent_id"
|
|
:to="`/notes/${store.currentNote.parent_id}`"
|
|
class="ctx-crumb ctx-crumb-parent"
|
|
>
|
|
↑ {{ parentNoteTitle || "Parent note" }}
|
|
</router-link>
|
|
<router-link
|
|
v-if="store.currentNote.project_id && projectTitle"
|
|
:to="`/projects/${store.currentNote.project_id}`"
|
|
class="ctx-crumb ctx-crumb-project"
|
|
>
|
|
{{ projectTitle }}
|
|
</router-link>
|
|
<span v-if="milestoneName" class="ctx-crumb ctx-crumb-milestone">
|
|
{{ milestoneName }}
|
|
</span>
|
|
</div>
|
|
|
|
<h1 class="note-title">{{ store.currentNote.title || "Untitled" }}</h1>
|
|
<p class="meta">
|
|
<span class="meta-item">
|
|
<Clock :size="16" />
|
|
Updated {{ relativeTime(store.currentNote.updated_at) }}
|
|
</span>
|
|
<span class="meta-sep" aria-hidden="true">·</span>
|
|
<span class="meta-item">
|
|
<Pencil :size="16" />
|
|
Created {{ relativeTime(store.currentNote.created_at) }}
|
|
</span>
|
|
</p>
|
|
<div class="tags" v-if="store.currentNote.tags.length">
|
|
<TagPill
|
|
v-for="tag in store.currentNote.tags"
|
|
:key="tag"
|
|
:tag="tag"
|
|
@click="onTagClick"
|
|
/>
|
|
</div>
|
|
<div
|
|
class="body prose"
|
|
:class="{ 'prose--checklist': isListNote }"
|
|
v-html="renderedBody"
|
|
@click="onBodyClick"
|
|
@change="onBodyChange"
|
|
></div>
|
|
|
|
<div v-if="backlinks.length" class="backlinks">
|
|
<h3 class="backlinks-heading">
|
|
<LinkIcon :size="16" />
|
|
Backlinks
|
|
<span class="backlinks-count">{{ backlinks.length }}</span>
|
|
</h3>
|
|
<div class="backlinks-grid">
|
|
<router-link
|
|
v-for="link in backlinks"
|
|
:key="`${link.type}-${link.id}`"
|
|
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
|
|
class="backlink-card"
|
|
>
|
|
<span :class="['backlink-type-badge', `badge-${link.type}`]">{{ link.type }}</span>
|
|
<span class="backlink-title">{{ link.title || "Untitled" }}</span>
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<p v-else>Note not found.</p>
|
|
</main>
|
|
<TableOfContents
|
|
v-if="store.currentNote?.body"
|
|
:body="store.currentNote.body"
|
|
class="toc-sidebar"
|
|
/>
|
|
</div>
|
|
|
|
<ShareDialog
|
|
v-if="showShare && store.currentNote"
|
|
resource-type="note"
|
|
:resource-id="store.currentNote.id"
|
|
:resource-title="store.currentNote.title || '(untitled)'"
|
|
@close="showShare = false"
|
|
/>
|
|
</template>
|
|
|
|
<style src="@/assets/viewer-shared.css" />
|
|
<style scoped>
|
|
.viewer-layout {
|
|
display: flex;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
gap: 2rem;
|
|
}
|
|
.viewer {
|
|
flex: 1;
|
|
min-width: 0;
|
|
max-width: 1100px;
|
|
margin: 2rem 0;
|
|
padding: 0 1rem;
|
|
}
|
|
.toc-sidebar {
|
|
margin-top: 2rem;
|
|
}
|
|
@media (max-width: 1200px) {
|
|
.toc-sidebar {
|
|
display: none;
|
|
}
|
|
}
|
|
.toolbar {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
/* Edit: Moss action-primary — switching from view to edit is operating
|
|
the software, not a brand moment. */
|
|
/* Convert + Share: Bronze action-secondary — alternate paths */
|
|
|
|
|
|
.meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
font-size: 0.83rem;
|
|
color: var(--color-text-muted);
|
|
margin: 0 0 0.75rem;
|
|
}
|
|
.meta-item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
}
|
|
.meta-sep {
|
|
opacity: 0.5;
|
|
}
|
|
.tags {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.backlinks {
|
|
margin-top: 2.5rem;
|
|
border-top: 1px solid var(--color-border);
|
|
padding-top: 1.25rem;
|
|
}
|
|
.backlinks-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--color-text-muted);
|
|
margin: 0 0 0.75rem;
|
|
}
|
|
.backlinks-count {
|
|
margin-left: 0.2rem;
|
|
font-size: 0.72rem;
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 999px;
|
|
padding: 0 0.4rem;
|
|
line-height: 1.4;
|
|
}
|
|
.backlinks-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
.backlink-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: var(--radius-md);
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
text-decoration: none;
|
|
color: var(--color-text);
|
|
transition: border-color 0.15s, box-shadow 0.15s;
|
|
font-size: 0.9rem;
|
|
}
|
|
.backlink-card:hover {
|
|
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
|
color: var(--color-primary);
|
|
}
|
|
.backlink-type-badge {
|
|
font-size: 0.68rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
font-weight: 500;
|
|
padding: 0.1rem 0.45rem;
|
|
border-radius: 999px;
|
|
flex-shrink: 0;
|
|
}
|
|
.badge-note {
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
color: var(--color-primary);
|
|
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
|
}
|
|
.badge-task {
|
|
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
|
color: #d97706;
|
|
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
|
|
}
|
|
.backlink-title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Skeleton loader ── */
|
|
@keyframes skel-shine {
|
|
to { background-position: 200% center; }
|
|
}
|
|
.viewer-skeleton {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.65rem;
|
|
padding-top: 0.5rem;
|
|
}
|
|
.skel-btn,
|
|
.skel-title,
|
|
.skel-meta,
|
|
.skel-line {
|
|
border-radius: var(--radius-sm);
|
|
background: linear-gradient(
|
|
90deg,
|
|
var(--color-bg-secondary) 25%,
|
|
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
|
|
var(--color-bg-secondary) 75%
|
|
);
|
|
background-size: 200% 100%;
|
|
animation: skel-shine 1.5s ease infinite;
|
|
}
|
|
.skel-toolbar {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.skel-btn { width: 70px; height: 32px; }
|
|
.skel-btn--wide { width: 90px; }
|
|
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--radius-md); }
|
|
.skel-meta { height: 0.85rem; width: 40%; }
|
|
.skel-line { height: 0.9rem; }
|
|
.skel-line--short { width: 55%; }
|
|
.skel-line--medium { width: 80%; }
|
|
</style>
|