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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 23:35:44 -05:00
commit 22a3a3c1d1
71 changed files with 7173 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
POSTGRES_USER=fabled
POSTGRES_PASSWORD=fabled
POSTGRES_DB=fabledassistant
SECRET_KEY=dev-secret-change-me
+29
View File
@@ -0,0 +1,29 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
*.egg
.venv/
venv/
# Node
node_modules/
frontend/dist/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Environment
.env
# Docker
docker-compose.override.yml
# Misc
*.log
.DS_Store
+25
View File
@@ -0,0 +1,25 @@
# Stage 1: Build Vue frontend
FROM node:20-alpine AS build-frontend
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
COPY frontend/ .
RUN npm run build
# Stage 2: Python runtime
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY pyproject.toml .
COPY src/ src/
RUN pip install --no-cache-dir .
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
COPY alembic.ini .
COPY alembic/ alembic/
# Ensure Python finds the source tree (where static files live) before site-packages
ENV PYTHONPATH=/app/src
EXPOSE 5000
CMD ["hypercorn", "fabledassistant.app:create_app()", "--bind", "0.0.0.0:5000"]
+19
View File
@@ -0,0 +1,19 @@
.PHONY: build up down logs health migrate
build:
docker compose build
up:
docker compose up -d
down:
docker compose down
logs:
docker compose logs -f
health:
curl -s http://localhost:5000/api/health
migrate:
docker compose exec app alembic upgrade head
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
prepend_sys_path = src
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+48
View File
@@ -0,0 +1,48 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from fabledassistant.config import Config
from fabledassistant.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=Config.DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
engine = create_async_engine(Config.DATABASE_URL)
async with engine.connect() as connection:
await connection.run_sync(do_run_migrations)
await engine.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
@@ -0,0 +1,76 @@
"""create tasks table
Revision ID: 0002
Revises:
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import ARRAY
revision = "0002"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
task_status = sa.Enum("todo", "in_progress", "done", name="task_status")
task_status.create(op.get_bind(), checkfirst=True)
task_priority = sa.Enum("none", "low", "medium", "high", name="task_priority")
task_priority.create(op.get_bind(), checkfirst=True)
op.create_table(
"tasks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.Text(), nullable=False, server_default=""),
sa.Column("description", sa.Text(), nullable=False, server_default=""),
sa.Column(
"status",
task_status,
nullable=False,
server_default="todo",
),
sa.Column(
"priority",
task_priority,
nullable=False,
server_default="none",
),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column(
"note_id",
sa.Integer(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("tags", ARRAY(sa.Text()), nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_tasks_tags", "tasks", ["tags"], postgresql_using="gin")
op.create_index("ix_tasks_note_id", "tasks", ["note_id"])
op.create_index("ix_tasks_status", "tasks", ["status"])
def downgrade() -> None:
op.drop_index("ix_tasks_status", table_name="tasks")
op.drop_index("ix_tasks_note_id", table_name="tasks")
op.drop_index("ix_tasks_tags", table_name="tasks")
op.drop_table("tasks")
sa.Enum(name="task_priority").drop(op.get_bind(), checkfirst=True)
sa.Enum(name="task_status").drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,48 @@
"""create companion notes for existing tasks
Revision ID: 0003
Revises: 0002
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Find tasks that don't have a companion note
tasks = conn.execute(
sa.text("SELECT id, title, tags FROM tasks WHERE note_id IS NULL")
).fetchall()
for task in tasks:
# Create a companion note
result = conn.execute(
sa.text(
"INSERT INTO notes (title, body, tags, created_at, updated_at) "
"VALUES (:title, '', :tags, NOW(), NOW()) RETURNING id"
),
{"title": task.title, "tags": task.tags},
)
note_id = result.scalar()
# Link the task to its companion note
conn.execute(
sa.text("UPDATE tasks SET note_id = :note_id WHERE id = :task_id"),
{"note_id": note_id, "task_id": task.id},
)
def downgrade() -> None:
# Remove companion notes that were created by this migration
# (notes linked to tasks that were previously unlinked)
# This is a best-effort downgrade - we can't perfectly distinguish
# migration-created notes from user-created ones
pass
+46
View File
@@ -0,0 +1,46 @@
services:
app:
build: .
ports:
- "5000:5000"
depends_on:
db:
condition: service_healthy
ollama:
condition: service_started
environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
OLLAMA_URL: "http://ollama:11434"
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${POSTGRES_USER:-fabled}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled}
POSTGRES_DB: ${POSTGRES_DB:-fabledassistant}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"]
interval: 5s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama
volumes:
- ollama_models:/root/.ollama
# To enable GPU support, uncomment the deploy section below
# (requires nvidia-container-toolkit)
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
volumes:
pgdata:
ollama_models:
+12
View File
@@ -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>
+1571
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme";
useTheme();
</script>
<template>
<AppHeader />
<router-view />
<ToastNotification />
</template>
+50
View File
@@ -0,0 +1,50 @@
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function apiDelete(path: string): Promise<void> {
const res = await fetch(path, { method: "DELETE" });
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
}
+145
View File
@@ -0,0 +1,145 @@
.prose {
line-height: 1.6;
}
.prose h1 {
margin: 1.25rem 0 0.5rem;
font-size: 1.5rem;
}
.prose h2 {
margin: 1rem 0 0.4rem;
font-size: 1.3rem;
}
.prose h3 {
margin: 0.75rem 0 0.3rem;
font-size: 1.1rem;
}
.prose h4,
.prose h5,
.prose h6 {
margin: 0.5rem 0 0.25rem;
}
.prose p {
margin: 0 0 0.6rem;
}
.prose ul,
.prose ol {
margin: 0 0 0.6rem;
padding-left: 1.5rem;
}
.prose li {
margin-bottom: 0.2rem;
}
.prose pre {
background: var(--color-code-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.75rem;
overflow-x: auto;
margin: 0 0 0.6rem;
}
.prose pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: 0.9em;
}
.prose code {
background: var(--color-code-inline-bg);
border-radius: 3px;
padding: 0.15rem 0.35rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 0.9em;
}
.prose table {
border-collapse: collapse;
width: 100%;
margin: 0 0 0.6rem;
}
.prose th,
.prose td {
border: 1px solid var(--color-border);
padding: 0.4rem 0.6rem;
text-align: left;
}
.prose thead th {
background: var(--color-bg-secondary);
font-weight: 600;
}
.prose tbody tr:nth-child(even) {
background: var(--color-table-stripe);
}
.prose blockquote {
border-left: 3px solid var(--color-border);
margin: 0 0 0.6rem;
padding: 0.25rem 0 0.25rem 0.75rem;
color: var(--color-text-secondary);
}
.prose blockquote p:last-child {
margin-bottom: 0;
}
.prose hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 1rem 0;
}
.prose img {
max-width: 100%;
height: auto;
}
.prose a {
color: var(--color-primary);
text-decoration: none;
}
.prose a:hover {
text-decoration: underline;
}
.prose .inline-tag {
color: var(--color-tag-text);
background: var(--color-tag-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
font-size: 0.9em;
}
.prose .inline-tag:hover {
filter: brightness(0.9);
text-decoration: none;
}
.prose .wikilink {
color: var(--color-wikilink);
background: var(--color-wikilink-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
font-size: 0.9em;
cursor: pointer;
}
.prose .wikilink:hover {
filter: brightness(0.9);
text-decoration: none;
}
+87
View File
@@ -0,0 +1,87 @@
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f5f5f5;
--color-bg-card: #ffffff;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-text-muted: #999999;
--color-border: #e0e0e0;
--color-input-border: #cccccc;
--color-primary: #1a73e8;
--color-danger: #d93025;
--color-tag-bg: #e8f0fe;
--color-tag-text: #1a73e8;
--color-shadow: rgba(0, 0, 0, 0.1);
--color-toast-success: #34a853;
--color-toast-error: #d93025;
--color-status-todo: #5f6368;
--color-status-todo-bg: #e8eaed;
--color-status-in-progress: #1a73e8;
--color-status-in-progress-bg: #e8f0fe;
--color-status-done: #34a853;
--color-status-done-bg: #e6f4ea;
--color-priority-low: #5f9ea0;
--color-priority-low-bg: #e0f2f1;
--color-priority-medium: #f9a825;
--color-priority-medium-bg: #fff8e1;
--color-priority-high: #d93025;
--color-priority-high-bg: #fce8e6;
--color-wikilink: #7b1fa2;
--color-wikilink-bg: #f3e5f5;
--color-overdue: #d93025;
--color-code-bg: #f6f8fa;
--color-code-inline-bg: #eff1f3;
--color-table-stripe: #f9f9f9;
}
[data-theme="dark"] {
--color-bg: #1a1a2e;
--color-bg-secondary: #16213e;
--color-bg-card: #1f2940;
--color-text: #e0e0e0;
--color-text-secondary: #a0a0b0;
--color-text-muted: #707080;
--color-border: #2a3a5c;
--color-input-border: #3a4a6c;
--color-primary: #5b9cf6;
--color-danger: #f44336;
--color-tag-bg: #1e3a5f;
--color-tag-text: #7bb8f6;
--color-shadow: rgba(0, 0, 0, 0.3);
--color-toast-success: #4caf50;
--color-toast-error: #f44336;
--color-status-todo: #9aa0a6;
--color-status-todo-bg: #2d333b;
--color-status-in-progress: #5b9cf6;
--color-status-in-progress-bg: #1e3a5f;
--color-status-done: #4caf50;
--color-status-done-bg: #1b3a20;
--color-priority-low: #80cbc4;
--color-priority-low-bg: #1a3a38;
--color-priority-medium: #fdd835;
--color-priority-medium-bg: #3a3520;
--color-priority-high: #f44336;
--color-priority-high-bg: #3a1a1a;
--color-wikilink: #ce93d8;
--color-wikilink-bg: #2a1a30;
--color-overdue: #f44336;
--color-code-bg: #161b22;
--color-code-inline-bg: #2a3040;
--color-table-stripe: #1a2030;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
transition: background-color 0.2s, color 0.2s;
}
View File
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { useTheme } from "@/composables/useTheme";
const { theme, toggleTheme } = useTheme();
</script>
<template>
<header class="app-header">
<nav class="nav">
<router-link to="/" class="nav-brand">Fabled Assistant</router-link>
<div class="nav-links">
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }}
</button>
</div>
</nav>
</header>
</template>
<style scoped>
.app-header {
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.nav {
max-width: 960px;
margin: 0 auto;
padding: 0.5rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand {
font-weight: 700;
font-size: 1.1rem;
color: var(--color-primary);
text-decoration: none;
}
.nav-links {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-link {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.95rem;
}
.nav-link:hover {
color: var(--color-primary);
}
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 1.1rem;
color: var(--color-text);
line-height: 1;
}
.theme-toggle:hover {
background: var(--color-bg-card);
}
</style>
@@ -0,0 +1,53 @@
<script setup lang="ts">
const emit = defineEmits<{
insert: [before: string, after: string, placeholder: string];
}>();
const buttons = [
{ label: "B", before: "**", after: "**", placeholder: "bold", title: "Bold" },
{ label: "I", before: "_", after: "_", placeholder: "italic", title: "Italic" },
{ label: "H1", before: "# ", after: "", placeholder: "Heading", title: "Heading 1" },
{ label: "H2", before: "## ", after: "", placeholder: "Heading", title: "Heading 2" },
{ label: "Link", before: "[", after: "](url)", placeholder: "text", title: "Link" },
{ label: "UL", before: "- ", after: "", placeholder: "item", title: "Bullet List" },
{ label: "OL", before: "1. ", after: "", placeholder: "item", title: "Numbered List" },
{ label: "`", before: "`", after: "`", placeholder: "code", title: "Inline Code" },
{ label: "```", before: "```\n", after: "\n```", placeholder: "code block", title: "Code Block" },
];
</script>
<template>
<div class="md-toolbar">
<button
v-for="btn in buttons"
:key="btn.label"
class="md-btn"
:title="btn.title"
@click="emit('insert', btn.before, btn.after, btn.placeholder)"
>
{{ btn.label }}
</button>
</div>
</template>
<style scoped>
.md-toolbar {
display: flex;
gap: 2px;
flex-wrap: wrap;
}
.md-btn {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 3px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
line-height: 1;
}
.md-btn:hover {
background: var(--color-bg-secondary);
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderPreview } from "@/utils/markdown";
defineProps<{ note: Note }>();
const emit = defineEmits<{ "tag-click": [tag: string] }>();
function onTagClick(tag: string) {
emit("tag-click", tag);
}
</script>
<template>
<router-link :to="`/notes/${note.id}`" class="note-card">
<h3 class="note-title">{{ note.title || "Untitled" }}</h3>
<div v-if="note.body" class="note-preview prose" v-html="renderPreview(note.body)"></div>
<div class="note-meta">
<TagPill
v-for="tag in note.tags"
:key="tag"
:tag="tag"
@click.stop="onTagClick(tag)"
/>
<span class="timestamp">{{ relativeTime(note.updated_at) }}</span>
</div>
</router-link>
</template>
<style scoped>
.note-card {
display: block;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
transition: box-shadow 0.15s;
}
.note-card:hover {
box-shadow: 0 2px 8px var(--color-shadow);
}
.note-title {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.note-preview {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
}
.note-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
total: number;
limit: number;
offset: number;
}>();
const emit = defineEmits<{
"update:offset": [offset: number];
}>();
const totalPages = computed(() => Math.ceil(props.total / props.limit) || 1);
const currentPage = computed(() => Math.floor(props.offset / props.limit) + 1);
const pages = computed(() => {
const total = totalPages.value;
const current = currentPage.value;
const result: (number | "...")[] = [];
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= current - 1 && i <= current + 1)) {
result.push(i);
} else if (result[result.length - 1] !== "...") {
result.push("...");
}
}
return result;
});
function goToPage(page: number) {
emit("update:offset", (page - 1) * props.limit);
}
</script>
<template>
<div v-if="totalPages > 1" class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="goToPage(currentPage - 1)"
>
Prev
</button>
<template v-for="(page, idx) in pages" :key="idx">
<span v-if="page === '...'" class="ellipsis">...</span>
<button
v-else
class="page-btn"
:class="{ active: page === currentPage }"
@click="goToPage(page as number)"
>
{{ page }}
</button>
</template>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="goToPage(currentPage + 1)"
>
Next
</button>
</div>
</template>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
margin-top: 1.5rem;
}
.page-btn {
padding: 0.35rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.85rem;
}
.page-btn:hover:not(:disabled) {
background: var(--color-bg-secondary);
}
.page-btn:disabled {
opacity: 0.4;
cursor: default;
}
.page-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.ellipsis {
padding: 0 0.25rem;
color: var(--color-text-muted);
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { TaskPriority } from "@/types/task";
const props = defineProps<{
priority: TaskPriority;
}>();
const labels: Record<TaskPriority, string> = {
none: "",
low: "Low",
medium: "Medium",
high: "High",
};
</script>
<template>
<span
v-if="props.priority !== 'none'"
:class="['priority-badge', `priority-${props.priority}`]"
>
{{ labels[props.priority] }}
</span>
</template>
<style scoped>
.priority-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.priority-low {
background: var(--color-priority-low-bg);
color: var(--color-priority-low);
}
.priority-medium {
background: var(--color-priority-medium-bg);
color: var(--color-priority-medium);
}
.priority-high {
background: var(--color-priority-high-bg);
color: var(--color-priority-high);
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref, watch } from "vue";
const emit = defineEmits<{ search: [query: string] }>();
const query = ref("");
let timeout: ReturnType<typeof setTimeout>;
watch(query, (val) => {
clearTimeout(timeout);
timeout = setTimeout(() => emit("search", val), 300);
});
</script>
<template>
<input
v-model="query"
type="text"
placeholder="Search notes..."
class="search-input"
/>
</template>
<style scoped>
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
background: var(--color-bg-card);
color: var(--color-text);
}
.search-input::placeholder {
color: var(--color-text-muted);
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { TaskStatus } from "@/types/task";
const props = defineProps<{
status: TaskStatus;
clickable?: boolean;
}>();
defineEmits<{ click: [] }>();
const labels: Record<TaskStatus, string> = {
todo: "Todo",
in_progress: "In Progress",
done: "Done",
};
</script>
<template>
<span
:class="['status-badge', `status-${props.status}`, { clickable }]"
@click="clickable ? $emit('click') : undefined"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
>
{{ labels[props.status] }}
</span>
</template>
<style scoped>
.status-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.status-todo {
background: var(--color-status-todo-bg);
color: var(--color-status-todo);
}
.status-in_progress {
background: var(--color-status-in-progress-bg);
color: var(--color-status-in-progress);
}
.status-done {
background: var(--color-status-done-bg);
color: var(--color-status-done);
}
.clickable {
cursor: pointer;
}
.clickable:hover {
filter: brightness(0.9);
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
defineProps<{
tag: string;
dismissible?: boolean;
}>();
defineEmits<{
click: [tag: string];
dismiss: [tag: string];
}>();
</script>
<template>
<span class="tag-pill" @click="$emit('click', tag)">
#{{ tag }}
<button
v-if="dismissible"
class="dismiss"
@click.stop="$emit('dismiss', tag)"
aria-label="Remove tag"
>
&times;
</button>
</span>
</template>
<style scoped>
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-tag-bg);
color: var(--color-tag-text);
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.tag-pill:hover {
filter: brightness(0.95);
}
.dismiss {
background: none;
border: none;
color: var(--color-tag-text);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 0 0.1rem;
opacity: 0.7;
}
.dismiss:hover {
opacity: 1;
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import type { Task, TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderPreview } from "@/utils/markdown";
const props = defineProps<{ task: Task }>();
const emit = defineEmits<{
"tag-click": [tag: string];
"status-toggle": [id: number, status: TaskStatus];
}>();
const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
};
function cycleStatus() {
emit("status-toggle", props.task.id, statusCycle[props.task.status]);
}
function isOverdue(): boolean {
if (!props.task.due_date || props.task.status === "done") return false;
return new Date(props.task.due_date) < new Date(new Date().toDateString());
}
</script>
<template>
<router-link :to="`/tasks/${task.id}`" class="task-card">
<div class="task-top">
<StatusBadge
:status="task.status"
clickable
@click.prevent.stop="cycleStatus"
/>
<PriorityBadge :priority="task.priority" />
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
</div>
<div v-if="task.description" class="task-preview prose" v-html="renderPreview(task.description)"></div>
<div class="task-meta">
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
Due: {{ task.due_date }}
</span>
<TagPill
v-for="tag in task.tags"
:key="tag"
:tag="tag"
@click.stop="emit('tag-click', tag)"
/>
<span class="timestamp">{{ relativeTime(task.updated_at) }}</span>
</div>
</router-link>
</template>
<style scoped>
.task-card {
display: block;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
transition: box-shadow 0.15s;
}
.task-card:hover {
box-shadow: 0 2px 8px var(--color-shadow);
}
.task-top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.task-title {
margin: 0;
font-size: 1.1rem;
}
.task-preview {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
}
.task-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.due-date.overdue {
color: var(--color-overdue);
font-weight: 600;
}
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useToastStore } from "@/stores/toast";
const toastStore = useToastStore();
</script>
<template>
<div class="toast-container">
<transition-group name="toast">
<div
v-for="toast in toastStore.toasts"
:key="toast.id"
class="toast"
:class="`toast--${toast.type}`"
>
{{ toast.message }}
</div>
</transition-group>
</div>
</template>
<style scoped>
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
padding: 0.75rem 1.25rem;
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
box-shadow: 0 2px 8px var(--color-shadow);
min-width: 200px;
}
.toast--success {
background: var(--color-toast-success);
}
.toast--error {
background: var(--color-toast-error);
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateY(-20px);
}
</style>
+259
View File
@@ -0,0 +1,259 @@
import { ref, type Ref } from "vue";
import { apiGet } from "@/api/client";
import type { NoteListResponse } from "@/types/note";
export interface AutocompleteItem {
label: string;
value: string;
}
export function useAutocomplete(
textareaRef: Ref<HTMLTextAreaElement | null>,
textRef: Ref<string>,
fetchTags: (q: string) => Promise<string[]>
) {
const acItems = ref<AutocompleteItem[]>([]);
const acVisible = ref(false);
const acIndex = ref(0);
const acTop = ref(0);
const acLeft = ref(0);
let acTriggerStart = -1;
let acType: "tag" | "wikilink" | null = null;
let debounceTimer: ReturnType<typeof setTimeout>;
function detectTrigger() {
const el = textareaRef.value;
if (!el) return;
const cursor = el.selectionStart;
const text = textRef.value;
// Check for [[ trigger
for (let i = cursor - 1; i >= 1; i--) {
if (text[i] === "\n") break;
if (text[i - 1] === "[" && text[i] === "[") {
const query = text.slice(i + 1, cursor);
if (!/\]/.test(query)) {
acTriggerStart = i - 1;
acType = "wikilink";
debouncedSearch(query);
return;
}
break;
}
}
// Check for # trigger
for (let i = cursor - 1; i >= 0; i--) {
const ch = text[i];
if (ch === "\n" || ch === " " || ch === "\t") break;
if (ch === "#") {
// Must be start of line or preceded by whitespace
if (i === 0 || /\s/.test(text[i - 1])) {
const query = text.slice(i + 1, cursor);
if (/^[\w/]*$/.test(query)) {
acTriggerStart = i;
acType = "tag";
debouncedSearch(query);
return;
}
}
break;
}
}
dismiss();
}
function debouncedSearch(query: string) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(query), 200);
}
async function doSearch(query: string) {
if (acType === "tag") {
try {
const tags = await fetchTags(query);
acItems.value = tags.slice(0, 8).map((t) => ({
label: `#${t}`,
value: t,
}));
} catch (e) {
console.error("Tag autocomplete fetch failed:", e);
acItems.value = [];
}
} else if (acType === "wikilink") {
try {
const data = await apiGet<NoteListResponse>(
`/api/notes?q=${encodeURIComponent(query)}&limit=8`
);
acItems.value = data.notes.map((n) => ({
label: n.title || "Untitled",
value: n.title || "Untitled",
}));
} catch (e) {
console.error("Wikilink autocomplete fetch failed:", e);
acItems.value = [];
}
}
if (acItems.value.length > 0) {
acIndex.value = 0;
positionDropdown();
acVisible.value = true;
} else if (acType === "tag") {
// Show "no tags" hint so the user knows the trigger is working
acItems.value = [{ label: "No tags yet", value: "" }];
acIndex.value = -1;
positionDropdown();
acVisible.value = true;
} else {
acVisible.value = false;
}
}
function positionDropdown() {
const el = textareaRef.value;
if (!el) return;
// Use a mirror div to measure cursor position
const mirror = document.createElement("div");
const style = getComputedStyle(el);
const props = [
"fontFamily",
"fontSize",
"fontWeight",
"lineHeight",
"letterSpacing",
"wordSpacing",
"textIndent",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"boxSizing",
"whiteSpace",
"wordWrap",
"overflowWrap",
] as const;
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.overflow = "hidden";
mirror.style.width = style.width;
for (const prop of props) {
mirror.style[prop] = style[prop];
}
const textBefore = el.value.slice(0, el.selectionStart);
mirror.textContent = textBefore;
const span = document.createElement("span");
span.textContent = "|";
mirror.appendChild(span);
document.body.appendChild(mirror);
const rect = el.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
const mirrorRect = mirror.getBoundingClientRect();
acTop.value =
rect.top + (spanRect.top - mirrorRect.top) - el.scrollTop + 20;
acLeft.value =
rect.left + (spanRect.left - mirrorRect.left) - el.scrollLeft;
document.body.removeChild(mirror);
}
function accept(index?: number) {
const el = textareaRef.value;
if (!el || !acVisible.value) return;
const idx = index ?? acIndex.value;
const item = acItems.value[idx];
if (!item || !item.value) return;
const cursor = el.selectionStart;
const text = textRef.value;
let replacement: string;
let end = cursor;
if (acType === "tag") {
replacement = `#${item.value} `;
} else {
replacement = `[[${item.value}]]`;
}
textRef.value =
text.slice(0, acTriggerStart) + replacement + text.slice(end);
const newPos = acTriggerStart + replacement.length;
dismiss();
// Restore cursor position after Vue updates the DOM
requestAnimationFrame(() => {
el.setSelectionRange(newPos, newPos);
el.focus();
});
}
function dismiss() {
acVisible.value = false;
acItems.value = [];
acType = null;
acTriggerStart = -1;
clearTimeout(debounceTimer);
}
function onKeydown(e: KeyboardEvent): boolean {
if (!acVisible.value) return false;
if (e.key === "ArrowDown") {
e.preventDefault();
acIndex.value = (acIndex.value + 1) % acItems.value.length;
return true;
}
if (e.key === "ArrowUp") {
e.preventDefault();
acIndex.value =
(acIndex.value - 1 + acItems.value.length) % acItems.value.length;
return true;
}
if (e.key === "Tab") {
e.preventDefault();
// Single match: accept immediately. Multiple: cycle then accept on second Tab.
if (acItems.value.length === 1) {
accept();
} else {
acIndex.value = (acIndex.value + 1) % acItems.value.length;
}
return true;
}
if (e.key === "Enter") {
e.preventDefault();
accept();
return true;
}
if (e.key === "Escape") {
e.preventDefault();
dismiss();
return true;
}
return false;
}
return {
acItems,
acVisible,
acIndex,
acTop,
acLeft,
detectTrigger,
accept,
dismiss,
onKeydown,
};
}
@@ -0,0 +1,11 @@
export function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
+29
View File
@@ -0,0 +1,29 @@
import { ref } from "vue";
type Theme = "light" | "dark";
const theme = ref<Theme>(getInitialTheme());
function getInitialTheme(): Theme {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
if (window.matchMedia("(prefers-color-scheme: light)").matches) return "light";
return "dark";
}
function applyTheme(t: Theme) {
document.documentElement.setAttribute("data-theme", t);
localStorage.setItem("theme", t);
}
export function useTheme() {
// Apply on first use
applyTheme(theme.value);
function toggleTheme() {
theme.value = theme.value === "dark" ? "light" : "dark";
applyTheme(theme.value);
}
return { theme, toggleTheme };
}
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./assets/theme.css";
import "./assets/prose.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
+55
View File
@@ -0,0 +1,55 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "@/views/HomeView.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/notes",
name: "notes",
component: () => import("@/views/NotesListView.vue"),
},
{
path: "/notes/new",
name: "note-new",
component: () => import("@/views/NoteEditorView.vue"),
},
{
path: "/notes/:id",
name: "note-view",
component: () => import("@/views/NoteViewerView.vue"),
},
{
path: "/notes/:id/edit",
name: "note-edit",
component: () => import("@/views/NoteEditorView.vue"),
},
{
path: "/tasks",
name: "tasks",
component: () => import("@/views/TasksListView.vue"),
},
{
path: "/tasks/new",
name: "task-new",
component: () => import("@/views/TaskEditorView.vue"),
},
{
path: "/tasks/:id",
name: "task-view",
component: () => import("@/views/TaskViewerView.vue"),
},
{
path: "/tasks/:id/edit",
name: "task-edit",
component: () => import("@/views/TaskEditorView.vue"),
},
],
});
export default router;
View File
+202
View File
@@ -0,0 +1,202 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import type { Note, NoteListResponse } from "@/types/note";
export const useNotesStore = defineStore("notes", () => {
const notes = ref<Note[]>([]);
const currentNote = ref<Note | null>(null);
const total = ref(0);
const loading = ref(false);
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
const sortOrder = ref<"asc" | "desc">("desc");
const searchQuery = ref("");
async function refresh() {
loading.value = true;
try {
const searchParams = new URLSearchParams();
if (searchQuery.value) searchParams.set("q", searchQuery.value);
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
searchParams.set("offset", String(offset.value));
const qs = searchParams.toString();
const data = await apiGet<NoteListResponse>(
`/api/notes${qs ? `?${qs}` : ""}`
);
notes.value = data.notes;
total.value = data.total;
} finally {
loading.value = false;
}
}
async function fetchNotes(params?: {
q?: string;
tag?: string[];
sort?: string;
order?: string;
limit?: number;
offset?: number;
}) {
if (params?.q !== undefined) searchQuery.value = params.q || "";
if (params?.tag) activeTagFilters.value = params.tag;
if (params?.sort) sortField.value = params.sort;
if (params?.order) sortOrder.value = params.order as "asc" | "desc";
if (params?.limit) limit.value = params.limit;
if (params?.offset !== undefined) offset.value = params.offset;
await refresh();
}
async function fetchNote(id: number) {
loading.value = true;
try {
currentNote.value = await apiGet<Note>(`/api/notes/${id}`);
} finally {
loading.value = false;
}
}
async function createNote(data: {
title: string;
body: string;
}): Promise<Note> {
const note = await apiPost<Note>("/api/notes", data);
return note;
}
async function updateNote(
id: number,
data: Partial<Pick<Note, "title" | "body">>
): Promise<Note> {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
if (currentNote.value?.id === id) {
currentNote.value = note;
}
return note;
}
async function deleteNote(id: number) {
await apiDelete(`/api/notes/${id}`);
notes.value = notes.value.filter((n) => n.id !== id);
if (currentNote.value?.id === id) {
currentNote.value = null;
}
}
function addTagFilter(tag: string) {
if (!activeTagFilters.value.includes(tag)) {
activeTagFilters.value.push(tag);
offset.value = 0;
refresh();
}
}
function removeTagFilter(tag: string) {
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
offset.value = 0;
refresh();
}
function clearTagFilters() {
activeTagFilters.value = [];
offset.value = 0;
refresh();
}
function setTagFilters(tags: string[]) {
activeTagFilters.value = [...tags];
offset.value = 0;
refresh();
}
function setSort(field: string, order: "asc" | "desc") {
sortField.value = field;
sortOrder.value = order;
offset.value = 0;
refresh();
}
function setOffset(newOffset: number) {
offset.value = newOffset;
refresh();
}
function setSearch(q: string) {
searchQuery.value = q;
offset.value = 0;
refresh();
}
async function resolveTitle(title: string): Promise<Note> {
return await apiPost<Note>("/api/notes/resolve-title", { title });
}
async function convertToTask(noteId: number) {
const task = await apiPost<Record<string, unknown>>(
`/api/notes/${noteId}/convert-to-task`,
{}
);
// Remove the note from local state since it was converted
notes.value = notes.value.filter((n) => n.id !== noteId);
if (currentNote.value?.id === noteId) {
currentNote.value = null;
}
return task;
}
async function fetchBacklinks(
noteId: number
): Promise<{ type: string; id: number; title: string }[]> {
const data = await apiGet<{
backlinks: { type: string; id: number; title: string }[];
}>(`/api/notes/${noteId}/backlinks`);
return data.backlinks;
}
async function fetchAllTags(q?: string): Promise<string[]> {
const params = q ? `?q=${encodeURIComponent(q)}` : "";
const data = await apiGet<{ tags: string[] }>(`/api/notes/tags${params}`);
return data.tags;
}
return {
notes,
currentNote,
total,
loading,
activeTagFilters,
limit,
offset,
sortField,
sortOrder,
searchQuery,
fetchNotes,
fetchNote,
createNote,
updateNote,
deleteNote,
addTagFilter,
removeTagFilter,
clearTagFilters,
setTagFilters,
setSort,
setOffset,
setSearch,
refresh,
resolveTitle,
convertToTask,
fetchBacklinks,
fetchAllTags,
};
});
+180
View File
@@ -0,0 +1,180 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task";
export const useTasksStore = defineStore("tasks", () => {
const tasks = ref<Task[]>([]);
const currentTask = ref<Task | null>(null);
const total = ref(0);
const loading = ref(false);
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const statusFilter = ref<TaskStatus | "">("");
const priorityFilter = ref<TaskPriority | "">("");
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
const sortOrder = ref<"asc" | "desc">("desc");
const searchQuery = ref("");
async function refresh() {
loading.value = true;
try {
const searchParams = new URLSearchParams();
if (searchQuery.value) searchParams.set("q", searchQuery.value);
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
if (statusFilter.value) searchParams.set("status", statusFilter.value);
if (priorityFilter.value)
searchParams.set("priority", priorityFilter.value);
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
searchParams.set("offset", String(offset.value));
const qs = searchParams.toString();
const data = await apiGet<TaskListResponse>(
`/api/tasks${qs ? `?${qs}` : ""}`
);
tasks.value = data.tasks;
total.value = data.total;
} finally {
loading.value = false;
}
}
async function fetchTask(id: number) {
loading.value = true;
try {
currentTask.value = await apiGet<Task>(`/api/tasks/${id}`);
} finally {
loading.value = false;
}
}
async function createTask(data: {
title: string;
description: string;
status?: TaskStatus;
priority?: TaskPriority;
due_date?: string | null;
}): Promise<Task> {
return await apiPost<Task>("/api/tasks", data);
}
async function updateTask(
id: number,
data: Partial<
Pick<Task, "title" | "description" | "status" | "priority" | "due_date">
>
): Promise<Task> {
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
}
async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
// Update in list if present
const idx = tasks.value.findIndex((t) => t.id === id);
if (idx !== -1) {
tasks.value[idx] = task;
}
if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
}
async function deleteTask(id: number) {
await apiDelete(`/api/tasks/${id}`);
tasks.value = tasks.value.filter((t) => t.id !== id);
if (currentTask.value?.id === id) {
currentTask.value = null;
}
}
function setStatusFilter(status: TaskStatus | "") {
statusFilter.value = status;
offset.value = 0;
refresh();
}
function setPriorityFilter(priority: TaskPriority | "") {
priorityFilter.value = priority;
offset.value = 0;
refresh();
}
function addTagFilter(tag: string) {
if (!activeTagFilters.value.includes(tag)) {
activeTagFilters.value.push(tag);
offset.value = 0;
refresh();
}
}
function removeTagFilter(tag: string) {
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
offset.value = 0;
refresh();
}
function clearTagFilters() {
activeTagFilters.value = [];
offset.value = 0;
refresh();
}
function setSort(field: string, order: "asc" | "desc") {
sortField.value = field;
sortOrder.value = order;
offset.value = 0;
refresh();
}
function setOffset(newOffset: number) {
offset.value = newOffset;
refresh();
}
function setSearch(q: string) {
searchQuery.value = q;
offset.value = 0;
refresh();
}
return {
tasks,
currentTask,
total,
loading,
activeTagFilters,
statusFilter,
priorityFilter,
limit,
offset,
sortField,
sortOrder,
searchQuery,
refresh,
fetchTask,
createTask,
updateTask,
patchStatus,
deleteTask,
setStatusFilter,
setPriorityFilter,
addTagFilter,
removeTagFilter,
clearTagFilters,
setSort,
setOffset,
setSearch,
};
});
+24
View File
@@ -0,0 +1,24 @@
import { ref } from "vue";
import { defineStore } from "pinia";
export interface Toast {
id: number;
message: string;
type: "success" | "error";
}
let nextId = 0;
export const useToastStore = defineStore("toast", () => {
const toasts = ref<Toast[]>([]);
function show(message: string, type: "success" | "error" = "success") {
const id = nextId++;
toasts.value.push({ id, message, type });
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id);
}, 3000);
}
return { toasts, show };
});
+14
View File
@@ -0,0 +1,14 @@
export interface Note {
id: number;
title: string;
body: string;
tags: string[];
parent_id: number | null;
created_at: string;
updated_at: string;
}
export interface NoteListResponse {
notes: Note[];
total: number;
}
+20
View File
@@ -0,0 +1,20 @@
export type TaskStatus = "todo" | "in_progress" | "done";
export type TaskPriority = "none" | "low" | "medium" | "high";
export interface Task {
id: number;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
due_date: string | null;
note_id: number | null;
tags: string[];
created_at: string;
updated_at: string;
}
export interface TaskListResponse {
tasks: Task[];
total: number;
}
+21
View File
@@ -0,0 +1,21 @@
import { marked } from "marked";
import DOMPurify from "dompurify";
import { linkifyTags, linkifyWikilinks } from "@/utils/tags";
export function renderMarkdown(text: string): string {
const html = marked(text) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
return DOMPurify.sanitize(withLinks, {
ADD_ATTR: ["data-tag", "data-title"],
});
}
export function renderPreview(text: string): string {
const html = marked(text) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
return DOMPurify.sanitize(withLinks, {
FORBID_TAGS: ["a", "img"],
});
}
+49
View File
@@ -0,0 +1,49 @@
const CODE_FENCE_RE = /```[\s\S]*?```|`[^`\n]+`/g;
const TAG_RE = /(?<!\w)#([\w]+(?:\/[\w]+)*)/g;
function escapeHtmlAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function extractTags(body: string): string[] {
const cleaned = body.replace(CODE_FENCE_RE, "");
const tags = new Set<string>();
let match;
while ((match = TAG_RE.exec(cleaned)) !== null) {
tags.add(match[1]);
}
TAG_RE.lastIndex = 0;
return [...tags].sort();
}
export function linkifyTags(html: string): string {
// Split on code/pre blocks to avoid linkifying inside them
const parts = html.split(/(<code[\s\S]*?<\/code>|<pre[\s\S]*?<\/pre>)/gi);
return parts
.map((part, i) => {
// Odd indices are code/pre blocks, skip them
if (i % 2 === 1) return part;
return part.replace(TAG_RE, (full, tag) => {
const encoded = encodeURIComponent(tag);
return `<a class="inline-tag" data-tag="${escapeHtmlAttr(tag)}" href="/notes?tag=${encoded}">${full}</a>`;
});
})
.join("");
}
const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
export function linkifyWikilinks(html: string): string {
const parts = html.split(/(<code[\s\S]*?<\/code>|<pre[\s\S]*?<\/pre>)/gi);
return parts
.map((part, i) => {
if (i % 2 === 1) return part;
return part.replace(WIKILINK_RE, (_full, title: string, display?: string) => {
const trimmed = title.trim();
const label = display || trimmed;
const encoded = encodeURIComponent(trimmed);
return `<a class="wikilink" data-title="${escapeHtmlAttr(trimmed)}" href="/notes/by-title?title=${encoded}">${escapeHtmlAttr(label)}</a>`;
});
})
.join("");
}
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet } from "@/api/client";
import type { Note, NoteListResponse } from "@/types/note";
import type { Task, TaskListResponse } from "@/types/task";
import NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue";
import type { TaskStatus } from "@/types/task";
import { useTasksStore } from "@/stores/tasks";
const recentNotes = ref<Note[]>([]);
const recentTasks = ref<Task[]>([]);
const loading = ref(true);
const tasksStore = useTasksStore();
onMounted(async () => {
try {
const notesData = await apiGet<NoteListResponse>(
"/api/notes?sort=updated_at&order=desc&limit=5"
);
recentNotes.value = notesData.notes;
} catch (e) {
console.error("Failed to load recent notes:", e);
}
try {
const tasksData = await apiGet<TaskListResponse>(
"/api/tasks?sort=updated_at&order=desc&limit=5"
);
recentTasks.value = tasksData.tasks;
} catch (e) {
console.error("Failed to load recent tasks:", e);
}
loading.value = false;
});
function onStatusToggle(id: number, status: TaskStatus) {
tasksStore.patchStatus(id, status).then((updated) => {
const idx = recentTasks.value.findIndex((t) => t.id === updated.id);
if (idx !== -1) {
recentTasks.value[idx] = updated;
}
});
}
</script>
<template>
<main class="home">
<h1>Fabled Assistant</h1>
<p v-if="loading" class="loading">Loading...</p>
<template v-else>
<section class="section">
<div class="section-header">
<h2>Recent Notes</h2>
<router-link to="/notes" class="see-all">See all</router-link>
</div>
<div v-if="recentNotes.length" class="cards">
<NoteCard
v-for="note in recentNotes"
:key="note.id"
:note="note"
/>
</div>
<div v-else class="empty-state">
<p class="empty-text">No notes yet.</p>
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Recent Tasks</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div v-if="recentTasks.length" class="cards">
<TaskCard
v-for="task in recentTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
<div v-else class="empty-state">
<p class="empty-text">No tasks yet.</p>
<router-link to="/tasks/new" class="btn-cta">+ New Task</router-link>
</div>
</section>
</template>
</main>
</template>
<style scoped>
.home {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.home h1 {
margin: 0 0 1.5rem;
}
.loading {
color: var(--color-text-secondary);
}
.section {
margin-bottom: 2rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.section-header h2 {
margin: 0;
font-size: 1.15rem;
}
.see-all {
color: var(--color-primary);
text-decoration: none;
font-size: 0.9rem;
}
.see-all:hover {
text-decoration: underline;
}
.cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.empty-state {
text-align: center;
padding: 1.5rem 0;
}
.empty-text {
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.btn-cta {
display: inline-block;
padding: 0.4rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
</style>
+429
View File
@@ -0,0 +1,429 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAutocomplete } from "@/composables/useAutocomplete";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
const toast = useToastStore();
const title = ref("");
const body = ref("");
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const noteId = computed(() =>
route.params.id ? Number(route.params.id) : null
);
const isEditing = computed(() => noteId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// Autocomplete
const {
acItems,
acVisible,
acIndex,
acTop,
acLeft,
detectTrigger,
accept: acAccept,
dismiss: acDismiss,
onKeydown: acOnKeydown,
} = useAutocomplete(textareaRef, body, (q) => store.fetchAllTags(q));
// Track saved state for dirty detection
let savedTitle = "";
let savedBody = "";
function markDirty() {
dirty.value = title.value !== savedTitle || body.value !== savedBody;
}
function autoGrow() {
const el = textareaRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.max(el.scrollHeight, 200) + "px";
}
function onBodyInput() {
markDirty();
autoGrow();
detectTrigger();
}
function onTextareaKeydown(e: KeyboardEvent) {
acOnKeydown(e);
}
function onTextareaBlur() {
setTimeout(acDismiss, 150);
}
function insertAtCursor(before: string, after: string, placeholder: string) {
const el = textareaRef.value;
if (!el) return;
showPreview.value = false;
nextTick(() => {
el.focus();
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = body.value.slice(start, end);
const insert = selected || placeholder;
body.value =
body.value.slice(0, start) + before + insert + after + body.value.slice(end);
markDirty();
nextTick(() => {
const newStart = start + before.length;
const newEnd = newStart + insert.length;
el.setSelectionRange(newStart, newEnd);
el.focus();
autoGrow();
});
});
}
onMounted(async () => {
if (noteId.value) {
await store.fetchNote(noteId.value);
if (store.currentNote) {
title.value = store.currentNote.title;
body.value = store.currentNote.body;
savedTitle = title.value;
savedBody = body.value;
}
}
nextTick(autoGrow);
});
async function save() {
if (saving.value) return;
saving.value = true;
try {
if (isEditing.value) {
await store.updateNote(noteId.value!, {
title: title.value,
body: body.value,
});
savedTitle = title.value;
savedBody = body.value;
dirty.value = false;
toast.show("Note saved");
} else {
const note = await store.createNote({
title: title.value,
body: body.value,
});
dirty.value = false;
toast.show("Note created");
router.push(`/notes/${note.id}`);
}
} catch {
toast.show("Failed to save note", "error");
} finally {
saving.value = false;
}
}
const showDeleteConfirm = ref(false);
function remove() {
if (noteId.value) {
showDeleteConfirm.value = true;
}
}
async function confirmDelete() {
showDeleteConfirm.value = false;
if (!noteId.value) return;
try {
await store.deleteNote(noteId.value);
dirty.value = false;
toast.show("Note deleted");
router.push("/notes");
} catch {
toast.show("Failed to delete note", "error");
}
}
// Ctrl+S handler
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
// Unsaved changes guard — in-app navigation
onBeforeRouteLeave(() => {
if (dirty.value) {
return confirm("You have unsaved changes. Leave anyway?");
}
});
// Unsaved changes guard — browser close/reload
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) {
e.preventDefault();
}
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script>
<template>
<main class="editor">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
Delete
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Title"
class="title-input"
@input="markDirty"
/>
<div class="editor-tabs">
<button
:class="['tab', { active: !showPreview }]"
@click="showPreview = false"
>
Write
</button>
<button
:class="['tab', { active: showPreview }]"
@click="showPreview = true"
>
Preview
</button>
</div>
<MarkdownToolbar v-show="!showPreview" @insert="insertAtCursor" />
<div class="textarea-wrapper" v-show="!showPreview">
<textarea
ref="textareaRef"
v-model="body"
placeholder="Write your note in Markdown... Use #tags inline"
class="body-input"
@input="onBodyInput"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
></textarea>
<!-- Autocomplete dropdown -->
<ul
v-if="acVisible"
class="ac-dropdown"
:style="{ top: acTop + 'px', left: acLeft + 'px' }"
>
<li
v-for="(item, i) in acItems"
:key="item.value"
:class="['ac-item', { active: i === acIndex }]"
@mousedown.prevent="acAccept(i)"
>
{{ item.label }}
</li>
</ul>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
></div>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
<div class="modal-card" @click.stop>
<h3 class="modal-title">Delete Note</h3>
<p class="modal-message">Are you sure you want to delete this note? This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
</main>
</template>
<style scoped>
.editor {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-back {
text-decoration: none;
color: var(--color-primary);
}
.btn-save {
padding: 0.4rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: default;
}
.btn-delete {
padding: 0.4rem 1rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: auto;
}
.title-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
font-size: 1.25rem;
font-weight: 600;
background: var(--color-bg-card);
color: var(--color-text);
}
.editor-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
}
.tab {
padding: 0.4rem 1rem;
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
}
.tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.textarea-wrapper {
position: relative;
}
.body-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
font-size: 1rem;
font-family: monospace;
min-height: 200px;
resize: none;
overflow: hidden;
background: var(--color-bg-card);
color: var(--color-text);
box-sizing: border-box;
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
min-height: 200px;
background: var(--color-bg-card);
}
.ac-dropdown {
position: fixed;
z-index: 100;
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
overflow-y: auto;
min-width: 160px;
}
.ac-item {
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
}
.ac-item:hover,
.ac-item.active {
background: var(--color-bg-secondary);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border-radius: 8px;
padding: 1.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-btn {
padding: 0.4rem 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-danger {
background: var(--color-danger);
color: #fff;
border-color: var(--color-danger);
}
</style>
+303
View File
@@ -0,0 +1,303 @@
<script setup lang="ts">
import { onMounted, computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime";
import { apiGet, apiPost } from "@/api/client";
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import StatusBadge from "@/components/StatusBadge.vue";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
const bodyEl = ref<HTMLElement | null>(null);
const linkedTasks = ref<Task[]>([]);
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const converting = ref(false);
const noteId = computed(() => Number(route.params.id));
const hasCompanionTask = computed(() => linkedTasks.value.length > 0);
async function loadNote(id: number) {
linkedTasks.value = [];
backlinks.value = [];
await store.fetchNote(id);
// If note is empty (no body), redirect to editor
if (store.currentNote && !store.currentNote.body.trim()) {
router.replace(`/notes/${id}/edit`);
return;
}
// Fetch tasks linked to this note & backlinks in parallel
const [taskData, backlinkData] = await Promise.allSettled([
apiGet<TaskListResponse>(`/api/tasks?note_id=${id}`),
store.fetchBacklinks(id),
]);
if (taskData.status === "fulfilled") {
linkedTasks.value = taskData.value.tasks;
}
if (backlinkData.status === "fulfilled") {
backlinks.value = backlinkData.value;
}
}
onMounted(() => loadNote(noteId.value));
// Re-fetch when navigating between notes (Vue reuses the component)
watch(() => route.params.id, (newId) => {
if (newId) loadNote(Number(newId));
});
const renderedBody = computed(() => {
if (!store.currentNote) return "";
return renderMarkdown(store.currentNote.body);
});
async function onBodyClick(e: MouseEvent) {
const target = e.target as HTMLElement;
const tagLink = target.closest(".inline-tag") as HTMLAnchorElement | null;
if (tagLink) {
e.preventDefault();
const tag = tagLink.dataset.tag;
if (tag) {
router.push({ path: "/notes", query: { tag } });
}
return;
}
const wikilink = target.closest(".wikilink") as HTMLAnchorElement | null;
if (wikilink) {
e.preventDefault();
const title = wikilink.dataset.title;
if (title) {
try {
const note = await apiPost<Note>(
"/api/notes/resolve-title",
{ title }
);
router.push(`/notes/${note.id}`);
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show(`Failed to resolve note "${title}"`, "error");
}
}
}
}
function onTagClick(tag: string) {
router.push({ path: "/notes", query: { tag } });
}
const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
};
async function toggleTaskStatus(task: Task) {
const newStatus = statusCycle[task.status];
try {
const { apiPatch } = await import("@/api/client");
const updated = await apiPatch<Task>(
`/api/tasks/${task.id}/status`,
{ status: newStatus }
);
const idx = linkedTasks.value.findIndex((t) => t.id === task.id);
if (idx !== -1) {
linkedTasks.value[idx] = updated;
}
} catch {
// silently fail
}
}
async function convertToTask() {
if (converting.value) return;
converting.value = true;
try {
const task = await store.convertToTask(noteId.value);
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Converted to task");
router.push(`/tasks/${task.id}`);
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Failed to convert note", "error");
} finally {
converting.value = false;
}
}
</script>
<template>
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentNote">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<router-link
:to="`/notes/${store.currentNote.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
<button
v-if="!hasCompanionTask"
class="btn-convert"
@click="convertToTask"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Task" }}
</button>
</div>
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentNote.updated_at) }}
&middot;
Created {{ relativeTime(store.currentNote.created_at) }}
</p>
<div class="tags" v-if="store.currentNote.tags.length">
<TagPill
v-for="tag in store.currentNote.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
ref="bodyEl"
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div v-if="linkedTasks.length" class="linked-tasks">
<h2>Companion Task</h2>
<ul class="linked-tasks-list">
<li v-for="task in linkedTasks" :key="task.id" class="linked-task-item">
<StatusBadge
:status="task.status"
clickable
@click="toggleTaskStatus(task)"
/>
<router-link :to="`/tasks/${task.id}`" class="linked-task-link">
{{ task.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Note not found.</p>
</main>
</template>
<style scoped>
.viewer {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.toolbar {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.btn-back,
.btn-edit {
text-decoration: none;
color: var(--color-primary);
}
.btn-convert {
margin-left: auto;
padding: 0.3rem 0.75rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-convert:hover {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.btn-convert:disabled {
opacity: 0.6;
cursor: default;
}
.meta {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.tags {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.linked-tasks,
.backlinks {
margin-top: 2rem;
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.linked-tasks h2,
.backlinks h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
}
.linked-tasks-list,
.backlinks-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.linked-task-item,
.backlink-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.linked-task-link,
.backlink-link {
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
}
.linked-task-link:hover,
.backlink-link:hover {
color: var(--color-primary);
}
.backlink-type {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
</style>
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import SearchBar from "@/components/SearchBar.vue";
import NoteCard from "@/components/NoteCard.vue";
import TagPill from "@/components/TagPill.vue";
import PaginationBar from "@/components/PaginationBar.vue";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
onMounted(() => {
const tag = route.query.tag;
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.setTagFilters(tags);
} else {
store.refresh();
}
});
watch(
() => route.query.tag,
(tag) => {
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.setTagFilters(tags);
}
}
);
function onSearch(q: string) {
store.setSearch(q);
}
function onTagClick(tag: string) {
store.addTagFilter(tag);
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
}
function onTagDismiss(tag: string) {
store.removeTagFilter(tag);
const newQuery = { ...route.query };
if (store.activeTagFilters.length > 0) {
newQuery.tag = store.activeTagFilters;
} else {
delete newQuery.tag;
}
router.replace({ query: newQuery });
}
function onSortChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setSort(value, store.sortOrder);
}
function toggleOrder() {
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
}
function onOffsetUpdate(offset: number) {
store.setOffset(offset);
}
</script>
<template>
<main class="notes-list">
<div class="header">
<h1>Notes</h1>
<router-link to="/notes/new" class="btn-new">+ New Note</router-link>
</div>
<SearchBar @search="onSearch" />
<div class="controls">
<div class="sort-controls">
<select :value="store.sortField" @change="onSortChange" class="sort-select">
<option value="updated_at">Updated</option>
<option value="created_at">Created</option>
<option value="title">Title</option>
</select>
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
{{ store.sortOrder === "asc" ? "\u2191" : "\u2193" }}
</button>
</div>
</div>
<div v-if="store.activeTagFilters.length" class="active-filters">
<span class="filter-label">Filtering by:</span>
<TagPill
v-for="tag in store.activeTagFilters"
:key="tag"
:tag="tag"
dismissible
@dismiss="onTagDismiss"
/>
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<p v-if="store.loading">Loading...</p>
<div
v-else-if="store.notes.length === 0"
class="empty-state"
>
<template v-if="store.searchQuery || store.activeTagFilters.length">
<p class="empty-title">No notes match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
<template v-else>
<p class="empty-title">No notes yet</p>
<p class="empty-subtitle">Create your first note to get started.</p>
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</template>
</div>
<div v-else class="cards">
<NoteCard
v-for="note in store.notes"
:key="note.id"
:note="note"
@tag-click="onTagClick"
/>
</div>
<PaginationBar
:total="store.total"
:limit="store.limit"
:offset="store.offset"
@update:offset="onOffsetUpdate"
/>
</main>
</template>
<style scoped>
.notes-list {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h1 {
margin: 0;
}
.btn-new {
padding: 0.5rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
.controls {
display: flex;
justify-content: flex-end;
margin-top: 0.75rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
}
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.active-filters {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.clear-filters {
background: none;
border: none;
color: var(--color-danger);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
.cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.5rem 1.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
</style>
+537
View File
@@ -0,0 +1,537 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAutocomplete } from "@/composables/useAutocomplete";
import { apiGet } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Note } from "@/types/note";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const notesStore = useNotesStore();
const toast = useToastStore();
const title = ref("");
const description = ref("");
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const dueDate = ref("");
const noteId = ref<number | null>(null);
const companionNote = ref<Note | null>(null);
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const taskId = computed(() =>
route.params.id ? Number(route.params.id) : null
);
const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(description.value));
// Autocomplete
const {
acItems,
acVisible,
acIndex,
acTop,
acLeft,
detectTrigger,
accept: acAccept,
dismiss: acDismiss,
onKeydown: acOnKeydown,
} = useAutocomplete(textareaRef, description, (q) => notesStore.fetchAllTags(q));
let savedTitle = "";
let savedDescription = "";
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
function markDirty() {
dirty.value =
title.value !== savedTitle ||
description.value !== savedDescription ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate;
}
function autoGrow() {
const el = textareaRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.max(el.scrollHeight, 200) + "px";
}
function onDescriptionInput() {
markDirty();
autoGrow();
detectTrigger();
}
function onTextareaKeydown(e: KeyboardEvent) {
acOnKeydown(e);
}
function onTextareaBlur() {
setTimeout(acDismiss, 150);
}
function insertAtCursor(before: string, after: string, placeholder: string) {
const el = textareaRef.value;
if (!el) return;
showPreview.value = false;
nextTick(() => {
el.focus();
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = description.value.slice(start, end);
const insert = selected || placeholder;
description.value =
description.value.slice(0, start) + before + insert + after + description.value.slice(end);
markDirty();
nextTick(() => {
const newStart = start + before.length;
const newEnd = newStart + insert.length;
el.setSelectionRange(newStart, newEnd);
el.focus();
autoGrow();
});
});
}
onMounted(async () => {
if (taskId.value) {
await store.fetchTask(taskId.value);
if (store.currentTask) {
title.value = store.currentTask.title;
description.value = store.currentTask.description;
status.value = store.currentTask.status;
priority.value = store.currentTask.priority;
dueDate.value = store.currentTask.due_date || "";
noteId.value = store.currentTask.note_id;
savedTitle = title.value;
savedDescription = description.value;
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
if (noteId.value) {
try {
companionNote.value = await apiGet<Note>(`/api/notes/${noteId.value}`);
} catch {
companionNote.value = null;
}
}
}
}
nextTick(autoGrow);
});
async function save() {
if (saving.value) return;
saving.value = true;
try {
const data = {
title: title.value,
description: description.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
savedTitle = title.value;
savedDescription = description.value;
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
dirty.value = false;
toast.show("Task saved");
} else {
const task = await store.createTask(data);
dirty.value = false;
toast.show("Task created");
router.push(`/tasks/${task.id}`);
}
} catch {
toast.show("Failed to save task", "error");
} finally {
saving.value = false;
}
}
const showDeleteConfirm = ref(false);
function remove() {
if (taskId.value) {
showDeleteConfirm.value = true;
}
}
async function confirmDelete() {
showDeleteConfirm.value = false;
if (!taskId.value) return;
try {
await store.deleteTask(taskId.value);
dirty.value = false;
toast.show("Task deleted");
router.push("/tasks");
} catch {
toast.show("Failed to delete task", "error");
}
}
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
}
onMounted(() => document.addEventListener("keydown", onKeydown));
onUnmounted(() => document.removeEventListener("keydown", onKeydown));
onBeforeRouteLeave(() => {
if (dirty.value) {
return confirm("You have unsaved changes. Leave anyway?");
}
});
function onBeforeUnload(e: BeforeUnloadEvent) {
if (dirty.value) {
e.preventDefault();
}
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script>
<template>
<main class="editor">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
Delete
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Task title"
class="title-input"
@input="markDirty"
/>
<div class="editor-tabs">
<button
:class="['tab', { active: !showPreview }]"
@click="showPreview = false"
>
Write
</button>
<button
:class="['tab', { active: showPreview }]"
@click="showPreview = true"
>
Preview
</button>
</div>
<MarkdownToolbar v-show="!showPreview" @insert="insertAtCursor" />
<div class="textarea-wrapper" v-show="!showPreview">
<textarea
ref="textareaRef"
v-model="description"
placeholder="Describe this task... Use #tags inline"
class="body-input"
@input="onDescriptionInput"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
></textarea>
<!-- Autocomplete dropdown -->
<ul
v-if="acVisible"
class="ac-dropdown"
:style="{ top: acTop + 'px', left: acLeft + 'px' }"
>
<li
v-for="(item, i) in acItems"
:key="item.value"
:class="['ac-item', { active: i === acIndex }]"
@mousedown.prevent="acAccept(i)"
>
{{ item.label }}
</li>
</ul>
</div>
<div
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
></div>
<div class="field-row">
<div class="field">
<label>Status</label>
<select v-model="status" @change="markDirty" class="field-select">
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
<div class="field">
<label>Priority</label>
<select v-model="priority" @change="markDirty" class="field-select">
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="field">
<label>Due Date</label>
<input
v-model="dueDate"
type="date"
class="field-input"
@input="markDirty"
/>
</div>
</div>
<div v-if="companionNote" class="field companion-note">
<label>Companion Note</label>
<router-link :to="`/notes/${companionNote.id}`" class="note-link">
{{ companionNote.title || "Untitled" }}
</router-link>
</div>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
<div class="modal-card" @click.stop>
<h3 class="modal-title">Delete Task</h3>
<p class="modal-message">Are you sure you want to delete this task? This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
</main>
</template>
<style scoped>
.editor {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-back {
text-decoration: none;
color: var(--color-primary);
}
.btn-save {
padding: 0.4rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: default;
}
.btn-delete {
padding: 0.4rem 1rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: auto;
}
.title-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
font-size: 1.25rem;
font-weight: 600;
background: var(--color-bg-card);
color: var(--color-text);
}
.editor-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
}
.tab {
padding: 0.4rem 1rem;
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
}
.tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.textarea-wrapper {
position: relative;
}
.body-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
font-size: 1rem;
font-family: monospace;
min-height: 200px;
resize: none;
overflow: hidden;
background: var(--color-bg-card);
color: var(--color-text);
box-sizing: border-box;
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
min-height: 200px;
background: var(--color-bg-card);
}
.ac-dropdown {
position: fixed;
z-index: 100;
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
overflow-y: auto;
min-width: 160px;
}
.ac-item {
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
}
.ac-item:hover,
.ac-item.active {
background: var(--color-bg-secondary);
}
.field-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
flex: 1;
}
.field label {
font-size: 0.85rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.field-select,
.field-input {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
}
.companion-note {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.note-link {
color: var(--color-primary);
text-decoration: none;
font-size: 0.9rem;
}
.note-link:hover {
text-decoration: underline;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border-radius: 8px;
padding: 1.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-btn {
padding: 0.4rem 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-danger {
background: var(--color-danger);
color: #fff;
border-color: var(--color-danger);
}
</style>
+279
View File
@@ -0,0 +1,279 @@
<script setup lang="ts">
import { onMounted, computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime";
import { apiGet, apiPost } from "@/api/client";
import type { Note } from "@/types/note";
import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const notesStore = useNotesStore();
const linkedNote = ref<Note | null>(null);
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const taskId = computed(() => Number(route.params.id));
async function loadTask(id: number) {
linkedNote.value = null;
backlinks.value = [];
await store.fetchTask(id);
if (store.currentTask?.note_id) {
try {
linkedNote.value = await apiGet<Note>(
`/api/notes/${store.currentTask.note_id}`
);
} catch {
linkedNote.value = null;
}
try {
backlinks.value = await notesStore.fetchBacklinks(store.currentTask.note_id);
} catch {
backlinks.value = [];
}
}
}
onMounted(() => loadTask(taskId.value));
watch(() => route.params.id, (newId) => {
if (newId) loadTask(Number(newId));
});
const renderedDescription = computed(() => {
if (!store.currentTask) return "";
return renderMarkdown(store.currentTask.description);
});
const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
};
function cycleStatus() {
if (!store.currentTask) return;
store.patchStatus(
store.currentTask.id,
statusCycle[store.currentTask.status]
);
}
function isOverdue(): boolean {
if (!store.currentTask?.due_date || store.currentTask.status === "done")
return false;
return (
new Date(store.currentTask.due_date) < new Date(new Date().toDateString())
);
}
async function onBodyClick(e: MouseEvent) {
const target = e.target as HTMLElement;
const tagLink = target.closest(".inline-tag") as HTMLAnchorElement | null;
if (tagLink) {
e.preventDefault();
const tag = tagLink.dataset.tag;
if (tag) {
router.push({ path: "/notes", query: { tag } });
}
return;
}
const wikilink = target.closest(".wikilink") as HTMLAnchorElement | null;
if (wikilink) {
e.preventDefault();
const title = wikilink.dataset.title;
if (title) {
try {
const note = await apiPost<Note>(
"/api/notes/resolve-title",
{ title }
);
router.push(`/notes/${note.id}`);
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show(`Failed to resolve note "${title}"`, "error");
}
}
}
}
function onTagClick(tag: string) {
router.push({ path: "/tasks", query: { tag } });
}
</script>
<template>
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-edit"
>
Edit
</router-link>
</div>
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentTask.updated_at) }}
&middot;
Created {{ relativeTime(store.currentTask.created_at) }}
</p>
<div class="badges">
<StatusBadge
:status="store.currentTask.status"
clickable
@click="cycleStatus"
/>
<PriorityBadge :priority="store.currentTask.priority" />
<span
v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]"
>
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div
v-if="linkedNote"
class="linked-note"
>
<router-link :to="`/notes/${linkedNote.id}`" class="note-link">
View Note
</router-link>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
class="body prose"
v-html="renderedDescription"
@click="onBodyClick"
></div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
</template>
<p v-else>Task not found.</p>
</main>
</template>
<style scoped>
.viewer {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.toolbar {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.btn-back,
.btn-edit {
text-decoration: none;
color: var(--color-primary);
}
.meta {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.badges {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.due-date {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.due-date.overdue {
color: var(--color-overdue);
font-weight: 600;
}
.linked-note {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
.note-link {
color: var(--color-primary);
text-decoration: none;
}
.note-link:hover {
text-decoration: underline;
}
.tags {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.backlinks {
margin-top: 2rem;
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.backlinks h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
}
.backlinks-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.backlink-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.backlink-link {
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
}
.backlink-link:hover {
color: var(--color-primary);
}
.backlink-type {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import type { TaskStatus } from "@/types/task";
import SearchBar from "@/components/SearchBar.vue";
import TaskCard from "@/components/TaskCard.vue";
import TagPill from "@/components/TagPill.vue";
import PaginationBar from "@/components/PaginationBar.vue";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
onMounted(() => {
const tag = route.query.tag;
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.activeTagFilters = tags;
}
if (route.query.status) {
store.statusFilter = route.query.status as TaskStatus;
}
store.refresh();
});
watch(
() => route.query.tag,
(tag) => {
if (tag) {
const tags = Array.isArray(tag) ? (tag as string[]) : [tag as string];
store.activeTagFilters = tags;
store.offset = 0;
store.refresh();
}
}
);
function onSearch(q: string) {
store.setSearch(q);
}
function onStatusFilterChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setStatusFilter(value as TaskStatus | "");
}
function onPriorityFilterChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setPriorityFilter(value as any);
}
function onTagClick(tag: string) {
store.addTagFilter(tag);
router.replace({ query: { ...route.query, tag: store.activeTagFilters } });
}
function onTagDismiss(tag: string) {
store.removeTagFilter(tag);
const newQuery = { ...route.query };
if (store.activeTagFilters.length > 0) {
newQuery.tag = store.activeTagFilters;
} else {
delete newQuery.tag;
}
router.replace({ query: newQuery });
}
function onSortChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setSort(value, store.sortOrder);
}
function toggleOrder() {
store.setSort(store.sortField, store.sortOrder === "asc" ? "desc" : "asc");
}
function onStatusToggle(id: number, status: TaskStatus) {
store.patchStatus(id, status);
}
function onOffsetUpdate(offset: number) {
store.setOffset(offset);
}
</script>
<template>
<main class="tasks-list">
<div class="header">
<h1>Tasks</h1>
<router-link to="/tasks/new" class="btn-new">+ New Task</router-link>
</div>
<SearchBar @search="onSearch" />
<div class="controls">
<div class="filter-controls">
<select :value="store.statusFilter" @change="onStatusFilterChange" class="filter-select">
<option value="">All Statuses</option>
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
<select :value="store.priorityFilter" @change="onPriorityFilterChange" class="filter-select">
<option value="">All Priorities</option>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="sort-controls">
<select :value="store.sortField" @change="onSortChange" class="sort-select">
<option value="updated_at">Updated</option>
<option value="created_at">Created</option>
<option value="title">Title</option>
<option value="due_date">Due Date</option>
<option value="priority">Priority</option>
</select>
<button class="sort-order" @click="toggleOrder" :title="store.sortOrder === 'asc' ? 'Ascending' : 'Descending'">
{{ store.sortOrder === "asc" ? "\u2191" : "\u2193" }}
</button>
</div>
</div>
<div v-if="store.activeTagFilters.length" class="active-filters">
<span class="filter-label">Filtering by:</span>
<TagPill
v-for="tag in store.activeTagFilters"
:key="tag"
:tag="tag"
dismissible
@dismiss="onTagDismiss"
/>
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<p v-if="store.loading">Loading...</p>
<div
v-else-if="store.tasks.length === 0"
class="empty-state"
>
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter || store.priorityFilter">
<p class="empty-title">No tasks match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
<template v-else>
<p class="empty-title">No tasks yet</p>
<p class="empty-subtitle">Create your first task to get started.</p>
<router-link to="/tasks/new" class="btn-cta">+ New Task</router-link>
</template>
</div>
<div v-else class="cards">
<TaskCard
v-for="task in store.tasks"
:key="task.id"
:task="task"
@tag-click="onTagClick"
@status-toggle="onStatusToggle"
/>
</div>
<PaginationBar
:total="store.total"
:limit="store.limit"
:offset="store.offset"
@update:offset="onOffsetUpdate"
/>
</main>
</template>
<style scoped>
.tasks-list {
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h1 {
margin: 0;
}
.btn-new {
padding: 0.5rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
.controls {
display: flex;
justify-content: space-between;
margin-top: 0.75rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-controls {
display: flex;
gap: 0.5rem;
}
.filter-select,
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
}
.sort-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.95rem;
}
.active-filters {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.clear-filters {
background: none;
border: none;
color: var(--color-danger);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
.cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.5rem 1.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}
+24
View File
@@ -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" }]
}
+22
View File
@@ -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"]
}
+17
View File
@@ -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",
},
},
});
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
build-backend = "setuptools.build_meta"
[project]
name = "fabledassistant"
version = "0.1.0"
description = "Self-hosted note-taking and task-tracking app with LLM integration"
requires-python = ">=3.12"
dependencies = [
"quart>=0.19",
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.29",
"alembic>=1.13",
"httpx>=0.27",
"hypercorn>=0.17",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
]
[tool.setuptools.packages.find]
where = ["src"]
View File
+57
View File
@@ -0,0 +1,57 @@
import logging
from pathlib import Path
from quart import Quart, jsonify, make_response, request, send_from_directory
from fabledassistant.config import Config
from fabledassistant.routes.api import api
from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.tasks import tasks_bp
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
def create_app() -> Quart:
app = Quart(__name__, static_folder=None)
app.secret_key = Config.SECRET_KEY
app.register_blueprint(api)
app.register_blueprint(notes_bp)
app.register_blueprint(tasks_bp)
@app.route("/")
async def serve_index():
resp = await make_response(
await send_from_directory(STATIC_DIR, "index.html")
)
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
@app.errorhandler(404)
async def handle_404(error):
# Return JSON 404 for API routes
if request.path.startswith("/api/"):
return jsonify({"error": "Not found"}), 404
# Try to serve static file
path = request.path.lstrip("/")
file_path = STATIC_DIR / path
if path and file_path.is_file():
return await send_from_directory(STATIC_DIR, path)
# SPA fallback
resp = await make_response(
await send_from_directory(STATIC_DIR, "index.html")
)
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
@app.errorhandler(500)
async def handle_500(error):
import traceback
traceback.print_exc()
logger.exception("Internal server error on %s %s", request.method, request.path)
if request.path.startswith("/api/"):
return jsonify({"error": str(error)}), 500
return "Internal Server Error", 500
return app
+10
View File
@@ -0,0 +1,10 @@
import os
class Config:
DATABASE_URL: str = os.environ.get(
"DATABASE_URL",
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-change-me")
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from fabledassistant.config import Config
engine = create_async_engine(Config.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
from fabledassistant.models.note import Note # noqa: E402, F401
from fabledassistant.models.task import Task # noqa: E402, F401
+40
View File
@@ -0,0 +1,40 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class Note(Base):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(Text, default="")
body: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
__table_args__ = (Index("ix_notes_tags", "tags", postgresql_using="gin"),)
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"body": self.body,
"tags": self.tags or [],
"parent_id": self.parent_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+70
View File
@@ -0,0 +1,70 @@
import enum
from datetime import date, datetime, timezone
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Index, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class TaskStatus(str, enum.Enum):
todo = "todo"
in_progress = "in_progress"
done = "done"
class TaskPriority(str, enum.Enum):
none = "none"
low = "low"
medium = "medium"
high = "high"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str] = mapped_column(Text, default="")
status: Mapped[TaskStatus] = mapped_column(
Enum(TaskStatus, name="task_status", create_constraint=False),
default=TaskStatus.todo,
)
priority: Mapped[TaskPriority] = mapped_column(
Enum(TaskPriority, name="task_priority", create_constraint=False),
default=TaskPriority.none,
)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
note_id: Mapped[int | None] = mapped_column(
ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
__table_args__ = (
Index("ix_tasks_tags", "tags", postgresql_using="gin"),
Index("ix_tasks_note_id", "note_id"),
Index("ix_tasks_status", "status"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"description": self.description,
"status": self.status.value,
"priority": self.priority.value,
"due_date": self.due_date.isoformat() if self.due_date else None,
"note_id": self.note_id,
"tags": self.tags or [],
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+8
View File
@@ -0,0 +1,8 @@
from quart import Blueprint, jsonify
api = Blueprint("api", __name__, url_prefix="/api")
@api.route("/health")
async def health():
return jsonify({"status": "ok"})
+120
View File
@@ -0,0 +1,120 @@
from quart import Blueprint, jsonify, request
from fabledassistant.services.notes import (
convert_note_to_task,
create_note,
delete_note,
get_all_tags,
get_backlinks,
get_note,
get_note_by_title,
get_or_create_note_by_title,
list_notes,
update_note,
)
from fabledassistant.utils.tags import extract_tags
notes_bp = Blueprint("notes", __name__, url_prefix="/api/notes")
@notes_bp.route("", methods=["GET"])
async def list_notes_route():
q = request.args.get("q")
tag = request.args.getlist("tag")
sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc")
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
notes, total = await list_notes(
q=q, tags=tag or None, sort=sort, order=order, limit=limit, offset=offset
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
@notes_bp.route("", methods=["POST"])
async def create_note_route():
data = await request.get_json()
body = data.get("body", "")
tags = extract_tags(body)
note = await create_note(
title=data.get("title", ""),
body=body,
tags=tags,
parent_id=data.get("parent_id"),
)
return jsonify(note.to_dict()), 201
@notes_bp.route("/tags", methods=["GET"])
async def list_tags_route():
q = request.args.get("q")
tags = await get_all_tags(q=q)
return jsonify({"tags": tags})
@notes_bp.route("/by-title", methods=["GET"])
async def get_note_by_title_route():
title = request.args.get("title", "")
if not title:
return jsonify({"error": "title parameter is required"}), 400
note = await get_note_by_title(title)
if note is None:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/resolve-title", methods=["POST"])
async def resolve_title_route():
data = await request.get_json()
title = data.get("title", "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
note = await get_or_create_note_by_title(title)
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["GET"])
async def get_note_route(note_id: int):
note = await get_note(note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["PUT"])
async def update_note_route(note_id: int):
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id"):
if key in data:
fields[key] = data[key]
if "body" in fields:
fields["tags"] = extract_tags(fields["body"])
note = await update_note(note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["DELETE"])
async def delete_note_route(note_id: int):
deleted = await delete_note(note_id)
if not deleted:
return jsonify({"error": "Note not found"}), 404
return "", 204
@notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"])
async def convert_note_to_task_route(note_id: int):
try:
task = await convert_note_to_task(note_id)
return jsonify(task.to_dict()), 201
except ValueError as e:
return jsonify({"error": str(e)}), 404
@notes_bp.route("/<int:note_id>/backlinks", methods=["GET"])
async def get_backlinks_route(note_id: int):
links = await get_backlinks(note_id)
return jsonify({"backlinks": links})
+123
View File
@@ -0,0 +1,123 @@
from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.models.task import TaskPriority, TaskStatus
from fabledassistant.services.tasks import (
create_task,
delete_task,
get_task,
list_tasks,
update_task,
)
from fabledassistant.utils.tags import extract_tags
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
@tasks_bp.route("", methods=["GET"])
async def list_tasks_route():
q = request.args.get("q")
tag = request.args.getlist("tag")
status = request.args.get("status")
priority = request.args.get("priority")
note_id = request.args.get("note_id", type=int)
sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc")
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
tasks, total = await list_tasks(
q=q,
tags=tag or None,
status=status,
priority=priority,
note_id=note_id,
sort=sort,
order=order,
limit=limit,
offset=offset,
)
return jsonify({"tasks": [t.to_dict() for t in tasks], "total": total})
@tasks_bp.route("", methods=["POST"])
async def create_task_route():
data = await request.get_json()
description = data.get("description", "")
tags = extract_tags(description)
due_date = None
if data.get("due_date"):
due_date = date.fromisoformat(data["due_date"])
status = TaskStatus(data["status"]) if "status" in data else TaskStatus.todo
priority = (
TaskPriority(data["priority"]) if "priority" in data else TaskPriority.none
)
task = await create_task(
title=data.get("title", ""),
description=description,
status=status,
priority=priority,
due_date=due_date,
tags=tags,
)
return jsonify(task.to_dict()), 201
@tasks_bp.route("/<int:task_id>", methods=["GET"])
async def get_task_route(task_id: int):
task = await get_task(task_id)
if task is None:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>", methods=["PUT"])
async def update_task_route(task_id: int):
data = await request.get_json()
fields = {}
for key in ("title", "description", "status", "priority"):
if key in data:
fields[key] = data[key]
if "due_date" in data:
fields["due_date"] = (
date.fromisoformat(data["due_date"]) if data["due_date"] else None
)
if "description" in fields:
fields["tags"] = extract_tags(fields["description"])
task = await update_task(task_id, **fields)
if task is None:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>/status", methods=["PATCH"])
async def patch_task_status(task_id: int):
data = await request.get_json()
status_val = data.get("status")
if not status_val:
return jsonify({"error": "status is required"}), 400
try:
TaskStatus(status_val)
except ValueError:
return jsonify({"error": f"Invalid status: {status_val}"}), 400
task = await update_task(task_id, status=status_val)
if task is None:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
async def delete_task_route(task_id: int):
deleted = await delete_task(task_id)
if not deleted:
return jsonify({"error": "Task not found"}), 404
return "", 204
+215
View File
@@ -0,0 +1,215 @@
from datetime import datetime, timezone
from sqlalchemy import func, or_, select, text
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.task import Task
async def create_note(
title: str = "",
body: str = "",
tags: list[str] | None = None,
parent_id: int | None = None,
) -> Note:
async with async_session() as session:
note = Note(
title=title,
body=body,
tags=tags or [],
parent_id=parent_id,
)
session.add(note)
await session.commit()
await session.refresh(note)
return note
async def get_note(note_id: int) -> Note | None:
async with async_session() as session:
return await session.get(Note, note_id)
async def list_notes(
q: str | None = None,
tags: list[str] | None = None,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
offset: int = 0,
) -> tuple[list[Note], int]:
async with async_session() as session:
query = select(Note)
count_query = select(func.count(Note.id))
if q:
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped_q}%"
filter_ = or_(Note.title.ilike(pattern), Note.body.ilike(pattern))
query = query.where(filter_)
count_query = count_query.where(filter_)
if tags:
for i, tag in enumerate(tags):
param_tag = f"tag_{i}"
param_prefix = f"tag_prefix_{i}"
tag_filter = text(
f"EXISTS (SELECT 1 FROM unnest(notes.tags) AS t"
f" WHERE t = :{param_tag} OR t LIKE :{param_prefix})"
).bindparams(**{param_tag: tag, param_prefix: tag + "/%"})
query = query.where(tag_filter)
count_query = count_query.where(tag_filter)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
else:
query = query.order_by(sort_col.desc())
query = query.limit(limit).offset(offset)
total = await session.scalar(count_query) or 0
result = await session.execute(query)
notes = list(result.scalars().all())
return notes, total
async def get_note_by_title(title: str) -> Note | None:
async with async_session() as session:
result = await session.execute(
select(Note).where(func.lower(Note.title) == func.lower(title.strip())).limit(1)
)
return result.scalars().first()
async def get_or_create_note_by_title(title: str) -> Note:
title = title.strip()
note = await get_note_by_title(title)
if note:
return note
return await create_note(title=title)
async def update_note(
note_id: int, _skip_cascade: bool = False, **fields: object
) -> Note | None:
async with async_session() as session:
note = await session.get(Note, note_id)
if note is None:
return None
for key, value in fields.items():
if hasattr(note, key):
setattr(note, key, value)
note.updated_at = datetime.now(timezone.utc)
# Sync title to companion task
if not _skip_cascade and "title" in fields:
result = await session.execute(
select(Task).where(Task.note_id == note_id).limit(1)
)
companion_task = result.scalars().first()
if companion_task:
companion_task.title = note.title
companion_task.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
return note
async def delete_note(note_id: int, _skip_cascade: bool = False) -> bool:
async with async_session() as session:
note = await session.get(Note, note_id)
if note is None:
return False
# Cascade delete companion task
if not _skip_cascade:
result = await session.execute(
select(Task).where(Task.note_id == note_id)
)
for companion_task in result.scalars().all():
companion_task.note_id = None # unlink to avoid FK issues
await session.delete(companion_task)
await session.delete(note)
await session.commit()
return True
async def get_all_tags(q: str | None = None) -> list[str]:
async with async_session() as session:
all_tags: set[str] = set()
for Model in (Note, Task):
result = await session.execute(select(Model))
for obj in result.scalars():
if obj.tags:
all_tags.update(obj.tags)
if q:
q_lower = q.lower()
all_tags = {t for t in all_tags if q_lower in t.lower()}
return sorted(all_tags)
async def convert_note_to_task(note_id: int) -> Task:
from fabledassistant.services.tasks import create_task
note = await get_note(note_id)
if note is None:
raise ValueError("Note not found")
# Create a new task (this auto-creates a companion note)
task = await create_task(title=note.title, tags=list(note.tags))
# Copy the original note's body to the companion note
if task.note_id:
await update_note(task.note_id, _skip_cascade=True, body=note.body, tags=list(note.tags))
# Delete the original note (skip cascade since we're managing this)
await delete_note(note_id, _skip_cascade=True)
return task
async def get_backlinks(note_id: int) -> list[dict]:
note = await get_note(note_id)
if note is None:
return []
title = note.title
if not title:
return []
async with async_session() as session:
# Escape SQL LIKE wildcards in title
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
# Search for [[Title]] or [[Title|...]] in note bodies
pattern = f"%[[{escaped}]]%"
pattern_alias = f"%[[{escaped}|%"
# Find notes with backlinks (exclude this note itself)
note_results = await session.execute(
select(Note.id, Note.title).where(
Note.id != note_id,
or_(Note.body.like(pattern), Note.body.like(pattern_alias)),
)
)
backlinks: list[dict] = []
for row in note_results.fetchall():
backlinks.append({"type": "note", "id": row[0], "title": row[1]})
# Find tasks with backlinks
task_results = await session.execute(
select(Task.id, Task.title).where(
or_(
Task.description.like(pattern),
Task.description.like(pattern_alias),
)
)
)
for row in task_results.fetchall():
backlinks.append({"type": "task", "id": row[0], "title": row[1]})
return backlinks
+145
View File
@@ -0,0 +1,145 @@
from datetime import datetime, timezone
from sqlalchemy import func, or_, select, text
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.task import Task, TaskPriority, TaskStatus
async def create_task(
title: str = "",
description: str = "",
status: TaskStatus = TaskStatus.todo,
priority: TaskPriority = TaskPriority.none,
due_date=None,
tags: list[str] | None = None,
) -> Task:
async with async_session() as session:
# Auto-create companion note
companion = Note(title=title, body="", tags=tags or [])
session.add(companion)
await session.flush()
task = Task(
title=title,
description=description,
status=status,
priority=priority,
due_date=due_date,
note_id=companion.id,
tags=tags or [],
)
session.add(task)
await session.commit()
await session.refresh(task)
return task
async def get_task(task_id: int) -> Task | None:
async with async_session() as session:
return await session.get(Task, task_id)
async def list_tasks(
q: str | None = None,
tags: list[str] | None = None,
status: str | None = None,
priority: str | None = None,
note_id: int | None = None,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
offset: int = 0,
) -> tuple[list[Task], int]:
async with async_session() as session:
query = select(Task)
count_query = select(func.count(Task.id))
if q:
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped_q}%"
filter_ = or_(Task.title.ilike(pattern), Task.description.ilike(pattern))
query = query.where(filter_)
count_query = count_query.where(filter_)
if tags:
for i, tag in enumerate(tags):
param_tag = f"tag_{i}"
param_prefix = f"tag_prefix_{i}"
tag_filter = text(
f"EXISTS (SELECT 1 FROM unnest(tasks.tags) AS t"
f" WHERE t = :{param_tag} OR t LIKE :{param_prefix})"
).bindparams(**{param_tag: tag, param_prefix: tag + "/%"})
query = query.where(tag_filter)
count_query = count_query.where(tag_filter)
if status:
query = query.where(Task.status == TaskStatus(status))
count_query = count_query.where(Task.status == TaskStatus(status))
if priority:
query = query.where(Task.priority == TaskPriority(priority))
count_query = count_query.where(Task.priority == TaskPriority(priority))
if note_id is not None:
query = query.where(Task.note_id == note_id)
count_query = count_query.where(Task.note_id == note_id)
sort_col = getattr(Task, sort, Task.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
else:
query = query.order_by(sort_col.desc())
query = query.limit(limit).offset(offset)
total = await session.scalar(count_query) or 0
result = await session.execute(query)
tasks = list(result.scalars().all())
return tasks, total
async def update_task(
task_id: int, _skip_cascade: bool = False, **fields: object
) -> Task | None:
async with async_session() as session:
task = await session.get(Task, task_id)
if task is None:
return None
for key, value in fields.items():
if not hasattr(task, key):
continue
if key == "status" and isinstance(value, str):
value = TaskStatus(value)
elif key == "priority" and isinstance(value, str):
value = TaskPriority(value)
setattr(task, key, value)
task.updated_at = datetime.now(timezone.utc)
# Sync title to companion note
if not _skip_cascade and "title" in fields and task.note_id:
companion = await session.get(Note, task.note_id)
if companion:
companion.title = task.title
companion.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(task)
return task
async def delete_task(task_id: int, _skip_cascade: bool = False) -> bool:
async with async_session() as session:
task = await session.get(Task, task_id)
if task is None:
return False
companion_note_id = task.note_id
await session.delete(task)
# Cascade delete companion note
if not _skip_cascade and companion_note_id:
companion = await session.get(Note, companion_note_id)
if companion:
await session.delete(companion)
await session.commit()
return True
View File
+11
View File
@@ -0,0 +1,11 @@
import re
_CODE_FENCE_RE = re.compile(r"```[\s\S]*?```|`[^`\n]+`")
_TAG_RE = re.compile(r"(?<!\w)#([\w]+(?:/[\w]+)*)")
def extract_tags(body: str) -> list[str]:
"""Extract #tags from body text, ignoring code blocks."""
cleaned = _CODE_FENCE_RE.sub("", body)
tags = _TAG_RE.findall(cleaned)
return sorted(set(tags))
+270
View File
@@ -0,0 +1,270 @@
# Fabled Assistant - Project Context
> **Purpose:** This file is the canonical reference for re-initializing Claude Code
> context on this project. It should be updated after every significant change.
## Last Updated
2026-02-08 — Phase 3 complete (Tasks CRUD + Wikilinks)
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
integrated LLM capabilities. It is designed to run on container infrastructure
(Docker Swarm) and connect to Ollama or any self-hostable LLM-compatible system
for AI-assisted features.
## Core Architecture
### Stack
| Layer | Technology | Notes |
|-------------|-----------|-------|
| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API |
| Backend/API | Quart (Python 3.12) | Async framework; serves both API and built frontend static files |
| LLM | Ollama | Or any OpenAI-compatible self-hosted LLM API |
| Database | PostgreSQL 16 | asyncpg driver, SQLAlchemy 2.0 async ORM, Alembic migrations |
| Deployment | Docker Compose | Single-container app + separate DB + LLM service |
### Key Design Decisions
- **Single container for frontend + API:** Quart serves the Vue.js production
build as static files and exposes the REST API under `/api/`.
- **Quart chosen for familiarity:** The maintainer (bvandeusen) knows Quart well.
- **LLM integration is a separate service:** The app communicates with Ollama (or
compatible) over HTTP.
- **Inline tag extraction:** Tags are extracted from note/task body text using
`#tag` syntax (Obsidian-style), not manually entered. Backend is source of truth
for tag extraction.
- **Hierarchical tags:** `#project/webapp` stored as `"project/webapp"`. Filtering
by `project` matches both `project` and `project/*` children via SQL `unnest` +
`LIKE` prefix.
- **Dark-first theming:** CSS custom properties on `:root` (light) and
`[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark.
- **Task-Note linking:** Tasks have an optional `note_id` FK (one task → one note).
Notes display their linked tasks in a "Linked Tasks" section.
- **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown
bodies resolve to notes by exact title match via `/api/notes/by-title`.
### High-Level Component Diagram
```
┌─────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────────┐ ┌────────────┐ │
│ │ fabledassistant │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
│ │ │ │ (static) │ │ │ ▲ │
│ │ │ └──────────┘ │ │ │ │
│ │ │ ┌──────────┐ │ │ HTTP/REST │
│ │ │ │ /api/* │──┼──┼─────────┘ │
│ │ │ └──────────┘ │ │ │
│ │ │ │ │ │ ┌────────────┐ │
│ │ │ ▼ │ │ │ PostgreSQL │ │
│ │ │ ┌──────────┐ │ │ │ 16 │ │
│ │ │ │ asyncpg │──┼──┼──▶ │ │
│ │ │ └──────────┘ │ │ └────────────┘ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────┘
```
## Data Model
### Notes (implemented)
- `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]),
`parent_id` (nullable FK to self), `created_at`, `updated_at`
- Tags are auto-extracted from body text on create/update via `#tag` regex
- Supports hierarchical organization via `parent_id`
- Lookup by exact title via `get_note_by_title()` for wikilink resolution
### Tasks (implemented)
- `id` (int PK), `title` (str), `description` (markdown str),
`status` (enum: todo/in_progress/done), `priority` (enum: none/low/medium/high),
`due_date` (date, nullable), `note_id` (FK to notes, nullable, SET NULL),
`tags` (ARRAY[str]), `created_at`, `updated_at`
- Tags auto-extracted from description on create/update
- Status enum with quick-toggle cycling: todo → in_progress → done → todo
- Indexes: GIN on tags, B-tree on note_id, B-tree on status
### LLM Interactions (Phase 4)
- Summarize notes, generate task breakdowns, search/query across notes,
chat-style assistant within the app
## Project Structure (Current)
```
fabledassistant/
├── summary.md # This file — canonical project context
├── pyproject.toml # Python project config
├── Dockerfile # Multi-stage build (Node → Python)
├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama)
├── alembic/ # DB migrations
│ ├── alembic.ini
│ ├── env.py # Async migration runner
│ └── versions/
│ └── 0002_create_tasks_table.py # Tasks table + enums migration
├── src/
│ └── fabledassistant/
│ ├── __init__.py
│ ├── app.py # Quart app factory, serves SPA + registers blueprints
│ ├── config.py # Config from env vars
│ ├── models/
│ │ ├── __init__.py # async_session factory, Base, imports Note + Task
│ │ ├── note.py # Note model (id, title, body, tags[], parent_id, timestamps)
│ │ └── task.py # Task model (id, title, description, status, priority, due_date, note_id, tags, timestamps)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint
│ │ ├── notes.py # /api/notes CRUD + /by-title endpoint
│ │ └── tasks.py # /api/tasks CRUD + PATCH status
│ ├── services/
│ │ ├── notes.py # Business logic: CRUD, hierarchical tag filter, get_note_by_title
│ │ └── tasks.py # Task CRUD: filter by status/priority/note_id/tags/search
│ ├── utils/
│ │ ├── __init__.py
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
│ └── static/ # Vue production build (generated by Dockerfile)
└── frontend/
├── package.json # deps: vue, pinia, vue-router, marked, dompurify
├── vite.config.ts
├── tsconfig.json
├── src/
│ ├── App.vue # Shell: AppHeader + router-view + ToastNotification
│ ├── main.ts # App init, imports theme.css
│ ├── assets/
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset
│ ├── api/
│ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete helpers
│ ├── composables/
│ │ └── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ ├── stores/
│ │ ├── notes.ts # Notes state: CRUD + tag filter, pagination, sort, search
│ │ ├── tasks.ts # Tasks state: CRUD + status/priority filter, patchStatus
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
│ ├── types/
│ │ ├── note.ts # Note, NoteListResponse interfaces
│ │ └── task.ts # Task, TaskStatus, TaskPriority, TaskListResponse
│ ├── utils/
│ │ └── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks()
│ ├── views/
│ │ ├── HomeView.vue # Landing page with health status + links to Notes/Tasks
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts
│ │ ├── NoteViewerView.vue # Markdown render: DOMPurify, inline tags, wikilinks, linked tasks
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle
│ │ ├── TaskEditorView.vue # Create/edit task: all fields, note-link autocomplete, Ctrl+S, dirty guard
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, linked note, wikilinks
│ ├── components/
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks links, theme toggle
│ │ ├── NoteCard.vue # Card with TagPill, tag-click emit
│ │ ├── TaskCard.vue # Card with StatusBadge (clickable), PriorityBadge, due date, tags
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
│ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
│ │ ├── PaginationBar.vue # Prev/next + page numbers
│ │ └── ToastNotification.vue # Fixed-position toast container
│ └── router/
│ └── index.ts # Routes: /, /notes/*, /tasks/*
└── public/
```
## API Endpoints (Current)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Health check |
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`) |
| POST | `/api/notes` | Create note (body: `{title, body}` — tags auto-extracted) |
| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (for wikilinks) |
| GET | `/api/notes/:id` | Get single note |
| PUT | `/api/notes/:id` | Update note (body: `{title?, body?}` — tags re-extracted if body changes) |
| DELETE | `/api/notes/:id` | Delete note |
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `note_id`, `sort`, `order`, `limit`, `offset`) |
| POST | `/api/tasks` | Create task (body: `{title, description, status?, priority?, due_date?, note_id?}`) |
| GET | `/api/tasks/:id` | Get single task |
| PUT | `/api/tasks/:id` | Update task |
| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) |
| DELETE | `/api/tasks/:id` | Delete task |
## Phased Roadmap
### Phase 1 — Skeleton & Dev Environment ✓
- [x] Initialize Python project (pyproject.toml, Quart app scaffold)
- [x] Initialize Vue.js project (Vite-based, inside `frontend/`)
- [x] Set up Dockerfile (multi-stage: build Vue, serve with Quart)
- [x] Docker Compose stack with Ollama service
- [x] Quart serves Vue static build + `/api/health` endpoint
- [x] Database setup (PostgreSQL 16, asyncpg, SQLAlchemy 2.0, Alembic)
### Phase 2 — Notes CRUD + UX ✓
- [x] Database model for notes (title, body, tags[], parent_id, timestamps)
- [x] REST API: create, read, update, delete, list notes with pagination
- [x] Vue views: note list, note editor (markdown), note viewer (rendered)
- [x] Search (ILIKE on title/body) and tag filtering (hierarchical via unnest)
- [x] Inline `#tag` extraction from body text (backend regex, skips code fences)
- [x] Hierarchical tag filtering (`#project` matches `project` and `project/*`)
- [x] Dark/light theming with CSS custom properties + toggle + localStorage
- [x] App header with navigation and theme toggle
- [x] Tag pills (clickable + dismissible) on cards, viewer, and list filter bar
- [x] Pagination bar with prev/next and page numbers
- [x] Sort controls (field + asc/desc)
- [x] Toast notifications (success/error, 3s auto-dismiss)
- [x] Ctrl+S save shortcut in editor
- [x] Unsaved changes guard (route leave + beforeunload)
- [x] DOMPurify sanitization on rendered markdown
- [x] Inline tag linkification in rendered markdown (clickable `#tag` links)
### Phase 3 — Tasks CRUD + Wikilinks ✓
- [x] Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums
- [x] Alembic migration for tasks table with PG enums and indexes
- [x] REST API: full CRUD + PATCH status toggle + filter by status/priority/note_id/tags
- [x] Task-Note linking via optional `note_id` FK
- [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (note-link autocomplete, Ctrl+S, dirty guard), viewer (rendered markdown)
- [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components
- [x] Obsidian-style wikilinks: `[[Title]]` and `[[Title|Display]]` in rendered markdown
- [x] Wikilink click handling resolves notes by title via `/api/notes/by-title`
- [x] "Linked Tasks" section on NoteViewerView with inline status toggling
- [x] Theme variables for status/priority/wikilink/overdue colors (light + dark)
### Phase 4 — LLM Integration (next)
- [ ] Ollama client in the backend (async HTTP via httpx/aiohttp)
- [ ] API endpoints for LLM features (summarize note, generate tasks from note,
freeform chat)
- [ ] Vue components for LLM interactions (chat panel, summarize button, etc.)
- [ ] Configurable LLM endpoint + model selection in app settings
### Phase 5 — Polish & Production Hardening
- [ ] Authentication (single-user or multi-user, TBD)
- [ ] Docker Swarm production stack (secrets, volumes, networking)
- [ ] Backup/restore strategy for data
- [ ] UI/UX refinements, responsive design
- [ ] Error handling, logging, monitoring
### Future / Stretch
- WebSocket streaming for LLM responses
- Tagging/labeling system with LLM-suggested tags
- Calendar/timeline view for tasks
- Import/export (Markdown files, JSON)
- Plugin/extension system
## Development Workflow
- All development and testing done via Docker: `docker compose up --build`
- No local dependency installation — everything containerized
- Frontend dev: Vite dev server with proxy to Quart (via Docker)
- Production build: Dockerfile multi-stage — Vite builds Vue into static files,
Quart serves them
## Open Questions
- Authentication model: single-user (password-only) vs multi-user?
- Should LLM streaming use WebSockets from Phase 4 or defer to Phase 5?
## Current Status
**Phase:** Phase 3 complete. Tasks CRUD with wikilinks fully implemented.
- Full notes CRUD with markdown editing, rendering, and wikilinks
- Full tasks CRUD with status/priority enums, filters, note linking
- Inline `#tag` extraction for both notes and tasks
- Obsidian-style `[[wikilinks]]` with title resolution
- StatusBadge with clickable cycling, PriorityBadge, overdue date styling
- "Linked Tasks" section on note viewer with inline status toggling
- Dark/light theme with status/priority/wikilink color variables
- Ready for Phase 4: LLM Integration