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:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fabled Assistant</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1571
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "fabledassistant-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"marked": "^15.0.0",
|
||||
"dompurify": "^3.1.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
@@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) }}
|
||||
·
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) }}
|
||||
·
|
||||
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>
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"composite": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:5000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user