Files
FabledScribe/frontend/src/views/NoteViewerView.vue
T
bvandeusen 4ba544e2af
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
refactor(theme): retire the --color-* shim — the sweep it promised, run
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.

73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.

One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.

Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.

Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).

Refs #2533
2026-08-08 22:42:37 -04:00

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(--fs-text-tertiary);
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(--fs-border-color);
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(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
.backlinks-count {
margin-left: 0.2rem;
font-size: 0.72rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
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(--fs-radius-lg);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
text-decoration: none;
color: var(--fs-text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.9rem;
}
.backlink-card:hover {
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
color: var(--fs-accent);
}
.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(--fs-accent) 12%, transparent);
color: var(--fs-accent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 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(--fs-radius-sm);
background: linear-gradient(
90deg,
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 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(--fs-radius-lg); }
.skel-meta { height: 0.85rem; width: 40%; }
.skel-line { height: 0.9rem; }
.skel-line--short { width: 55%; }
.skel-line--medium { width: 80%; }
</style>