Initial commit: note-taking/task-tracking app with LLM integration scaffold

Vue 3 + TypeScript frontend with Pinia stores, markdown rendering (marked + DOMPurify),
wikilink/tag linkification, and autocomplete. Quart async backend with SQLAlchemy 2.0,
PostgreSQL ARRAY columns, task-note companion linking, backlinks, and note-to-task
conversion. Docker Compose setup with PostgreSQL 16 and Ollama.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 23:35:44 -05:00
commit 22a3a3c1d1
71 changed files with 7173 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme";
useTheme();
</script>
<template>
<AppHeader />
<router-view />
<ToastNotification />
</template>
+50
View File
@@ -0,0 +1,50 @@
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function apiDelete(path: string): Promise<void> {
const res = await fetch(path, { method: "DELETE" });
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
}
+145
View File
@@ -0,0 +1,145 @@
.prose {
line-height: 1.6;
}
.prose h1 {
margin: 1.25rem 0 0.5rem;
font-size: 1.5rem;
}
.prose h2 {
margin: 1rem 0 0.4rem;
font-size: 1.3rem;
}
.prose h3 {
margin: 0.75rem 0 0.3rem;
font-size: 1.1rem;
}
.prose h4,
.prose h5,
.prose h6 {
margin: 0.5rem 0 0.25rem;
}
.prose p {
margin: 0 0 0.6rem;
}
.prose ul,
.prose ol {
margin: 0 0 0.6rem;
padding-left: 1.5rem;
}
.prose li {
margin-bottom: 0.2rem;
}
.prose pre {
background: var(--color-code-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.75rem;
overflow-x: auto;
margin: 0 0 0.6rem;
}
.prose pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: 0.9em;
}
.prose code {
background: var(--color-code-inline-bg);
border-radius: 3px;
padding: 0.15rem 0.35rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 0.9em;
}
.prose table {
border-collapse: collapse;
width: 100%;
margin: 0 0 0.6rem;
}
.prose th,
.prose td {
border: 1px solid var(--color-border);
padding: 0.4rem 0.6rem;
text-align: left;
}
.prose thead th {
background: var(--color-bg-secondary);
font-weight: 600;
}
.prose tbody tr:nth-child(even) {
background: var(--color-table-stripe);
}
.prose blockquote {
border-left: 3px solid var(--color-border);
margin: 0 0 0.6rem;
padding: 0.25rem 0 0.25rem 0.75rem;
color: var(--color-text-secondary);
}
.prose blockquote p:last-child {
margin-bottom: 0;
}
.prose hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 1rem 0;
}
.prose img {
max-width: 100%;
height: auto;
}
.prose a {
color: var(--color-primary);
text-decoration: none;
}
.prose a:hover {
text-decoration: underline;
}
.prose .inline-tag {
color: var(--color-tag-text);
background: var(--color-tag-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
font-size: 0.9em;
}
.prose .inline-tag:hover {
filter: brightness(0.9);
text-decoration: none;
}
.prose .wikilink {
color: var(--color-wikilink);
background: var(--color-wikilink-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
font-size: 0.9em;
cursor: pointer;
}
.prose .wikilink:hover {
filter: brightness(0.9);
text-decoration: none;
}
+87
View File
@@ -0,0 +1,87 @@
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f5f5f5;
--color-bg-card: #ffffff;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-text-muted: #999999;
--color-border: #e0e0e0;
--color-input-border: #cccccc;
--color-primary: #1a73e8;
--color-danger: #d93025;
--color-tag-bg: #e8f0fe;
--color-tag-text: #1a73e8;
--color-shadow: rgba(0, 0, 0, 0.1);
--color-toast-success: #34a853;
--color-toast-error: #d93025;
--color-status-todo: #5f6368;
--color-status-todo-bg: #e8eaed;
--color-status-in-progress: #1a73e8;
--color-status-in-progress-bg: #e8f0fe;
--color-status-done: #34a853;
--color-status-done-bg: #e6f4ea;
--color-priority-low: #5f9ea0;
--color-priority-low-bg: #e0f2f1;
--color-priority-medium: #f9a825;
--color-priority-medium-bg: #fff8e1;
--color-priority-high: #d93025;
--color-priority-high-bg: #fce8e6;
--color-wikilink: #7b1fa2;
--color-wikilink-bg: #f3e5f5;
--color-overdue: #d93025;
--color-code-bg: #f6f8fa;
--color-code-inline-bg: #eff1f3;
--color-table-stripe: #f9f9f9;
}
[data-theme="dark"] {
--color-bg: #1a1a2e;
--color-bg-secondary: #16213e;
--color-bg-card: #1f2940;
--color-text: #e0e0e0;
--color-text-secondary: #a0a0b0;
--color-text-muted: #707080;
--color-border: #2a3a5c;
--color-input-border: #3a4a6c;
--color-primary: #5b9cf6;
--color-danger: #f44336;
--color-tag-bg: #1e3a5f;
--color-tag-text: #7bb8f6;
--color-shadow: rgba(0, 0, 0, 0.3);
--color-toast-success: #4caf50;
--color-toast-error: #f44336;
--color-status-todo: #9aa0a6;
--color-status-todo-bg: #2d333b;
--color-status-in-progress: #5b9cf6;
--color-status-in-progress-bg: #1e3a5f;
--color-status-done: #4caf50;
--color-status-done-bg: #1b3a20;
--color-priority-low: #80cbc4;
--color-priority-low-bg: #1a3a38;
--color-priority-medium: #fdd835;
--color-priority-medium-bg: #3a3520;
--color-priority-high: #f44336;
--color-priority-high-bg: #3a1a1a;
--color-wikilink: #ce93d8;
--color-wikilink-bg: #2a1a30;
--color-overdue: #f44336;
--color-code-bg: #161b22;
--color-code-inline-bg: #2a3040;
--color-table-stripe: #1a2030;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
transition: background-color 0.2s, color 0.2s;
}
View File
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { useTheme } from "@/composables/useTheme";
const { theme, toggleTheme } = useTheme();
</script>
<template>
<header class="app-header">
<nav class="nav">
<router-link to="/" class="nav-brand">Fabled Assistant</router-link>
<div class="nav-links">
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }}
</button>
</div>
</nav>
</header>
</template>
<style scoped>
.app-header {
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.nav {
max-width: 960px;
margin: 0 auto;
padding: 0.5rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand {
font-weight: 700;
font-size: 1.1rem;
color: var(--color-primary);
text-decoration: none;
}
.nav-links {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-link {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.95rem;
}
.nav-link:hover {
color: var(--color-primary);
}
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 1.1rem;
color: var(--color-text);
line-height: 1;
}
.theme-toggle:hover {
background: var(--color-bg-card);
}
</style>
@@ -0,0 +1,53 @@
<script setup lang="ts">
const emit = defineEmits<{
insert: [before: string, after: string, placeholder: string];
}>();
const buttons = [
{ label: "B", before: "**", after: "**", placeholder: "bold", title: "Bold" },
{ label: "I", before: "_", after: "_", placeholder: "italic", title: "Italic" },
{ label: "H1", before: "# ", after: "", placeholder: "Heading", title: "Heading 1" },
{ label: "H2", before: "## ", after: "", placeholder: "Heading", title: "Heading 2" },
{ label: "Link", before: "[", after: "](url)", placeholder: "text", title: "Link" },
{ label: "UL", before: "- ", after: "", placeholder: "item", title: "Bullet List" },
{ label: "OL", before: "1. ", after: "", placeholder: "item", title: "Numbered List" },
{ label: "`", before: "`", after: "`", placeholder: "code", title: "Inline Code" },
{ label: "```", before: "```\n", after: "\n```", placeholder: "code block", title: "Code Block" },
];
</script>
<template>
<div class="md-toolbar">
<button
v-for="btn in buttons"
:key="btn.label"
class="md-btn"
:title="btn.title"
@click="emit('insert', btn.before, btn.after, btn.placeholder)"
>
{{ btn.label }}
</button>
</div>
</template>
<style scoped>
.md-toolbar {
display: flex;
gap: 2px;
flex-wrap: wrap;
}
.md-btn {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 3px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
line-height: 1;
}
.md-btn:hover {
background: var(--color-bg-secondary);
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderPreview } from "@/utils/markdown";
defineProps<{ note: Note }>();
const emit = defineEmits<{ "tag-click": [tag: string] }>();
function onTagClick(tag: string) {
emit("tag-click", tag);
}
</script>
<template>
<router-link :to="`/notes/${note.id}`" class="note-card">
<h3 class="note-title">{{ note.title || "Untitled" }}</h3>
<div v-if="note.body" class="note-preview prose" v-html="renderPreview(note.body)"></div>
<div class="note-meta">
<TagPill
v-for="tag in note.tags"
:key="tag"
:tag="tag"
@click.stop="onTagClick(tag)"
/>
<span class="timestamp">{{ relativeTime(note.updated_at) }}</span>
</div>
</router-link>
</template>
<style scoped>
.note-card {
display: block;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
transition: box-shadow 0.15s;
}
.note-card:hover {
box-shadow: 0 2px 8px var(--color-shadow);
}
.note-title {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.note-preview {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
}
.note-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
total: number;
limit: number;
offset: number;
}>();
const emit = defineEmits<{
"update:offset": [offset: number];
}>();
const totalPages = computed(() => Math.ceil(props.total / props.limit) || 1);
const currentPage = computed(() => Math.floor(props.offset / props.limit) + 1);
const pages = computed(() => {
const total = totalPages.value;
const current = currentPage.value;
const result: (number | "...")[] = [];
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= current - 1 && i <= current + 1)) {
result.push(i);
} else if (result[result.length - 1] !== "...") {
result.push("...");
}
}
return result;
});
function goToPage(page: number) {
emit("update:offset", (page - 1) * props.limit);
}
</script>
<template>
<div v-if="totalPages > 1" class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="goToPage(currentPage - 1)"
>
Prev
</button>
<template v-for="(page, idx) in pages" :key="idx">
<span v-if="page === '...'" class="ellipsis">...</span>
<button
v-else
class="page-btn"
:class="{ active: page === currentPage }"
@click="goToPage(page as number)"
>
{{ page }}
</button>
</template>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="goToPage(currentPage + 1)"
>
Next
</button>
</div>
</template>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
margin-top: 1.5rem;
}
.page-btn {
padding: 0.35rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.85rem;
}
.page-btn:hover:not(:disabled) {
background: var(--color-bg-secondary);
}
.page-btn:disabled {
opacity: 0.4;
cursor: default;
}
.page-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.ellipsis {
padding: 0 0.25rem;
color: var(--color-text-muted);
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { TaskPriority } from "@/types/task";
const props = defineProps<{
priority: TaskPriority;
}>();
const labels: Record<TaskPriority, string> = {
none: "",
low: "Low",
medium: "Medium",
high: "High",
};
</script>
<template>
<span
v-if="props.priority !== 'none'"
:class="['priority-badge', `priority-${props.priority}`]"
>
{{ labels[props.priority] }}
</span>
</template>
<style scoped>
.priority-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.priority-low {
background: var(--color-priority-low-bg);
color: var(--color-priority-low);
}
.priority-medium {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.priority-high {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref, watch } from "vue";
const emit = defineEmits<{ search: [query: string] }>();
const query = ref("");
let timeout: ReturnType<typeof setTimeout>;
watch(query, (val) => {
clearTimeout(timeout);
timeout = setTimeout(() => emit("search", val), 300);
});
</script>
<template>
<input
v-model="query"
type="text"
placeholder="Search notes..."
class="search-input"
/>
</template>
<style scoped>
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
background: var(--color-bg-card);
color: var(--color-text);
}
.search-input::placeholder {
color: var(--color-text-muted);
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { TaskStatus } from "@/types/task";
const props = defineProps<{
status: TaskStatus;
clickable?: boolean;
}>();
defineEmits<{ click: [] }>();
const labels: Record<TaskStatus, string> = {
todo: "Todo",
in_progress: "In Progress",
done: "Done",
};
</script>
<template>
<span
:class="['status-badge', `status-${props.status}`, { clickable }]"
@click="clickable ? $emit('click') : undefined"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
>
{{ labels[props.status] }}
</span>
</template>
<style scoped>
.status-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.status-todo {
background: var(--color-status-todo-bg);
color: var(--color-status-todo);
}
.status-in_progress {
background: var(--color-status-in-progress-bg);
color: var(--color-status-in-progress);
}
.status-done {
background: var(--color-status-done-bg);
color: var(--color-status-done);
}
.clickable {
cursor: pointer;
}
.clickable:hover {
filter: brightness(0.9);
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
defineProps<{
tag: string;
dismissible?: boolean;
}>();
defineEmits<{
click: [tag: string];
dismiss: [tag: string];
}>();
</script>
<template>
<span class="tag-pill" @click="$emit('click', tag)">
#{{ tag }}
<button
v-if="dismissible"
class="dismiss"
@click.stop="$emit('dismiss', tag)"
aria-label="Remove tag"
>
&times;
</button>
</span>
</template>
<style scoped>
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-tag-bg);
color: var(--color-tag-text);
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.tag-pill:hover {
filter: brightness(0.95);
}
.dismiss {
background: none;
border: none;
color: var(--color-tag-text);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 0 0.1rem;
opacity: 0.7;
}
.dismiss:hover {
opacity: 1;
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import type { Task, TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderPreview } from "@/utils/markdown";
const props = defineProps<{ task: Task }>();
const emit = defineEmits<{
"tag-click": [tag: string];
"status-toggle": [id: number, status: TaskStatus];
}>();
const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
};
function cycleStatus() {
emit("status-toggle", props.task.id, statusCycle[props.task.status]);
}
function isOverdue(): boolean {
if (!props.task.due_date || props.task.status === "done") return false;
return new Date(props.task.due_date) < new Date(new Date().toDateString());
}
</script>
<template>
<router-link :to="`/tasks/${task.id}`" class="task-card">
<div class="task-top">
<StatusBadge
:status="task.status"
clickable
@click.prevent.stop="cycleStatus"
/>
<PriorityBadge :priority="task.priority" />
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
</div>
<div v-if="task.description" class="task-preview prose" v-html="renderPreview(task.description)"></div>
<div class="task-meta">
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
Due: {{ task.due_date }}
</span>
<TagPill
v-for="tag in task.tags"
:key="tag"
:tag="tag"
@click.stop="emit('tag-click', tag)"
/>
<span class="timestamp">{{ relativeTime(task.updated_at) }}</span>
</div>
</router-link>
</template>
<style scoped>
.task-card {
display: block;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
transition: box-shadow 0.15s;
}
.task-card:hover {
box-shadow: 0 2px 8px var(--color-shadow);
}
.task-top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.task-title {
margin: 0;
font-size: 1.1rem;
}
.task-preview {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
}
.task-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.due-date.overdue {
color: var(--color-overdue);
font-weight: 600;
}
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useToastStore } from "@/stores/toast";
const toastStore = useToastStore();
</script>
<template>
<div class="toast-container">
<transition-group name="toast">
<div
v-for="toast in toastStore.toasts"
:key="toast.id"
class="toast"
:class="`toast--${toast.type}`"
>
{{ toast.message }}
</div>
</transition-group>
</div>
</template>
<style scoped>
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
padding: 0.75rem 1.25rem;
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
box-shadow: 0 2px 8px var(--color-shadow);
min-width: 200px;
}
.toast--success {
background: var(--color-toast-success);
}
.toast--error {
background: var(--color-toast-error);
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateY(-20px);
}
</style>
+259
View File
@@ -0,0 +1,259 @@
import { ref, type Ref } from "vue";
import { apiGet } from "@/api/client";
import type { NoteListResponse } from "@/types/note";
export interface AutocompleteItem {
label: string;
value: string;
}
export function useAutocomplete(
textareaRef: Ref<HTMLTextAreaElement | null>,
textRef: Ref<string>,
fetchTags: (q: string) => Promise<string[]>
) {
const acItems = ref<AutocompleteItem[]>([]);
const acVisible = ref(false);
const acIndex = ref(0);
const acTop = ref(0);
const acLeft = ref(0);
let acTriggerStart = -1;
let acType: "tag" | "wikilink" | null = null;
let debounceTimer: ReturnType<typeof setTimeout>;
function detectTrigger() {
const el = textareaRef.value;
if (!el) return;
const cursor = el.selectionStart;
const text = textRef.value;
// Check for [[ trigger
for (let i = cursor - 1; i >= 1; i--) {
if (text[i] === "\n") break;
if (text[i - 1] === "[" && text[i] === "[") {
const query = text.slice(i + 1, cursor);
if (!/\]/.test(query)) {
acTriggerStart = i - 1;
acType = "wikilink";
debouncedSearch(query);
return;
}
break;
}
}
// Check for # trigger
for (let i = cursor - 1; i >= 0; i--) {
const ch = text[i];
if (ch === "\n" || ch === " " || ch === "\t") break;
if (ch === "#") {
// Must be start of line or preceded by whitespace
if (i === 0 || /\s/.test(text[i - 1])) {
const query = text.slice(i + 1, cursor);
if (/^[\w/]*$/.test(query)) {
acTriggerStart = i;
acType = "tag";
debouncedSearch(query);
return;
}
}
break;
}
}
dismiss();
}
function debouncedSearch(query: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(query), 200);
}
async function doSearch(query: string) {
if (acType === "tag") {
try {
const tags = await fetchTags(query);
acItems.value = tags.slice(0, 8).map((t) => ({
label: `#${t}`,
value: t,
}));
} catch (e) {
console.error("Tag autocomplete fetch failed:", e);
acItems.value = [];
}
} else if (acType === "wikilink") {
try {
const data = await apiGet<NoteListResponse>(
`/api/notes?q=${encodeURIComponent(query)}&limit=8`
);
acItems.value = data.notes.map((n) => ({
label: n.title || "Untitled",
value: n.title || "Untitled",
}));
} catch (e) {
console.error("Wikilink autocomplete fetch failed:", e);
acItems.value = [];
}
}
if (acItems.value.length > 0) {
acIndex.value = 0;
positionDropdown();
acVisible.value = true;
} else if (acType === "tag") {
// Show "no tags" hint so the user knows the trigger is working
acItems.value = [{ label: "No tags yet", value: "" }];
acIndex.value = -1;
positionDropdown();
acVisible.value = true;
} else {
acVisible.value = false;
}
}
function positionDropdown() {
const el = textareaRef.value;
if (!el) return;
// Use a mirror div to measure cursor position
const mirror = document.createElement("div");
const style = getComputedStyle(el);
const props = [
"fontFamily",
"fontSize",
"fontWeight",
"lineHeight",
"letterSpacing",
"wordSpacing",
"textIndent",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"boxSizing",
"whiteSpace",
"wordWrap",
"overflowWrap",
] as const;
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.overflow = "hidden";
mirror.style.width = style.width;
for (const prop of props) {
mirror.style[prop] = style[prop];
}
const textBefore = el.value.slice(0, el.selectionStart);
mirror.textContent = textBefore;
const span = document.createElement("span");
span.textContent = "|";
mirror.appendChild(span);
document.body.appendChild(mirror);
const rect = el.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
const mirrorRect = mirror.getBoundingClientRect();
acTop.value =
rect.top + (spanRect.top - mirrorRect.top) - el.scrollTop + 20;
acLeft.value =
rect.left + (spanRect.left - mirrorRect.left) - el.scrollLeft;
document.body.removeChild(mirror);
}
function accept(index?: number) {
const el = textareaRef.value;
if (!el || !acVisible.value) return;
const idx = index ?? acIndex.value;
const item = acItems.value[idx];
if (!item || !item.value) return;
const cursor = el.selectionStart;
const text = textRef.value;
let replacement: string;
let end = cursor;
if (acType === "tag") {
replacement = `#${item.value} `;
} else {
replacement = `[[${item.value}]]`;
}
textRef.value =
text.slice(0, acTriggerStart) + replacement + text.slice(end);
const newPos = acTriggerStart + replacement.length;
dismiss();
// Restore cursor position after Vue updates the DOM
requestAnimationFrame(() => {
el.setSelectionRange(newPos, newPos);
el.focus();
});
}
function dismiss() {
acVisible.value = false;
acItems.value = [];
acType = null;
acTriggerStart = -1;
clearTimeout(debounceTimer);
}
function onKeydown(e: KeyboardEvent): boolean {
if (!acVisible.value) return false;
if (e.key === "ArrowDown") {
e.preventDefault();
acIndex.value = (acIndex.value + 1) % acItems.value.length;
return true;
}
if (e.key === "ArrowUp") {
e.preventDefault();
acIndex.value =
(acIndex.value - 1 + acItems.value.length) % acItems.value.length;
return true;
}
if (e.key === "Tab") {
e.preventDefault();
// Single match: accept immediately. Multiple: cycle then accept on second Tab.
if (acItems.value.length === 1) {
accept();
} else {
acIndex.value = (acIndex.value + 1) % acItems.value.length;
}
return true;
}
if (e.key === "Enter") {
e.preventDefault();
accept();
return true;
}
if (e.key === "Escape") {
e.preventDefault();
dismiss();
return true;
}
return false;
}
return {
acItems,
acVisible,
acIndex,
acTop,
acLeft,
detectTrigger,
accept,
dismiss,
onKeydown,
};
}
@@ -0,0 +1,11 @@
export function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
+29
View File
@@ -0,0 +1,29 @@
import { ref } from "vue";
type Theme = "light" | "dark";
const theme = ref<Theme>(getInitialTheme());
function getInitialTheme(): Theme {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
if (window.matchMedia("(prefers-color-scheme: light)").matches) return "light";
return "dark";
}
function applyTheme(t: Theme) {
document.documentElement.setAttribute("data-theme", t);
localStorage.setItem("theme", t);
}
export function useTheme() {
// Apply on first use
applyTheme(theme.value);
function toggleTheme() {
theme.value = theme.value === "dark" ? "light" : "dark";
applyTheme(theme.value);
}
return { theme, toggleTheme };
}
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./assets/theme.css";
import "./assets/prose.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
+55
View File
@@ -0,0 +1,55 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "@/views/HomeView.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/notes",
name: "notes",
component: () => import("@/views/NotesListView.vue"),
},
{
path: "/notes/new",
name: "note-new",
component: () => import("@/views/NoteEditorView.vue"),
},
{
path: "/notes/:id",
name: "note-view",
component: () => import("@/views/NoteViewerView.vue"),
},
{
path: "/notes/:id/edit",
name: "note-edit",
component: () => import("@/views/NoteEditorView.vue"),
},
{
path: "/tasks",
name: "tasks",
component: () => import("@/views/TasksListView.vue"),
},
{
path: "/tasks/new",
name: "task-new",
component: () => import("@/views/TaskEditorView.vue"),
},
{
path: "/tasks/:id",
name: "task-view",
component: () => import("@/views/TaskViewerView.vue"),
},
{
path: "/tasks/:id/edit",
name: "task-edit",
component: () => import("@/views/TaskEditorView.vue"),
},
],
});
export default router;
View File
+202
View File
@@ -0,0 +1,202 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import type { Note, NoteListResponse } from "@/types/note";
export const useNotesStore = defineStore("notes", () => {
const notes = ref<Note[]>([]);
const currentNote = ref<Note | null>(null);
const total = ref(0);
const loading = ref(false);
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
const sortOrder = ref<"asc" | "desc">("desc");
const searchQuery = ref("");
async function refresh() {
loading.value = true;
try {
const searchParams = new URLSearchParams();
if (searchQuery.value) searchParams.set("q", searchQuery.value);
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
searchParams.set("offset", String(offset.value));
const qs = searchParams.toString();
const data = await apiGet<NoteListResponse>(
`/api/notes${qs ? `?${qs}` : ""}`
);
notes.value = data.notes;
total.value = data.total;
} finally {
loading.value = false;
}
}
async function fetchNotes(params?: {
q?: string;
tag?: string[];
sort?: string;
order?: string;
limit?: number;
offset?: number;
}) {
if (params?.q !== undefined) searchQuery.value = params.q || "";
if (params?.tag) activeTagFilters.value = params.tag;
if (params?.sort) sortField.value = params.sort;
if (params?.order) sortOrder.value = params.order as "asc" | "desc";
if (params?.limit) limit.value = params.limit;
if (params?.offset !== undefined) offset.value = params.offset;
await refresh();
}
async function fetchNote(id: number) {
loading.value = true;
try {
currentNote.value = await apiGet<Note>(`/api/notes/${id}`);
} finally {
loading.value = false;
}
}
async function createNote(data: {
title: string;
body: string;
}): Promise<Note> {
const note = await apiPost<Note>("/api/notes", data);
return note;
}
async function updateNote(
id: number,
data: Partial<Pick<Note, "title" | "body">>
): Promise<Note> {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
if (currentNote.value?.id === id) {
currentNote.value = note;
}
return note;
}
async function deleteNote(id: number) {
await apiDelete(`/api/notes/${id}`);
notes.value = notes.value.filter((n) => n.id !== id);
if (currentNote.value?.id === id) {
currentNote.value = null;
}
}
function addTagFilter(tag: string) {
if (!activeTagFilters.value.includes(tag)) {
activeTagFilters.value.push(tag);
offset.value = 0;
refresh();
}
}
function removeTagFilter(tag: string) {
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
offset.value = 0;
refresh();
}
function clearTagFilters() {
activeTagFilters.value = [];
offset.value = 0;
refresh();
}
function setTagFilters(tags: string[]) {
activeTagFilters.value = [...tags];
offset.value = 0;
refresh();
}
function setSort(field: string, order: "asc" | "desc") {
sortField.value = field;
sortOrder.value = order;
offset.value = 0;
refresh();
}
function setOffset(newOffset: number) {
offset.value = newOffset;
refresh();
}
function setSearch(q: string) {
searchQuery.value = q;
offset.value = 0;
refresh();
}
async function resolveTitle(title: string): Promise<Note> {
return await apiPost<Note>("/api/notes/resolve-title", { title });
}
async function convertToTask(noteId: number) {
const task = await apiPost<Record<string, unknown>>(
`/api/notes/${noteId}/convert-to-task`,
{}
);
// Remove the note from local state since it was converted
notes.value = notes.value.filter((n) => n.id !== noteId);
if (currentNote.value?.id === noteId) {
currentNote.value = null;
}
return task;
}
async function fetchBacklinks(
noteId: number
): Promise<{ type: string; id: number; title: string }[]> {
const data = await apiGet<{
backlinks: { type: string; id: number; title: string }[];
}>(`/api/notes/${noteId}/backlinks`);
return data.backlinks;
}
async function fetchAllTags(q?: string): Promise<string[]> {
const params = q ? `?q=${encodeURIComponent(q)}` : "";
const data = await apiGet<{ tags: string[] }>(`/api/notes/tags${params}`);
return data.tags;
}
return {
notes,
currentNote,
total,
loading,
activeTagFilters,
limit,
offset,
sortField,
sortOrder,
searchQuery,
fetchNotes,
fetchNote,
createNote,
updateNote,
deleteNote,
addTagFilter,
removeTagFilter,
clearTagFilters,
setTagFilters,
setSort,
setOffset,
setSearch,
refresh,
resolveTitle,
convertToTask,
fetchBacklinks,
fetchAllTags,
};
});
+180
View File
@@ -0,0 +1,180 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task";
export const useTasksStore = defineStore("tasks", () => {
const tasks = ref<Task[]>([]);
const currentTask = ref<Task | null>(null);
const total = ref(0);
const loading = ref(false);
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const statusFilter = ref<TaskStatus | "">("");
const priorityFilter = ref<TaskPriority | "">("");
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
const sortOrder = ref<"asc" | "desc">("desc");
const searchQuery = ref("");
async function refresh() {
loading.value = true;
try {
const searchParams = new URLSearchParams();
if (searchQuery.value) searchParams.set("q", searchQuery.value);
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
if (statusFilter.value) searchParams.set("status", statusFilter.value);
if (priorityFilter.value)
searchParams.set("priority", priorityFilter.value);
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
searchParams.set("offset", String(offset.value));
const qs = searchParams.toString();
const data = await apiGet<TaskListResponse>(
`/api/tasks${qs ? `?${qs}` : ""}`
);
tasks.value = data.tasks;
total.value = data.total;
} finally {
loading.value = false;
}
}
async function fetchTask(id: number) {
loading.value = true;
try {
currentTask.value = await apiGet<Task>(`/api/tasks/${id}`);
} finally {
loading.value = false;
}
}
async function createTask(data: {
title: string;
description: string;
status?: TaskStatus;
priority?: TaskPriority;
due_date?: string | null;
}): Promise<Task> {
return await apiPost<Task>("/api/tasks", data);
}
async function updateTask(
id: number,
data: Partial<
Pick<Task, "title" | "description" | "status" | "priority" | "due_date">
>
): Promise<Task> {
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
}
async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
// Update in list if present
const idx = tasks.value.findIndex((t) => t.id === id);
if (idx !== -1) {
tasks.value[idx] = task;
}
if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
}
async function deleteTask(id: number) {
await apiDelete(`/api/tasks/${id}`);
tasks.value = tasks.value.filter((t) => t.id !== id);
if (currentTask.value?.id === id) {
currentTask.value = null;
}
}
function setStatusFilter(status: TaskStatus | "") {
statusFilter.value = status;
offset.value = 0;
refresh();
}
function setPriorityFilter(priority: TaskPriority | "") {
priorityFilter.value = priority;
offset.value = 0;
refresh();
}
function addTagFilter(tag: string) {
if (!activeTagFilters.value.includes(tag)) {
activeTagFilters.value.push(tag);
offset.value = 0;
refresh();
}
}
function removeTagFilter(tag: string) {
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
offset.value = 0;
refresh();
}
function clearTagFilters() {
activeTagFilters.value = [];
offset.value = 0;
refresh();
}
function setSort(field: string, order: "asc" | "desc") {
sortField.value = field;
sortOrder.value = order;
offset.value = 0;
refresh();
}
function setOffset(newOffset: number) {
offset.value = newOffset;
refresh();
}
function setSearch(q: string) {
searchQuery.value = q;
offset.value = 0;
refresh();
}
return {
tasks,
currentTask,
total,
loading,
activeTagFilters,
statusFilter,
priorityFilter,
limit,
offset,
sortField,
sortOrder,
searchQuery,
refresh,
fetchTask,
createTask,
updateTask,
patchStatus,
deleteTask,
setStatusFilter,
setPriorityFilter,
addTagFilter,
removeTagFilter,
clearTagFilters,
setSort,
setOffset,
setSearch,
};
});
+24
View File
@@ -0,0 +1,24 @@
import { ref } from "vue";
import { defineStore } from "pinia";
export interface Toast {
id: number;
message: string;
type: "success" | "error";
}
let nextId = 0;
export const useToastStore = defineStore("toast", () => {
const toasts = ref<Toast[]>([]);
function show(message: string, type: "success" | "error" = "success") {
const id = nextId++;
toasts.value.push({ id, message, type });
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id);
}, 3000);
}
return { toasts, show };
});
+14
View File
@@ -0,0 +1,14 @@
export interface Note {
id: number;
title: string;
body: string;
tags: string[];
parent_id: number | null;
created_at: string;
updated_at: string;
}
export interface NoteListResponse {
notes: Note[];
total: number;
}
+20
View File
@@ -0,0 +1,20 @@
export type TaskStatus = "todo" | "in_progress" | "done";
export type TaskPriority = "none" | "low" | "medium" | "high";
export interface Task {
id: number;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
due_date: string | null;
note_id: number | null;
tags: string[];
created_at: string;
updated_at: string;
}
export interface TaskListResponse {
tasks: Task[];
total: number;
}
+21
View File
@@ -0,0 +1,21 @@
import { marked } from "marked";
import DOMPurify from "dompurify";
import { linkifyTags, linkifyWikilinks } from "@/utils/tags";
export function renderMarkdown(text: string): string {
const html = marked(text) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
return DOMPurify.sanitize(withLinks, {
ADD_ATTR: ["data-tag", "data-title"],
});
}
export function renderPreview(text: string): string {
const html = marked(text) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
return DOMPurify.sanitize(withLinks, {
FORBID_TAGS: ["a", "img"],
});
}
+49
View File
@@ -0,0 +1,49 @@
const CODE_FENCE_RE = /```[\s\S]*?```|`[^`\n]+`/g;
const TAG_RE = /(?<!\w)#([\w]+(?:\/[\w]+)*)/g;
function escapeHtmlAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function extractTags(body: string): string[] {
const cleaned = body.replace(CODE_FENCE_RE, "");
const tags = new Set<string>();
let match;
while ((match = TAG_RE.exec(cleaned)) !== null) {
tags.add(match[1]);
}
TAG_RE.lastIndex = 0;
return [...tags].sort();
}
export function linkifyTags(html: string): string {
// Split on code/pre blocks to avoid linkifying inside them
const parts = html.split(/(<code[\s\S]*?<\/code>|<pre[\s\S]*?<\/pre>)/gi);
return parts
.map((part, i) => {
// Odd indices are code/pre blocks, skip them
if (i % 2 === 1) return part;
return part.replace(TAG_RE, (full, tag) => {
const encoded = encodeURIComponent(tag);
return `<a class="inline-tag" data-tag="${escapeHtmlAttr(tag)}" href="/notes?tag=${encoded}">${full}</a>`;
});
})
.join("");
}
const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
export function linkifyWikilinks(html: string): string {
const parts = html.split(/(<code[\s\S]*?<\/code>|<pre[\s\S]*?<\/pre>)/gi);
return parts
.map((part, i) => {
if (i % 2 === 1) return part;
return part.replace(WIKILINK_RE, (_full, title: string, display?: string) => {
const trimmed = title.trim();
const label = display || trimmed;
const encoded = encodeURIComponent(trimmed);
return `<a class="wikilink" data-title="${escapeHtmlAttr(trimmed)}" href="/notes/by-title?title=${encoded}">${escapeHtmlAttr(label)}</a>`;
});
})
.join("");
}
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet } from "@/api/client";
import type { Note, NoteListResponse } from "@/types/note";
import type { Task, TaskListResponse } from "@/types/task";
import NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue";
import type { TaskStatus } from "@/types/task";
import { useTasksStore } from "@/stores/tasks";
const recentNotes = ref<Note[]>([]);
const recentTasks = ref<Task[]>([]);
const loading = ref(true);
const tasksStore = useTasksStore();
onMounted(async () => {
try {
const notesData = await apiGet<NoteListResponse>(
"/api/notes?sort=updated_at&order=desc&limit=5"
);
recentNotes.value = notesData.notes;
} catch (e) {
console.error("Failed to load recent notes:", e);
}
try {
const tasksData = await apiGet<TaskListResponse>(
"/api/tasks?sort=updated_at&order=desc&limit=5"
);
recentTasks.value = tasksData.tasks;
} catch (e) {
console.error("Failed to load recent tasks:", e);
}
loading.value = false;
});
function onStatusToggle(id: number, status: TaskStatus) {
tasksStore.patchStatus(id, status).then((updated) => {
const idx = recentTasks.value.findIndex((t) => t.id === updated.id);
if (idx !== -1) {
recentTasks.value[idx] = updated;
}
});
}
</script>
<template>
<main class="home">
<h1>Fabled Assistant</h1>
<p v-if="loading" class="loading">Loading...</p>
<template v-else>
<section class="section">
<div class="section-header">
<h2>Recent Notes</h2>
<router-link to="/notes" class="see-all">See all</router-link>
</div>
<div v-if="recentNotes.length" class="cards">
<NoteCard
v-for="note in recentNotes"
:key="note.id"
:note="note"
/>
</div>
<div v-else class="empty-state">
<p class="empty-text">No notes yet.</p>
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Recent Tasks</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div v-if="recentTasks.length" class="cards">
<TaskCard
v-for="task in recentTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
<div v-else class="empty-state">
<p class="empty-text">No tasks yet.</p>
<router-link to="/tasks/new" class="btn-cta">+ New Task</router-link>
</div>
</section>
</template>
</main>
</template>
<style scoped>
.home {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.home h1 {
margin: 0 0 1.5rem;
}
.loading {
color: var(--color-text-secondary);
}
.section {
margin-bottom: 2rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.section-header h2 {
margin: 0;
font-size: 1.15rem;
}
.see-all {
color: var(--color-primary);
text-decoration: none;
font-size: 0.9rem;
}
.see-all:hover {
text-decoration: underline;
}
.cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.empty-state {
text-align: center;
padding: 1.5rem 0;
}
.empty-text {
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.btn-cta {
display: inline-block;
padding: 0.4rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
</style>
+429
View File
@@ -0,0 +1,429 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAutocomplete } from "@/composables/useAutocomplete";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
const toast = useToastStore();
const title = ref("");
const body = ref("");
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const noteId = computed(() =>
route.params.id ? Number(route.params.id) : null
);
const isEditing = computed(() => noteId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// Autocomplete
const {
acItems,
acVisible,
acIndex,
acTop,
acLeft,
detectTrigger,
accept: acAccept,
dismiss: acDismiss,
onKeydown: acOnKeydown,
} = useAutocomplete(textareaRef, body, (q) => store.fetchAllTags(q));
// Track saved state for dirty detection
let savedTitle = "";
let savedBody = "";
function markDirty() {
dirty.value = title.value !== savedTitle || body.value !== savedBody;
}
function autoGrow() {
const el = textareaRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.max(el.scrollHeight, 200) + "px";
}
function onBodyInput() {
markDirty();
autoGrow();
detectTrigger();
}
function onTextareaKeydown(e: KeyboardEvent) {
acOnKeydown(e);
}
function onTextareaBlur() {
setTimeout(acDismiss, 150);
}
function insertAtCursor(before: string, after: string, placeholder: string) {
const el = textareaRef.value;
if (!el) return;
showPreview.value = false;
nextTick(() => {
el.focus();
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = body.value.slice(start, end);
const insert = selected || placeholder;
body.value =
body.value.slice(0, start) + before + insert + after + body.value.slice(end);
markDirty();
nextTick(() => {
const newStart = start + before.length;
const newEnd = newStart + insert.length;
el.setSelectionRange(newStart, newEnd);
el.focus();
autoGrow();
});
});
}
onMounted(async () => {
if (noteId.value) {
await store.fetchNote(noteId.value);
if (store.currentNote) {
title.value = store.currentNote.title;
body.value = store.currentNote.body;
savedTitle = title.value;
savedBody = body.value;
}
}
nextTick(autoGrow);
});
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,
});
savedTitle = title.value;
savedBody = body.value;
dirty.value = false;
toast.show("Note saved");
} else {
const note = await store.createNote({
title: title.value,
body: body.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("/notes");
} catch {
toast.show("Failed to delete note", "error");
}
}
// Ctrl+S handler
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));
// Unsaved changes guard — in-app navigation
onBeforeRouteLeave(() => {
if (dirty.value) {
return confirm("You have unsaved changes. Leave anyway?");
}
});
// Unsaved changes guard — browser close/reload
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) {
e.preventDefault();
}
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script>
<template>
<main class="editor">
<div class="toolbar">
<router-link to="/notes" 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>
</div>
<input
v-model="title"
type="text"
placeholder="Title"
class="title-input"
@input="markDirty"
/>
<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" @insert="insertAtCursor" />
<div class="textarea-wrapper" v-show="!showPreview">
<textarea
ref="textareaRef"
v-model="body"
placeholder="Write your note in Markdown... Use #tags inline"
class="body-input"
@input="onBodyInput"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
></textarea>
<!-- Autocomplete dropdown -->
<ul
v-if="acVisible"
class="ac-dropdown"
:style="{ top: acTop + 'px', left: acLeft + 'px' }"
>
<li
v-for="(item, i) in acItems"
:key="item.value"
:class="['ac-item', { active: i === acIndex }]"
@mousedown.prevent="acAccept(i)"
>
{{ item.label }}
</li>
</ul>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
></div>
<!-- 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 Note</h3>
<p class="modal-message">Are you sure you want to delete this note? 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>
.editor {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-back {
text-decoration: none;
color: var(--color-primary);
}
.btn-save {
padding: 0.4rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: default;
}
.btn-delete {
padding: 0.4rem 1rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: auto;
}
.title-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
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.4rem 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);
}
.textarea-wrapper {
position: relative;
}
.body-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
font-size: 1rem;
font-family: monospace;
min-height: 200px;
resize: none;
overflow: hidden;
background: var(--color-bg-card);
color: var(--color-text);
box-sizing: border-box;
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
min-height: 200px;
background: var(--color-bg-card);
}
.ac-dropdown {
position: fixed;
z-index: 100;
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
overflow-y: auto;
min-width: 160px;
}
.ac-item {
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
}
.ac-item:hover,
.ac-item.active {
background: var(--color-bg-secondary);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border-radius: 8px;
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.4rem 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
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);
}
</style>
+303
View File
@@ -0,0 +1,303 @@
<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 { apiGet, apiPost } from "@/api/client";
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import StatusBadge from "@/components/StatusBadge.vue";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
const bodyEl = ref<HTMLElement | null>(null);
const linkedTasks = ref<Task[]>([]);
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const converting = ref(false);
const noteId = computed(() => Number(route.params.id));
const hasCompanionTask = computed(() => linkedTasks.value.length > 0);
async function loadNote(id: number) {
linkedTasks.value = [];
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;
}
// Fetch tasks linked to this note & backlinks in parallel
const [taskData, backlinkData] = await Promise.allSettled([
apiGet<TaskListResponse>(`/api/tasks?note_id=${id}`),
store.fetchBacklinks(id),
]);
if (taskData.status === "fulfilled") {
linkedTasks.value = taskData.value.tasks;
}
if (backlinkData.status === "fulfilled") {
backlinks.value = backlinkData.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 renderedBody = computed(() => {
if (!store.currentNote) return "";
return renderMarkdown(store.currentNote.body);
});
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 } });
}
const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
};
async function toggleTaskStatus(task: Task) {
const newStatus = statusCycle[task.status];
try {
const { apiPatch } = await import("@/api/client");
const updated = await apiPatch<Task>(
`/api/tasks/${task.id}/status`,
{ status: newStatus }
);
const idx = linkedTasks.value.findIndex((t) => t.id === task.id);
if (idx !== -1) {
linkedTasks.value[idx] = updated;
}
} catch {
// silently fail
}
}
async function convertToTask() {
if (converting.value) return;
converting.value = true;
try {
const task = await store.convertToTask(noteId.value);
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Converted to task");
router.push(`/tasks/${task.id}`);
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Failed to convert note", "error");
} finally {
converting.value = false;
}
}
</script>
<template>
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentNote">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<router-link
:to="`/notes/${store.currentNote.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
v-if="!hasCompanionTask"
class="btn-convert"
@click="convertToTask"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Task" }}
</button>
</div>
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentNote.updated_at) }}
&middot;
Created {{ relativeTime(store.currentNote.created_at) }}
</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
ref="bodyEl"
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div v-if="linkedTasks.length" class="linked-tasks">
<h2>Companion Task</h2>
<ul class="linked-tasks-list">
<li v-for="task in linkedTasks" :key="task.id" class="linked-task-item">
<StatusBadge
:status="task.status"
clickable
@click="toggleTaskStatus(task)"
/>
<router-link :to="`/tasks/${task.id}`" class="linked-task-link">
{{ task.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Note not found.</p>
</main>
</template>
<style scoped>
.viewer {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.toolbar {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.btn-back,
.btn-edit {
text-decoration: none;
color: var(--color-primary);
}
.btn-convert {
margin-left: auto;
padding: 0.3rem 0.75rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-convert:hover {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.btn-convert:disabled {
opacity: 0.6;
cursor: default;
}
.meta {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.tags {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.linked-tasks,
.backlinks {
margin-top: 2rem;
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.linked-tasks h2,
.backlinks h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
}
.linked-tasks-list,
.backlinks-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.linked-task-item,
.backlink-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.linked-task-link,
.backlink-link {
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
}
.linked-task-link:hover,
.backlink-link:hover {
color: var(--color-primary);
}
.backlink-type {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
</style>
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import SearchBar from "@/components/SearchBar.vue";
import NoteCard from "@/components/NoteCard.vue";
import TagPill from "@/components/TagPill.vue";
import PaginationBar from "@/components/PaginationBar.vue";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
onMounted(() => {
const tag = route.query.tag;
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.setTagFilters(tags);
} else {
store.refresh();
}
});
watch(
() => route.query.tag,
(tag) => {
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.setTagFilters(tags);
}
}
);
function onSearch(q: string) {
store.setSearch(q);
}
function onTagClick(tag: string) {
store.addTagFilter(tag);
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
}
function onTagDismiss(tag: string) {
store.removeTagFilter(tag);
const newQuery = { ...route.query };
if (store.activeTagFilters.length > 0) {
newQuery.tag = store.activeTagFilters;
} else {
delete newQuery.tag;
}
router.replace({ query: newQuery });
}
function onSortChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setSort(value, store.sortOrder);
}
function toggleOrder() {
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
}
function onOffsetUpdate(offset: number) {
store.setOffset(offset);
}
</script>
<template>
<main class="notes-list">
<div class="header">
<h1>Notes</h1>
<router-link to="/notes/new" class="btn-new">+ New Note</router-link>
</div>
<SearchBar @search="onSearch" />
<div class="controls">
<div class="sort-controls">
<select :value="store.sortField" @change="onSortChange" class="sort-select">
<option value="updated_at">Updated</option>
<option value="created_at">Created</option>
<option value="title">Title</option>
</select>
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
{{ store.sortOrder === "asc" ? "\u2191" : "\u2193" }}
</button>
</div>
</div>
<div v-if="store.activeTagFilters.length" class="active-filters">
<span class="filter-label">Filtering by:</span>
<TagPill
v-for="tag in store.activeTagFilters"
:key="tag"
:tag="tag"
dismissible
@dismiss="onTagDismiss"
/>
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<p v-if="store.loading">Loading...</p>
<div
v-else-if="store.notes.length === 0"
class="empty-state"
>
<template v-if="store.searchQuery || store.activeTagFilters.length">
<p class="empty-title">No notes match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
<template v-else>
<p class="empty-title">No notes yet</p>
<p class="empty-subtitle">Create your first note to get started.</p>
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</template>
</div>
<div v-else class="cards">
<NoteCard
v-for="note in store.notes"
:key="note.id"
:note="note"
@tag-click="onTagClick"
/>
</div>
<PaginationBar
:total="store.total"
:limit="store.limit"
:offset="store.offset"
@update:offset="onOffsetUpdate"
/>
</main>
</template>
<style scoped>
.notes-list {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h1 {
margin: 0;
}
.btn-new {
padding: 0.5rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
.controls {
display: flex;
justify-content: flex-end;
margin-top: 0.75rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
}
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.active-filters {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.clear-filters {
background: none;
border: none;
color: var(--color-danger);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
.cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.5rem 1.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
</style>
+537
View File
@@ -0,0 +1,537 @@
<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 { useAutocomplete } from "@/composables/useAutocomplete";
import { apiGet } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Note } from "@/types/note";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const notesStore = useNotesStore();
const toast = useToastStore();
const title = ref("");
const description = ref("");
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const dueDate = ref("");
const noteId = ref<number | null>(null);
const companionNote = ref<Note | null>(null);
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const taskId = computed(() =>
route.params.id ? Number(route.params.id) : null
);
const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(description.value));
// Autocomplete
const {
acItems,
acVisible,
acIndex,
acTop,
acLeft,
detectTrigger,
accept: acAccept,
dismiss: acDismiss,
onKeydown: acOnKeydown,
} = useAutocomplete(textareaRef, description, (q) => notesStore.fetchAllTags(q));
let savedTitle = "";
let savedDescription = "";
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
function markDirty() {
dirty.value =
title.value !== savedTitle ||
description.value !== savedDescription ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate;
}
function autoGrow() {
const el = textareaRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.max(el.scrollHeight, 200) + "px";
}
function onDescriptionInput() {
markDirty();
autoGrow();
detectTrigger();
}
function onTextareaKeydown(e: KeyboardEvent) {
acOnKeydown(e);
}
function onTextareaBlur() {
setTimeout(acDismiss, 150);
}
function insertAtCursor(before: string, after: string, placeholder: string) {
const el = textareaRef.value;
if (!el) return;
showPreview.value = false;
nextTick(() => {
el.focus();
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = description.value.slice(start, end);
const insert = selected || placeholder;
description.value =
description.value.slice(0, start) + before + insert + after + description.value.slice(end);
markDirty();
nextTick(() => {
const newStart = start + before.length;
const newEnd = newStart + insert.length;
el.setSelectionRange(newStart, newEnd);
el.focus();
autoGrow();
});
});
}
onMounted(async () => {
if (taskId.value) {
await store.fetchTask(taskId.value);
if (store.currentTask) {
title.value = store.currentTask.title;
description.value = store.currentTask.description;
status.value = store.currentTask.status;
priority.value = store.currentTask.priority;
dueDate.value = store.currentTask.due_date || "";
noteId.value = store.currentTask.note_id;
savedTitle = title.value;
savedDescription = description.value;
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
if (noteId.value) {
try {
companionNote.value = await apiGet<Note>(`/api/notes/${noteId.value}`);
} catch {
companionNote.value = null;
}
}
}
}
nextTick(autoGrow);
});
async function save() {
if (saving.value) return;
saving.value = true;
try {
const data = {
title: title.value,
description: description.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
savedTitle = title.value;
savedDescription = description.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">
<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>
</div>
<input
v-model="title"
type="text"
placeholder="Task title"
class="title-input"
@input="markDirty"
/>
<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" @insert="insertAtCursor" />
<div class="textarea-wrapper" v-show="!showPreview">
<textarea
ref="textareaRef"
v-model="description"
placeholder="Describe this task... Use #tags inline"
class="body-input"
@input="onDescriptionInput"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
></textarea>
<!-- Autocomplete dropdown -->
<ul
v-if="acVisible"
class="ac-dropdown"
:style="{ top: acTop + 'px', left: acLeft + 'px' }"
>
<li
v-for="(item, i) in acItems"
:key="item.value"
:class="['ac-item', { active: i === acIndex }]"
@mousedown.prevent="acAccept(i)"
>
{{ item.label }}
</li>
</ul>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
></div>
<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 v-if="companionNote" class="field companion-note">
<label>Companion Note</label>
<router-link :to="`/notes/${companionNote.id}`" class="note-link">
{{ companionNote.title || "Untitled" }}
</router-link>
</div>
<!-- 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>
.editor {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-back {
text-decoration: none;
color: var(--color-primary);
}
.btn-save {
padding: 0.4rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: default;
}
.btn-delete {
padding: 0.4rem 1rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: auto;
}
.title-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
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.4rem 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);
}
.textarea-wrapper {
position: relative;
}
.body-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
font-size: 1rem;
font-family: monospace;
min-height: 200px;
resize: none;
overflow: hidden;
background: var(--color-bg-card);
color: var(--color-text);
box-sizing: border-box;
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
min-height: 200px;
background: var(--color-bg-card);
}
.ac-dropdown {
position: fixed;
z-index: 100;
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
overflow-y: auto;
min-width: 160px;
}
.ac-item {
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
}
.ac-item:hover,
.ac-item.active {
background: var(--color-bg-secondary);
}
.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: 4px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
}
.companion-note {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.note-link {
color: var(--color-primary);
text-decoration: none;
font-size: 0.9rem;
}
.note-link:hover {
text-decoration: underline;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border-radius: 8px;
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.4rem 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
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);
}
</style>
+279
View File
@@ -0,0 +1,279 @@
<script setup lang="ts">
import { onMounted, computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime";
import { apiGet, apiPost } from "@/api/client";
import type { Note } from "@/types/note";
import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const notesStore = useNotesStore();
const linkedNote = ref<Note | null>(null);
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const taskId = computed(() => Number(route.params.id));
async function loadTask(id: number) {
linkedNote.value = null;
backlinks.value = [];
await store.fetchTask(id);
if (store.currentTask?.note_id) {
try {
linkedNote.value = await apiGet<Note>(
`/api/notes/${store.currentTask.note_id}`
);
} catch {
linkedNote.value = null;
}
try {
backlinks.value = await notesStore.fetchBacklinks(store.currentTask.note_id);
} catch {
backlinks.value = [];
}
}
}
onMounted(() => loadTask(taskId.value));
watch(() => route.params.id, (newId) => {
if (newId) loadTask(Number(newId));
});
const renderedDescription = computed(() => {
if (!store.currentTask) return "";
return renderMarkdown(store.currentTask.description);
});
const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
};
function cycleStatus() {
if (!store.currentTask) return;
store.patchStatus(
store.currentTask.id,
statusCycle[store.currentTask.status]
);
}
function isOverdue(): boolean {
if (!store.currentTask?.due_date || store.currentTask.status === "done")
return false;
return (
new Date(store.currentTask.due_date) < new Date(new Date().toDateString())
);
}
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: "/tasks", query: { tag } });
}
</script>
<template>
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
</div>
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentTask.updated_at) }}
&middot;
Created {{ relativeTime(store.currentTask.created_at) }}
</p>
<div class="badges">
<StatusBadge
:status="store.currentTask.status"
clickable
@click="cycleStatus"
/>
<PriorityBadge :priority="store.currentTask.priority" />
<span
v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]"
>
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div
v-if="linkedNote"
class="linked-note"
>
<router-link :to="`/notes/${linkedNote.id}`" class="note-link">
View Note
</router-link>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
class="body prose"
v-html="renderedDescription"
@click="onBodyClick"
></div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Task not found.</p>
</main>
</template>
<style scoped>
.viewer {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.toolbar {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.btn-back,
.btn-edit {
text-decoration: none;
color: var(--color-primary);
}
.meta {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.badges {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.due-date {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.due-date.overdue {
color: var(--color-overdue);
font-weight: 600;
}
.linked-note {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
.note-link {
color: var(--color-primary);
text-decoration: none;
}
.note-link:hover {
text-decoration: underline;
}
.tags {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.backlinks {
margin-top: 2rem;
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.backlinks h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
}
.backlinks-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.backlink-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.backlink-link {
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
}
.backlink-link:hover {
color: var(--color-primary);
}
.backlink-type {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import type { TaskStatus } from "@/types/task";
import SearchBar from "@/components/SearchBar.vue";
import TaskCard from "@/components/TaskCard.vue";
import TagPill from "@/components/TagPill.vue";
import PaginationBar from "@/components/PaginationBar.vue";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
onMounted(() => {
const tag = route.query.tag;
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.activeTagFilters = tags;
}
if (route.query.status) {
store.statusFilter = route.query.status as TaskStatus;
}
store.refresh();
});
watch(
() => route.query.tag,
(tag) => {
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.activeTagFilters = tags;
store.offset = 0;
store.refresh();
}
}
);
function onSearch(q: string) {
store.setSearch(q);
}
function onStatusFilterChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setStatusFilter(value as TaskStatus | "");
}
function onPriorityFilterChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setPriorityFilter(value as any);
}
function onTagClick(tag: string) {
store.addTagFilter(tag);
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
}
function onTagDismiss(tag: string) {
store.removeTagFilter(tag);
const newQuery = { ...route.query };
if (store.activeTagFilters.length > 0) {
newQuery.tag = store.activeTagFilters;
} else {
delete newQuery.tag;
}
router.replace({ query: newQuery });
}
function onSortChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setSort(value, store.sortOrder);
}
function toggleOrder() {
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
}
function onStatusToggle(id: number, status: TaskStatus) {
store.patchStatus(id, status);
}
function onOffsetUpdate(offset: number) {
store.setOffset(offset);
}
</script>
<template>
<main class="tasks-list">
<div class="header">
<h1>Tasks</h1>
<router-link to="/tasks/new" class="btn-new">+ New Task</router-link>
</div>
<SearchBar @search="onSearch" />
<div class="controls">
<div class="filter-controls">
<select :value="store.statusFilter" @change="onStatusFilterChange" class="filter-select">
<option value="">All Statuses</option>
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
<select :value="store.priorityFilter" @change="onPriorityFilterChange" class="filter-select">
<option value="">All Priorities</option>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="sort-controls">
<select :value="store.sortField" @change="onSortChange" class="sort-select">
<option value="updated_at">Updated</option>
<option value="created_at">Created</option>
<option value="title">Title</option>
<option value="due_date">Due Date</option>
<option value="priority">Priority</option>
</select>
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
{{ store.sortOrder === "asc" ? "\u2191" : "\u2193" }}
</button>
</div>
</div>
<div v-if="store.activeTagFilters.length" class="active-filters">
<span class="filter-label">Filtering by:</span>
<TagPill
v-for="tag in store.activeTagFilters"
:key="tag"
:tag="tag"
dismissible
@dismiss="onTagDismiss"
/>
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<p v-if="store.loading">Loading...</p>
<div
v-else-if="store.tasks.length === 0"
class="empty-state"
>
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter || store.priorityFilter">
<p class="empty-title">No tasks match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
<template v-else>
<p class="empty-title">No tasks yet</p>
<p class="empty-subtitle">Create your first task to get started.</p>
<router-link to="/tasks/new" class="btn-cta">+ New Task</router-link>
</template>
</div>
<div v-else class="cards">
<TaskCard
v-for="task in store.tasks"
:key="task.id"
:task="task"
@tag-click="onTagClick"
@status-toggle="onStatusToggle"
/>
</div>
<PaginationBar
:total="store.total"
:limit="store.limit"
:offset="store.offset"
@update:offset="onOffsetUpdate"
/>
</main>
</template>
<style scoped>
.tasks-list {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h1 {
margin: 0;
}
.btn-new {
padding: 0.5rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
.controls {
display: flex;
justify-content: space-between;
margin-top: 0.75rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-controls {
display: flex;
gap: 0.5rem;
}
.filter-select,
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.active-filters {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.clear-filters {
background: none;
border: none;
color: var(--color-danger);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
.cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.5rem 1.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}