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