Merge pull request 'v26.03.11.1' (#1) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#1
This commit was merged in pull request #1.
This commit is contained in:
@@ -1,67 +0,0 @@
|
||||
# Branch push (dev): builds and pushes :dev + :<sha>
|
||||
# Branch push (main): builds and pushes :latest + :<sha>
|
||||
# Tag push (v1.2.0): builds and pushes :latest + :<sha> + :v1.2.0
|
||||
#
|
||||
# To cut a versioned release:
|
||||
# git tag v1.2.0 && git push origin v1.2.0
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# REGISTRY_USER — your Forgejo username
|
||||
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
|
||||
name: Build & push image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "frontend/**"
|
||||
- "Dockerfile"
|
||||
- "pyproject.toml"
|
||||
- "alembic/**"
|
||||
- "alembic.ini"
|
||||
- ".forgejo/workflows/build.yml"
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate image tags
|
||||
id: tags
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||
elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
fi
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Forgejo registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
|
||||
+92
-21
@@ -1,14 +1,28 @@
|
||||
# Runs on every push and pull request.
|
||||
# Fast checks only — no Docker build. Second runs take ~30s due to caching.
|
||||
name: CI
|
||||
# CI runs first; build only proceeds if all checks pass.
|
||||
#
|
||||
# Push to dev: typecheck + lint + test → build :dev + :<sha>
|
||||
# Push to main: typecheck + lint + test only (no build — wait for release tag)
|
||||
# Tag v*: typecheck + lint + test → build :latest + :<sha> + :<version>
|
||||
# Pull request: typecheck + lint + test only (no build)
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# REGISTRY_USER — your Forgejo username
|
||||
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
|
||||
name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "frontend/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
- "alembic/**"
|
||||
- "alembic.ini"
|
||||
- "Dockerfile"
|
||||
- "assets/**"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -18,16 +32,20 @@ on:
|
||||
- "pyproject.toml"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: py3.12-node22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -41,32 +59,85 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: python:3.12-slim
|
||||
runs-on: py3.12-node22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install ruff
|
||||
run: pip install --break-system-packages ruff
|
||||
|
||||
- name: Lint
|
||||
run: ruff check src/
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: python:3.12-slim
|
||||
runs-on: py3.12-node22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# git is required by setuptools-scm during package build
|
||||
- name: Install git
|
||||
run: apt-get update -qq && apt-get install -y -qq git
|
||||
# Python 3.12 is pre-installed in the runner-base image (py3.12-node22).
|
||||
- name: Cache pip wheels
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: pip-
|
||||
|
||||
- name: Create virtual environment
|
||||
run: python3.12 -m venv /opt/venv
|
||||
|
||||
- name: Install package with dev deps
|
||||
run: pip install -e ".[dev]"
|
||||
run: |
|
||||
/opt/venv/bin/pip install --upgrade pip setuptools wheel
|
||||
/opt/venv/bin/pip install --no-build-isolation http-ece
|
||||
/opt/venv/bin/pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v
|
||||
run: /opt/venv/bin/python -m pytest tests/ -v
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
needs: [typecheck, lint, test]
|
||||
# Build on dev branch pushes and version tag pushes only.
|
||||
# main branch pushes run CI for safety but do not build —
|
||||
# the release tag (v*) is the sole trigger for a production image.
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
|
||||
runs-on: py3.12-node22
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
BUILD_VERSION="dev"
|
||||
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
fi
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to Forgejo registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
|
||||
|
||||
@@ -14,9 +14,15 @@ frontend/dist/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.vscodium/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
settings.local.json
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
docs/superpowers/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -27,3 +33,4 @@ docker-compose.override.yml
|
||||
# Misc
|
||||
*.log
|
||||
.DS_Store
|
||||
.superpowers/
|
||||
|
||||
+6
-1
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build Vue frontend
|
||||
FROM node:20-alpine AS build-frontend
|
||||
FROM node:22-alpine AS build-frontend
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install -g npm@latest --quiet && npm install
|
||||
@@ -21,5 +21,10 @@ COPY alembic/ alembic/
|
||||
# Ensure Python finds the source tree (where static files live) before site-packages
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
|
||||
# Falls back to "dev" for local / untagged builds
|
||||
ARG BUILD_VERSION=dev
|
||||
ENV APP_VERSION=$BUILD_VERSION
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||
|
||||
@@ -1,31 +1,61 @@
|
||||
# Fabled Assistant
|
||||
|
||||
A self-hosted note-taking and task-tracking application with integrated LLM capabilities. Write, organize, and enhance your notes with the help of a local AI assistant — all running on your own hardware.
|
||||
A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware.
|
||||
|
||||
## What It Does
|
||||
|
||||
**Notes with inline formatting** — Write in Markdown with a live-preview editor. Headings, bold, italic, lists, and code blocks render inline as you type, similar to Obsidian or Typora.
|
||||
**Notes with inline formatting** — Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks without leaving the keyboard.
|
||||
|
||||
**Task tracking** — Any note can become a task with a status (todo, in progress, done), priority level, and due date. Convert freely between notes and tasks without losing content.
|
||||
**Task tracking** — Notes convert freely to tasks (and back). Tasks carry status (`todo` → `in_progress` → `done`), priority, due date, sub-tasks, milestone assignment, and work logs with time tracking.
|
||||
|
||||
**Wikilinks and backlinks** — Link notes together with `[[Title]]` syntax. Click a wikilink to navigate to (or auto-create) the referenced note. Each note shows what links to it.
|
||||
**Projects and milestones** — Group related notes and tasks into projects. Milestones give projects a timeline and show completion progress. A kanban-style project view groups tasks by milestone.
|
||||
|
||||
**Tag organization** — Add `#tags` anywhere in your text. Tags are extracted automatically and support hierarchical filtering (e.g., `#project/webapp` matches both `project` and `project/webapp`). Tag autocomplete suggests existing tags as you type.
|
||||
**Project Workspace** — `/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically.
|
||||
|
||||
**AI writing assistant** — Select a section of your note and give the assistant an instruction ("summarize this", "make it more concise", "add examples"). The assistant streams a suggestion that you can accept or reject. Powered by Ollama running locally.
|
||||
**Wikilinks and backlinks** — Link notes with `[[Title]]` syntax. Click a wikilink to navigate to (or create) the referenced note. Each note shows what links to it. The editor suggests existing note titles as candidate links.
|
||||
|
||||
**AI chat** — Have conversations with your AI assistant. The chat is context-aware: it can automatically find and reference your notes to give more relevant answers. Attach specific notes for focused discussions. Save useful responses as new notes.
|
||||
**Tag organisation** — Tags are first-class columns (stored as a PostgreSQL array, not extracted from body text). Tag autocomplete, tag-based filtering, and a force-directed graph view show how notes cluster.
|
||||
|
||||
**Multi-user support** — Multiple users can share the same instance with isolated data. The first registered user becomes the admin.
|
||||
**Knowledge graph** — `/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; project hub nodes (invisible) attract project members. Click any node to open a slide-in peek panel.
|
||||
|
||||
**Dark and light themes** — Defaults to dark mode with a one-click toggle. All views respect your preference.
|
||||
**AI chat** — Full conversation history with SSE streaming. The assistant automatically retrieves semantically relevant notes (RAG) and injects them as context. Attach specific notes by paperclip for focused discussions. Useful replies can be saved directly as notes.
|
||||
|
||||
**AI writing assistant** — Select a passage in the editor, give an instruction ("make this more concise", "add examples"), and stream a diff-style suggestion you can accept or reject.
|
||||
|
||||
**Web research** — The assistant can search the web (SearXNG), fetch pages, and synthesise findings. Research results are saved as notes. A lightweight `search_web` tool answers quick questions inline.
|
||||
|
||||
**Collaboration and sharing** — Share any project or note with other users or groups. Three permission levels: `viewer`, `editor`, `admin`. A "Shared with me" page lists all incoming shares. In-app notifications (with push) alert recipients when items are shared or when they are added to a group.
|
||||
|
||||
**Groups** — Admins can create platform-wide groups, assign users roles (`member` / `owner`), and share resources with the group in one action.
|
||||
|
||||
**In-app notifications** — A bell icon in the nav shows unread notification count with a 60-second polling interval. Clicking a notification navigates to the relevant resource and marks it read.
|
||||
|
||||
**CalDAV calendar** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) and have the assistant create, list, search, update, and delete calendar events via natural language.
|
||||
|
||||
**Push notifications** — Web Push (VAPID) notifies you when AI generation completes, even in another tab. Configurable per-user from the Notifications settings tab.
|
||||
|
||||
**PWA** — Installable as a desktop or mobile app. Service worker caches the shell; push is handled by `public/sw.js`.
|
||||
|
||||
**Data export and backup** — Export your data as a Markdown ZIP (with YAML frontmatter) or a JSON array from the Data settings tab. Admins can export/restore full application backups (version 2 includes projects, milestones, task logs, AI drafts, note versions, push subscriptions) with proper ID remapping on restore.
|
||||
|
||||
**OAuth / OIDC login** — Supports PKCE-based OIDC in addition to local username/password auth. `LOCAL_AUTH_ENABLED` can disable local login entirely for SSO-only deployments.
|
||||
|
||||
**Multi-user with isolation** — All data is scoped to the owning user. Access to shared resources is resolved through `services/access.py` using the permission rank system. The first registered user becomes admin.
|
||||
|
||||
**Daily Briefing** — A scheduled, dialogue-based morning briefing accessible at `/briefing`. The assistant compiles your tasks, calendar events, projects, weather forecast (Open-Meteo), and RSS feed digest at 4am, then checks in at 8am, 12pm, and 4pm. You can reply interactively — the briefing is a real conversation, not a widget. The assistant learns your preferences over time via a profile note it maintains. Configure locations, work schedule, RSS feeds, and active slots from Settings → Briefing.
|
||||
|
||||
**Dark and light themes** — Defaults to dark (slate-indigo palette). One-click toggle in the header.
|
||||
|
||||
**Keyboard shortcuts** — `g`+`h/n/t/p/c/g` navigate sections; `n` new note; `t` new task; `?` shows the shortcut panel. Full list available in-app.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||
- A machine with enough resources to run an LLM (8GB+ RAM recommended for smaller models)
|
||||
- A machine with enough RAM to run an LLM (8 GB+ recommended for smaller models like `llama3.2`)
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -42,54 +72,69 @@ A self-hosted note-taking and task-tracking application with integrated LLM capa
|
||||
|
||||
3. Open `http://localhost:5000` in your browser.
|
||||
|
||||
4. Register the first user account (this account becomes the admin).
|
||||
4. Register the first user account — this account becomes the admin.
|
||||
|
||||
5. Go to **Settings** to download an LLM model. Smaller models like `llama3.2` (2GB) work well for getting started.
|
||||
5. Go to **Settings → General** to pull an LLM model (`llama3.2` at 2 GB is a good starting point).
|
||||
|
||||
### Day-to-Day Usage
|
||||
|
||||
- **Create a note** from the Notes page. Use Markdown — formatting renders live in the editor.
|
||||
- **Link notes** by typing `[[` to get a wikilink autocomplete dropdown.
|
||||
- **Tag your notes** by typing `#` followed by a tag name. Autocomplete suggests existing tags.
|
||||
- **Use the AI assistant** in the bottom panel of the editor. Select a section, write an instruction, and click Generate.
|
||||
- **Chat with the AI** from the Chat page. Attach notes for context or let the assistant find relevant notes automatically.
|
||||
- **Convert notes to tasks** from the note viewer to add status, priority, and due dates.
|
||||
- **Save chat responses** as notes to preserve useful AI-generated content.
|
||||
- **Backup your data** from Settings (admin users can export and restore full backups).
|
||||
- **Create a note** from the Notes page. Use Markdown — formatting renders live.
|
||||
- **Link notes** by typing `[[` for a wikilink autocomplete dropdown.
|
||||
- **Tag your notes** — add tags in the sidebar tag input; autocomplete suggests existing tags.
|
||||
- **Use the AI writing assistant** — select text in the editor, write an instruction, stream a suggestion.
|
||||
- **Chat with the AI** — the assistant finds relevant notes automatically. Attach specific notes for focused context.
|
||||
- **Convert notes ↔ tasks** from the viewer toolbar.
|
||||
- **Open a project workspace** from the project page — chat, tasks, and note editor in one view.
|
||||
- **Share a project or note** — click Share in the project/note/task viewer toolbar, search for users or pick a group.
|
||||
- **Manage groups** (admins) — Settings → Groups tab.
|
||||
- **Backup your data** — Settings → Data tab for personal export; admin section for full application backup.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is done via environment variables. See `docker-compose.yml` for defaults.
|
||||
Configuration is via environment variables. See `docker-compose.yml` for defaults.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL connection string |
|
||||
| `SECRET_KEY` | (required) | Session signing key |
|
||||
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint |
|
||||
| `DEFAULT_MODEL` | `llama3.1` | Default LLM model to auto-pull on startup |
|
||||
| `SECURE_COOKIES` | `false` | Set to `true` when behind TLS to add `Secure` flag to session cookies |
|
||||
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||
| `DEFAULT_MODEL` | `llama3.1` | LLM model to warm on startup |
|
||||
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG |
|
||||
| `SECURE_COOKIES` | `false` | Set `true` behind TLS |
|
||||
| `LOG_LEVEL` | `INFO` | Logging verbosity |
|
||||
| `OIDC_ISSUER` | — | OIDC issuer URL for SSO login |
|
||||
| `OIDC_CLIENT_ID` | — | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
|
||||
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local login |
|
||||
| `SEARXNG_URL` | — | SearXNG base URL for web search |
|
||||
| `BASE_URL` | — | Public URL (used in email links and OIDC redirect) |
|
||||
|
||||
For production deployments, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
|
||||
For production, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Reverse Proxy (Required)
|
||||
|
||||
Fabled Assistant does **not** handle SSL/TLS. You must run it behind a reverse proxy for production use:
|
||||
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
|
||||
|
||||
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
|
||||
- Terminate TLS at the proxy and forward traffic to port 5000
|
||||
- Terminate TLS at the proxy; forward to port 5000
|
||||
- **Do not expose port 5000 directly to the internet**
|
||||
- **Rate-limit auth endpoints** at the proxy layer — recommended: limit `/api/auth/login` and `/api/auth/register` to ~5 requests/minute per IP to prevent brute-force attacks
|
||||
- **Set Content Security Policy headers** at the proxy — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'`
|
||||
- **Rate-limit auth endpoints** — recommended: ≤5 req/min per IP on `/api/auth/login` and `/api/auth/register`
|
||||
- **Set CSP headers** — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'`
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- **Set a strong `SECRET_KEY`** — used to sign session cookies. Generate one with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`.
|
||||
- **Registration auto-closes** — After the first user registers (who becomes admin), registration is closed by default. The admin can re-enable it from the user management page (`/admin/users`).
|
||||
- **Use Docker secrets in production** — `docker-compose.prod.yml` reads `SECRET_KEY_FILE` and `DATABASE_URL_FILE` instead of plain environment variables.
|
||||
- **Keep Ollama on an internal network** — The default compose files keep Ollama on a Docker-internal network, not exposed to the host.
|
||||
- **Strong `SECRET_KEY`** — generate with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`
|
||||
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
|
||||
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions.
|
||||
- **Keep Ollama on an internal network** — both compose files keep it off the host network.
|
||||
|
||||
---
|
||||
|
||||
## Technical Overview
|
||||
|
||||
@@ -98,61 +143,69 @@ Fabled Assistant does **not** handle SSL/TLS. You must run it behind a reverse p
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router |
|
||||
| Editor | Tiptap (ProseMirror) with custom extensions |
|
||||
| Backend | Python 3.12, Quart (async Flask-like framework) |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic migrations |
|
||||
| LLM | Ollama (or any OpenAI-compatible API) |
|
||||
| Deployment | Docker Compose (app + PostgreSQL + Ollama) |
|
||||
| Editor | Tiptap (ProseMirror) with custom slash-command extension |
|
||||
| Backend | Python 3.12, Quart (async ASGI) |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic |
|
||||
| LLM | Ollama (local) — any OpenAI-compatible API |
|
||||
| Search | SearXNG (optional, self-hosted) |
|
||||
| Push | Web Push / VAPID (pywebpush 2.x) |
|
||||
| Deployment | Docker Compose |
|
||||
|
||||
### Architecture
|
||||
|
||||
The application runs as a single container serving both the Vue.js SPA and the REST API. The frontend is built by Vite during the Docker image build and served as static files by Quart. API routes live under `/api/`.
|
||||
The app runs as a single container serving the Vue SPA and REST API (`/api/`). The frontend is built by Vite during the Docker image build and served as static files by Quart.
|
||||
|
||||
LLM interactions use Server-Sent Events (SSE) for streaming responses. Chat generation runs in background `asyncio` tasks with an in-memory event buffer that supports client reconnection without data loss.
|
||||
LLM interactions stream via Server-Sent Events (SSE). Chat generation runs in background `asyncio` tasks with an in-memory event buffer supporting client reconnection without data loss. An abort mechanism lets users cancel in-flight generations.
|
||||
|
||||
The editor uses a Markdown-to-HTML-to-Markdown round-trip: content is stored as Markdown, converted to HTML for Tiptap editing, and serialized back to Markdown on every change. ProseMirror decoration plugins provide visual highlighting for tags and wikilinks without custom node types.
|
||||
Semantic search (RAG) uses `nomic-embed-text` via Ollama to generate embeddings stored in PostgreSQL. Notes above 0.60 cosine similarity are auto-injected into the system prompt; notes between 0.45–0.60 appear as sidebar suggestions.
|
||||
|
||||
Permission resolution is centralised in `services/access.py`. All resource access goes through `get_project_permission` / `get_note_permission`, which check ownership, direct shares, group membership shares, and note→project inheritance in order, returning the highest applicable permission.
|
||||
|
||||
### Database
|
||||
|
||||
All data is stored in PostgreSQL. The schema uses a unified model where tasks are notes with additional attributes (`status`, `priority`, `due_date`). Migrations are idempotent raw SQL and run automatically on container startup.
|
||||
PostgreSQL with SQLAlchemy 2.0 async. Tasks are notes with non-null `status` (unified `Note` model). Tags are stored as a `ARRAY[text]` column. Migrations run automatically on startup via Alembic.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
fabledassistant/
|
||||
├── docker-compose.yml # Development stack
|
||||
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
||||
├── Dockerfile # Multi-stage build (Node + Python)
|
||||
├── alembic/ # Database migrations
|
||||
├── src/fabledassistant/ # Python backend
|
||||
│ ├── app.py # Quart app factory
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── routes/ # API route blueprints
|
||||
│ ├── services/ # Business logic
|
||||
│ └── static/ # Built frontend (generated)
|
||||
└── frontend/ # Vue.js frontend
|
||||
├── docker-compose.yml # Development stack
|
||||
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
||||
├── Dockerfile # Multi-stage build (Node → Python)
|
||||
├── alembic/ # Database migrations
|
||||
├── src/fabledassistant/
|
||||
│ ├── app.py # Quart app factory + blueprint registration
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── routes/ # API blueprints
|
||||
│ ├── services/ # Business logic (access, sharing, groups, …)
|
||||
│ └── static/ # Built frontend (generated at build time)
|
||||
└── frontend/
|
||||
└── src/
|
||||
├── views/ # Page components
|
||||
├── components/ # Reusable UI components
|
||||
├── extensions/ # Tiptap/ProseMirror plugins
|
||||
├── composables/ # Vue composables
|
||||
├── stores/ # Pinia state management
|
||||
└── api/ # API client
|
||||
├── views/ # Page-level components
|
||||
├── components/ # Reusable UI components
|
||||
├── composables/ # Vue composables (autosave, shortcuts, …)
|
||||
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
|
||||
└── api/ # Typed API client (client.ts)
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
All development is done via Docker:
|
||||
All development is done via Docker. No local dependency installation required.
|
||||
|
||||
```bash
|
||||
# Start the dev stack
|
||||
# Start the dev stack (hot-reload not included — rebuild on changes)
|
||||
docker compose up --build
|
||||
|
||||
# Reset database (destroy volumes and rebuild)
|
||||
# Reset the database
|
||||
docker compose down -v && docker compose up --build
|
||||
|
||||
# Lint, format, typecheck, test (runs inside Docker via Makefile)
|
||||
make check
|
||||
```
|
||||
|
||||
No local dependency installation required.
|
||||
CI runs on Forgejo Actions with a custom runner image (`py3.12-node22`) that has Python 3.12 and Node 22 pre-installed. Pushes to `dev` build a `:dev` image; merges to `main` build `:latest`.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Add groups, group_memberships, project_shares, note_shares, notifications tables.
|
||||
|
||||
Revision ID: 0025
|
||||
Revises: 0024
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0025"
|
||||
down_revision = "0024"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"groups",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("description", sa.Text()),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"group_memberships",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"group_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("groups.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("role", sa.Text(), nullable=False, server_default="member"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("group_id", "user_id", name="uq_gm_group_user"),
|
||||
)
|
||||
op.create_index("idx_gm_user", "group_memberships", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"project_shares",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"project_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"shared_with_user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
),
|
||||
sa.Column(
|
||||
"shared_with_group_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("groups.id", ondelete="CASCADE"),
|
||||
),
|
||||
sa.Column("permission", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"invited_by",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)",
|
||||
name="ck_ps_exclusive_target",
|
||||
),
|
||||
)
|
||||
op.create_index("idx_ps_project", "project_shares", ["project_id"])
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX idx_ps_user ON project_shares(project_id, shared_with_user_id)"
|
||||
" WHERE shared_with_user_id IS NOT NULL"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX idx_ps_group ON project_shares(project_id, shared_with_group_id)"
|
||||
" WHERE shared_with_group_id IS NOT NULL"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"note_shares",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"note_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"shared_with_user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
),
|
||||
sa.Column(
|
||||
"shared_with_group_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("groups.id", ondelete="CASCADE"),
|
||||
),
|
||||
sa.Column("permission", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"invited_by",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)",
|
||||
name="ck_ns_exclusive_target",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX idx_ns_user ON note_shares(note_id, shared_with_user_id)"
|
||||
" WHERE shared_with_user_id IS NOT NULL"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX idx_ns_group ON note_shares(note_id, shared_with_group_id)"
|
||||
" WHERE shared_with_group_id IS NOT NULL"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("type", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"payload",
|
||||
postgresql.JSONB(),
|
||||
nullable=False,
|
||||
server_default="{}",
|
||||
),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True)),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX idx_notif_user_unread ON notifications(user_id, read_at)"
|
||||
" WHERE read_at IS NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("notifications")
|
||||
op.drop_table("note_shares")
|
||||
op.drop_table("project_shares")
|
||||
op.drop_table("group_memberships")
|
||||
op.drop_table("groups")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Add briefing tables: rss_feeds, rss_items, weather_cache, conversation briefing columns."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0026"
|
||||
down_revision = "0025"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Extend conversations table
|
||||
op.execute("""
|
||||
ALTER TABLE conversations
|
||||
ADD COLUMN IF NOT EXISTS conversation_type TEXT NOT NULL DEFAULT 'chat',
|
||||
ADD COLUMN IF NOT EXISTS briefing_date DATE
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_conversations_type ON conversations(conversation_type)")
|
||||
|
||||
# RSS feeds
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
category TEXT,
|
||||
last_fetched_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_rss_feeds_user_url UNIQUE (user_id, url)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_rss_feeds_user_id ON rss_feeds(user_id)")
|
||||
|
||||
# RSS items
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rss_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feed_id INTEGER NOT NULL REFERENCES rss_feeds(id) ON DELETE CASCADE,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
published_at TIMESTAMPTZ,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_rss_items_feed_guid UNIQUE (feed_id, guid)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_rss_items_feed_id ON rss_items(feed_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_rss_items_published_at ON rss_items(published_at)")
|
||||
|
||||
# Weather cache
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS weather_cache (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
location_key TEXT NOT NULL,
|
||||
location_label TEXT NOT NULL DEFAULT '',
|
||||
forecast_json JSONB,
|
||||
previous_json JSONB,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_weather_cache_user_location UNIQUE (user_id, location_key)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_weather_cache_user_id ON weather_cache(user_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_weather_cache_user_id")
|
||||
op.execute("DROP TABLE IF EXISTS weather_cache")
|
||||
op.execute("DROP INDEX IF EXISTS ix_rss_items_published_at")
|
||||
op.execute("DROP INDEX IF EXISTS ix_rss_items_feed_id")
|
||||
op.execute("DROP TABLE IF EXISTS rss_items")
|
||||
op.execute("DROP INDEX IF EXISTS ix_rss_feeds_user_id")
|
||||
op.execute("DROP TABLE IF EXISTS rss_feeds")
|
||||
op.execute("DROP INDEX IF EXISTS ix_conversations_type")
|
||||
op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS briefing_date")
|
||||
op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS conversation_type")
|
||||
@@ -0,0 +1,538 @@
|
||||
# Backup Service Rewrite Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rewrite `services/backup.py` so that full and user backup/restore correctly includes every model added since the original implementation (projects, milestones, task logs, drafts, versions), with correct FK re-mapping on restore.
|
||||
|
||||
**Architecture:** Bump backup JSON format to version 2. Export is additive — all new tables exported. Restore builds an ID map for each table and patches FK references in the correct dependency order. V1 backups continue to restore via the existing code path.
|
||||
|
||||
**Tech Stack:** Python/SQLAlchemy 2.0 async, no new dependencies.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-11-backup-rewrite-design.md`
|
||||
|
||||
**Dependency note:** This plan can be split into Part A (pre-sharing models) and Part B (sharing models). Part A is independent and should be done first.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend export — full backup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/backup.py`
|
||||
|
||||
- [ ] **Step 1: Read the current `export_full_backup()` function**
|
||||
|
||||
Understand what is currently exported: users, notes (partial fields), conversations+messages, settings.
|
||||
|
||||
- [ ] **Step 2: Rewrite `export_full_backup()` to version 2**
|
||||
|
||||
Add all missing models to the import list at the top of `backup.py`:
|
||||
```python
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
from fabledassistant.models.task_log import TaskLog
|
||||
from fabledassistant.models.note_draft import NoteDraft
|
||||
from fabledassistant.models.note_version import NoteVersion
|
||||
from fabledassistant.models.push_subscription import PushSubscription
|
||||
```
|
||||
|
||||
Rewrite `export_full_backup()`:
|
||||
```python
|
||||
async def export_full_backup() -> dict:
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
projects = (await session.execute(select(Project))).scalars().all()
|
||||
milestones = (await session.execute(select(Milestone))).scalars().all()
|
||||
notes = (await session.execute(select(Note))).scalars().all()
|
||||
task_logs = (await session.execute(select(TaskLog))).scalars().all()
|
||||
note_drafts = (await session.execute(select(NoteDraft))).scalars().all()
|
||||
note_versions = (await session.execute(select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.version_number))).scalars().all()
|
||||
conversations = (await session.execute(
|
||||
select(Conversation).options(selectinload(Conversation.messages))
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
push_subs = (await session.execute(select(PushSubscription))).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 2,
|
||||
"scope": "full",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"_security_notice": (
|
||||
"This backup contains hashed passwords and push subscription keys. "
|
||||
"Store it securely and restrict access."
|
||||
),
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"password_hash": u.password_hash,
|
||||
"oauth_sub": u.oauth_sub,
|
||||
"role": u.role,
|
||||
"session_version": u.session_version,
|
||||
"created_at": u.created_at.isoformat(),
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"id": p.id,
|
||||
"user_id": p.user_id,
|
||||
"title": p.title,
|
||||
"description": p.description,
|
||||
"goal": p.goal,
|
||||
"status": p.status,
|
||||
"color": p.color,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
"updated_at": p.updated_at.isoformat(),
|
||||
}
|
||||
for p in projects
|
||||
],
|
||||
"milestones": [
|
||||
{
|
||||
"id": m.id,
|
||||
"user_id": m.user_id,
|
||||
"project_id": m.project_id,
|
||||
"title": m.title,
|
||||
"description": m.description,
|
||||
"status": m.status,
|
||||
"order_index": m.order_index,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"updated_at": m.updated_at.isoformat(),
|
||||
}
|
||||
for m in milestones
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"user_id": n.user_id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"tags": n.tags or [],
|
||||
"parent_id": n.parent_id,
|
||||
"project_id": n.project_id,
|
||||
"milestone_id": n.milestone_id,
|
||||
"is_task": n.is_task,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||
"is_starred": getattr(n, "is_starred", False),
|
||||
"is_pinned": getattr(n, "is_pinned", False),
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"updated_at": n.updated_at.isoformat(),
|
||||
}
|
||||
for n in notes
|
||||
],
|
||||
"task_logs": [
|
||||
{
|
||||
"id": tl.id,
|
||||
"user_id": tl.user_id,
|
||||
"note_id": tl.note_id,
|
||||
"description": tl.description,
|
||||
"duration_minutes": tl.duration_minutes,
|
||||
"logged_at": tl.logged_at.isoformat() if tl.logged_at else None,
|
||||
"created_at": tl.created_at.isoformat(),
|
||||
}
|
||||
for tl in task_logs
|
||||
],
|
||||
"note_drafts": [
|
||||
{
|
||||
"id": nd.id,
|
||||
"user_id": nd.user_id,
|
||||
"note_id": nd.note_id,
|
||||
"title": nd.title,
|
||||
"body": nd.body,
|
||||
"saved_at": nd.saved_at.isoformat() if nd.saved_at else None,
|
||||
}
|
||||
for nd in note_drafts
|
||||
],
|
||||
"note_versions": [
|
||||
{
|
||||
"id": nv.id,
|
||||
"user_id": nv.user_id,
|
||||
"note_id": nv.note_id,
|
||||
"title": nv.title,
|
||||
"body": nv.body,
|
||||
"version_number": nv.version_number,
|
||||
"created_at": nv.created_at.isoformat(),
|
||||
}
|
||||
for nv in note_versions
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.id,
|
||||
"user_id": c.user_id,
|
||||
"title": c.title,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"context_note_id": m.context_note_id,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in c.messages
|
||||
],
|
||||
}
|
||||
for c in conversations
|
||||
],
|
||||
"settings": [
|
||||
{"user_id": s.user_id, "key": s.key, "value": s.value}
|
||||
for s in settings
|
||||
],
|
||||
"push_subscriptions": [
|
||||
{
|
||||
"user_id": ps.user_id,
|
||||
"endpoint": ps.endpoint,
|
||||
"p256dh": ps.p256dh,
|
||||
"auth": ps.auth,
|
||||
"created_at": ps.created_at.isoformat(),
|
||||
}
|
||||
for ps in push_subs
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Check what exact field names exist on each model before assuming. Use `getattr(obj, "field", default)` for fields that may not exist on older DB versions.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/backup.py
|
||||
git commit -m "feat(backup): v2 full export with projects, milestones, task_logs, drafts, versions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extend export — user backup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/backup.py`
|
||||
|
||||
- [ ] **Step 1: Rewrite `export_user_backup(user_id)`**
|
||||
|
||||
Same structure as full backup but filtered to `WHERE user_id = uid`. Apply same field additions. Omit sensitive fields (password_hash, oauth_sub) from user self-export.
|
||||
|
||||
```python
|
||||
async def export_user_backup(user_id: int) -> dict:
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
projects = (await session.execute(select(Project).where(Project.user_id == user_id))).scalars().all()
|
||||
milestones = (await session.execute(select(Milestone).where(Milestone.user_id == user_id))).scalars().all()
|
||||
notes = (await session.execute(select(Note).where(Note.user_id == user_id))).scalars().all()
|
||||
task_logs = (await session.execute(select(TaskLog).where(TaskLog.user_id == user_id))).scalars().all()
|
||||
note_drafts = (await session.execute(select(NoteDraft).where(NoteDraft.user_id == user_id))).scalars().all()
|
||||
note_versions = (await session.execute(
|
||||
select(NoteVersion).where(NoteVersion.user_id == user_id)
|
||||
.order_by(NoteVersion.note_id, NoteVersion.version_number)
|
||||
)).scalars().all()
|
||||
conversations = (await session.execute(
|
||||
select(Conversation).options(selectinload(Conversation.messages))
|
||||
.where(Conversation.user_id == user_id)
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting).where(Setting.user_id == user_id))).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 2,
|
||||
"scope": "user",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
} if user else None,
|
||||
"projects": [...], # same as full but user-filtered
|
||||
"milestones": [...],
|
||||
"notes": [...],
|
||||
"task_logs": [...],
|
||||
"note_drafts": [...],
|
||||
"note_versions": [...],
|
||||
"conversations": [...],
|
||||
"settings": [...],
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/backup.py
|
||||
git commit -m "feat(backup): v2 user backup with all models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Rewrite restore for v2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/backup.py`
|
||||
|
||||
- [ ] **Step 1: Add version dispatch to `restore_full_backup`**
|
||||
|
||||
```python
|
||||
async def restore_full_backup(data: dict) -> dict:
|
||||
version = data.get("version", 1)
|
||||
if version == 1:
|
||||
return await _restore_v1(data)
|
||||
return await _restore_v2(data)
|
||||
```
|
||||
|
||||
Move existing restore code into `_restore_v1(data)` with no changes.
|
||||
|
||||
- [ ] **Step 2: Implement `_restore_v2(data)` with full FK re-mapping**
|
||||
|
||||
Restore order (respects FK dependencies):
|
||||
1. Users → build `user_id_map`
|
||||
2. Projects (fk: user_id) → build `project_id_map`
|
||||
3. Milestones (fk: user_id, project_id) → build `milestone_id_map`
|
||||
4. Notes — first pass: insert without `parent_id` (fk: user_id, project_id, milestone_id) → build `note_id_map`
|
||||
5. Notes — second pass: patch `parent_id` using `note_id_map`
|
||||
6. TaskLogs (fk: user_id, note_id via note_id_map)
|
||||
7. NoteDrafts (fk: user_id, note_id via note_id_map)
|
||||
8. NoteVersions (fk: user_id, note_id via note_id_map) — export only, no restore by default (skip unless flag set)
|
||||
9. Conversations (fk: user_id) → Messages (fk: conversation_id, context_note_id via note_id_map)
|
||||
10. Settings (fk: user_id)
|
||||
|
||||
```python
|
||||
async def _restore_v2(data: dict) -> dict:
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
stats = {
|
||||
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
|
||||
"task_logs": 0, "note_drafts": 0, "conversations": 0,
|
||||
"messages": 0, "settings": 0
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
user_id_map: dict[int, int] = {}
|
||||
project_id_map: dict[int, int] = {}
|
||||
milestone_id_map: dict[int, int] = {}
|
||||
note_id_map: dict[int, int] = {}
|
||||
|
||||
# 1. Users
|
||||
for u_data in data.get("users", []):
|
||||
old_id = u_data["id"]
|
||||
user = User(
|
||||
username=u_data["username"],
|
||||
email=u_data.get("email"),
|
||||
password_hash=u_data.get("password_hash"),
|
||||
oauth_sub=u_data.get("oauth_sub"),
|
||||
role=u_data.get("role", "user"),
|
||||
session_version=u_data.get("session_version", 1),
|
||||
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
user_id_map[old_id] = user.id
|
||||
stats["users"] += 1
|
||||
|
||||
# 2. Projects
|
||||
for p_data in data.get("projects", []):
|
||||
mapped_uid = user_id_map.get(p_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
proj = Project(
|
||||
user_id=mapped_uid,
|
||||
title=p_data.get("title", ""),
|
||||
description=p_data.get("description"),
|
||||
goal=p_data.get("goal"),
|
||||
status=p_data.get("status", "active"),
|
||||
color=p_data.get("color"),
|
||||
created_at=datetime.fromisoformat(p_data["created_at"]) if p_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(p_data["updated_at"]) if p_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(proj)
|
||||
await session.flush()
|
||||
project_id_map[p_data["id"]] = proj.id
|
||||
stats["projects"] += 1
|
||||
|
||||
# 3. Milestones
|
||||
for m_data in data.get("milestones", []):
|
||||
mapped_uid = user_id_map.get(m_data.get("user_id", 0))
|
||||
mapped_pid = project_id_map.get(m_data.get("project_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
ms = Milestone(
|
||||
user_id=mapped_uid,
|
||||
project_id=mapped_pid,
|
||||
title=m_data.get("title", ""),
|
||||
description=m_data.get("description"),
|
||||
status=m_data.get("status", "open"),
|
||||
order_index=m_data.get("order_index", 0),
|
||||
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(m_data["updated_at"]) if m_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(ms)
|
||||
await session.flush()
|
||||
milestone_id_map[m_data["id"]] = ms.id
|
||||
stats["milestones"] += 1
|
||||
|
||||
# 4. Notes — first pass (no parent_id)
|
||||
note_objects: list[tuple[int, int]] = [] # (old_parent_id, new_note_id)
|
||||
for n_data in data.get("notes", []):
|
||||
mapped_uid = user_id_map.get(n_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
due = None
|
||||
if n_data.get("due_date"):
|
||||
due = date.fromisoformat(n_data["due_date"])
|
||||
note = Note(
|
||||
user_id=mapped_uid,
|
||||
title=n_data.get("title", ""),
|
||||
body=n_data.get("body", ""),
|
||||
tags=n_data.get("tags", []),
|
||||
parent_id=None, # patched in second pass
|
||||
project_id=project_id_map.get(n_data.get("project_id")) if n_data.get("project_id") else None,
|
||||
milestone_id=milestone_id_map.get(n_data.get("milestone_id")) if n_data.get("milestone_id") else None,
|
||||
is_task=n_data.get("is_task", False),
|
||||
status=n_data.get("status"),
|
||||
priority=n_data.get("priority"),
|
||||
due_date=due,
|
||||
created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(note)
|
||||
await session.flush()
|
||||
note_id_map[n_data["id"]] = note.id
|
||||
if n_data.get("parent_id"):
|
||||
note_objects.append((n_data["parent_id"], note.id))
|
||||
stats["notes"] += 1
|
||||
|
||||
# 5. Notes — second pass: patch parent_id
|
||||
for old_parent_id, new_note_id in note_objects:
|
||||
new_parent_id = note_id_map.get(old_parent_id)
|
||||
if new_parent_id:
|
||||
note_row = await session.get(Note, new_note_id)
|
||||
if note_row:
|
||||
note_row.parent_id = new_parent_id
|
||||
|
||||
# 6. TaskLogs
|
||||
for tl_data in data.get("task_logs", []):
|
||||
mapped_uid = user_id_map.get(tl_data.get("user_id", 0))
|
||||
mapped_nid = note_id_map.get(tl_data.get("note_id", 0))
|
||||
if mapped_uid is None or mapped_nid is None:
|
||||
continue
|
||||
from fabledassistant.models.task_log import TaskLog
|
||||
tl = TaskLog(
|
||||
user_id=mapped_uid,
|
||||
note_id=mapped_nid,
|
||||
description=tl_data.get("description", ""),
|
||||
duration_minutes=tl_data.get("duration_minutes"),
|
||||
logged_at=datetime.fromisoformat(tl_data["logged_at"]) if tl_data.get("logged_at") else None,
|
||||
created_at=datetime.fromisoformat(tl_data["created_at"]) if tl_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(tl)
|
||||
stats["task_logs"] += 1
|
||||
|
||||
# 7. NoteDrafts
|
||||
for nd_data in data.get("note_drafts", []):
|
||||
mapped_uid = user_id_map.get(nd_data.get("user_id", 0))
|
||||
mapped_nid = note_id_map.get(nd_data.get("note_id", 0))
|
||||
if mapped_uid is None or mapped_nid is None:
|
||||
continue
|
||||
from fabledassistant.models.note_draft import NoteDraft
|
||||
nd = NoteDraft(
|
||||
user_id=mapped_uid,
|
||||
note_id=mapped_nid,
|
||||
title=nd_data.get("title", ""),
|
||||
body=nd_data.get("body", ""),
|
||||
saved_at=datetime.fromisoformat(nd_data["saved_at"]) if nd_data.get("saved_at") else None,
|
||||
)
|
||||
session.add(nd)
|
||||
stats["note_drafts"] += 1
|
||||
|
||||
# 8. Conversations + Messages
|
||||
for c_data in data.get("conversations", []):
|
||||
mapped_uid = user_id_map.get(c_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
conv = Conversation(
|
||||
user_id=mapped_uid,
|
||||
title=c_data.get("title", ""),
|
||||
created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(conv)
|
||||
await session.flush()
|
||||
stats["conversations"] += 1
|
||||
for m_data in c_data.get("messages", []):
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role=m_data["role"],
|
||||
content=m_data.get("content", ""),
|
||||
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
|
||||
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(msg)
|
||||
stats["messages"] += 1
|
||||
|
||||
# 9. Settings
|
||||
for s_data in data.get("settings", []):
|
||||
mapped_uid = user_id_map.get(s_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
setting = Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", ""))
|
||||
session.add(setting)
|
||||
stats["settings"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2 backup: %s", stats)
|
||||
return stats
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add missing imports at top of file**
|
||||
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test restore round-trip**
|
||||
|
||||
```bash
|
||||
# Export
|
||||
curl -s -b cookies.txt "http://localhost:5000/api/admin/backup?scope=full" -o /tmp/backup_v2.json
|
||||
# Inspect structure
|
||||
python3 -c "import json; d=json.load(open('/tmp/backup_v2.json')); print(list(d.keys()))"
|
||||
# Expected keys: version, scope, exported_at, users, projects, milestones, notes, task_logs, ...
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck**
|
||||
|
||||
```bash
|
||||
docker compose exec app python -m py_compile src/fabledassistant/services/backup.py
|
||||
```
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/backup.py
|
||||
git commit -m "feat(backup): v2 restore with full FK re-mapping; v1 restore preserved"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verification
|
||||
|
||||
- [ ] **Step 1: Full round-trip test**
|
||||
|
||||
1. Export full backup as admin
|
||||
2. Verify JSON has `"version": 2` and all expected top-level keys
|
||||
3. Verify notes have `project_id`, `milestone_id`, `is_task` fields
|
||||
4. Verify `task_logs` array has entries (create a task log entry first if needed)
|
||||
5. Restore backup to a clean test instance (or verify restore code path runs without error in dry-run)
|
||||
|
||||
- [ ] **Step 2: V1 backward compat test**
|
||||
|
||||
Take an old v1 backup JSON (or construct one without the `version` key) and run restore. Verify it takes the v1 path and doesn't error.
|
||||
|
||||
- [ ] **Step 3: Commit final verification note**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "chore(backup): v2 backup rewrite verified"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
Generated
+985
-561
File diff suppressed because it is too large
Load Diff
+16
-16
@@ -9,27 +9,27 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-link": "^2.11.0",
|
||||
"@tiptap/extension-placeholder": "^2.11.0",
|
||||
"@tiptap/extension-task-item": "^2.27.2",
|
||||
"@tiptap/extension-task-list": "^2.27.2",
|
||||
"@tiptap/pm": "^2.11.0",
|
||||
"@tiptap/starter-kit": "^2.11.0",
|
||||
"@tiptap/suggestion": "^2.11.0",
|
||||
"@tiptap/vue-3": "^2.11.0",
|
||||
"@tiptap/core": "^3.0.0",
|
||||
"@tiptap/extension-link": "^3.0.0",
|
||||
"@tiptap/extension-list": "^3.0.0",
|
||||
"@tiptap/extensions": "^3.0.0",
|
||||
"@tiptap/pm": "^3.0.0",
|
||||
"@tiptap/starter-kit": "^3.0.0",
|
||||
"@tiptap/suggestion": "^3.0.0",
|
||||
"@tiptap/vue-3": "^3.0.0",
|
||||
"d3": "^7",
|
||||
"dompurify": "^3.1.0",
|
||||
"marked": "^15.0.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
"marked": "^17.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "3.5.30",
|
||||
"vue-router": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3": "^7",
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.0.0",
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-5
@@ -2,12 +2,22 @@
|
||||
self.addEventListener('push', event => {
|
||||
if (!event.data) return;
|
||||
const data = event.data.json();
|
||||
const notifUrl = data.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Fabled Assistant', {
|
||||
body: data.body || '',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
data: { url: data.url || '/' },
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||
// Suppress notification if the user already has the relevant page focused
|
||||
for (const client of clientList) {
|
||||
if (client.visibilityState === 'visible' && client.url.includes(notifUrl)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return self.registration.showNotification(data.title || 'Fabled Assistant', {
|
||||
body: data.body || '',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
data: { url: notifUrl },
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+22
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch } from "vue";
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppHeader from "@/components/AppHeader.vue";
|
||||
import ToastNotification from "@/components/ToastNotification.vue";
|
||||
@@ -8,10 +8,12 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiGet } from "@/api/client";
|
||||
|
||||
useTheme();
|
||||
|
||||
const router = useRouter();
|
||||
const appVersion = ref("dev");
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -123,6 +125,12 @@ onMounted(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
startAppServices();
|
||||
}
|
||||
try {
|
||||
const data = await apiGet<{ version: string }>("/api/version");
|
||||
appVersion.value = data.version;
|
||||
} catch {
|
||||
// silent — version display is non-critical
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -150,6 +158,7 @@ onUnmounted(() => {
|
||||
<div id="main-content" class="app-content">
|
||||
<router-view />
|
||||
</div>
|
||||
<footer class="app-footer">v{{ appVersion }}</footer>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard shortcuts overlay -->
|
||||
@@ -299,6 +308,18 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Version footer */
|
||||
.app-footer {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.45;
|
||||
user-select: none;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Shortcuts overlay */
|
||||
.shortcuts-overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -75,6 +75,111 @@ export async function apiDelete(path: string): Promise<void> {
|
||||
return handleResponse<void>(res, path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sharing, Groups, Notifications, User search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ShareEntry {
|
||||
id: number
|
||||
permission: string
|
||||
shared_with_user_id: number | null
|
||||
shared_with_group_id: number | null
|
||||
username?: string
|
||||
group_name?: string
|
||||
invited_by: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GroupEntry {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
created_by: number | null
|
||||
member_count: number
|
||||
is_member: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
id: number
|
||||
group_id: number
|
||||
user_id: number
|
||||
role: string
|
||||
username: string
|
||||
email: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface NotificationEntry {
|
||||
id: number
|
||||
user_id: number
|
||||
type: string
|
||||
payload: Record<string, unknown>
|
||||
read_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UserSearchResult {
|
||||
id: number
|
||||
username: string
|
||||
email: string | null
|
||||
}
|
||||
|
||||
// --- User search ---
|
||||
export const searchUsers = (q: string) =>
|
||||
apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users)
|
||||
|
||||
// --- Groups ---
|
||||
export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups)
|
||||
export const createGroup = (name: string, description?: string) =>
|
||||
apiPost<GroupEntry>('/api/groups', { name, description })
|
||||
export const updateGroup = (id: number, data: { name?: string; description?: string }) =>
|
||||
apiPatch<GroupEntry>(`/api/groups/${id}`, data)
|
||||
export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`)
|
||||
export const getGroupDetail = (id: number) =>
|
||||
apiGet<GroupEntry & { members: GroupMember[] }>(`/api/groups/${id}`)
|
||||
export const listGroupMembers = (id: number) =>
|
||||
apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members)
|
||||
export const addGroupMember = (groupId: number, userId: number, role: string) =>
|
||||
apiPost<GroupMember>(`/api/groups/${groupId}/members`, { user_id: userId, role })
|
||||
export const updateGroupMember = (groupId: number, userId: number, role: string) =>
|
||||
apiPatch<GroupMember>(`/api/groups/${groupId}/members/${userId}`, { role })
|
||||
export const removeGroupMember = (groupId: number, userId: number) =>
|
||||
apiDelete(`/api/groups/${groupId}/members/${userId}`)
|
||||
|
||||
// --- Project shares ---
|
||||
export const listProjectShares = (projectId: number) =>
|
||||
apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares)
|
||||
export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
|
||||
apiPost<ShareEntry>(`/api/projects/${projectId}/shares`, body)
|
||||
export const updateProjectShare = (projectId: number, shareId: number, permission: string) =>
|
||||
apiPatch<ShareEntry>(`/api/projects/${projectId}/shares/${shareId}`, { permission })
|
||||
export const deleteProjectShare = (projectId: number, shareId: number) =>
|
||||
apiDelete(`/api/projects/${projectId}/shares/${shareId}`)
|
||||
|
||||
// --- Note / task shares ---
|
||||
export const listNoteShares = (noteId: number) =>
|
||||
apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares)
|
||||
export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
|
||||
apiPost<ShareEntry>(`/api/notes/${noteId}/shares`, body)
|
||||
export const updateNoteShare = (noteId: number, shareId: number, permission: string) =>
|
||||
apiPatch<ShareEntry>(`/api/notes/${noteId}/shares/${shareId}`, { permission })
|
||||
export const deleteNoteShare = (noteId: number, shareId: number) =>
|
||||
apiDelete(`/api/notes/${noteId}/shares/${shareId}`)
|
||||
|
||||
// --- Shared-with-me ---
|
||||
export const getSharedWithMe = () =>
|
||||
apiGet<{ projects: Record<string, unknown>[]; notes: Record<string, unknown>[] }>('/api/shared-with-me')
|
||||
|
||||
// --- In-app notifications ---
|
||||
export const getNotifications = (all = false) =>
|
||||
apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications)
|
||||
export const getNotificationCount = () =>
|
||||
apiGet<{ count: number }>('/api/notifications/count').then(r => r.count)
|
||||
export const markNotificationRead = (id: number) => apiPost<void>(`/api/notifications/${id}/read`, {})
|
||||
export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {})
|
||||
|
||||
export interface SSEStreamHandle {
|
||||
close(): void;
|
||||
/** Resolves when the stream closes (normally or via error/abort). */
|
||||
@@ -195,6 +300,122 @@ export function apiSSEStream(
|
||||
return { close: () => controller.abort(), done };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Briefing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BriefingLocation {
|
||||
label: string;
|
||||
address: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface BriefingSlots {
|
||||
compilation: boolean;
|
||||
morning: boolean;
|
||||
midday: boolean;
|
||||
afternoon: boolean;
|
||||
}
|
||||
|
||||
export interface BriefingConfig {
|
||||
enabled: boolean;
|
||||
locations: {
|
||||
home?: BriefingLocation;
|
||||
work?: BriefingLocation;
|
||||
};
|
||||
use_caldav_event_locations: boolean;
|
||||
work_days: number[];
|
||||
slots: BriefingSlots;
|
||||
notifications: boolean;
|
||||
}
|
||||
|
||||
export interface BriefingFeed {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string | null;
|
||||
last_fetched_at: string | null;
|
||||
}
|
||||
|
||||
export interface BriefingConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
briefing_date: string | null;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BriefingMessage {
|
||||
id: number;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
||||
enabled: false,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
};
|
||||
|
||||
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
||||
try {
|
||||
const data = await apiGet<BriefingConfig>('/api/briefing/config');
|
||||
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
|
||||
} catch {
|
||||
return { ...DEFAULT_BRIEFING_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
|
||||
await apiPut('/api/briefing/config', config);
|
||||
}
|
||||
|
||||
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
|
||||
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createBriefingFeed(url: string): Promise<BriefingFeed> {
|
||||
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', { url });
|
||||
return { ...data, last_fetched_at: null };
|
||||
}
|
||||
|
||||
export async function deleteBriefingFeed(id: number): Promise<void> {
|
||||
await apiDelete(`/api/briefing/feeds/${id}`);
|
||||
}
|
||||
|
||||
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
|
||||
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
|
||||
return data.conversations;
|
||||
}
|
||||
|
||||
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
|
||||
return apiGet('/api/briefing/conversations/today');
|
||||
}
|
||||
|
||||
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
|
||||
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
|
||||
return data.messages;
|
||||
}
|
||||
|
||||
export async function triggerBriefingSlot(slot: string): Promise<void> {
|
||||
await apiPost('/api/briefing/trigger', { slot });
|
||||
}
|
||||
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||
try {
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
|
||||
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiStreamPost(
|
||||
path: string,
|
||||
body: unknown,
|
||||
|
||||
@@ -51,15 +51,23 @@
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-save {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
padding: 0.45rem 1.1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
opacity: 0.95;
|
||||
}
|
||||
.btn-save:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-delete {
|
||||
@@ -86,13 +94,26 @@
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.title-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-bg-card);
|
||||
padding: 0.4rem 0;
|
||||
border: none;
|
||||
border-bottom: 1.5px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.title-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&display=swap');
|
||||
|
||||
:root {
|
||||
--color-bg: #f5f5fb;
|
||||
--color-bg-secondary: #ededf5;
|
||||
@@ -38,33 +40,37 @@
|
||||
--color-input-bar-text: #1a1a1a;
|
||||
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
|
||||
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
|
||||
--color-bubble-user-text: #3a3a4a;
|
||||
--color-bubble-asst-shadow: 0 2px 16px rgba(99, 102, 241, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
[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-bg: #111113;
|
||||
--color-bg-secondary: #18181f;
|
||||
--color-bg-card: #1e1e27;
|
||||
--color-text: #e4e4f0;
|
||||
--color-text-secondary: #8888a8;
|
||||
--color-text-muted: #52526a;
|
||||
--color-border: rgba(99, 102, 241, 0.10);
|
||||
--color-input-border: rgba(99, 102, 241, 0.22);
|
||||
--color-primary: #818cf8;
|
||||
--color-danger: #f44336;
|
||||
--color-tag-bg: #1e3a5f;
|
||||
--color-tag-text: #7bb8f6;
|
||||
--color-shadow: rgba(0, 0, 0, 0.3);
|
||||
--color-tag-bg: #2a2a45;
|
||||
--color-tag-text: #a5b4fc;
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
--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-todo-bg: #2a2a35;
|
||||
--color-status-in-progress: #818cf8;
|
||||
--color-status-in-progress-bg: #2a2a45;
|
||||
--color-status-done: #4caf50;
|
||||
--color-status-done-bg: #1b3a20;
|
||||
--color-priority-low: #80cbc4;
|
||||
@@ -73,18 +79,22 @@
|
||||
--color-priority-medium-bg: #3a3520;
|
||||
--color-priority-high: #f44336;
|
||||
--color-priority-high-bg: #3a1a1a;
|
||||
--color-wikilink: #ce93d8;
|
||||
--color-wikilink-bg: #2a1a30;
|
||||
--color-wikilink: #c4b5fd;
|
||||
--color-wikilink-bg: #2a1a45;
|
||||
--color-overdue: #f44336;
|
||||
--color-code-bg: #161b22;
|
||||
--color-code-inline-bg: #2a3040;
|
||||
--color-table-stripe: #1a2030;
|
||||
--color-code-bg: #16161d;
|
||||
--color-code-inline-bg: #1e1e2d;
|
||||
--color-table-stripe: #16161e;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #facc15;
|
||||
--color-input-bar-bg: #1c1c1e;
|
||||
--color-input-bar-text: #ffffff;
|
||||
--color-input-bar-placeholder: rgba(255, 255, 255, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.6);
|
||||
--color-input-bar-bg: #1e1e27;
|
||||
--color-input-bar-text: #e4e4f0;
|
||||
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
|
||||
--color-overlay: rgba(0, 0, 0, 0.65);
|
||||
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
|
||||
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
|
||||
--color-bubble-user-text: #b0b0c8;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(99, 102, 241, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -103,6 +113,11 @@ body {
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
@@ -113,6 +128,13 @@ a:focus-visible {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
button:not(:disabled):active,
|
||||
.btn:not(:disabled):active,
|
||||
[role="button"]:not(:disabled):active {
|
||||
transform: scale(0.97);
|
||||
transition: transform 0.08s ease;
|
||||
}
|
||||
|
||||
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
|
||||
@media (max-width: 768px) {
|
||||
.hide-mobile {
|
||||
@@ -131,6 +153,22 @@ a:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* Thin indigo-tinted scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(99, 102, 241, 0.45);
|
||||
}
|
||||
|
||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||
.inline-assist-btn {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const isChatActive = computed(() => route.path.startsWith("/chat"));
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
const gearOpen = ref(false);
|
||||
const gearMenuRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const statusShortLabel = computed(() => {
|
||||
if (chatStore.ollamaStatus === "unavailable") return "Offline";
|
||||
@@ -48,16 +50,6 @@ function toggleMobileMenu() {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||
}
|
||||
|
||||
function toggleGear() {
|
||||
gearOpen.value = !gearOpen.value;
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (gearMenuRef.value && !gearMenuRef.value.contains(e.target as Node)) {
|
||||
gearOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout();
|
||||
mobileMenuOpen.value = false;
|
||||
@@ -66,11 +58,7 @@ async function handleLogout() {
|
||||
|
||||
router.afterEach(() => {
|
||||
mobileMenuOpen.value = false;
|
||||
gearOpen.value = false;
|
||||
});
|
||||
|
||||
onMounted(() => document.addEventListener("click", handleClickOutside));
|
||||
onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -78,7 +66,7 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
<nav class="nav">
|
||||
<!-- Left: brand -->
|
||||
<router-link to="/" class="nav-brand">
|
||||
<AppLogo />
|
||||
<AppLogo :size="34" />
|
||||
Fabled Assistant
|
||||
</router-link>
|
||||
|
||||
@@ -87,8 +75,10 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" class="nav-link">Chat</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Right: status + utilities + gear + user -->
|
||||
@@ -98,26 +88,21 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
<span class="status-text">{{ statusShortLabel }}</span>
|
||||
</span>
|
||||
|
||||
<NotificationBell />
|
||||
|
||||
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
|
||||
|
||||
<button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
||||
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
||||
</button>
|
||||
|
||||
<!-- Gear dropdown -->
|
||||
<div class="gear-menu" ref="gearMenuRef">
|
||||
<button class="btn-icon btn-gear" @click.stop="toggleGear" :class="{ active: gearOpen }" title="Settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="gearOpen" class="gear-dropdown">
|
||||
<router-link to="/settings" class="gear-item">Settings</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/users" class="gear-item">Users</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/logs" class="gear-item">Logs</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Settings link -->
|
||||
<router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</router-link>
|
||||
|
||||
<div class="user-info">
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
@@ -139,12 +124,12 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" class="nav-link">Chat</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/users" class="nav-link">Users</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/logs" class="nav-link">Logs</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<div class="mobile-actions">
|
||||
<span class="status-indicator" :class="statusClass" :title="statusLabel">
|
||||
@@ -166,11 +151,10 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
padding: 0.5rem 1.25rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -182,8 +166,11 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
@@ -217,12 +204,13 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-bg-card);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-bg-card);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -275,40 +263,6 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Gear dropdown */
|
||||
.gear-menu {
|
||||
position: relative;
|
||||
}
|
||||
.gear-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow, rgba(0,0,0,0.15));
|
||||
min-width: 140px;
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.gear-item {
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.gear-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.gear-item.router-link-active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* User info */
|
||||
.user-info {
|
||||
display: flex;
|
||||
|
||||
@@ -37,8 +37,8 @@ defineProps<{ size?: number }>();
|
||||
|
||||
<style scoped>
|
||||
.logo-book {
|
||||
fill: var(--color-text);
|
||||
stroke: var(--color-text-secondary);
|
||||
fill: var(--color-primary);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
.logo-spine {
|
||||
stroke: var(--color-text-secondary);
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import {
|
||||
saveBriefingConfig,
|
||||
geocodeAddress,
|
||||
createBriefingFeed,
|
||||
type BriefingConfig,
|
||||
} from '@/api/client'
|
||||
|
||||
const emit = defineEmits<{ done: [] }>()
|
||||
|
||||
const step = ref(1)
|
||||
const TOTAL_STEPS = 4
|
||||
|
||||
const config = reactive<BriefingConfig>({
|
||||
enabled: true,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
})
|
||||
|
||||
// Step 2 — locations
|
||||
const homeAddress = ref('')
|
||||
const workAddress = ref('')
|
||||
const geocodingHome = ref(false)
|
||||
const geocodingWork = ref(false)
|
||||
const homeConfirmed = ref<string | null>(null)
|
||||
const workConfirmed = ref<string | null>(null)
|
||||
const homeError = ref('')
|
||||
const workError = ref('')
|
||||
|
||||
async function lookupHome() {
|
||||
if (!homeAddress.value.trim()) return
|
||||
geocodingHome.value = true
|
||||
homeError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(homeAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.home = { label: 'Home', address: homeAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
homeConfirmed.value = r.display_name
|
||||
} else {
|
||||
homeError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingHome.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupWork() {
|
||||
if (!workAddress.value.trim()) return
|
||||
geocodingWork.value = true
|
||||
workError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(workAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.work = { label: 'Work', address: workAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
workConfirmed.value = r.display_name
|
||||
} else {
|
||||
workError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingWork.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — work days
|
||||
function toggleDay(d: number) {
|
||||
const idx = config.work_days.indexOf(d)
|
||||
if (idx === -1) config.work_days.push(d)
|
||||
else config.work_days.splice(idx, 1)
|
||||
config.work_days.sort()
|
||||
}
|
||||
|
||||
// Step 4 — RSS feeds
|
||||
const newFeedUrl = ref('')
|
||||
const pendingFeeds = ref<Array<{ url: string }>>([])
|
||||
|
||||
function addPendingFeed() {
|
||||
if (!newFeedUrl.value.trim()) return
|
||||
pendingFeeds.value.push({ url: newFeedUrl.value.trim() })
|
||||
newFeedUrl.value = ''
|
||||
}
|
||||
function removePendingFeed(i: number) { pendingFeeds.value.splice(i, 1) }
|
||||
|
||||
// Finish
|
||||
const finishing = ref(false)
|
||||
async function finish() {
|
||||
finishing.value = true
|
||||
try {
|
||||
await saveBriefingConfig(config)
|
||||
for (const f of pendingFeeds.value) {
|
||||
try { await createBriefingFeed(f.url) } catch { /* best-effort */ }
|
||||
}
|
||||
emit('done')
|
||||
} finally {
|
||||
finishing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="wizard-overlay">
|
||||
<div class="wizard-card" role="dialog" aria-label="Briefing Setup">
|
||||
<!-- Progress bar -->
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-progress-fill" :style="{ width: `${(step / TOTAL_STEPS) * 100}%` }"></div>
|
||||
</div>
|
||||
<div class="wizard-step-label">Step {{ step }} of {{ TOTAL_STEPS }}</div>
|
||||
|
||||
<!-- Step 1: Welcome -->
|
||||
<div v-if="step === 1" class="wizard-body">
|
||||
<h2 class="wizard-title">Good morning.</h2>
|
||||
<p class="wizard-text">
|
||||
The Briefing is a daily conversation that summarises your day — tasks, calendar,
|
||||
weather, and news — and checks in a few times throughout the day.
|
||||
</p>
|
||||
<p class="wizard-text">
|
||||
It learns from your responses over time and adapts to your schedule.
|
||||
Let's take a minute to set it up.
|
||||
</p>
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-primary" @click="step = 2">Get started</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Locations -->
|
||||
<div v-else-if="step === 2" class="wizard-body">
|
||||
<h2 class="wizard-title">Where are you?</h2>
|
||||
<p class="wizard-text">
|
||||
Enter your home and work addresses. The briefing uses these to fetch weather
|
||||
for the right places. You can skip either one.
|
||||
</p>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Home address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="homeAddress" class="wizard-input" placeholder="e.g. 123 Main St, Springfield" @keydown.enter="lookupHome" />
|
||||
<button class="btn-wizard-secondary" @click="lookupHome" :disabled="geocodingHome">
|
||||
{{ geocodingHome ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="homeConfirmed" class="wizard-confirmed">✓ {{ homeConfirmed }}</div>
|
||||
<div v-if="homeError" class="wizard-error">{{ homeError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Work address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="workAddress" class="wizard-input" placeholder="e.g. 456 Office Ave, Portland" @keydown.enter="lookupWork" />
|
||||
<button class="btn-wizard-secondary" @click="lookupWork" :disabled="geocodingWork">
|
||||
{{ geocodingWork ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="workConfirmed" class="wizard-confirmed">✓ {{ workConfirmed }}</div>
|
||||
<div v-if="workError" class="wizard-error">{{ workError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 1">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 3">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Work schedule -->
|
||||
<div v-else-if="step === 3" class="wizard-body">
|
||||
<h2 class="wizard-title">When do you go to the office?</h2>
|
||||
<p class="wizard-text">
|
||||
The 8am slot is labelled "you're at the office" on days you have marked as office days.
|
||||
Toggle any days you typically commute.
|
||||
</p>
|
||||
|
||||
<div class="wizard-days">
|
||||
<button
|
||||
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="idx"
|
||||
:class="['wizard-day-btn', { active: config.work_days.includes(idx) }]"
|
||||
@click="toggleDay(idx)"
|
||||
type="button"
|
||||
>{{ day }}</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 2">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 4">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: RSS feeds -->
|
||||
<div v-else-if="step === 4" class="wizard-body">
|
||||
<h2 class="wizard-title">What do you follow?</h2>
|
||||
<p class="wizard-text">
|
||||
Add RSS or Atom feeds and the briefing will summarise recent items each morning.
|
||||
You can add or remove feeds any time in Settings.
|
||||
</p>
|
||||
|
||||
<div v-if="pendingFeeds.length" class="wizard-feeds-list">
|
||||
<div v-for="(f, i) in pendingFeeds" :key="i" class="wizard-feed-row">
|
||||
<span class="wizard-feed-url">{{ f.url }}</span>
|
||||
<button class="btn-wizard-remove" @click="removePendingFeed(i)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-add-feed">
|
||||
<input v-model="newFeedUrl" class="wizard-input" placeholder="https://…/feed.xml" style="flex: 1" />
|
||||
<button class="btn-wizard-secondary" @click="addPendingFeed" :disabled="!newFeedUrl.trim()">Add</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions" style="margin-top: 1.5rem">
|
||||
<button class="btn-wizard-ghost" @click="step = 3">Back</button>
|
||||
<button class="btn-wizard-ghost" @click="finish" :disabled="finishing">Skip</button>
|
||||
<button class="btn-wizard-primary" @click="finish" :disabled="finishing">
|
||||
{{ finishing ? 'Setting up…' : 'Enable Briefing' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wizard-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.wizard-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
width: 520px;
|
||||
max-width: 94vw;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.wizard-progress {
|
||||
height: 3px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.wizard-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1, #4f46e5);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.wizard-step-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
padding: 0.5rem 1.5rem 0;
|
||||
}
|
||||
.wizard-body {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
}
|
||||
.wizard-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.wizard-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.wizard-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.wizard-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.wizard-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wizard-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-input:focus { border-color: var(--color-primary); }
|
||||
.wizard-confirmed {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-success, #22c55e);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-error {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-danger, #ef4444);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-days {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.wizard-day-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-day-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.wizard-feeds-list {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.wizard-feed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wizard-feed-url {
|
||||
flex: 1;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-wizard-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-wizard-remove:hover { color: var(--color-danger, #ef4444); }
|
||||
.wizard-add-feed {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-wizard-primary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-secondary {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-ghost {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-ghost:hover { background: var(--color-bg-secondary); }
|
||||
.btn-wizard-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -118,17 +118,22 @@ const timingParts = computed((): string[] => {
|
||||
|
||||
.message-bubble {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
/* User prompts: recessed, muted — margin notes */
|
||||
.role-user .message-bubble {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--color-bubble-user-bg);
|
||||
border: 1px solid var(--color-bubble-user-border);
|
||||
color: var(--color-bubble-user-text);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* Assistant responses: elevated, lit — the primary text */
|
||||
.role-assistant .message-bubble {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
box-shadow: var(--color-bubble-asst-shadow);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -145,16 +150,23 @@ const timingParts = computed((): string[] => {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.role-user .role-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.role-assistant .role-label {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid rgba(129, 140, 248, 0.35);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thinking-summary {
|
||||
@@ -192,7 +204,6 @@ details[open] .thinking-summary::before {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.message-content {
|
||||
font-size: 0.95rem;
|
||||
@@ -203,22 +214,6 @@ details[open] .thinking-summary::before {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* User bubble content overrides for readability on primary bg */
|
||||
.role-user .message-content :deep(a) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.role-user .message-content :deep(code) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
.role-user .message-content :deep(pre) {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.role-user .message-content :deep(blockquote) {
|
||||
border-left-color: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -180,9 +180,9 @@ defineExpose({ focus });
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
:placeholder="
|
||||
store.chatReady
|
||||
? 'Start a new chat... (Enter to send)'
|
||||
: 'Chat unavailable'
|
||||
!store.chatReady ? 'Chat unavailable'
|
||||
: store.streaming ? 'Type to queue… (Enter to queue)'
|
||||
: 'Start a new chat… (Enter to send)'
|
||||
"
|
||||
:disabled="!store.chatReady"
|
||||
rows="1"
|
||||
|
||||
@@ -1,130 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
|
||||
const props = defineProps<{
|
||||
editor: Editor | null;
|
||||
}>();
|
||||
const props = defineProps<{ editor: Editor | null }>();
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: "B",
|
||||
title: "Bold",
|
||||
command: () => props.editor?.chain().focus().toggleBold().run(),
|
||||
isActive: () => props.editor?.isActive("bold") ?? false,
|
||||
},
|
||||
{
|
||||
label: "I",
|
||||
title: "Italic",
|
||||
command: () => props.editor?.chain().focus().toggleItalic().run(),
|
||||
isActive: () => props.editor?.isActive("italic") ?? false,
|
||||
},
|
||||
{
|
||||
label: "H1",
|
||||
title: "Heading 1",
|
||||
command: () =>
|
||||
props.editor?.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
isActive: () => props.editor?.isActive("heading", { level: 1 }) ?? false,
|
||||
},
|
||||
{
|
||||
label: "H2",
|
||||
title: "Heading 2",
|
||||
command: () =>
|
||||
props.editor?.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
isActive: () => props.editor?.isActive("heading", { level: 2 }) ?? false,
|
||||
},
|
||||
{
|
||||
label: "Link",
|
||||
title: "Link",
|
||||
command: () => {
|
||||
const ed = props.editor;
|
||||
if (!ed) return;
|
||||
if (ed.isActive("link")) {
|
||||
ed.chain().focus().unsetLink().run();
|
||||
} else {
|
||||
const url = prompt("URL:");
|
||||
if (url) {
|
||||
ed.chain().focus().setLink({ href: url }).run();
|
||||
// SVG icons — Material Design paths (24x24) or custom SVG
|
||||
const ICONS: Record<string, string> = {
|
||||
bold: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>`,
|
||||
italic: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>`,
|
||||
strike: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/></svg>`,
|
||||
h1: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">1</text></svg>`,
|
||||
h2: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">2</text></svg>`,
|
||||
h3: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">3</text></svg>`,
|
||||
ul: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`,
|
||||
ol: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2zm1-9h1V4H2v1h1zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2zm5-3h13V6H7v2zm0 4h13v-2H7v2zm0 4h13v-2H7v2z"/></svg>`,
|
||||
task: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>`,
|
||||
code: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>`,
|
||||
codeblock: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM4 19h16v2H4z"/></svg>`,
|
||||
quote: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>`,
|
||||
link: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`,
|
||||
wikilink: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/></svg>`,
|
||||
};
|
||||
|
||||
const groups = [
|
||||
[
|
||||
{ id: "bold", title: "Bold (Ctrl+B)", isActive: () => props.editor?.isActive("bold") ?? false, command: () => props.editor?.chain().focus().toggleBold().run() },
|
||||
{ id: "italic", title: "Italic (Ctrl+I)", isActive: () => props.editor?.isActive("italic") ?? false, command: () => props.editor?.chain().focus().toggleItalic().run() },
|
||||
{ id: "strike", title: "Strikethrough", isActive: () => props.editor?.isActive("strike") ?? false, command: () => props.editor?.chain().focus().toggleStrike().run() },
|
||||
],
|
||||
[
|
||||
{ id: "h1", title: "Heading 1", isActive: () => props.editor?.isActive("heading", { level: 1 }) ?? false, command: () => props.editor?.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ id: "h2", title: "Heading 2", isActive: () => props.editor?.isActive("heading", { level: 2 }) ?? false, command: () => props.editor?.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ id: "h3", title: "Heading 3", isActive: () => props.editor?.isActive("heading", { level: 3 }) ?? false, command: () => props.editor?.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
],
|
||||
[
|
||||
{ id: "ul", title: "Bullet List", isActive: () => props.editor?.isActive("bulletList") ?? false, command: () => props.editor?.chain().focus().toggleBulletList().run() },
|
||||
{ id: "ol", title: "Ordered List", isActive: () => props.editor?.isActive("orderedList") ?? false, command: () => props.editor?.chain().focus().toggleOrderedList().run() },
|
||||
{ id: "task", title: "Task List", isActive: () => props.editor?.isActive("taskList") ?? false, command: () => props.editor?.chain().focus().toggleTaskList().run() },
|
||||
],
|
||||
[
|
||||
{ id: "code", title: "Inline Code", isActive: () => props.editor?.isActive("code") ?? false, command: () => props.editor?.chain().focus().toggleCode().run() },
|
||||
{ id: "codeblock", title: "Code Block", isActive: () => props.editor?.isActive("codeBlock") ?? false, command: () => props.editor?.chain().focus().toggleCodeBlock().run() },
|
||||
{ id: "quote", title: "Blockquote", isActive: () => props.editor?.isActive("blockquote") ?? false, command: () => props.editor?.chain().focus().toggleBlockquote().run() },
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "link",
|
||||
title: "Link",
|
||||
isActive: () => props.editor?.isActive("link") ?? false,
|
||||
command: () => {
|
||||
const ed = props.editor;
|
||||
if (!ed) return;
|
||||
if (ed.isActive("link")) {
|
||||
ed.chain().focus().unsetLink().run();
|
||||
} else {
|
||||
const url = prompt("URL:");
|
||||
if (url) ed.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
isActive: () => props.editor?.isActive("link") ?? false,
|
||||
},
|
||||
{
|
||||
label: "UL",
|
||||
title: "Bullet List",
|
||||
command: () => props.editor?.chain().focus().toggleBulletList().run(),
|
||||
isActive: () => props.editor?.isActive("bulletList") ?? false,
|
||||
},
|
||||
{
|
||||
label: "OL",
|
||||
title: "Numbered List",
|
||||
command: () => props.editor?.chain().focus().toggleOrderedList().run(),
|
||||
isActive: () => props.editor?.isActive("orderedList") ?? false,
|
||||
},
|
||||
{
|
||||
label: "`",
|
||||
title: "Inline Code",
|
||||
command: () => props.editor?.chain().focus().toggleCode().run(),
|
||||
isActive: () => props.editor?.isActive("code") ?? false,
|
||||
},
|
||||
{
|
||||
label: "```",
|
||||
title: "Code Block",
|
||||
command: () => props.editor?.chain().focus().toggleCodeBlock().run(),
|
||||
isActive: () => props.editor?.isActive("codeBlock") ?? false,
|
||||
},
|
||||
{
|
||||
label: "☑",
|
||||
title: "Task List",
|
||||
command: () => props.editor?.chain().focus().toggleTaskList().run(),
|
||||
isActive: () => props.editor?.isActive("taskList") ?? false,
|
||||
},
|
||||
{
|
||||
label: "[[",
|
||||
title: "Insert wikilink (type note title to search)",
|
||||
command: () => props.editor?.chain().focus().insertContent("[[").run(),
|
||||
isActive: () => false,
|
||||
},
|
||||
{
|
||||
id: "wikilink",
|
||||
title: "Insert wikilink [[ ]]",
|
||||
isActive: () => false,
|
||||
command: () => props.editor?.chain().focus().insertContent("[[").run(),
|
||||
},
|
||||
],
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="md-toolbar">
|
||||
<button
|
||||
v-for="btn in buttons"
|
||||
:key="btn.label"
|
||||
:class="['md-btn', { active: btn.isActive() }]"
|
||||
:title="btn.title"
|
||||
@click="btn.command()"
|
||||
>
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
<div class="md-toolbar" role="toolbar" aria-label="Text formatting">
|
||||
<template v-for="(group, gi) in groups" :key="gi">
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
v-for="btn in group"
|
||||
:key="btn.id"
|
||||
:class="['md-btn', { active: btn.isActive() }]"
|
||||
:title="btn.title"
|
||||
type="button"
|
||||
@mousedown.prevent="btn.command()"
|
||||
>
|
||||
<span class="btn-icon" v-html="ICONS[btn.id]" />
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="gi < groups.length - 1" class="toolbar-sep" aria-hidden="true" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.md-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.toolbar-sep {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--color-border);
|
||||
flex-shrink: 0;
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.md-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.md-btn:hover {
|
||||
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);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.md-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
.md-btn.active:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,15 +60,16 @@ function goEdit() {
|
||||
.note-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
transition: box-shadow 0.15s;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.note-card:hover {
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Compact single-row layout */
|
||||
@@ -77,6 +78,19 @@ function goEdit() {
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transform: none !important;
|
||||
}
|
||||
.note-card.compact:first-child {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.note-card.compact:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(99, 102, 241, 0.04);
|
||||
transform: none;
|
||||
}
|
||||
.note-title-compact {
|
||||
font-size: 0.9rem;
|
||||
@@ -136,6 +150,9 @@ function goEdit() {
|
||||
font-size: 0.9rem;
|
||||
max-height: 7.5em;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
|
||||
}
|
||||
.note-meta {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import NotificationsPanel from './NotificationsPanel.vue'
|
||||
|
||||
const store = useNotificationsStore()
|
||||
const open = ref(false)
|
||||
const wrapRef = ref<HTMLElement | null>(null)
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
if (open.value) store.fetchAll()
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (open.value && wrapRef.value && !wrapRef.value.contains(e.target as Node)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchCount()
|
||||
pollInterval = setInterval(() => store.fetchCount(), 60_000)
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
document.removeEventListener('click', onDocClick, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bell-wrap" ref="wrapRef">
|
||||
<button
|
||||
class="btn-bell"
|
||||
:class="{ active: open }"
|
||||
@click="toggle"
|
||||
aria-label="Notifications"
|
||||
:title="`${store.count} unread notification${store.count !== 1 ? 's' : ''}`"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<span v-if="store.count > 0" class="bell-badge" aria-live="polite">{{ store.count > 99 ? '99+' : store.count }}</span>
|
||||
</button>
|
||||
<NotificationsPanel v-if="open" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bell-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-bell {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.45rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.btn-bell:hover,
|
||||
.btn-bell.active {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 3px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { relativeTime } from '@/composables/useRelativeTime'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const store = useNotificationsStore()
|
||||
const router = useRouter()
|
||||
|
||||
const typeIcon: Record<string, string> = {
|
||||
project_shared: '📁',
|
||||
note_shared: '📝',
|
||||
group_added: '👥',
|
||||
}
|
||||
|
||||
async function handleClick(notif: { id: number; payload: Record<string, unknown> }) {
|
||||
await store.markRead(notif.id)
|
||||
const url = notif.payload.url as string | undefined
|
||||
if (url) {
|
||||
emit('close')
|
||||
router.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => store.fetchAll())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notif-panel" role="dialog" aria-label="Notifications">
|
||||
<header class="notif-panel-header">
|
||||
<span class="notif-panel-title">Notifications</span>
|
||||
<button
|
||||
v-if="store.count > 0"
|
||||
class="btn-mark-all"
|
||||
@click="store.markAll()"
|
||||
>Mark all read</button>
|
||||
</header>
|
||||
|
||||
<ul class="notif-list" v-if="store.items.length">
|
||||
<li
|
||||
v-for="n in store.items"
|
||||
:key="n.id"
|
||||
class="notif-item"
|
||||
@click="handleClick(n)"
|
||||
>
|
||||
<span class="notif-icon">{{ typeIcon[n.type] ?? '🔔' }}</span>
|
||||
<div class="notif-body">
|
||||
<p class="notif-msg">
|
||||
<strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong>
|
||||
{{ n.type === 'project_shared'
|
||||
? ` shared "${n.payload.project_title}" with you as ${n.payload.permission}`
|
||||
: n.type === 'note_shared'
|
||||
? ` shared "${n.payload.note_title}" with you as ${n.payload.permission}`
|
||||
: ` added you to "${n.payload.group_name}" as ${n.payload.role}` }}
|
||||
</p>
|
||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||
</div>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss">✕</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notif-empty">No unread notifications</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notif-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 340px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.notif-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.notif-panel-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-mark-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-mark-all:hover { text-decoration: underline; }
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notif-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.notif-item:last-child { border-bottom: none; }
|
||||
.notif-item:hover { background: var(--color-hover); }
|
||||
|
||||
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
|
||||
|
||||
.notif-body { flex: 1; min-width: 0; }
|
||||
.notif-msg {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
|
||||
|
||||
.btn-notif-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.25rem;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.btn-notif-close:hover { color: var(--color-text); }
|
||||
|
||||
.notif-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,417 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
searchUsers,
|
||||
listGroups,
|
||||
listProjectShares,
|
||||
createProjectShare,
|
||||
updateProjectShare,
|
||||
deleteProjectShare,
|
||||
listNoteShares,
|
||||
createNoteShare,
|
||||
updateNoteShare,
|
||||
deleteNoteShare,
|
||||
type ShareEntry,
|
||||
type GroupEntry,
|
||||
type UserSearchResult,
|
||||
} from '@/api/client'
|
||||
|
||||
const props = defineProps<{
|
||||
resourceType: 'project' | 'note'
|
||||
resourceId: number
|
||||
resourceTitle: string
|
||||
}>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const addMode = ref<'user' | 'group'>('user')
|
||||
const userQuery = ref('')
|
||||
const userResults = ref<UserSearchResult[]>([])
|
||||
const selectedUser = ref<UserSearchResult | null>(null)
|
||||
const selectedGroupId = ref<number | ''>('')
|
||||
const newPermission = ref('viewer')
|
||||
const groups = ref<GroupEntry[]>([])
|
||||
const shares = ref<ShareEntry[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function debounceSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(async () => {
|
||||
if (userQuery.value.length < 2) { userResults.value = []; return }
|
||||
userResults.value = await searchUsers(userQuery.value)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectUser(u: UserSearchResult) {
|
||||
selectedUser.value = u
|
||||
userQuery.value = u.username
|
||||
userResults.value = []
|
||||
}
|
||||
|
||||
async function loadShares() {
|
||||
if (props.resourceType === 'project') {
|
||||
shares.value = await listProjectShares(props.resourceId)
|
||||
} else {
|
||||
shares.value = await listNoteShares(props.resourceId)
|
||||
}
|
||||
}
|
||||
|
||||
async function addShare() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const body: { user_id?: number; group_id?: number; permission: string } = {
|
||||
permission: newPermission.value,
|
||||
}
|
||||
if (addMode.value === 'user' && selectedUser.value) {
|
||||
body.user_id = selectedUser.value.id
|
||||
} else if (addMode.value === 'group' && selectedGroupId.value) {
|
||||
body.group_id = selectedGroupId.value as number
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (props.resourceType === 'project') {
|
||||
await createProjectShare(props.resourceId, body)
|
||||
} else {
|
||||
await createNoteShare(props.resourceId, body)
|
||||
}
|
||||
selectedUser.value = null
|
||||
userQuery.value = ''
|
||||
selectedGroupId.value = ''
|
||||
newPermission.value = 'viewer'
|
||||
await loadShares()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changePermission(share: ShareEntry, permission: string) {
|
||||
if (props.resourceType === 'project') {
|
||||
await updateProjectShare(props.resourceId, share.id, permission)
|
||||
} else {
|
||||
await updateNoteShare(props.resourceId, share.id, permission)
|
||||
}
|
||||
await loadShares()
|
||||
}
|
||||
|
||||
async function removeShare(share: ShareEntry) {
|
||||
if (props.resourceType === 'project') {
|
||||
await deleteProjectShare(props.resourceId, share.id)
|
||||
} else {
|
||||
await deleteNoteShare(props.resourceId, share.id)
|
||||
}
|
||||
await loadShares()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
groups.value = await listGroups()
|
||||
await loadShares()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="share-overlay" @click.self="emit('close')">
|
||||
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
|
||||
<header class="share-header">
|
||||
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||
</header>
|
||||
|
||||
<!-- Add share form -->
|
||||
<div class="share-add">
|
||||
<div class="share-tabs">
|
||||
<button :class="['share-tab', { active: addMode === 'user' }]" @click="addMode = 'user'">User</button>
|
||||
<button :class="['share-tab', { active: addMode === 'group' }]" @click="addMode = 'group'">Group</button>
|
||||
</div>
|
||||
|
||||
<div v-if="addMode === 'user'" class="share-target-form">
|
||||
<div class="user-search-wrap">
|
||||
<input
|
||||
v-model="userQuery"
|
||||
class="share-input"
|
||||
placeholder="Search by username or email…"
|
||||
@input="debounceSearch"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ul v-if="userResults.length" class="user-results">
|
||||
<li v-for="u in userResults" :key="u.id" @click="selectUser(u)" class="user-result-item">
|
||||
<span class="user-result-name">{{ u.username }}</span>
|
||||
<span class="user-result-email">{{ u.email }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<template v-if="selectedUser">
|
||||
<select v-model="newPermission" class="perm-select">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-add-share" @click="addShare" :disabled="saving">Add</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else class="share-target-form">
|
||||
<select v-model="selectedGroupId" class="share-input">
|
||||
<option value="" disabled>Select a group…</option>
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }} ({{ g.member_count }} members)</option>
|
||||
</select>
|
||||
<select v-model="newPermission" class="perm-select">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-add-share" @click="addShare" :disabled="saving || !selectedGroupId">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current shares -->
|
||||
<div class="shares-list-wrap">
|
||||
<p class="shares-label">Current access</p>
|
||||
<ul class="shares-list">
|
||||
<li v-for="share in shares" :key="share.id" class="share-row">
|
||||
<span class="share-target-icon">{{ share.shared_with_user_id ? '👤' : '👥' }}</span>
|
||||
<span class="share-target-name">
|
||||
{{ share.shared_with_user_id ? share.username : share.group_name }}
|
||||
</span>
|
||||
<select
|
||||
class="perm-select-inline"
|
||||
:value="share.permission"
|
||||
@change="changePermission(share, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove">✕</button>
|
||||
</li>
|
||||
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.share-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.share-dialog {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 480px;
|
||||
max-width: 95vw;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.share-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.share-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.btn-close:hover { color: var(--color-text); }
|
||||
|
||||
.share-add {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.share-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.share-tab {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.share-tab.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.share-target-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-search-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.share-input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.share-input:focus { border-color: var(--color-primary); }
|
||||
|
||||
.user-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
margin-top: 2px;
|
||||
list-style: none;
|
||||
padding: 0.25rem 0;
|
||||
z-index: 10;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.user-result-item:hover { background: var(--color-bg-secondary); }
|
||||
|
||||
.user-result-name { font-weight: 600; font-size: 0.88rem; }
|
||||
.user-result-email { color: var(--color-text-muted); font-size: 0.8rem; }
|
||||
|
||||
.perm-select {
|
||||
padding: 0.45rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add-share {
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-add-share:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.shares-list-wrap {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.shares-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.shares-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.share-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
|
||||
.share-target-name { flex: 1; font-size: 0.88rem; font-weight: 500; }
|
||||
|
||||
.perm-select-inline {
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-remove-share {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-remove-share:hover { color: var(--color-danger, #ef4444); }
|
||||
|
||||
.shares-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -37,16 +37,16 @@ const labels: Record<TaskStatus, string> = {
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.status-todo {
|
||||
background: var(--color-status-todo-bg);
|
||||
color: var(--color-status-todo);
|
||||
background: color-mix(in srgb, var(--color-status-todo-bg) 78%, var(--color-status-todo) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-todo) 85%, #000 15%);
|
||||
}
|
||||
.status-in_progress {
|
||||
background: var(--color-status-in-progress-bg);
|
||||
color: var(--color-status-in-progress);
|
||||
background: color-mix(in srgb, var(--color-status-in-progress-bg) 78%, var(--color-status-in-progress) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-in-progress) 85%, #000 15%);
|
||||
}
|
||||
.status-done {
|
||||
background: var(--color-status-done-bg);
|
||||
color: var(--color-status-done);
|
||||
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -36,9 +36,11 @@ defineEmits<{
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.tag-pill:hover {
|
||||
filter: brightness(0.95);
|
||||
color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
.dismiss {
|
||||
background: none;
|
||||
|
||||
@@ -59,6 +59,14 @@ function isOverdue(): boolean {
|
||||
<PriorityBadge :priority="task.priority!" />
|
||||
<span class="task-title-compact">{{ task.title || "Untitled" }}</span>
|
||||
<span v-if="projectTitle" class="project-crumb">{{ projectTitle }}</span>
|
||||
<div class="task-tags-compact">
|
||||
<TagPill
|
||||
v-for="tag in task.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click.stop="emit('tag-click', tag)"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="task.due_date" :class="['due-compact', { overdue: isOverdue() }]">
|
||||
{{ task.due_date }}
|
||||
</span>
|
||||
@@ -97,15 +105,16 @@ function isOverdue(): boolean {
|
||||
.task-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
transition: box-shadow 0.15s;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.task-card:hover {
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Compact single-row layout */
|
||||
@@ -113,7 +122,7 @@ function isOverdue(): boolean {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
@@ -163,6 +172,11 @@ function isOverdue(): boolean {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-tags-compact {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due-compact {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -3,12 +3,11 @@ import { watch, onBeforeUnmount } from "vue";
|
||||
import { Editor, EditorContent } from "@tiptap/vue-3";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Placeholder } from "@tiptap/extensions";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { serializeToMarkdown } from "@/utils/markdownSerializer";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { TagDecoration } from "@/extensions/TagDecoration";
|
||||
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
|
||||
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
|
||||
@@ -71,6 +70,7 @@ try {
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
||||
link: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
|
||||
@@ -9,9 +9,11 @@ const props = defineProps<{
|
||||
|
||||
const appliedTags = ref<Set<string>>(new Set());
|
||||
const applyingTag = ref<string | null>(null);
|
||||
const confirmState = ref<"idle" | "creating" | "created" | "denied">("idle");
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.toolCall.status === "declined") return "Declined";
|
||||
if (props.toolCall.result.requires_confirmation) return "Similar content found";
|
||||
if (!props.toolCall.result.success) return "Error";
|
||||
switch (props.toolCall.result.type) {
|
||||
case "task": return "Created task";
|
||||
@@ -210,6 +212,33 @@ function toggle() {
|
||||
if (hasDetail.value) collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
async function confirmDuplicate() {
|
||||
if (confirmState.value !== "idle") return;
|
||||
confirmState.value = "creating";
|
||||
const args = props.toolCall.arguments as Record<string, unknown>;
|
||||
const isTask = props.toolCall.function === "create_task";
|
||||
const endpoint = isTask ? "/api/tasks" : "/api/notes";
|
||||
const payload: Record<string, unknown> = {
|
||||
title: args.title,
|
||||
body: args.body ?? "",
|
||||
tags: args.tags ?? [],
|
||||
...(args.due_date ? { due_date: args.due_date } : {}),
|
||||
...(args.priority ? { priority: args.priority } : {}),
|
||||
...(args.project ? { project: args.project } : {}),
|
||||
...(isTask ? { status: args.status ?? "todo" } : {}),
|
||||
};
|
||||
try {
|
||||
await apiPost(endpoint, payload);
|
||||
confirmState.value = "created";
|
||||
} catch {
|
||||
confirmState.value = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
function denyDuplicate() {
|
||||
confirmState.value = "denied";
|
||||
}
|
||||
|
||||
async function applyTag(tag: string) {
|
||||
if (!noteId.value || appliedTags.value.has(tag) || applyingTag.value) return;
|
||||
applyingTag.value = tag;
|
||||
@@ -228,7 +257,8 @@ async function applyTag(tag: string) {
|
||||
<div
|
||||
class="tool-call-card"
|
||||
:class="{
|
||||
error: toolCall.status === 'error',
|
||||
error: toolCall.status === 'error' && !toolCall.result.requires_confirmation,
|
||||
'requires-confirm': toolCall.result.requires_confirmation,
|
||||
declined: toolCall.status === 'declined',
|
||||
running: toolCall.status === 'running',
|
||||
collapsible: hasDetail,
|
||||
@@ -244,6 +274,28 @@ async function applyTag(tag: string) {
|
||||
{{ toolCall.arguments.title ?? toolCall.arguments.summary ?? toolCall.arguments.query ?? toolCall.function }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="toolCall.result.requires_confirmation">
|
||||
<router-link
|
||||
v-if="toolCall.result.similar_note"
|
||||
:to="`/notes/${toolCall.result.similar_note.id}`"
|
||||
class="tool-link"
|
||||
@click.stop
|
||||
>{{ toolCall.result.similar_note.title }}</router-link>
|
||||
<span v-else class="tool-error">{{ toolCall.result.error }}</span>
|
||||
<template v-if="confirmState === 'created'">
|
||||
<span class="confirm-created">✓ Created</span>
|
||||
</template>
|
||||
<template v-else-if="confirmState === 'denied'">
|
||||
<span class="confirm-denied">Skipped</span>
|
||||
</template>
|
||||
<template v-else-if="confirmState !== 'creating'">
|
||||
<button class="btn-confirm-duplicate" @click.stop="confirmDuplicate">Create anyway</button>
|
||||
<button class="btn-deny-duplicate" @click.stop="denyDuplicate">Skip</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="tool-summary-count">Creating…</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="toolCall.status === 'error'">
|
||||
<span class="tool-error">{{ toolCall.result.error }}</span>
|
||||
</template>
|
||||
@@ -577,4 +629,46 @@ async function applyTag(tag: string) {
|
||||
}
|
||||
.tag-pill:disabled:not(.applied) { opacity: 0.6; cursor: wait; }
|
||||
.tag-check { font-size: 0.65rem; }
|
||||
|
||||
/* Duplicate confirmation */
|
||||
.tool-call-card.requires-confirm {
|
||||
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 60%, transparent);
|
||||
}
|
||||
.confirm-created {
|
||||
color: var(--color-success, #2ecc71);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-denied {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.btn-confirm-duplicate {
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-confirm-duplicate:hover { opacity: 0.9; }
|
||||
.btn-deny-duplicate {
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-deny-duplicate:hover {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
|
||||
interface NoteVersion {
|
||||
id: number;
|
||||
note_id: number;
|
||||
title: string;
|
||||
tags: string[];
|
||||
body?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: number;
|
||||
currentBody: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "restore", body: string, tags: string[]): void;
|
||||
}>();
|
||||
|
||||
const expanded = ref(false);
|
||||
const view = ref<"list" | "diff">("list");
|
||||
const versions = ref<NoteVersion[]>([]);
|
||||
const selectedVersion = ref<NoteVersion | null>(null);
|
||||
const loading = ref(false);
|
||||
const loadingDetail = ref(false);
|
||||
|
||||
const diff = computed<DiffLine[]>(() => {
|
||||
if (!selectedVersion.value?.body) return [];
|
||||
const aLines = props.currentBody.split("\n");
|
||||
const bLines = selectedVersion.value.body.split("\n");
|
||||
const m = aLines.length, n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--)
|
||||
for (let j = n - 1; j >= 0; j--)
|
||||
dp[i][j] = aLines[i] === bLines[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
const result: DiffLine[] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] });
|
||||
else result.push({ type: "insert", text: bLines[j++] });
|
||||
}
|
||||
while (i < m) result.push({ type: "delete", text: aLines[i++] });
|
||||
while (j < n) result.push({ type: "insert", text: bLines[j++] });
|
||||
return result;
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short", day: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
expanded.value = !expanded.value;
|
||||
if (expanded.value && versions.value.length === 0) {
|
||||
await loadVersions();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ versions: NoteVersion[] }>(
|
||||
`/api/notes/${props.noteId}/versions`
|
||||
);
|
||||
versions.value = data.versions;
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(v: NoteVersion) {
|
||||
if (v.body === undefined) {
|
||||
loadingDetail.value = true;
|
||||
try {
|
||||
const full = await apiGet<NoteVersion>(
|
||||
`/api/notes/${props.noteId}/versions/${v.id}`
|
||||
);
|
||||
const listItem = versions.value.find((x) => x.id === v.id);
|
||||
if (listItem) listItem.body = full.body;
|
||||
selectedVersion.value = full;
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
loadingDetail.value = false;
|
||||
}
|
||||
} else {
|
||||
selectedVersion.value = v;
|
||||
}
|
||||
view.value = "diff";
|
||||
}
|
||||
|
||||
function back() {
|
||||
view.value = "list";
|
||||
selectedVersion.value = null;
|
||||
}
|
||||
|
||||
function restore() {
|
||||
if (!selectedVersion.value?.body) return;
|
||||
emit("restore", selectedVersion.value.body, selectedVersion.value.tags ?? []);
|
||||
back();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vh-section">
|
||||
<button class="vh-header" @click="toggle">
|
||||
<span class="vh-title">Version History</span>
|
||||
<span class="vh-chevron">{{ expanded ? "▴" : "▾" }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="expanded" class="vh-body">
|
||||
|
||||
<!-- List view -->
|
||||
<template v-if="view === 'list'">
|
||||
<div v-if="loading" class="vh-empty">Loading...</div>
|
||||
<div v-else-if="!versions.length" class="vh-empty">No snapshots yet.</div>
|
||||
<div
|
||||
v-for="v in versions"
|
||||
:key="v.id"
|
||||
class="vh-item"
|
||||
@click="selectVersion(v)"
|
||||
>
|
||||
{{ formatDate(v.created_at) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Diff view -->
|
||||
<template v-else>
|
||||
<div class="vh-diff-actions">
|
||||
<button class="vh-btn-back" @click="back">← Back</button>
|
||||
<button
|
||||
class="vh-btn-restore"
|
||||
:disabled="!selectedVersion?.body"
|
||||
@click="restore"
|
||||
>Restore</button>
|
||||
</div>
|
||||
<div class="vh-diff-wrap">
|
||||
<div v-if="loadingDetail" class="vh-empty">Loading...</div>
|
||||
<DiffView v-else :diff="diff" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vh-section {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.vh-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.vh-header:hover { background: var(--color-bg-secondary); }
|
||||
|
||||
.vh-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.vh-chevron {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vh-body {
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.vh-empty {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.vh-item {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.vh-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.vh-diff-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.75rem 0.35rem;
|
||||
}
|
||||
|
||||
.vh-btn-back {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.vh-btn-restore {
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.vh-btn-restore:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.vh-diff-wrap {
|
||||
padding: 0 0.5rem 0.25rem;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -28,23 +28,18 @@ interface NoteItem {
|
||||
const toast = useToastStore();
|
||||
const notesStore = useNotesStore();
|
||||
|
||||
// View state: "list" or "editor"
|
||||
const view = ref<"list" | "editor">("list");
|
||||
|
||||
// List state
|
||||
const projectNotes = ref<NoteItem[]>([]);
|
||||
const listLoading = ref(false);
|
||||
const deletingId = ref<number | null>(null); // confirming delete for this id
|
||||
const pendingDelete = ref<number | null>(null); // mid-deletion
|
||||
const deletingId = ref<number | null>(null);
|
||||
const pendingDelete = ref<number | null>(null);
|
||||
const searchQuery = ref("");
|
||||
|
||||
const filteredNotes = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
if (!q) return projectNotes.value;
|
||||
return projectNotes.value.filter(
|
||||
(n) =>
|
||||
n.title.toLowerCase().includes(q) ||
|
||||
n.tags?.some((t) => t.toLowerCase().includes(q))
|
||||
(n) => n.title.toLowerCase().includes(q) || n.tags?.some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,6 +67,8 @@ const newNoteTitle = ref("");
|
||||
const creatingNote = ref(false);
|
||||
const newNoteTitleRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function _noteKey(pid: number) { return `workspace_note_${pid}`; }
|
||||
|
||||
async function startNewNote() {
|
||||
showNewNoteInput.value = true;
|
||||
newNoteTitle.value = "";
|
||||
@@ -104,7 +101,7 @@ async function createNote() {
|
||||
}
|
||||
}
|
||||
|
||||
// TipTap editor ref (for MarkdownToolbar)
|
||||
// TipTap editor ref
|
||||
const titleRef = ref<HTMLInputElement | null>(null);
|
||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
const tiptapEditor = computed<Editor | null>(() =>
|
||||
@@ -184,7 +181,7 @@ async function openNote(id: number) {
|
||||
dirty.value = false;
|
||||
tagSuggestions.dismissTagSuggestions();
|
||||
linkSuggestions.value = [];
|
||||
view.value = "editor";
|
||||
localStorage.setItem(_noteKey(props.projectId), String(id));
|
||||
fetchLinkSuggestions();
|
||||
emit("note-loaded", note.id);
|
||||
} catch {
|
||||
@@ -202,7 +199,6 @@ async function saveNote() {
|
||||
tags: noteTags.value,
|
||||
});
|
||||
dirty.value = false;
|
||||
// Refresh updated_at in the list
|
||||
const idx = projectNotes.value.findIndex((n) => n.id === editingId.value);
|
||||
if (idx !== -1) {
|
||||
projectNotes.value[idx] = {
|
||||
@@ -211,7 +207,6 @@ async function saveNote() {
|
||||
tags: noteTags.value,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
// Re-sort by updated_at
|
||||
projectNotes.value.sort((a, b) =>
|
||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
);
|
||||
@@ -223,16 +218,9 @@ async function saveNote() {
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
view.value = "list";
|
||||
deletingId.value = null;
|
||||
searchQuery.value = "";
|
||||
}
|
||||
|
||||
function requestDelete(id: number, e: Event) {
|
||||
e.stopPropagation();
|
||||
if (deletingId.value === id) {
|
||||
// Second click — confirm
|
||||
confirmDelete(id);
|
||||
} else {
|
||||
deletingId.value = id;
|
||||
@@ -253,7 +241,7 @@ async function confirmDelete(id: number) {
|
||||
editingId.value = null;
|
||||
noteTitle.value = "";
|
||||
noteBody.value = "";
|
||||
view.value = "list";
|
||||
localStorage.removeItem(_noteKey(props.projectId));
|
||||
}
|
||||
} catch {
|
||||
toast.show("Failed to delete note", "error");
|
||||
@@ -292,93 +280,27 @@ watch(
|
||||
);
|
||||
|
||||
useAutoSave(dirty, saving, saveNote, 60_000);
|
||||
onMounted(loadProjectNotes);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjectNotes();
|
||||
const stored = localStorage.getItem(_noteKey(props.projectId));
|
||||
if (stored) {
|
||||
const id = Number(stored);
|
||||
if (projectNotes.value.find((n) => n.id === id)) {
|
||||
openNote(id).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ws-note-editor">
|
||||
|
||||
<!-- ── EDITOR VIEW ── -->
|
||||
<template v-if="view === 'editor'">
|
||||
<div class="panel-header">
|
||||
<button class="btn-back-arrow" @click="backToList">← Notes</button>
|
||||
<div class="editor-header-right">
|
||||
<WordCount :body="noteBody" />
|
||||
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
||||
<span v-if="saving" class="saving-txt">Saving...</span>
|
||||
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-title-row">
|
||||
<input
|
||||
ref="titleRef"
|
||||
v-model="noteTitle"
|
||||
class="note-title-input"
|
||||
placeholder="Note title"
|
||||
@keydown.ctrl.s.prevent="saveNote"
|
||||
@keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tag-row">
|
||||
<TagInput
|
||||
v-model="noteTags"
|
||||
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
||||
/>
|
||||
<button
|
||||
class="btn-suggest-tags"
|
||||
:disabled="tagSuggestions.suggestingTags.value"
|
||||
title="Auto-suggest tags from title and body"
|
||||
@click="tagSuggestions.fetchTagSuggestions()"
|
||||
>
|
||||
{{ tagSuggestions.suggestingTags.value ? '...' : 'Suggest' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag suggestions strip -->
|
||||
<div v-if="tagSuggestions.suggestedTags.value.length" class="tag-suggestions">
|
||||
<span class="tag-suggestions-label">Suggested:</span>
|
||||
<button
|
||||
v-for="tag in tagSuggestions.suggestedTags.value"
|
||||
:key="tag"
|
||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
||||
>
|
||||
#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}
|
||||
</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
<div class="toolbar-row">
|
||||
<MarkdownToolbar :editor="tiptapEditor" />
|
||||
</div>
|
||||
|
||||
<!-- Link suggestions strip -->
|
||||
<div v-if="linkSuggestions.length" class="link-suggest-strip">
|
||||
<span class="link-suggest-label">Links:</span>
|
||||
<span
|
||||
v-for="s in linkSuggestions"
|
||||
:key="s.note_id"
|
||||
class="link-suggest-chip"
|
||||
:title="`Appears ${s.count}× unlinked`"
|
||||
>
|
||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||
</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||
<TiptapEditor ref="editorRef" v-model="noteBody" @escape="titleRef?.focus()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── LIST VIEW ── -->
|
||||
<template v-else>
|
||||
<div class="panel-header">
|
||||
<!-- Left rail: always-visible note list -->
|
||||
<div class="note-rail">
|
||||
<div class="rail-header">
|
||||
<template v-if="showNewNoteInput">
|
||||
<input
|
||||
ref="newNoteTitleRef"
|
||||
@@ -389,77 +311,134 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
@keydown.enter="createNote"
|
||||
@keydown.escape="cancelNewNote"
|
||||
/>
|
||||
<button class="btn-new-note-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
{{ creatingNote ? '...' : '+' }}
|
||||
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
{{ creatingNote ? '…' : '+' }}
|
||||
</button>
|
||||
<button class="btn-new-note-cancel" aria-label="Cancel new note" @click="cancelNewNote">✕</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote">✕</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="panel-title">Notes</span>
|
||||
<span class="rail-title">Notes</span>
|
||||
<button class="btn-new-note" @click="startNewNote" title="New note">+ New</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="note-search-bar">
|
||||
<div class="rail-search">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="note-search-input"
|
||||
placeholder="Search notes..."
|
||||
class="rail-search-input"
|
||||
placeholder="Search…"
|
||||
type="search"
|
||||
aria-label="Search notes"
|
||||
/>
|
||||
<button v-if="searchQuery" class="btn-search-clear" @click="searchQuery = ''" title="Clear search">✕</button>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''">✕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="listLoading" class="state-msg">Loading...</div>
|
||||
|
||||
<div v-if="listLoading" class="rail-state">Loading…</div>
|
||||
<ul v-else class="note-list">
|
||||
<li
|
||||
v-for="note in filteredNotes"
|
||||
:key="note.id"
|
||||
class="note-row"
|
||||
:class="{ active: editingId === note.id }"
|
||||
:class="['note-row', { active: editingId === note.id }]"
|
||||
@click="openNote(note.id)"
|
||||
>
|
||||
<div class="note-row-main">
|
||||
<span class="note-row-title">{{ note.title }}</span>
|
||||
<span v-if="note.tags?.length" class="note-row-tags">
|
||||
<span
|
||||
v-for="tag in note.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
:class="['note-tag-pill', { 'tag-match': matchedTags(note).includes(tag) }]"
|
||||
>#{{ tag }}</span>
|
||||
<span v-if="note.tags.length > 3" class="note-tag-more">+{{ note.tags.length - 3 }}</span>
|
||||
</span>
|
||||
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
|
||||
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="note.tags?.length" class="note-row-tags">
|
||||
<span
|
||||
v-for="tag in note.tags.slice(0, 2)"
|
||||
:key="tag"
|
||||
:class="['note-tag-pill', { 'tag-match': matchedTags(note).includes(tag) }]"
|
||||
>#{{ tag }}</span>
|
||||
<span v-if="note.tags.length > 2" class="note-tag-more">+{{ note.tags.length - 2 }}</span>
|
||||
</div>
|
||||
<div class="note-row-actions" @click.stop>
|
||||
<template v-if="deletingId === note.id">
|
||||
<button
|
||||
class="btn-confirm-delete"
|
||||
:disabled="pendingDelete === note.id"
|
||||
@click="requestDelete(note.id, $event)"
|
||||
>
|
||||
{{ pendingDelete === note.id ? '...' : 'Delete?' }}
|
||||
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)">✕</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
class="btn-delete"
|
||||
title="Delete note"
|
||||
@click="requestDelete(note.id, $event)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li v-if="filteredNotes.length === 0" class="state-msg">
|
||||
{{ searchQuery.trim() ? 'No notes match.' : 'No notes yet — ask the agent to create one.' }}
|
||||
<li v-if="filteredNotes.length === 0" class="rail-state">
|
||||
{{ searchQuery.trim() ? 'No matches.' : 'No notes yet.' }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Right pane: editor -->
|
||||
<div class="note-editor-pane">
|
||||
<template v-if="editingId">
|
||||
<div class="panel-header">
|
||||
<div class="editor-header-right">
|
||||
<WordCount :body="noteBody" />
|
||||
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
||||
<span v-if="saving" class="saving-txt">Saving…</span>
|
||||
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-title-row">
|
||||
<input
|
||||
ref="titleRef"
|
||||
v-model="noteTitle"
|
||||
class="note-title-input"
|
||||
placeholder="Note title"
|
||||
@keydown.ctrl.s.prevent="saveNote"
|
||||
@keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tag-row">
|
||||
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
|
||||
<button
|
||||
class="btn-suggest-tags"
|
||||
:disabled="tagSuggestions.suggestingTags.value"
|
||||
title="Auto-suggest tags from title and body"
|
||||
@click="tagSuggestions.fetchTagSuggestions()"
|
||||
>
|
||||
{{ tagSuggestions.suggestingTags.value ? '…' : 'Suggest' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tagSuggestions.suggestedTags.value.length" class="tag-suggestions">
|
||||
<span class="tag-suggestions-label">Suggested:</span>
|
||||
<button
|
||||
v-for="tag in tagSuggestions.suggestedTags.value"
|
||||
:key="tag"
|
||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
||||
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-row">
|
||||
<MarkdownToolbar :editor="tiptapEditor" />
|
||||
</div>
|
||||
|
||||
<div v-if="linkSuggestions.length" class="link-suggest-strip">
|
||||
<span class="link-suggest-label">Links:</span>
|
||||
<span v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-chip" :title="`Appears ${s.count}× unlinked`">
|
||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||
</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||
<TiptapEditor ref="editorRef" v-model="noteBody" @escape="titleRef?.focus()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="editor-empty-state">
|
||||
<p>Select a note or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -467,41 +446,278 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
<style scoped>
|
||||
.ws-note-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ── Left rail ── */
|
||||
.note-rail {
|
||||
width: 155px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rail-title {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-new-note {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.rail-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rail-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.rail-search-input:focus { outline: none; }
|
||||
.rail-search-input::-webkit-search-cancel-button { display: none; }
|
||||
|
||||
.btn-search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-search-clear:hover { color: var(--color-text); }
|
||||
|
||||
.rail-state {
|
||||
padding: 1rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Note list */
|
||||
.note-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.note-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
gap: 0.15rem;
|
||||
border-left: 2px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.note-row.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.note-row-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.note-row-title {
|
||||
flex: 1;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.note-row-age {
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-row-tags {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.note-tag-pill {
|
||||
font-size: 0.58rem;
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.3rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 5rem;
|
||||
}
|
||||
.note-tag-pill.tag-match {
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
outline: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.note-tag-more {
|
||||
font-size: 0.58rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-row-actions {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
.btn-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
|
||||
.btn-confirm-delete {
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
}
|
||||
.btn-cancel-delete:hover { color: var(--color-text); }
|
||||
|
||||
/* Inline new note */
|
||||
.new-note-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.new-note-input:focus { outline: none; }
|
||||
|
||||
.btn-confirm {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-confirm:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-cancel:hover { color: var(--color-text); }
|
||||
|
||||
/* ── Right editor pane ── */
|
||||
.note-editor-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Editor UI */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-back-arrow {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-back-arrow:hover { text-decoration: underline; }
|
||||
|
||||
.editor-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -523,7 +739,6 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
}
|
||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* Editor */
|
||||
.note-title-row {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
@@ -549,11 +764,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-row > :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
|
||||
.btn-suggest-tags {
|
||||
background: none;
|
||||
@@ -567,10 +778,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.btn-suggest-tags:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-suggest-tags:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-suggest-tags:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.tag-suggestions {
|
||||
@@ -583,12 +791,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-suggestions-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-suggestions-label { font-size: 0.72rem; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
|
||||
.btn-tag-suggestion {
|
||||
background: none;
|
||||
@@ -633,7 +836,6 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-suggest-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
@@ -642,10 +844,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.link-suggest-chip {
|
||||
display: inline-flex;
|
||||
}
|
||||
.link-suggest-chip { display: inline-flex; }
|
||||
|
||||
.btn-chip-link {
|
||||
background: none;
|
||||
@@ -658,9 +857,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-chip-link:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
}
|
||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
|
||||
|
||||
.btn-link-all {
|
||||
background: none;
|
||||
@@ -679,212 +876,4 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Search bar */
|
||||
.note-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-search-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.note-search-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.note-search-input::-webkit-search-cancel-button { display: none; }
|
||||
|
||||
.btn-search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.25rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.btn-search-clear:hover { color: var(--color-text); }
|
||||
|
||||
/* List */
|
||||
.note-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.note-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.note-row.active { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
|
||||
|
||||
.note-row-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.note-row-title {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.note-row-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
.note-tag-pill {
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.35rem;
|
||||
white-space: nowrap;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 6rem;
|
||||
}
|
||||
|
||||
.note-tag-pill.tag-match {
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
outline: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.note-tag-more {
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-row-age {
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-row-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
}
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
.btn-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
|
||||
.btn-confirm-delete {
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.btn-cancel-delete:hover { color: var(--color-text); }
|
||||
|
||||
.state-msg {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-new-note {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.new-note-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 5px;
|
||||
padding: 0.22rem 0.5rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.new-note-input:focus { outline: none; }
|
||||
|
||||
.btn-new-note-confirm {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.22rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-new-note-confirm:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.btn-new-note-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-new-note-cancel:hover { color: var(--color-text); }
|
||||
</style>
|
||||
|
||||
@@ -56,6 +56,17 @@ const STATUS_ICON: Record<string, string> = {
|
||||
done: "✓",
|
||||
};
|
||||
|
||||
const PRIORITY_CLASS: Record<string, string> = {
|
||||
high: "priority-high",
|
||||
medium: "priority-medium",
|
||||
low: "priority-low",
|
||||
};
|
||||
|
||||
function isRowOverdue(task: Task): boolean {
|
||||
if (!task.due_date || task.status === "done") return false;
|
||||
return task.due_date < new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Group tasks by milestone, "No Milestone" first
|
||||
const groupedTasks = computed(() => {
|
||||
const noMs = tasks.value.filter((t) => t.milestone_id === null);
|
||||
@@ -207,45 +218,99 @@ defineExpose({ reload: loadAll });
|
||||
<template>
|
||||
<div class="ws-task-panel">
|
||||
|
||||
<!-- Slide-over detail view -->
|
||||
<Transition name="slide">
|
||||
<!-- Task list — always visible, shrinks when detail is open -->
|
||||
<div :class="['task-list-view', { 'has-detail': !!activeTask }]">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Tasks</span>
|
||||
</div>
|
||||
|
||||
<div class="task-add">
|
||||
<input
|
||||
v-model="newTaskTitle"
|
||||
class="task-add-input"
|
||||
placeholder="New task..."
|
||||
@keydown.enter="addTask"
|
||||
/>
|
||||
<button class="btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state-msg">Loading...</div>
|
||||
|
||||
<div v-else class="groups-scroll">
|
||||
<!-- No Milestone group (always first) -->
|
||||
<div class="ms-group">
|
||||
<button class="ms-group-header" @click="toggleGroup(null)">
|
||||
<span class="ms-chevron">{{ collapsedGroups.has(null) ? '▶' : '▼' }}</span>
|
||||
<span class="ms-name">No Milestone</span>
|
||||
<span class="ms-count">{{ groupedTasks.noMilestone.length }}</span>
|
||||
</button>
|
||||
<ul v-show="!collapsedGroups.has(null)" class="task-items">
|
||||
<li
|
||||
v-for="task in groupedTasks.noMilestone"
|
||||
:key="task.id"
|
||||
:class="['task-row', { 'task-active': activeTask?.id === task.id }]"
|
||||
@click="openTask(task)"
|
||||
>
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Milestone groups -->
|
||||
<div v-for="{ milestone, tasks: msTasks } in groupedTasks.milestoneGroups" :key="milestone.id" class="ms-group">
|
||||
<button class="ms-group-header" @click="toggleGroup(milestone.id)">
|
||||
<span class="ms-chevron">{{ collapsedGroups.has(milestone.id) ? '▶' : '▼' }}</span>
|
||||
<span class="ms-name">{{ milestone.title }}</span>
|
||||
<span :class="['ms-status', `ms-status-${milestone.status}`]">{{ milestone.status.replace('_',' ') }}</span>
|
||||
<span class="ms-count">{{ msTasks.length }}</span>
|
||||
</button>
|
||||
<ul v-show="!collapsedGroups.has(milestone.id)" class="task-items">
|
||||
<li
|
||||
v-for="task in msTasks"
|
||||
:key="task.id"
|
||||
:class="['task-row', { 'task-active': activeTask?.id === task.id }]"
|
||||
@click="openTask(task)"
|
||||
>
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail pane — bottom split when a task is selected -->
|
||||
<Transition name="detail-fade">
|
||||
<div v-if="activeTask" class="task-detail">
|
||||
<div class="detail-header">
|
||||
<button class="btn-back-arrow" @click="closeTask">← Tasks</button>
|
||||
<RouterLink
|
||||
:to="`/tasks/${activeTask.id}/edit`"
|
||||
target="_blank"
|
||||
class="btn-edit-task"
|
||||
title="Open full editor"
|
||||
>Edit ↗</RouterLink>
|
||||
<span
|
||||
:class="['status-badge', `status-${activeTask.status}`]"
|
||||
@click="cycleStatus(activeTask, $event)"
|
||||
title="Click to cycle status"
|
||||
>
|
||||
{{ STATUS_ICON[activeTask.status] ?? "○" }}
|
||||
{{ activeTask.status.replace("_", " ") }}
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
||||
</span>
|
||||
<template v-if="deleteConfirmPending">
|
||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">
|
||||
{{ deletingTask ? '...' : 'Delete?' }}
|
||||
</button>
|
||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask">✕</button>
|
||||
</template>
|
||||
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail">✕</button>
|
||||
</div>
|
||||
|
||||
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
||||
|
||||
<div class="detail-meta">
|
||||
<span v-if="activeTask.priority && activeTask.priority !== 'none'" class="meta-chip priority">
|
||||
{{ activeTask.priority }}
|
||||
</span>
|
||||
<span v-if="activeTask.due_date" class="meta-chip due">
|
||||
Due {{ activeTask.due_date }}
|
||||
</span>
|
||||
<span v-if="activeTask.priority && activeTask.priority !== 'none'" class="meta-chip priority">{{ activeTask.priority }}</span>
|
||||
<span v-if="activeTask.due_date" class="meta-chip due">Due {{ activeTask.due_date }}</span>
|
||||
<select
|
||||
class="milestone-select"
|
||||
:value="activeTask.milestone_id ?? ''"
|
||||
@@ -268,90 +333,6 @@ defineExpose({ reload: loadAll });
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Task list (always mounted, hidden during slide-over via v-show) -->
|
||||
<div v-show="!activeTask" class="task-list-view">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Tasks</span>
|
||||
</div>
|
||||
|
||||
<div class="task-add">
|
||||
<input
|
||||
v-model="newTaskTitle"
|
||||
class="task-add-input"
|
||||
placeholder="New task..."
|
||||
@keydown.enter="addTask"
|
||||
/>
|
||||
<button class="btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state-msg">Loading...</div>
|
||||
|
||||
<div v-else class="groups-scroll">
|
||||
<!-- No Milestone group (always first) -->
|
||||
<div class="ms-group">
|
||||
<button
|
||||
class="ms-group-header"
|
||||
@click="toggleGroup(null)"
|
||||
>
|
||||
<span class="ms-chevron">{{ collapsedGroups.has(null) ? '▶' : '▼' }}</span>
|
||||
<span class="ms-name">No Milestone</span>
|
||||
<span class="ms-count">{{ groupedTasks.noMilestone.length }}</span>
|
||||
</button>
|
||||
<ul v-show="!collapsedGroups.has(null)" class="task-items">
|
||||
<li
|
||||
v-for="task in groupedTasks.noMilestone"
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
@click="openTask(task)"
|
||||
>
|
||||
<button
|
||||
:class="['status-dot', `status-${task.status}`]"
|
||||
:title="`${task.status} — click to cycle`"
|
||||
@click="cycleStatus(task, $event)"
|
||||
>{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Milestone groups -->
|
||||
<div
|
||||
v-for="{ milestone, tasks: msTasks } in groupedTasks.milestoneGroups"
|
||||
:key="milestone.id"
|
||||
class="ms-group"
|
||||
>
|
||||
<button
|
||||
class="ms-group-header"
|
||||
@click="toggleGroup(milestone.id)"
|
||||
>
|
||||
<span class="ms-chevron">{{ collapsedGroups.has(milestone.id) ? '▶' : '▼' }}</span>
|
||||
<span class="ms-name">{{ milestone.title }}</span>
|
||||
<span :class="['ms-status', `ms-status-${milestone.status}`]">{{ milestone.status.replace('_',' ') }}</span>
|
||||
<span class="ms-count">{{ msTasks.length }}</span>
|
||||
</button>
|
||||
<ul v-show="!collapsedGroups.has(milestone.id)" class="task-items">
|
||||
<li
|
||||
v-for="task in msTasks"
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
@click="openTask(task)"
|
||||
>
|
||||
<button
|
||||
:class="['status-dot', `status-${task.status}`]"
|
||||
:title="`${task.status} — click to cycle`"
|
||||
@click="cycleStatus(task, $event)"
|
||||
>{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -370,9 +351,16 @@ defineExpose({ reload: loadAll });
|
||||
.task-list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.task-list-view.has-detail {
|
||||
flex: 0 0 44%;
|
||||
}
|
||||
.task-active {
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)) !important;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 0.6rem 0.75rem;
|
||||
@@ -510,15 +498,15 @@ defineExpose({ reload: loadAll });
|
||||
|
||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
|
||||
|
||||
/* ── Slide-over detail ── */
|
||||
/* ── Detail pane (bottom split) ── */
|
||||
.task-detail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-surface);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 5;
|
||||
background: var(--color-surface);
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
@@ -530,17 +518,6 @@ defineExpose({ reload: loadAll });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-back-arrow {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-back-arrow:hover { text-decoration: underline; }
|
||||
|
||||
.status-badge {
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 12px;
|
||||
@@ -669,11 +646,45 @@ defineExpose({ reload: loadAll });
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Slide transition */
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.2s ease;
|
||||
/* Detail fade transition */
|
||||
.detail-fade-enter-active,
|
||||
.detail-fade-leave-active { transition: opacity 0.15s; }
|
||||
.detail-fade-enter-from,
|
||||
.detail-fade-leave-to { opacity: 0; }
|
||||
|
||||
/* Priority dots on task rows */
|
||||
.priority-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.slide-enter-from { transform: translateX(100%); }
|
||||
.slide-leave-to { transform: translateX(100%); }
|
||||
.priority-high { background: #ef4444; }
|
||||
.priority-medium { background: #f59e0b; }
|
||||
.priority-low { background: #3b82f6; }
|
||||
|
||||
/* Due date on task rows */
|
||||
.task-due {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-due.overdue {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Close detail button */
|
||||
.btn-close-detail {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
.btn-close-detail:hover { color: var(--color-text); }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Extension } from "@tiptap/vue-3";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { createSuggestionRenderer } from "./suggestionRenderer";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Extension } from "@tiptap/vue-3";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Extension } from "@tiptap/vue-3";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { createSuggestionRenderer } from "./suggestionRenderer";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Extension } from "@tiptap/vue-3";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Extension } from "@tiptap/vue-3";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { createSuggestionRenderer } from "./suggestionRenderer";
|
||||
|
||||
@@ -105,21 +105,23 @@ const router = createRouter({
|
||||
name: "chat-conversation",
|
||||
component: () => import("@/views/ChatView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/shared",
|
||||
name: "shared-with-me",
|
||||
component: () => import("@/views/SharedWithMeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/briefing",
|
||||
name: "briefing",
|
||||
component: () => import("@/views/BriefingView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
component: () => import("@/views/SettingsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/admin/users",
|
||||
name: "admin-users",
|
||||
component: () => import("@/views/UserManagementView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/admin/logs",
|
||||
name: "admin-logs",
|
||||
component: () => import("@/views/LogsView.vue"),
|
||||
},
|
||||
{ path: "/admin/users", redirect: "/settings" },
|
||||
{ path: "/admin/logs", redirect: "/settings" },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -30,12 +30,24 @@ interface ConvStreamState {
|
||||
contextMeta: ContextMeta | null;
|
||||
}
|
||||
|
||||
interface QueuedMessage {
|
||||
content: string;
|
||||
contextNoteId?: number | null;
|
||||
includeNoteIds?: number[];
|
||||
think: boolean;
|
||||
contextNoteTitle?: string;
|
||||
excludeNoteIds?: number[];
|
||||
ragProjectId?: number | null;
|
||||
workspaceProjectId?: number | null;
|
||||
}
|
||||
|
||||
export const useChatStore = defineStore("chat", () => {
|
||||
const conversations = ref<Conversation[]>([]);
|
||||
const currentConversation = ref<ConversationDetail | null>(null);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const convStreams = ref<Record<number, ConvStreamState>>({});
|
||||
const convQueues = ref<Record<number, QueuedMessage[]>>({});
|
||||
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
|
||||
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
|
||||
const defaultModel = ref("");
|
||||
@@ -67,6 +79,44 @@ export const useChatStore = defineStore("chat", () => {
|
||||
return convStreams.value[id]?.streaming ?? false;
|
||||
}
|
||||
|
||||
const queuedCount = computed(() => {
|
||||
const id = currentConversation.value?.id;
|
||||
return id ? (convQueues.value[id]?.length ?? 0) : 0;
|
||||
});
|
||||
|
||||
const queuedMessages = computed(() => {
|
||||
const id = currentConversation.value?.id;
|
||||
return id ? (convQueues.value[id] ?? []) : [];
|
||||
});
|
||||
|
||||
function _saveQueue(convId: number) {
|
||||
const q = convQueues.value[convId] ?? [];
|
||||
if (q.length) {
|
||||
localStorage.setItem(`fa_conv_queue_${convId}`, JSON.stringify(q));
|
||||
} else {
|
||||
localStorage.removeItem(`fa_conv_queue_${convId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function _loadQueue(convId: number) {
|
||||
try {
|
||||
const raw = localStorage.getItem(`fa_conv_queue_${convId}`);
|
||||
if (raw) {
|
||||
convQueues.value[convId] = JSON.parse(raw) as QueuedMessage[];
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupt data
|
||||
}
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
const id = currentConversation.value?.id;
|
||||
if (id) {
|
||||
convQueues.value[id] = [];
|
||||
_saveQueue(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Chat is usable when Ollama is up and model is at least installed (cold starts still work)
|
||||
const chatReady = computed(
|
||||
() => ollamaStatus.value === "available" && (modelStatus.value === "loaded" || modelStatus.value === "cold")
|
||||
@@ -107,6 +157,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
currentConversation.value = await apiGet<ConversationDetail>(
|
||||
`/api/chat/conversations/${id}`
|
||||
);
|
||||
_loadQueue(id);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load conversation", "error");
|
||||
throw e;
|
||||
@@ -123,7 +174,11 @@ export const useChatStore = defineStore("chat", () => {
|
||||
if (currentConversation.value && idSet.has(currentConversation.value.id)) {
|
||||
currentConversation.value = null;
|
||||
}
|
||||
for (const id of ids) delete convStreams.value[id];
|
||||
for (const id of ids) {
|
||||
delete convStreams.value[id];
|
||||
delete convQueues.value[id];
|
||||
localStorage.removeItem(`fa_conv_queue_${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConversation(id: number) {
|
||||
@@ -134,6 +189,8 @@ export const useChatStore = defineStore("chat", () => {
|
||||
currentConversation.value = null;
|
||||
}
|
||||
delete convStreams.value[id];
|
||||
delete convQueues.value[id];
|
||||
localStorage.removeItem(`fa_conv_queue_${id}`);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete conversation", "error");
|
||||
throw e;
|
||||
@@ -173,6 +230,18 @@ export const useChatStore = defineStore("chat", () => {
|
||||
|
||||
const convId = currentConversation.value.id;
|
||||
const s = _getOrInitStream(convId);
|
||||
|
||||
// If already streaming, queue the message for after the current stream ends.
|
||||
if (s.streaming) {
|
||||
if (!convQueues.value[convId]) convQueues.value[convId] = [];
|
||||
convQueues.value[convId].push({
|
||||
content, contextNoteId, includeNoteIds, think, contextNoteTitle,
|
||||
excludeNoteIds, ragProjectId, workspaceProjectId,
|
||||
});
|
||||
_saveQueue(convId);
|
||||
return;
|
||||
}
|
||||
|
||||
s.contextMeta = null;
|
||||
|
||||
// If a write tool is waiting for confirmation, intercept single-word yes/no
|
||||
@@ -361,6 +430,24 @@ export const useChatStore = defineStore("chat", () => {
|
||||
s.status = "";
|
||||
s.pendingTool = null;
|
||||
}
|
||||
|
||||
// Process next queued message, if any.
|
||||
// Use setTimeout so this frame resolves before the next send begins.
|
||||
const queue = convQueues.value[convId];
|
||||
if (queue?.length && currentConversation.value?.id === convId) {
|
||||
const next = queue.shift()!;
|
||||
_saveQueue(convId);
|
||||
setTimeout(() => sendMessage(
|
||||
next.content,
|
||||
next.contextNoteId,
|
||||
next.includeNoteIds,
|
||||
next.think,
|
||||
next.contextNoteTitle,
|
||||
next.excludeNoteIds,
|
||||
next.ragProjectId,
|
||||
next.workspaceProjectId,
|
||||
), 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnectIfGenerating(convId: number): Promise<void> {
|
||||
@@ -501,6 +588,9 @@ export const useChatStore = defineStore("chat", () => {
|
||||
defaultModel,
|
||||
chatReady,
|
||||
isStreamingConv,
|
||||
queuedCount,
|
||||
queuedMessages,
|
||||
clearQueue,
|
||||
fetchConversations,
|
||||
createConversation,
|
||||
fetchConversation,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
getNotificationCount,
|
||||
getNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationEntry,
|
||||
} from '@/api/client'
|
||||
|
||||
export const useNotificationsStore = defineStore('notifications', () => {
|
||||
const count = ref(0)
|
||||
const items = ref<NotificationEntry[]>([])
|
||||
|
||||
async function fetchCount() {
|
||||
try {
|
||||
count.value = await getNotificationCount()
|
||||
} catch {
|
||||
// silently ignore — polling failure shouldn't surface to user
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAll() {
|
||||
try {
|
||||
items.value = await getNotifications(false)
|
||||
count.value = items.value.length
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(id: number) {
|
||||
await markNotificationRead(id)
|
||||
items.value = items.value.filter(n => n.id !== id)
|
||||
count.value = Math.max(0, count.value - 1)
|
||||
}
|
||||
|
||||
async function markAll() {
|
||||
await markAllNotificationsRead()
|
||||
items.value = []
|
||||
count.value = 0
|
||||
}
|
||||
|
||||
return { count, items, fetchCount, fetchAll, markRead, markAll }
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
const buf = new ArrayBuffer(rawData.length)
|
||||
const outputArray = new Uint8Array<ArrayBuffer>(buf)
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface GenerationTiming {
|
||||
export interface ToolCallRecord {
|
||||
function: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[] };
|
||||
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[]; requires_confirmation?: boolean; similar_note?: { id: number; title: string } };
|
||||
status: "running" | "success" | "error" | "declined";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { marked } from "marked";
|
||||
import { marked, type RendererThis, type Tokens } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { linkifyTags, linkifyWikilinks } from "@/utils/tags";
|
||||
|
||||
@@ -28,7 +28,8 @@ export function stripFirstLineTags(text: string): string {
|
||||
}
|
||||
|
||||
const headingRenderer = {
|
||||
heading({ text, depth }: { text: string; depth: number }): string {
|
||||
heading(this: RendererThis, { tokens, depth }: Tokens.Heading): string {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const id = slugify(text);
|
||||
return `<h${depth} id="${id}">${text}</h${depth}>`;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import ChatMessage from '@/components/ChatMessage.vue'
|
||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||
import {
|
||||
getBriefingConfig,
|
||||
getBriefingConversations,
|
||||
getBriefingToday,
|
||||
getBriefingConvMessages,
|
||||
triggerBriefingSlot,
|
||||
type BriefingConversation,
|
||||
type BriefingMessage,
|
||||
} from '@/api/client'
|
||||
import type { Message } from '@/types/chat'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// Setup wizard
|
||||
const showWizard = ref(false)
|
||||
const wizardChecked = ref(false)
|
||||
|
||||
async function checkSetup() {
|
||||
const config = await getBriefingConfig()
|
||||
if (!config.enabled) showWizard.value = true
|
||||
wizardChecked.value = true
|
||||
}
|
||||
|
||||
async function onWizardDone() {
|
||||
showWizard.value = false
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
// Conversations list for the dropdown
|
||||
const conversations = ref<BriefingConversation[]>([])
|
||||
const selectedConvId = ref<number | null>(null)
|
||||
const todayConvId = ref<number | null>(null)
|
||||
|
||||
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||
|
||||
// Messages for selected conversation
|
||||
const messages = ref<BriefingMessage[]>([])
|
||||
const loadingMessages = ref(false)
|
||||
|
||||
async function loadAll() {
|
||||
const [convList, today] = await Promise.all([
|
||||
getBriefingConversations(),
|
||||
getBriefingToday().catch(() => null),
|
||||
])
|
||||
conversations.value = convList
|
||||
if (today) {
|
||||
todayConvId.value = today.id
|
||||
// Ensure today is in the list
|
||||
if (!convList.find((c) => c.id === today.id)) {
|
||||
conversations.value = [
|
||||
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
|
||||
...convList,
|
||||
]
|
||||
}
|
||||
selectedConvId.value = today.id
|
||||
messages.value = today.messages
|
||||
// Load into chatStore so we can stream
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedConvId, async (id) => {
|
||||
if (!id) return
|
||||
if (id === todayConvId.value) {
|
||||
await chatStore.fetchConversation(id)
|
||||
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
|
||||
return
|
||||
}
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
messages.value = await getBriefingConvMessages(id)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Refresh messages after streaming ends
|
||||
watch(() => chatStore.streaming, async (streaming) => {
|
||||
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
|
||||
const today = await getBriefingToday().catch(() => null)
|
||||
if (today) messages.value = today.messages
|
||||
}
|
||||
})
|
||||
|
||||
// Input
|
||||
const input = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
async function send() {
|
||||
const text = input.value.trim()
|
||||
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
|
||||
// Ensure today's conv is loaded in chatStore
|
||||
if (chatStore.currentConversation?.id !== todayConvId.value) {
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
}
|
||||
input.value = ''
|
||||
sending.value = true
|
||||
try {
|
||||
await chatStore.sendMessage(text)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
// Manual trigger
|
||||
const triggering = ref(false)
|
||||
async function triggerNow() {
|
||||
triggering.value = true
|
||||
try {
|
||||
await triggerBriefingSlot('compilation')
|
||||
// Reload
|
||||
await loadAll()
|
||||
} finally {
|
||||
triggering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown label
|
||||
function convLabel(c: BriefingConversation): string {
|
||||
if (c.id === todayConvId.value) return 'Today'
|
||||
if (c.briefing_date) {
|
||||
const d = new Date(c.briefing_date)
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
return c.title || 'Briefing'
|
||||
}
|
||||
|
||||
// Convert BriefingMessage to Message for ChatMessage component
|
||||
function toMsg(m: BriefingMessage): Message {
|
||||
return {
|
||||
id: m.id,
|
||||
conversation_id: -1,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
context_note_id: null,
|
||||
context_note_title: null,
|
||||
created_at: m.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await checkSetup()
|
||||
if (!showWizard.value) await loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="briefing-root">
|
||||
<!-- Setup wizard overlay -->
|
||||
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
|
||||
|
||||
<!-- Main view (shown after wizard check and only if not showing wizard) -->
|
||||
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
|
||||
<!-- Header -->
|
||||
<header class="briefing-header">
|
||||
<div class="briefing-header-left">
|
||||
<h1 class="briefing-title">Briefing</h1>
|
||||
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
|
||||
</div>
|
||||
<div class="briefing-header-right">
|
||||
<!-- Conversation history dropdown -->
|
||||
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
|
||||
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn-trigger"
|
||||
@click="triggerNow"
|
||||
:disabled="triggering"
|
||||
title="Manually trigger morning briefing now"
|
||||
>{{ triggering ? '…' : 'Refresh' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="briefing-messages-wrap">
|
||||
<div v-if="loadingMessages" class="briefing-loading">Loading…</div>
|
||||
<template v-else>
|
||||
<div v-if="!messages.length" class="briefing-empty">
|
||||
<p>No briefing yet for today.</p>
|
||||
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
|
||||
</div>
|
||||
<div v-else class="briefing-messages">
|
||||
<ChatMessage
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:message="toMsg(msg)"
|
||||
:is-streaming="false"
|
||||
/>
|
||||
<!-- Live streaming bubble for today -->
|
||||
<ChatMessage
|
||||
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
|
||||
:message="{
|
||||
id: -1,
|
||||
conversation_id: todayConvId ?? -1,
|
||||
role: 'assistant',
|
||||
content: chatStore.streamingContent,
|
||||
context_note_id: null,
|
||||
context_note_title: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}"
|
||||
:is-streaming="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Input bar (today only) -->
|
||||
<div v-if="isToday" class="briefing-input-bar">
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="briefing-input"
|
||||
placeholder="Reply to your briefing…"
|
||||
rows="1"
|
||||
@keydown="onKeydown"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="send"
|
||||
:disabled="!input.trim() || chatStore.streaming || sending"
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.briefing-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.briefing-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 0 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.briefing-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.briefing-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.briefing-today-badge {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.briefing-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.briefing-conv-select {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-trigger {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-trigger:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.briefing-messages-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.briefing-loading,
|
||||
.briefing-empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.briefing-empty-hint {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.briefing-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.briefing-input-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
padding: 0.75rem 0 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.briefing-input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.briefing-input:focus { border-color: var(--color-primary); }
|
||||
|
||||
.btn-send {
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-send:hover:not(:disabled) { opacity: 0.9; }
|
||||
</style>
|
||||
@@ -114,7 +114,8 @@ const streamingRendered = computed(() => {
|
||||
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (!store.chatReady) return "Chat unavailable";
|
||||
return "Type a message... (Enter to send, Shift+Enter for new line)";
|
||||
if (store.streaming) return "Type to queue next message… (Enter to queue)";
|
||||
return "Type a message… (Enter to send, Shift+Enter for new line)";
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -283,7 +284,7 @@ async function removeConversation(id: number) {
|
||||
|
||||
async function sendMessage() {
|
||||
const content = messageInput.value.trim();
|
||||
if (!content || store.streaming) return;
|
||||
if (!content) return;
|
||||
|
||||
// Auto-create conversation if none selected
|
||||
if (!store.currentConversation) {
|
||||
@@ -475,6 +476,13 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", onGlobalKeydown);
|
||||
// Clean up empty conversation when navigating away from the chat view entirely
|
||||
if (prevConvId) {
|
||||
const conv = store.conversations.find((c) => c.id === prevConvId);
|
||||
if (conv && conv.message_count === 0) {
|
||||
store.deleteConversation(prevConvId);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -631,6 +639,25 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queued messages — shown as pending bubbles -->
|
||||
<template v-if="store.queuedMessages.length">
|
||||
<div
|
||||
v-for="(q, i) in store.queuedMessages"
|
||||
:key="`queued-${i}`"
|
||||
class="chat-message role-user queued-message"
|
||||
>
|
||||
<div class="message-bubble queued-bubble">
|
||||
<div class="queued-badge">Queued</div>
|
||||
<div class="message-content">{{ q.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queued-clear-row">
|
||||
<button class="queued-clear-btn" @click="store.clearQueue()" aria-label="Cancel queued messages">
|
||||
Cancel {{ store.queuedMessages.length }} queued
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p
|
||||
v-if="!store.currentConversation.messages.length && !store.streaming"
|
||||
class="empty-msg"
|
||||
@@ -775,7 +802,7 @@ onUnmounted(() => {
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
:placeholder="inputPlaceholder"
|
||||
:disabled="store.streaming || !store.chatReady"
|
||||
:disabled="!store.chatReady"
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button
|
||||
@@ -827,7 +854,6 @@ onUnmounted(() => {
|
||||
.chat-sidebar {
|
||||
width: 260px;
|
||||
min-width: 200px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-secondary);
|
||||
@@ -848,9 +874,11 @@ onUnmounted(() => {
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-new-conv:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
|
||||
}
|
||||
|
||||
.btn-select-mode {
|
||||
@@ -971,13 +999,17 @@ onUnmounted(() => {
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.25rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.conv-item:hover {
|
||||
background: var(--color-bg-card);
|
||||
background: rgba(99,102,241,0.05);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
.conv-item.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
.conv-info {
|
||||
flex: 1;
|
||||
@@ -997,7 +1029,7 @@ onUnmounted(() => {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.conv-item.active .conv-date {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-delete-conv {
|
||||
background: none;
|
||||
@@ -1009,7 +1041,7 @@ onUnmounted(() => {
|
||||
line-height: 1;
|
||||
}
|
||||
.conv-item.active .btn-delete-conv {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-delete-conv:hover {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
@@ -1027,7 +1059,6 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.chat-header h2 {
|
||||
margin: 0;
|
||||
@@ -1096,7 +1127,6 @@ onUnmounted(() => {
|
||||
.context-sidebar {
|
||||
width: 220px;
|
||||
min-width: 180px;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1200,10 +1230,11 @@ onUnmounted(() => {
|
||||
.streaming-bubble {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
border-bottom-left-radius: 4px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
box-shadow: var(--color-bubble-asst-shadow);
|
||||
}
|
||||
.streaming-bubble .message-header {
|
||||
display: flex;
|
||||
@@ -1213,7 +1244,7 @@ onUnmounted(() => {
|
||||
.streaming-bubble .role-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
@@ -1240,8 +1271,8 @@ onUnmounted(() => {
|
||||
}
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid rgba(129, 140, 248, 0.35);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thinking-summary {
|
||||
@@ -1279,7 +1310,6 @@ details[open] .thinking-summary::before {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.streaming-status-dot {
|
||||
display: inline-block;
|
||||
@@ -1497,13 +1527,17 @@ details[open] .thinking-summary::before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-send:not(:disabled):hover {
|
||||
box-shadow: 0 0 14px rgba(129, 140, 248, 0.5);
|
||||
}
|
||||
.btn-send:disabled {
|
||||
opacity: 0.35;
|
||||
@@ -1601,4 +1635,47 @@ details[open] .thinking-summary::before {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message.role-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.queued-bubble {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 18px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
opacity: 0.45;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
.queued-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.queued-clear-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.queued-clear-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.queued-clear-btn:hover {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -258,7 +258,7 @@ function initGraph() {
|
||||
openPeek(d);
|
||||
}
|
||||
})
|
||||
.on("mouseover", (event: MouseEvent, d: GraphNode) => {
|
||||
.on("mouseover", function(event: MouseEvent, d: GraphNode) {
|
||||
const rect = containerRef.value!.getBoundingClientRect();
|
||||
tooltip.value = {
|
||||
visible: true,
|
||||
@@ -266,14 +266,28 @@ function initGraph() {
|
||||
y: event.clientY - rect.top - 8,
|
||||
node: d,
|
||||
};
|
||||
select(this as SVGGElement).select("circle")
|
||||
.transition().duration(150)
|
||||
.attr("r", (d.radius ?? 8) * 1.3);
|
||||
select(this as SVGGElement).select("text")
|
||||
.transition().duration(150)
|
||||
.style("font-weight", "600")
|
||||
.style("font-size", "11px");
|
||||
})
|
||||
.on("mousemove", (event: MouseEvent) => {
|
||||
const rect = containerRef.value!.getBoundingClientRect();
|
||||
tooltip.value.x = event.clientX - rect.left + 12;
|
||||
tooltip.value.y = event.clientY - rect.top - 8;
|
||||
})
|
||||
.on("mouseout", () => {
|
||||
.on("mouseout", function(_event: MouseEvent, d: GraphNode) {
|
||||
tooltip.value.visible = false;
|
||||
select(this as SVGGElement).select("circle")
|
||||
.transition().duration(150)
|
||||
.attr("r", d.radius ?? 8);
|
||||
select(this as SVGGElement).select("text")
|
||||
.transition().duration(150)
|
||||
.style("font-weight", null)
|
||||
.style("font-size", null);
|
||||
});
|
||||
|
||||
nodeSel
|
||||
|
||||
+557
-402
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ import TagInput from "@/components/TagInput.vue";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import HistoryPanel from "@/components/HistoryPanel.vue";
|
||||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -35,7 +35,6 @@ const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
const sidebarOpen = ref(true);
|
||||
const showHistory = ref(false);
|
||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
const titleRef = ref<HTMLInputElement | null>(null);
|
||||
const tiptapEditor = computed<Editor | null>(() => {
|
||||
@@ -321,12 +320,11 @@ onUnmounted(() => assist.clearSelection());
|
||||
<main class="editor-page note-editor-page">
|
||||
<div class="editor-header">
|
||||
<div class="toolbar">
|
||||
<router-link to="/notes" class="btn-back">Back</router-link>
|
||||
<router-link to="/notes" class="btn-back">← Notes</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>
|
||||
<button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button>
|
||||
<WordCount :body="body" />
|
||||
</div>
|
||||
<input
|
||||
@@ -513,6 +511,13 @@ onUnmounted(() => assist.clearSelection());
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<VersionHistorySection
|
||||
v-if="noteId"
|
||||
:note-id="noteId"
|
||||
:current-body="body"
|
||||
@restore="(b, t) => { body = b; tags = t; markDirty(); }"
|
||||
/>
|
||||
|
||||
</div><!-- /sidebar-content -->
|
||||
</aside>
|
||||
</div><!-- /note-body -->
|
||||
@@ -527,15 +532,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
>✨ Assist</button>
|
||||
</teleport>
|
||||
|
||||
<!-- History panel -->
|
||||
<HistoryPanel
|
||||
v-if="showHistory && noteId"
|
||||
:note-id="noteId"
|
||||
:current-body="body"
|
||||
@restore="(b: string, t: string[]) => { body = b; tags = t; markDirty(); }"
|
||||
@close="showHistory = false"
|
||||
/>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<ConfirmDialog
|
||||
v-if="showDeleteConfirm"
|
||||
@@ -575,8 +571,12 @@ onUnmounted(() => assist.clearSelection());
|
||||
|
||||
.body-tabs-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
@@ -586,7 +586,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
border-radius: 8px;
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@@ -734,22 +734,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* History button */
|
||||
.btn-history {
|
||||
padding: 0.45rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-history:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Narrow screen: sidebar collapses */
|
||||
@media (max-width: 720px) {
|
||||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
@@ -8,13 +8,14 @@ import { apiPost, apiGet } from "@/api/client";
|
||||
import type { Note } from "@/types/note";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import TableOfContents from "@/components/TableOfContents.vue";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useNotesStore();
|
||||
const bodyEl = ref<HTMLElement | null>(null);
|
||||
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
|
||||
const converting = ref(false);
|
||||
const showShare = ref(false);
|
||||
|
||||
// Context enrichment
|
||||
const projectTitle = ref<string | null>(null);
|
||||
@@ -139,7 +140,20 @@ async function convertToTask() {
|
||||
<template>
|
||||
<div class="viewer-layout">
|
||||
<main class="viewer">
|
||||
<p v-if="store.loading">Loading...</p>
|
||||
<div v-if="store.loading" class="viewer-skeleton" aria-label="Loading note">
|
||||
<div class="skel-toolbar">
|
||||
<div class="skel-btn"></div>
|
||||
<div class="skel-btn skel-btn--wide"></div>
|
||||
</div>
|
||||
<div class="skel-title"></div>
|
||||
<div class="skel-meta"></div>
|
||||
<div class="skel-line"></div>
|
||||
<div class="skel-line skel-line--short"></div>
|
||||
<div class="skel-line"></div>
|
||||
<div class="skel-line skel-line--medium"></div>
|
||||
<div class="skel-line"></div>
|
||||
<div class="skel-line skel-line--short"></div>
|
||||
</div>
|
||||
<template v-else-if="store.currentNote">
|
||||
<div class="toolbar">
|
||||
<router-link to="/notes" class="btn-back">← Notes</router-link>
|
||||
@@ -157,6 +171,7 @@ async function convertToTask() {
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Task" }}
|
||||
</button>
|
||||
<button class="btn-share" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent → project → milestone -->
|
||||
@@ -183,11 +198,17 @@ async function convertToTask() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
|
||||
<h1 class="note-title">{{ store.currentNote.title || "Untitled" }}</h1>
|
||||
<p class="meta">
|
||||
Updated {{ relativeTime(store.currentNote.updated_at) }}
|
||||
·
|
||||
Created {{ relativeTime(store.currentNote.created_at) }}
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
|
||||
Updated {{ relativeTime(store.currentNote.updated_at) }}
|
||||
</span>
|
||||
<span class="meta-sep" aria-hidden="true">·</span>
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
Created {{ relativeTime(store.currentNote.created_at) }}
|
||||
</span>
|
||||
</p>
|
||||
<div class="tags" v-if="store.currentNote.tags.length">
|
||||
<TagPill
|
||||
@@ -198,25 +219,28 @@ async function convertToTask() {
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="bodyEl"
|
||||
class="body prose"
|
||||
v-html="renderedBody"
|
||||
@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>
|
||||
<h3 class="backlinks-heading">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
Backlinks
|
||||
<span class="backlinks-count">{{ backlinks.length }}</span>
|
||||
</h3>
|
||||
<div class="backlinks-grid">
|
||||
<router-link
|
||||
v-for="link in backlinks"
|
||||
:key="`${link.type}-${link.id}`"
|
||||
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
|
||||
class="backlink-card"
|
||||
>
|
||||
<span :class="['backlink-type-badge', `badge-${link.type}`]">{{ link.type }}</span>
|
||||
<span class="backlink-title">{{ link.title || "Untitled" }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else>Note not found.</p>
|
||||
@@ -227,6 +251,14 @@ async function convertToTask() {
|
||||
class="toc-sidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ShareDialog
|
||||
v-if="showShare && store.currentNote"
|
||||
resource-type="note"
|
||||
:resource-id="store.currentNote.id"
|
||||
:resource-title="store.currentNote.title || '(untitled)'"
|
||||
@close="showShare = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/viewer-shared.css" />
|
||||
@@ -257,8 +289,7 @@ async function convertToTask() {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-back,
|
||||
.btn-edit {
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
@@ -270,11 +301,30 @@ async function convertToTask() {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover,
|
||||
.btn-edit:hover {
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
opacity: 0.95;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
@@ -295,11 +345,45 @@ async function convertToTask() {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.meta {
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.note-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.meta-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -307,41 +391,114 @@ async function convertToTask() {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.backlinks {
|
||||
margin-top: 2rem;
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 1rem;
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.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 {
|
||||
.backlinks-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.backlink-link {
|
||||
color: var(--color-text);
|
||||
.backlinks-count {
|
||||
margin-left: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.4rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.backlinks-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.backlink-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.backlink-link:hover {
|
||||
.backlink-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.backlink-type {
|
||||
font-size: 0.75rem;
|
||||
.backlink-type-badge {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-note {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
.badge-task {
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
color: #d97706;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
}
|
||||
.backlink-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Skeleton loader ── */
|
||||
@keyframes skel-shine {
|
||||
to { background-position: 200% center; }
|
||||
}
|
||||
.viewer-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.skel-btn,
|
||||
.skel-title,
|
||||
.skel-meta,
|
||||
.skel-line {
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-secondary) 25%,
|
||||
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
|
||||
var(--color-bg-secondary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shine 1.5s ease infinite;
|
||||
}
|
||||
.skel-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.skel-btn { width: 70px; height: 32px; }
|
||||
.skel-btn--wide { width: 90px; }
|
||||
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--radius-md); }
|
||||
.skel-meta { height: 0.85rem; width: 40%; }
|
||||
.skel-line { height: 0.9rem; }
|
||||
.skel-line--short { width: 55%; }
|
||||
.skel-line--medium { width: 80%; }
|
||||
</style>
|
||||
|
||||
@@ -139,7 +139,14 @@ function onOffsetUpdate(offset: number) {
|
||||
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
|
||||
</div>
|
||||
|
||||
<p v-if="store.loading">Loading...</p>
|
||||
<div v-if="store.loading" class="note-list-skeleton">
|
||||
<div v-if="viewMode === 'grid'" class="skeleton-grid">
|
||||
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
|
||||
</div>
|
||||
<div v-else class="skeleton-rows">
|
||||
<div class="skeleton-row" v-for="i in 8" :key="i"></div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -201,11 +208,16 @@ function onOffsetUpdate(offset: number) {
|
||||
}
|
||||
.btn-new {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-new:hover {
|
||||
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
@@ -275,10 +287,43 @@ function onOffsetUpdate(offset: number) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.note-list-skeleton {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 140px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
.skeleton-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.skeleton-row {
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Grid layout */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -312,11 +357,16 @@ function onOffsetUpdate(offset: number) {
|
||||
.btn-cta {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-cta:hover {
|
||||
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.kb-active-item {
|
||||
|
||||
@@ -150,13 +150,16 @@ function truncate(text: string | null, max = 120): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="loading-msg">Loading...</p>
|
||||
<div v-if="loading" class="skeleton-grid">
|
||||
<div class="skeleton-card" v-for="i in 4" :key="i"></div>
|
||||
</div>
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
|
||||
<div v-else-if="filteredProjects.length === 0" class="empty-state">
|
||||
<div v-else-if="filteredProjects.length === 0" class="empty-state-rich">
|
||||
<div class="empty-icon">◈</div>
|
||||
<p class="empty-title">No projects yet</p>
|
||||
<p class="empty-subtitle">Create your first project to organize tasks and notes.</p>
|
||||
<button class="btn-cta" @click="openNewProjectModal">+ New Project</button>
|
||||
<p class="empty-sub">Organise your notes and tasks into a project</p>
|
||||
<button class="empty-action" @click="openNewProjectModal">New project →</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="projects-grid">
|
||||
@@ -327,32 +330,28 @@ function truncate(text: string | null, max = 120): string {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
||||
.empty-title { font-size: 1rem; font-weight: 600; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--color-primary); color: #fff; }
|
||||
|
||||
.skeleton-card {
|
||||
height: 140px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-text);
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.empty-subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-cta {
|
||||
display: inline-block;
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
@@ -367,7 +366,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
@@ -375,6 +374,7 @@ function truncate(text: string | null, max = 120): string {
|
||||
.project-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
|
||||
+515
-546
File diff suppressed because it is too large
Load Diff
+1722
-185
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getSharedWithMe } from '@/api/client'
|
||||
|
||||
interface SharedProject {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
status: string
|
||||
color: string | null
|
||||
permission: string
|
||||
owner_username: string
|
||||
}
|
||||
|
||||
interface SharedNote {
|
||||
id: number
|
||||
title: string
|
||||
is_task: boolean
|
||||
permission: string
|
||||
owner_username: string
|
||||
}
|
||||
|
||||
const projects = ref<SharedProject[]>([])
|
||||
const notes = ref<SharedNote[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await getSharedWithMe()
|
||||
projects.value = data.projects as unknown as SharedProject[]
|
||||
notes.value = data.notes as unknown as SharedNote[]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shared-page page-container">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Shared with me</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading-state">Loading…</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Projects -->
|
||||
<section class="shared-section">
|
||||
<h2 class="section-heading">Projects</h2>
|
||||
<div v-if="projects.length" class="shared-grid">
|
||||
<router-link
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
:to="`/projects/${p.id}`"
|
||||
class="shared-card"
|
||||
>
|
||||
<div class="card-color-bar" :style="p.color ? `background: ${p.color}` : ''" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">{{ p.title }}</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-owner">by {{ p.owner_username }}</span>
|
||||
<span class="perm-badge" :class="`perm-${p.permission}`">{{ p.permission }}</span>
|
||||
</div>
|
||||
<p v-if="p.description" class="card-desc">{{ p.description }}</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<p v-else class="empty-msg">No projects shared with you yet.</p>
|
||||
</section>
|
||||
|
||||
<!-- Notes & Tasks -->
|
||||
<section class="shared-section">
|
||||
<h2 class="section-heading">Notes & Tasks</h2>
|
||||
<div v-if="notes.length" class="shared-list">
|
||||
<router-link
|
||||
v-for="n in notes"
|
||||
:key="n.id"
|
||||
:to="n.is_task ? `/tasks/${n.id}` : `/notes/${n.id}`"
|
||||
class="shared-row"
|
||||
>
|
||||
<span class="row-icon">{{ n.is_task ? '✓' : '📄' }}</span>
|
||||
<span class="row-title">{{ n.title || '(untitled)' }}</span>
|
||||
<span class="row-owner">by {{ n.owner_username }}</span>
|
||||
<span class="perm-badge" :class="`perm-${n.permission}`">{{ n.permission }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<p v-else class="empty-msg">No notes or tasks shared with you yet.</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shared-page {
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
color: var(--color-muted);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shared-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.shared-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.shared-card {
|
||||
display: flex;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.shared-card:hover {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-color-bar {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.9rem 1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.3rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.card-owner {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-muted);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.shared-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.shared-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.shared-row:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.row-title {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row-owner {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.perm-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
|
||||
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
||||
.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.88rem;
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from "vue";
|
||||
import { ref, onMounted, computed, nextTick, onUnmounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
@@ -20,8 +20,9 @@ import TagInput from "@/components/TagInput.vue";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -98,7 +99,7 @@ async function toggleSubTask(sub: SubTask) {
|
||||
toast.show("Failed to update sub-task", "error");
|
||||
}
|
||||
}
|
||||
const showPreview = ref(true);
|
||||
const showPreview = ref(false);
|
||||
const sidebarOpen = ref(true);
|
||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
const titleRef = ref<HTMLInputElement | null>(null);
|
||||
@@ -116,46 +117,62 @@ const renderedPreview = computed(() => renderMarkdown(body.value));
|
||||
// AI Assist
|
||||
const assist = useAssist(body, taskId, projectId);
|
||||
|
||||
const assistLabel = computed(() => {
|
||||
if (assist.isProofreading.value) return "Proofreading document...";
|
||||
const t = assist.target.value;
|
||||
if (!t) return "Generating...";
|
||||
const heading = t.text.split("\n")[0];
|
||||
return `Revising: "${heading.length > 50 ? heading.slice(0, 50) + "..." : heading}"`;
|
||||
// Scope selector — matches note editor pattern
|
||||
const scopeOptions = computed(() => {
|
||||
const opts: Array<{ value: string; label: string }> = [
|
||||
{ value: "__document__", label: "Whole document" },
|
||||
];
|
||||
for (const section of assist.sections.value) {
|
||||
opts.push({
|
||||
value: String(assist.sections.value.indexOf(section)),
|
||||
label: section.heading || "(preamble)",
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
});
|
||||
|
||||
// Assist panel toggle (persisted)
|
||||
const ASSIST_KEY = 'fa-assist-open';
|
||||
const assistOpen = ref(localStorage.getItem(ASSIST_KEY) !== 'false');
|
||||
function toggleAssist() {
|
||||
assistOpen.value = !assistOpen.value;
|
||||
localStorage.setItem(ASSIST_KEY, String(assistOpen.value));
|
||||
}
|
||||
const scopeSelectValue = computed({
|
||||
get() {
|
||||
if (assist.scopeMode.value === "document") return "__document__";
|
||||
if (assist.selectedSection.value) {
|
||||
const idx = assist.sections.value.indexOf(assist.selectedSection.value);
|
||||
return idx >= 0 ? String(idx) : "__document__";
|
||||
}
|
||||
return "__document__";
|
||||
},
|
||||
set(val: string) {
|
||||
if (val === "__document__") {
|
||||
assist.scopeMode.value = "document";
|
||||
assist.selectedSection.value = null;
|
||||
} else {
|
||||
const idx = Number(val);
|
||||
const section = assist.sections.value[idx];
|
||||
if (section) {
|
||||
assist.scopeMode.value = "section";
|
||||
assist.selectSection(section);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Floating inline assist button
|
||||
const instructionRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
|
||||
({ start, end }) => {
|
||||
assist.scopeMode.value = "section";
|
||||
assist.selectTextRange(start, end);
|
||||
assistOpen.value = true;
|
||||
localStorage.setItem(ASSIST_KEY, 'true');
|
||||
nextTick(() => instructionRef.value?.focus());
|
||||
}
|
||||
);
|
||||
|
||||
function handleAssistAccept() {
|
||||
const newBody = assist.accept();
|
||||
if (newBody !== body.value) {
|
||||
body.value = newBody;
|
||||
markDirty();
|
||||
toast.show("Section updated");
|
||||
}
|
||||
body.value = newBody;
|
||||
markDirty();
|
||||
toast.show("Task updated");
|
||||
}
|
||||
|
||||
function truncateTarget(text: string, max = 60): string {
|
||||
const first = text.split("\n")[0];
|
||||
return first.length > max ? first.slice(0, max) + "..." : first;
|
||||
}
|
||||
onUnmounted(() => assist.clearSelection());
|
||||
|
||||
// Tag suggestions
|
||||
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
|
||||
@@ -266,6 +283,8 @@ onMounted(async () => {
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedParentId = parentId.value;
|
||||
// Start in preview mode only if the task already has body content
|
||||
showPreview.value = body.value.trim().length > 0;
|
||||
}
|
||||
loadSubTasks();
|
||||
} else {
|
||||
@@ -304,6 +323,7 @@ async function save() {
|
||||
savedParentId = parentId.value;
|
||||
dirty.value = false;
|
||||
toast.show("Task saved");
|
||||
window.location.reload();
|
||||
} else {
|
||||
const task = await store.createTask(data);
|
||||
dirty.value = false;
|
||||
@@ -379,16 +399,11 @@ useEditorGuards(dirty, save);
|
||||
<main class="editor-page task-editor-page">
|
||||
<div class="editor-header">
|
||||
<div class="toolbar">
|
||||
<router-link to="/tasks" class="btn-back">Back</router-link>
|
||||
<router-link to="/tasks" class="btn-back">← Tasks</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>
|
||||
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
|
||||
✨ Assist
|
||||
</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
|
||||
<WordCount :body="body" />
|
||||
</div>
|
||||
<input
|
||||
@@ -416,38 +431,36 @@ useEditorGuards(dirty, save);
|
||||
<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" :editor="tiptapEditor" />
|
||||
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
|
||||
</div>
|
||||
|
||||
<!-- Inline assist output -->
|
||||
<InlineAssistPanel
|
||||
v-if="assist.state.value !== 'idle'"
|
||||
:phase="assist.state.value as 'streaming' | 'review'"
|
||||
:label="assistLabel"
|
||||
:streaming-text="assist.streamingText.value"
|
||||
:diff="assist.diff.value"
|
||||
:proposed-text="assist.proposedText.value"
|
||||
@accept="handleAssistAccept"
|
||||
@reject="assist.reject()"
|
||||
@cancel="assist.clearSelection()"
|
||||
/>
|
||||
<!-- Streaming preview -->
|
||||
<template v-if="assist.state.value === 'streaming'">
|
||||
<div class="stream-label">Generating...</div>
|
||||
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
|
||||
</template>
|
||||
|
||||
<!-- Editor / preview (hidden during review) -->
|
||||
<div v-show="!showPreview && assist.state.value !== 'review'" class="body-editor-wrap">
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Describe this task..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="showPreview && assist.state.value !== 'review'"
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
></div>
|
||||
<!-- Review: full-document diff -->
|
||||
<template v-else-if="assist.state.value === 'review'">
|
||||
<DiffView :diff="assist.diff.value" class="main-diff" />
|
||||
</template>
|
||||
|
||||
<!-- Normal: editor or preview -->
|
||||
<template v-else>
|
||||
<div v-show="!showPreview" class="body-editor-wrap">
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Describe this task..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@selectionChange="onSelectionChange"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="showPreview" class="preview-pane prose" v-html="renderedPreview" />
|
||||
</template>
|
||||
|
||||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||||
|
||||
<!-- Work log -->
|
||||
<TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" />
|
||||
@@ -585,51 +598,57 @@ useEditorGuards(dirty, save);
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div><!-- /sidebar-content -->
|
||||
</aside><!-- /task-sidebar -->
|
||||
<div class="sb-divider"></div>
|
||||
|
||||
<!-- ✨ Assist panel overlays on top of sidebar when open -->
|
||||
<aside v-if="assistOpen" class="assist-panel">
|
||||
<div class="assist-panel-header">
|
||||
<span class="assist-panel-title">✨ AI Assist</span>
|
||||
<button class="btn-proofread" @click="assist.proofread()" :disabled="assist.state.value === 'streaming'">Proofread</button>
|
||||
<button class="btn-close-assist" aria-label="Close assist panel" @click="toggleAssist">×</button>
|
||||
</div>
|
||||
<div class="assist-panel-body">
|
||||
<div v-if="assist.state.value === 'idle'" class="assist-idle">
|
||||
<div class="assist-sections-label">Sections</div>
|
||||
<div class="assist-sections">
|
||||
<div
|
||||
v-for="(section, i) in assist.sections.value"
|
||||
:key="i"
|
||||
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
|
||||
@click="assist.selectSection(section)"
|
||||
>{{ section.heading || '(preamble)' }}</div>
|
||||
<div v-if="!assist.sections.value.length" class="assist-empty">Write some content to get started.</div>
|
||||
<!-- Writing Assistant -->
|
||||
<div class="assist-section">
|
||||
<div class="assist-section-title">✨ Writing Assistant</div>
|
||||
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Scope</label>
|
||||
<select v-model="scopeSelectValue" class="sb-select" :disabled="assist.state.value === 'streaming'">
|
||||
<option v-for="opt in scopeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<template v-if="assist.target.value">
|
||||
<div class="assist-target-preview">Editing: <em>{{ truncateTarget(assist.target.value.text) }}</em></div>
|
||||
|
||||
<template v-if="assist.state.value === 'idle'">
|
||||
<textarea
|
||||
ref="instructionRef"
|
||||
v-model="assist.instruction.value"
|
||||
placeholder="What should I do with this section?"
|
||||
placeholder="What should I do?"
|
||||
class="assist-instruction"
|
||||
rows="3"
|
||||
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
|
||||
></textarea>
|
||||
<div class="assist-input-actions">
|
||||
<button class="btn-generate" @click="assist.submit()" :disabled="!assist.canSubmit.value">Generate</button>
|
||||
<button class="btn-clear" @click="assist.clearSelection()">Clear</button>
|
||||
<button class="btn-proofread" @click="assist.proofread()">Proofread</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="assist.state.value === 'streaming'">
|
||||
<div class="assist-active-hint">Generating… see main area</div>
|
||||
<button class="btn-clear" @click="assist.clearSelection()">Cancel</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="assist.state.value === 'review'">
|
||||
<div class="assist-active-hint">Review the diff in the main area</div>
|
||||
<div class="assist-actions">
|
||||
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
|
||||
<button class="btn-reject" @click="assist.reject()">Reject</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="assist-hint">Select a section above or highlight text in the editor.</div>
|
||||
</div>
|
||||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||||
<div v-if="assist.state.value !== 'idle'" class="assist-active-hint">
|
||||
{{ assist.state.value === 'streaming' ? 'Generating in editor…' : 'Review diff in editor ↑' }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<VersionHistorySection
|
||||
v-if="taskId"
|
||||
:note-id="taskId"
|
||||
:current-body="body"
|
||||
@restore="(b, t) => { body = b; tags = t; markDirty(); }"
|
||||
/>
|
||||
|
||||
</div><!-- /sidebar-content -->
|
||||
</aside><!-- /task-sidebar -->
|
||||
|
||||
</div><!-- /task-body -->
|
||||
|
||||
@@ -684,8 +703,12 @@ useEditorGuards(dirty, save);
|
||||
|
||||
.body-tabs-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.body-editor-wrap {
|
||||
@@ -836,6 +859,42 @@ useEditorGuards(dirty, save);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Streaming preview */
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.stream-preview {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
min-height: 200px;
|
||||
}
|
||||
.main-diff {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Writing Assistant section (in sidebar) */
|
||||
.assist-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.assist-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
.tag-suggest-row {
|
||||
display: flex;
|
||||
|
||||
@@ -12,6 +12,7 @@ import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import TableOfContents from "@/components/TableOfContents.vue";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -19,6 +20,7 @@ const store = useTasksStore();
|
||||
const notesStore = useNotesStore();
|
||||
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
|
||||
const converting = ref(false);
|
||||
const showShare = ref(false);
|
||||
|
||||
// Context enrichment
|
||||
const projectTitle = ref<string | null>(null);
|
||||
@@ -111,6 +113,26 @@ function cycleStatus() {
|
||||
);
|
||||
}
|
||||
|
||||
const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: null,
|
||||
};
|
||||
|
||||
const advanceLabel = computed(() => {
|
||||
const s = store.currentTask?.status as TaskStatus | undefined;
|
||||
if (!s) return null;
|
||||
const next = forwardStatus[s];
|
||||
if (!next) return null;
|
||||
return next === "in_progress" ? "→ In Progress" : "→ Done";
|
||||
});
|
||||
|
||||
function advanceStatus() {
|
||||
if (!store.currentTask) return;
|
||||
const next = forwardStatus[store.currentTask.status as TaskStatus];
|
||||
if (next) store.patchStatus(store.currentTask.id, next);
|
||||
}
|
||||
|
||||
function isOverdue(): boolean {
|
||||
if (!store.currentTask?.due_date || store.currentTask.status === "done")
|
||||
return false;
|
||||
@@ -182,7 +204,21 @@ const subTaskProgress = computed(() => {
|
||||
<template>
|
||||
<div class="viewer-layout">
|
||||
<main class="viewer">
|
||||
<p v-if="store.loading">Loading...</p>
|
||||
<div v-if="store.loading" class="viewer-skeleton" aria-label="Loading task">
|
||||
<div class="skel-toolbar">
|
||||
<div class="skel-btn"></div>
|
||||
<div class="skel-btn skel-btn--wide"></div>
|
||||
<div class="skel-btn"></div>
|
||||
</div>
|
||||
<div class="skel-title"></div>
|
||||
<div class="skel-meta"></div>
|
||||
<div class="skel-badges"></div>
|
||||
<div class="skel-line"></div>
|
||||
<div class="skel-line skel-line--short"></div>
|
||||
<div class="skel-line"></div>
|
||||
<div class="skel-line skel-line--medium"></div>
|
||||
<div class="skel-line skel-line--short"></div>
|
||||
</div>
|
||||
<template v-else-if="store.currentTask">
|
||||
<div class="toolbar">
|
||||
<router-link to="/tasks" class="btn-back">← Tasks</router-link>
|
||||
@@ -192,6 +228,13 @@ const subTaskProgress = computed(() => {
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
<button
|
||||
v-if="advanceLabel"
|
||||
class="btn-advance"
|
||||
@click="advanceStatus"
|
||||
>
|
||||
{{ advanceLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-convert"
|
||||
@click="convertToNote"
|
||||
@@ -199,6 +242,7 @@ const subTaskProgress = computed(() => {
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Note" }}
|
||||
</button>
|
||||
<button class="btn-share" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent task → project → milestone -->
|
||||
@@ -225,11 +269,17 @@ const subTaskProgress = computed(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
|
||||
<h1 class="task-title">{{ store.currentTask.title || "Untitled" }}</h1>
|
||||
<p class="meta">
|
||||
Updated {{ relativeTime(store.currentTask.updated_at) }}
|
||||
·
|
||||
Created {{ relativeTime(store.currentTask.created_at) }}
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
|
||||
Updated {{ relativeTime(store.currentTask.updated_at) }}
|
||||
</span>
|
||||
<span class="meta-sep" aria-hidden="true">·</span>
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
Created {{ relativeTime(store.currentTask.created_at) }}
|
||||
</span>
|
||||
</p>
|
||||
<div class="badges">
|
||||
<StatusBadge
|
||||
@@ -291,18 +341,22 @@ const subTaskProgress = computed(() => {
|
||||
</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>
|
||||
<h3 class="backlinks-heading">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
Backlinks
|
||||
<span class="backlinks-count">{{ backlinks.length }}</span>
|
||||
</h3>
|
||||
<div class="backlinks-grid">
|
||||
<router-link
|
||||
v-for="link in backlinks"
|
||||
:key="`${link.type}-${link.id}`"
|
||||
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
|
||||
class="backlink-card"
|
||||
>
|
||||
<span :class="['backlink-type-badge', `badge-${link.type}`]">{{ link.type }}</span>
|
||||
<span class="backlink-title">{{ link.title || "Untitled" }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else>Task not found.</p>
|
||||
@@ -313,6 +367,14 @@ const subTaskProgress = computed(() => {
|
||||
class="toc-sidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ShareDialog
|
||||
v-if="showShare && store.currentTask"
|
||||
resource-type="note"
|
||||
:resource-id="store.currentTask.id"
|
||||
:resource-title="store.currentTask.title || '(untitled)'"
|
||||
@close="showShare = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/viewer-shared.css" />
|
||||
@@ -343,8 +405,7 @@ const subTaskProgress = computed(() => {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-back,
|
||||
.btn-edit {
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
@@ -356,11 +417,44 @@ const subTaskProgress = computed(() => {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover,
|
||||
.btn-edit:hover {
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
opacity: 0.95;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-advance {
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-advance:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
@@ -381,11 +475,45 @@ const subTaskProgress = computed(() => {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.meta {
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.task-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.meta-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -510,41 +638,116 @@ const subTaskProgress = computed(() => {
|
||||
}
|
||||
|
||||
.backlinks {
|
||||
margin-top: 2rem;
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 1rem;
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.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 {
|
||||
.backlinks-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.backlink-link {
|
||||
color: var(--color-text);
|
||||
.backlinks-count {
|
||||
margin-left: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.4rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.backlinks-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.backlink-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.backlink-link:hover {
|
||||
.backlink-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.backlink-type {
|
||||
font-size: 0.75rem;
|
||||
.backlink-type-badge {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-note {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
.badge-task {
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
color: #d97706;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
}
|
||||
.backlink-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Skeleton loader ── */
|
||||
@keyframes skel-shine {
|
||||
to { background-position: 200% center; }
|
||||
}
|
||||
.viewer-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.skel-btn,
|
||||
.skel-title,
|
||||
.skel-meta,
|
||||
.skel-badges,
|
||||
.skel-line {
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-secondary) 25%,
|
||||
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
|
||||
var(--color-bg-secondary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shine 1.5s ease infinite;
|
||||
}
|
||||
.skel-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.skel-btn { width: 70px; height: 32px; }
|
||||
.skel-btn--wide { width: 90px; }
|
||||
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--radius-md); }
|
||||
.skel-meta { height: 0.85rem; width: 45%; }
|
||||
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
|
||||
.skel-line { height: 0.9rem; }
|
||||
.skel-line--short { width: 50%; }
|
||||
.skel-line--medium { width: 78%; }
|
||||
</style>
|
||||
|
||||
@@ -8,9 +8,8 @@ import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigati
|
||||
import SearchBar from "@/components/SearchBar.vue";
|
||||
import TaskCard from "@/components/TaskCard.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
|
||||
type ViewMode = "flat" | "grouped";
|
||||
type ViewMode = "smart" | "grouped";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -22,29 +21,23 @@ function onFocusSearch() {
|
||||
searchBarRef.value?.focus();
|
||||
}
|
||||
|
||||
const viewMode = ref<ViewMode>(
|
||||
(localStorage.getItem("fabled-tasks-view-mode") as ViewMode) ?? "flat"
|
||||
);
|
||||
const _storedMode = localStorage.getItem("fabled-tasks-view-mode");
|
||||
const viewMode = ref<ViewMode>(_storedMode === "grouped" ? "grouped" : "smart");
|
||||
|
||||
function setViewMode(mode: ViewMode) {
|
||||
viewMode.value = mode;
|
||||
localStorage.setItem("fabled-tasks-view-mode", mode);
|
||||
if (mode === "grouped") {
|
||||
store.limit = 100;
|
||||
store.offset = 0;
|
||||
} else {
|
||||
store.limit = 20;
|
||||
store.offset = 0;
|
||||
}
|
||||
store.limit = mode === "grouped" ? 100 : 200;
|
||||
store.offset = 0;
|
||||
store.refresh();
|
||||
}
|
||||
|
||||
const isFlat = computed(() => viewMode.value === "flat");
|
||||
const { activeIndex } = useListKeyboardNavigation(
|
||||
// Keyboard nav disabled — smart sections span multiple containers
|
||||
useListKeyboardNavigation(
|
||||
computed(() => store.tasks),
|
||||
(task) => router.push(`/tasks/${task.id}`),
|
||||
".kb-active-item",
|
||||
isFlat,
|
||||
computed(() => false),
|
||||
);
|
||||
|
||||
// Project map for group labels and task breadcrumbs
|
||||
@@ -66,6 +59,36 @@ interface TaskGroup {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
interface TaskSection {
|
||||
key: string;
|
||||
label: string;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
const smartSections = computed<TaskSection[]>(() => {
|
||||
if (viewMode.value !== "smart") return [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const weekEnd = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const overdue: Task[] = [], dueToday: Task[] = [], thisWeek: Task[] = [],
|
||||
upcoming: Task[] = [], noDueDate: Task[] = [], done: Task[] = [];
|
||||
for (const task of store.tasks) {
|
||||
if (task.status === "done") { done.push(task); continue; }
|
||||
if (!task.due_date) { noDueDate.push(task); continue; }
|
||||
if (task.due_date < today) { overdue.push(task); continue; }
|
||||
if (task.due_date === today) { dueToday.push(task); continue; }
|
||||
if (task.due_date <= weekEnd) { thisWeek.push(task); continue; }
|
||||
upcoming.push(task);
|
||||
}
|
||||
const sections: TaskSection[] = [];
|
||||
if (overdue.length) sections.push({ key: "overdue", label: "Overdue", tasks: overdue });
|
||||
if (dueToday.length) sections.push({ key: "today", label: "Due Today", tasks: dueToday });
|
||||
if (thisWeek.length) sections.push({ key: "week", label: "This Week", tasks: thisWeek });
|
||||
if (upcoming.length) sections.push({ key: "upcoming", label: "Upcoming", tasks: upcoming });
|
||||
if (noDueDate.length) sections.push({ key: "no-date", label: "No Due Date", tasks: noDueDate });
|
||||
if (done.length) sections.push({ key: "done", label: "Completed", tasks: done });
|
||||
return sections;
|
||||
});
|
||||
|
||||
const groupedTasks = computed<TaskGroup[]>(() => {
|
||||
if (viewMode.value !== "grouped") return [];
|
||||
const map = new Map<number | null, Task[]>();
|
||||
@@ -101,9 +124,8 @@ onMounted(async () => {
|
||||
if (route.query.status) {
|
||||
store.statusFilter = route.query.status as TaskStatus;
|
||||
}
|
||||
if (viewMode.value === "grouped") {
|
||||
store.limit = 100;
|
||||
}
|
||||
store.limit = viewMode.value === "grouped" ? 100 : 200;
|
||||
collapsedGroups.value.add("done");
|
||||
await Promise.all([store.refresh(), loadProjects()]);
|
||||
document.addEventListener("shortcut:focus-search", onFocusSearch);
|
||||
});
|
||||
@@ -167,10 +189,6 @@ function onStatusToggle(id: number, status: TaskStatus) {
|
||||
store.patchStatus(id, status);
|
||||
}
|
||||
|
||||
function onOffsetUpdate(offset: number) {
|
||||
store.setOffset(offset);
|
||||
}
|
||||
|
||||
// Collapse state for grouped sections
|
||||
const collapsedGroups = ref<Set<string>>(new Set());
|
||||
function toggleGroup(key: string) {
|
||||
@@ -222,9 +240,9 @@ function toggleGroup(key: string) {
|
||||
<!-- View mode toggle -->
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
:class="['toggle-btn', { active: viewMode === 'flat' }]"
|
||||
title="Flat list"
|
||||
@click="setViewMode('flat')"
|
||||
:class="['toggle-btn', { active: viewMode === 'smart' }]"
|
||||
title="Smart sections (by due date)"
|
||||
@click="setViewMode('smart')"
|
||||
>☰</button>
|
||||
<button
|
||||
:class="['toggle-btn', { active: viewMode === 'grouped' }]"
|
||||
@@ -247,7 +265,9 @@ function toggleGroup(key: string) {
|
||||
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
|
||||
</div>
|
||||
|
||||
<p v-if="store.loading">Loading...</p>
|
||||
<div v-if="store.loading" class="task-list-skeleton">
|
||||
<div class="skeleton-row" v-for="i in 6" :key="i"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.tasks.length === 0" class="empty-state">
|
||||
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter || store.priorityFilter">
|
||||
@@ -255,9 +275,12 @@ function toggleGroup(key: string) {
|
||||
<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>
|
||||
<div class="empty-state-rich">
|
||||
<div class="empty-icon">✓</div>
|
||||
<p class="empty-title">No tasks yet</p>
|
||||
<p class="empty-sub">Create your first task to start tracking work</p>
|
||||
<router-link to="/tasks/new" class="empty-action">New task →</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -295,30 +318,28 @@ function toggleGroup(key: string) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Flat compact list -->
|
||||
<div v-else class="cards-list">
|
||||
<div
|
||||
v-for="(task, i) in store.tasks"
|
||||
:key="task.id"
|
||||
:class="{ 'kb-active-item': activeIndex === i }"
|
||||
>
|
||||
<TaskCard
|
||||
:task="task"
|
||||
compact
|
||||
:project-title="task.project_id ? (projectMap.get(task.project_id) ?? undefined) : undefined"
|
||||
@tag-click="onTagClick"
|
||||
@status-toggle="onStatusToggle"
|
||||
/>
|
||||
<!-- Smart sections view (by due date) -->
|
||||
<template v-else>
|
||||
<div v-for="section in smartSections" :key="section.key" class="task-section">
|
||||
<button class="section-header" @click="toggleGroup(section.key)">
|
||||
<span :class="['section-dot', `dot-${section.key}`]"></span>
|
||||
<span class="section-label">{{ section.label }}</span>
|
||||
<span class="section-count">{{ section.tasks.length }}</span>
|
||||
<span class="section-chevron">{{ collapsedGroups.has(section.key) ? "▶" : "▼" }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedGroups.has(section.key)" class="section-tasks">
|
||||
<TaskCard
|
||||
v-for="task in section.tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
compact
|
||||
:project-title="task.project_id ? (projectMap.get(task.project_id) ?? undefined) : undefined"
|
||||
@tag-click="onTagClick"
|
||||
@status-toggle="onStatusToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
v-if="viewMode === 'flat'"
|
||||
:total="store.total"
|
||||
:limit="store.limit"
|
||||
:offset="store.offset"
|
||||
@update:offset="onOffsetUpdate"
|
||||
/>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -340,11 +361,16 @@ function toggleGroup(key: string) {
|
||||
}
|
||||
.btn-new {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.btn-new:hover {
|
||||
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
@@ -426,12 +452,80 @@ function toggleGroup(key: string) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Flat compact list */
|
||||
.cards-list {
|
||||
/* Skeleton loading */
|
||||
.task-list-skeleton {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.skeleton-row {
|
||||
height: 44px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Smart sections */
|
||||
.task-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0.3rem 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.section-header:hover .section-label {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.section-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-overdue { background: var(--color-danger, #e74c3c); }
|
||||
.dot-today { background: var(--color-primary); }
|
||||
.dot-week { background: #f59e0b; }
|
||||
.dot-upcoming { background: var(--color-text-secondary); }
|
||||
.dot-no-date { background: var(--color-text-muted); }
|
||||
.dot-done { background: var(--color-status-done, #22c55e); }
|
||||
.section-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.section-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
.section-chevron {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.section-tasks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: 1rem;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Grouped view */
|
||||
@@ -513,6 +607,40 @@ function toggleGroup(key: string) {
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.empty-state-rich {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.empty-state-rich .empty-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.empty-action {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.empty-action:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kb-active-item {
|
||||
outline: 2px solid var(--color-primary);
|
||||
|
||||
@@ -22,6 +22,7 @@ const projectId = computed(() => Number(route.params.projectId));
|
||||
interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
goal?: string;
|
||||
}
|
||||
|
||||
const project = ref<Project | null>(null);
|
||||
@@ -56,12 +57,11 @@ function _loadPanelState(pid: number) {
|
||||
const panelOpen = ref({ tasks: true, chat: true, notes: true });
|
||||
|
||||
const gridColumns = computed(() => {
|
||||
const cols = [
|
||||
panelOpen.value.tasks ? "1fr" : "0px",
|
||||
panelOpen.value.chat ? "1fr" : "0px",
|
||||
panelOpen.value.notes ? "1fr" : "0px",
|
||||
];
|
||||
return cols.join(" ");
|
||||
return [
|
||||
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px",
|
||||
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px",
|
||||
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
|
||||
].join(" ");
|
||||
});
|
||||
|
||||
// SSE watcher
|
||||
@@ -124,9 +124,14 @@ function togglePanel(panel: keyof typeof panelOpen.value) {
|
||||
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
messageInput.value = text;
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const content = messageInput.value.trim();
|
||||
if (!content || chatStore.streaming) return;
|
||||
if (!content) return;
|
||||
|
||||
messageInput.value = "";
|
||||
resetTextareaHeight();
|
||||
@@ -230,7 +235,7 @@ onUnmounted(async () => {
|
||||
<router-link :to="project ? `/projects/${project.id}` : '/projects'" class="ws-back">
|
||||
← {{ project?.title ?? "Project" }}
|
||||
</router-link>
|
||||
<span class="ws-title">Workspace</span>
|
||||
<span class="ws-title">{{ project?.goal ?? '' }}</span>
|
||||
<div class="ws-panel-toggles">
|
||||
<button
|
||||
:class="['panel-toggle', { active: panelOpen.tasks }]"
|
||||
@@ -261,11 +266,15 @@ onUnmounted(async () => {
|
||||
|
||||
<!-- Left: Tasks -->
|
||||
<div v-show="panelOpen.tasks" class="ws-panel">
|
||||
<WorkspaceTaskPanel
|
||||
v-if="project"
|
||||
ref="taskPanelRef"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
<Transition name="panel-fade">
|
||||
<div v-if="panelOpen.tasks" class="panel-inner">
|
||||
<WorkspaceTaskPanel
|
||||
v-if="project"
|
||||
ref="taskPanelRef"
|
||||
:project-id="project.id"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Center: Chat -->
|
||||
@@ -318,12 +327,36 @@ onUnmounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
<!-- Queued messages — shown as pending bubbles -->
|
||||
<template v-if="chatStore.queuedMessages.length">
|
||||
<div
|
||||
v-for="(q, i) in chatStore.queuedMessages"
|
||||
:key="`queued-${i}`"
|
||||
class="ws-message role-user queued-message"
|
||||
>
|
||||
<div class="message-bubble queued-bubble">
|
||||
<div class="queued-badge">Queued</div>
|
||||
<div class="message-content">{{ q.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queued-clear-row">
|
||||
<button class="queued-clear-btn" @click="chatStore.clearQueue()" aria-label="Cancel queued messages">
|
||||
Cancel {{ chatStore.queuedMessages.length }} queued
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
|
||||
class="empty-chat-msg"
|
||||
class="empty-chat-prompt"
|
||||
>
|
||||
Ask the agent to create notes or tasks for this project.
|
||||
</p>
|
||||
<p class="empty-hint">What would you like to work on?</p>
|
||||
<div class="quick-chips">
|
||||
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
|
||||
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
|
||||
<button class="quick-chip" @click="prefill('Add tasks for ')">✓ Add tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-area">
|
||||
@@ -331,9 +364,8 @@ onUnmounted(async () => {
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
class="chat-input"
|
||||
placeholder="Message the agent... (Enter to send)"
|
||||
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent… (Enter to send)'"
|
||||
rows="1"
|
||||
:disabled="chatStore.streaming"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
></textarea>
|
||||
@@ -358,11 +390,15 @@ onUnmounted(async () => {
|
||||
|
||||
<!-- Right: Note Editor -->
|
||||
<div v-show="panelOpen.notes" class="ws-panel">
|
||||
<WorkspaceNoteEditor
|
||||
v-if="project"
|
||||
:project-id="project.id"
|
||||
:active-note-id="activeNoteId"
|
||||
/>
|
||||
<Transition name="panel-fade">
|
||||
<div v-if="panelOpen.notes" class="panel-inner">
|
||||
<WorkspaceNoteEditor
|
||||
v-if="project"
|
||||
:project-id="project.id"
|
||||
:active-note-id="activeNoteId"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -402,10 +438,13 @@ onUnmounted(async () => {
|
||||
}
|
||||
|
||||
.ws-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ws-panel-toggles {
|
||||
@@ -443,6 +482,21 @@ onUnmounted(async () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-fade-enter-active,
|
||||
.panel-fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.panel-fade-enter-from,
|
||||
.panel-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Chat panel */
|
||||
.ws-chat-panel {
|
||||
display: flex;
|
||||
@@ -460,12 +514,37 @@ onUnmounted(async () => {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-chat-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
.empty-chat-prompt {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.empty-hint {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.quick-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.quick-chip {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.quick-chip:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
display: flex;
|
||||
@@ -625,4 +704,47 @@ details[open] .thinking-summary::before {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ws-message.role-user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.queued-bubble {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 18px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
opacity: 0.45;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
.queued-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.queued-clear-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.queued-clear-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.queued-clear-btn:hover {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Runner base image for Forgejo act_runner job containers.
|
||||
# Pre-installs Python 3.12 and Node 20 so the test job skips the
|
||||
# ~3-4 minute deadsnakes PPA install on every run.
|
||||
#
|
||||
# Build and push (one-time setup, re-run if this file changes):
|
||||
# docker build -f infra/Dockerfile.runner-base \
|
||||
# -t git.fabledsword.com/bvandeusen/runner-base:py3.12-node22 .
|
||||
# docker push git.fabledsword.com/bvandeusen/runner-base:py3.12-node22
|
||||
#
|
||||
# Then redeploy the act_runner stack in Portainer to pick up the new label.
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Split into separate RUN steps so each pushed layer is smaller.
|
||||
# One combined layer would be ~280MB and exceeds the nginx upload timeout.
|
||||
|
||||
# Python 3.12 ships in Ubuntu 24.04 main repos — no PPA needed.
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -y -qq \
|
||||
python3.12 python3.12-dev python3.12-venv python3-pip \
|
||||
git curl ca-certificates gnupg && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node 22 LTS via NodeSource
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y -qq nodejs && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Docker CLI — daemon comes from the host socket mount, only CLI needed.
|
||||
# docker.io from Ubuntu repos is sufficient for docker login + buildx.
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -y -qq docker.io && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
@@ -8,6 +8,10 @@ log:
|
||||
runner:
|
||||
capacity: 2 # max concurrent jobs
|
||||
timeout: 30m
|
||||
# Maps runs-on labels to Docker images for job containers.
|
||||
# Change this (and delete /data/.runner) to switch the base image.
|
||||
labels:
|
||||
- "py3.12-node22:docker://git.fabledsword.com/bvandeusen/runner-base:py3.12-node22"
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
|
||||
@@ -13,7 +13,6 @@ services:
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
|
||||
- GITEA_RUNNER_NAME=swarm-runner
|
||||
# Maps the runs-on: ubuntu-latest label used in workflow files to a Docker image.
|
||||
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bullseye
|
||||
- CONFIG_FILE=/config/config.yml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
@@ -18,6 +18,8 @@ dependencies = [
|
||||
"aiosmtplib>=3.0",
|
||||
"caldav>=1.3",
|
||||
"icalendar>=5.0",
|
||||
"feedparser>=6.0",
|
||||
"APScheduler>=3.10,<4.0",
|
||||
"pywebpush>=2.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from fabledassistant.config import Config
|
||||
from fabledassistant.routes.admin import admin_bp
|
||||
from fabledassistant.routes.api import api
|
||||
from fabledassistant.routes.auth import auth_bp
|
||||
from fabledassistant.routes.briefing import briefing_bp
|
||||
from fabledassistant.routes.chat import chat_bp
|
||||
from fabledassistant.routes.export import export_bp
|
||||
from fabledassistant.routes.notes import notes_bp
|
||||
@@ -22,6 +23,10 @@ from fabledassistant.routes.push import push_bp
|
||||
from fabledassistant.routes.quick_capture import quick_capture_bp
|
||||
from fabledassistant.routes.settings import settings_bp
|
||||
from fabledassistant.routes.tasks import tasks_bp
|
||||
from fabledassistant.routes.groups import groups_bp
|
||||
from fabledassistant.routes.shares import shares_bp
|
||||
from fabledassistant.routes.in_app_notifications import notifications_bp
|
||||
from fabledassistant.routes.users import users_bp
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,6 +61,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(briefing_bp)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(export_bp)
|
||||
app.register_blueprint(images_bp)
|
||||
@@ -67,6 +73,10 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(task_logs_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
app.register_blueprint(groups_bp)
|
||||
app.register_blueprint(shares_bp)
|
||||
app.register_blueprint(notifications_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
@@ -230,6 +240,15 @@ def create_app() -> Quart:
|
||||
|
||||
asyncio.create_task(_delayed_backfill())
|
||||
|
||||
# Start briefing scheduler
|
||||
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
|
||||
start_briefing_scheduler(asyncio.get_event_loop())
|
||||
|
||||
@app.after_serving
|
||||
async def shutdown():
|
||||
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
|
||||
stop_briefing_scheduler()
|
||||
|
||||
@app.route("/")
|
||||
async def serve_index():
|
||||
resp = await make_response(
|
||||
@@ -238,17 +257,34 @@ def create_app() -> Quart:
|
||||
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return resp
|
||||
|
||||
# File extensions that belong to the SPA or its assets.
|
||||
# Anything else with an extension is not a valid app path and gets a hard 404.
|
||||
_SPA_EXTENSIONS = {
|
||||
"", ".html", ".js", ".css", ".ico", ".png", ".jpg", ".jpeg",
|
||||
".svg", ".webp", ".woff", ".woff2", ".ttf", ".otf", ".map", ".json",
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
# Try to serve a real static file first
|
||||
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
|
||||
|
||||
# Reject paths with file extensions the app doesn't serve.
|
||||
# This turns scanner probes (.php, .asp, .cgi, etc.) into honest 404s
|
||||
# instead of serving them the Vue SPA with a misleading 200.
|
||||
from pathlib import PurePosixPath
|
||||
suffix = PurePosixPath(request.path).suffix.lower()
|
||||
if suffix not in _SPA_EXTENSIONS:
|
||||
return "", 404
|
||||
|
||||
# SPA fallback for clean client-side routes (/notes/123, /chat, etc.)
|
||||
resp = await make_response(
|
||||
await send_from_directory(STATIC_DIR, "index.html")
|
||||
)
|
||||
|
||||
@@ -35,3 +35,8 @@ from fabledassistant.models.milestone import Milestone # noqa: E402, F401
|
||||
from fabledassistant.models.task_log import TaskLog # noqa: E402, F401
|
||||
from fabledassistant.models.note_draft import NoteDraft # noqa: E402, F401
|
||||
from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
|
||||
from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401
|
||||
from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
||||
from fabledassistant.models.notification import Notification # noqa: E402, F401
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401
|
||||
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from sqlalchemy import ForeignKey, Index, Integer, Text
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -16,6 +18,11 @@ class Conversation(Base, TimestampMixin):
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
model: Mapped[str] = mapped_column(Text, default="")
|
||||
# 'chat' (default) or 'briefing'. Briefing conversations are hidden from
|
||||
# the main chat view and managed by the briefing pipeline.
|
||||
conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat")
|
||||
# For briefing conversations only: the calendar date this briefing covers.
|
||||
briefing_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
messages: Mapped[list["Message"]] = relationship(
|
||||
back_populates="conversation",
|
||||
@@ -38,6 +45,8 @@ class Conversation(Base, TimestampMixin):
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"model": self.model,
|
||||
"conversation_type": self.conversation_type,
|
||||
"briefing_date": self.briefing_date.isoformat() if self.briefing_date else None,
|
||||
"message_count": msg_count,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
|
||||
|
||||
|
||||
class Group(Base, TimestampMixin):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
created_by: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
memberships: Mapped[list["GroupMembership"]] = relationship(
|
||||
"GroupMembership", back_populates="group", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"created_by": self.created_by,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class GroupMembership(Base, CreatedAtMixin):
|
||||
__tablename__ = "group_memberships"
|
||||
__table_args__ = (UniqueConstraint("group_id", "user_id", name="uq_gm_group_user"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
group_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(Text, nullable=False, default="member")
|
||||
|
||||
group: Mapped["Group"] = relationship("Group", back_populates="memberships")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"group_id": self.group_id,
|
||||
"user_id": self.user_id,
|
||||
"role": self.role,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import CreatedAtMixin
|
||||
|
||||
|
||||
class Notification(Base, CreatedAtMixin):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# project_shared | note_shared | group_added
|
||||
type: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"type": self.type,
|
||||
"payload": self.payload,
|
||||
"read_at": self.read_at.isoformat() if self.read_at else None,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class RssFeed(Base):
|
||||
__tablename__ = "rss_feeds"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
||||
url: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
category: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
items: Mapped[list["RssItem"]] = relationship(
|
||||
back_populates="feed", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "url", name="uq_rss_feeds_user_url"),
|
||||
Index("ix_rss_feeds_user_id", "user_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"category": self.category,
|
||||
"last_fetched_at": self.last_fetched_at.isoformat() if self.last_fetched_at else None,
|
||||
}
|
||||
|
||||
|
||||
class RssItem(Base):
|
||||
__tablename__ = "rss_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
feed_id: Mapped[int] = mapped_column(Integer, ForeignKey("rss_feeds.id", ondelete="CASCADE"))
|
||||
guid: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
url: Mapped[str] = mapped_column(Text, default="")
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Truncated to 2000 chars to keep DB size reasonable
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
feed: Mapped["RssFeed"] = relationship(back_populates="items")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("feed_id", "guid", name="uq_rss_items_feed_guid"),
|
||||
Index("ix_rss_items_feed_id", "feed_id"),
|
||||
Index("ix_rss_items_published_at", "published_at"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"feed_id": self.feed_id,
|
||||
"guid": self.guid,
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
"published_at": self.published_at.isoformat() if self.published_at else None,
|
||||
"content": self.content,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import TimestampMixin
|
||||
|
||||
|
||||
class ProjectShare(Base, TimestampMixin):
|
||||
__tablename__ = "project_shares"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)",
|
||||
name="ck_ps_exclusive_target",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
shared_with_user_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
shared_with_group_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("groups.id", ondelete="CASCADE")
|
||||
)
|
||||
permission: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
invited_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"project_id": self.project_id,
|
||||
"shared_with_user_id": self.shared_with_user_id,
|
||||
"shared_with_group_id": self.shared_with_group_id,
|
||||
"permission": self.permission,
|
||||
"invited_by": self.invited_by,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class NoteShare(Base, TimestampMixin):
|
||||
__tablename__ = "note_shares"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)",
|
||||
name="ck_ns_exclusive_target",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
note_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
shared_with_user_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
shared_with_group_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("groups.id", ondelete="CASCADE")
|
||||
)
|
||||
permission: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
invited_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"note_id": self.note_id,
|
||||
"shared_with_user_id": self.shared_with_user_id,
|
||||
"shared_with_group_id": self.shared_with_group_id,
|
||||
"permission": self.permission,
|
||||
"invited_by": self.invited_by,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class WeatherCache(Base):
|
||||
__tablename__ = "weather_cache"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
||||
# Unique key per location, e.g. "home", "work", or "event:<uid>"
|
||||
location_key: Mapped[str] = mapped_column(Text)
|
||||
location_label: Mapped[str] = mapped_column(Text, default="")
|
||||
# Current 7-day forecast from Open-Meteo (raw JSON)
|
||||
forecast_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
# Previous forecast — used to detect changes between fetches
|
||||
previous_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "location_key", name="uq_weather_cache_user_location"),
|
||||
Index("ix_weather_cache_user_id", "user_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"location_key": self.location_key,
|
||||
"location_label": self.location_label,
|
||||
"forecast_json": self.forecast_json,
|
||||
"fetched_at": self.fetched_at.isoformat(),
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
api = Blueprint("api", __name__, url_prefix="/api")
|
||||
@@ -6,3 +8,8 @@ api = Blueprint("api", __name__, url_prefix="/api")
|
||||
@api.route("/health")
|
||||
async def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@api.route("/version")
|
||||
async def version():
|
||||
return jsonify({"version": os.environ.get("APP_VERSION", "dev")})
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Briefing API: RSS feeds, weather, and briefing configuration."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.auth import login_required
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.models.rss_feed import RssFeed
|
||||
from fabledassistant.services import rss as rss_svc
|
||||
from fabledassistant.services import weather as weather_svc
|
||||
from fabledassistant.services.briefing_conversations import (
|
||||
get_or_create_today_conversation,
|
||||
list_briefing_conversations,
|
||||
post_message,
|
||||
)
|
||||
from fabledassistant.services.settings import get_setting, set_settings_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing")
|
||||
|
||||
_REQUIRE = login_required
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/config", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_config():
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
except Exception:
|
||||
config = {}
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@briefing_bp.route("/config", methods=["PUT"])
|
||||
@_REQUIRE
|
||||
async def put_config():
|
||||
data = await request.get_json()
|
||||
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/feeds", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def list_feeds():
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
|
||||
)
|
||||
feeds = list(result.scalars().all())
|
||||
return jsonify([f.to_dict() for f in feeds])
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def add_feed():
|
||||
data = await request.get_json()
|
||||
url = (data.get("url") or "").strip()
|
||||
if not url:
|
||||
return jsonify({"error": "url required"}), 400
|
||||
category = data.get("category") or None
|
||||
|
||||
async with async_session() as session:
|
||||
# Prevent duplicates
|
||||
existing = await session.execute(
|
||||
select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
return jsonify({"error": "Feed already added"}), 409
|
||||
|
||||
feed = RssFeed(user_id=g.user.id, url=url, title="", category=category)
|
||||
session.add(feed)
|
||||
await session.commit()
|
||||
await session.refresh(feed)
|
||||
feed_id = feed.id
|
||||
|
||||
# Fetch in background to auto-populate title and get initial items
|
||||
asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url))
|
||||
|
||||
return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds/<int:feed_id>", methods=["DELETE"])
|
||||
@_REQUIRE
|
||||
async def delete_feed(feed_id: int):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id)
|
||||
)
|
||||
feed = result.scalars().first()
|
||||
if not feed:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
await session.delete(feed)
|
||||
await session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds/refresh", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def refresh_feeds():
|
||||
results = await rss_svc.refresh_all_feeds(g.user.id)
|
||||
total_new = sum(results.values())
|
||||
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
|
||||
|
||||
|
||||
@briefing_bp.route("/feeds/recent", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def recent_items():
|
||||
limit = min(int(request.args.get("limit", 20)), 100)
|
||||
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
|
||||
return jsonify({"items": items})
|
||||
|
||||
|
||||
# ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/weather", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_weather():
|
||||
data = await weather_svc.get_cached_weather(g.user.id)
|
||||
return jsonify({"locations": data})
|
||||
|
||||
|
||||
@briefing_bp.route("/weather/geocode", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def geocode_location():
|
||||
data = await request.get_json()
|
||||
query = (data.get("query") or "").strip()
|
||||
if not query:
|
||||
return jsonify({"error": "query required"}), 400
|
||||
try:
|
||||
lat, lon, label = await weather_svc.geocode(query)
|
||||
return jsonify({"lat": lat, "lon": lon, "label": label})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
@briefing_bp.route("/weather/refresh", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def refresh_weather():
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else {}
|
||||
except Exception:
|
||||
config = {}
|
||||
|
||||
locations = config.get("locations", {})
|
||||
refreshed = []
|
||||
for key, loc in locations.items():
|
||||
if not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=g.user.id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
refreshed.append(key)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
|
||||
return jsonify({"refreshed": refreshed})
|
||||
|
||||
|
||||
# ── Briefing Conversations ─────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/conversations", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_conversations():
|
||||
convs = await list_briefing_conversations(g.user.id)
|
||||
return jsonify({"conversations": convs})
|
||||
|
||||
|
||||
@briefing_bp.route("/conversations/today", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_today_conversation():
|
||||
model = await get_setting(g.user.id, "default_model", "")
|
||||
conv = await get_or_create_today_conversation(g.user.id, model)
|
||||
# Load messages
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv.id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = [m.to_dict() for m in result.scalars().all()]
|
||||
data = conv.to_dict()
|
||||
data["messages"] = messages
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@briefing_bp.route("/conversations/<int:conv_id>/messages", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_conversation_messages(conv_id: int):
|
||||
# Verify ownership
|
||||
async with async_session() as session:
|
||||
conv = await session.get(Conversation, conv_id)
|
||||
if not conv or conv.user_id != g.user.id or conv.conversation_type != "briefing":
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
result = await session.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv_id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = [m.to_dict() for m in result.scalars().all()]
|
||||
return jsonify({"messages": messages})
|
||||
|
||||
|
||||
@briefing_bp.route("/trigger", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def manual_trigger():
|
||||
"""Dev/admin endpoint to manually trigger a briefing compilation."""
|
||||
data = await request.get_json() or {}
|
||||
slot = data.get("slot", "compilation")
|
||||
if slot not in ("compilation", "morning", "midday", "afternoon"):
|
||||
return jsonify({"error": "invalid slot"}), 400
|
||||
|
||||
from fabledassistant.services.briefing_pipeline import run_compilation
|
||||
|
||||
model = await get_setting(g.user.id, "default_model", "")
|
||||
conv = await get_or_create_today_conversation(g.user.id, model)
|
||||
text = await run_compilation(g.user.id, slot, model)
|
||||
msg = await post_message(conv.id, "assistant", text)
|
||||
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
|
||||
@@ -40,6 +40,7 @@ async def list_conversations_route():
|
||||
uid = get_current_user_id()
|
||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
conv_type = request.args.get("type", "chat")
|
||||
# Apply retention policy before returning list
|
||||
retention_str = await get_setting(uid, "chat_retention_days", "90")
|
||||
try:
|
||||
@@ -48,7 +49,7 @@ async def list_conversations_route():
|
||||
retention_days = 90
|
||||
if retention_days > 0:
|
||||
await cleanup_old_conversations(uid, retention_days)
|
||||
conversations, total = await list_conversations(uid, limit=limit, offset=offset)
|
||||
conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type)
|
||||
return jsonify({
|
||||
"conversations": conversations,
|
||||
"total": total,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.services import groups as group_svc
|
||||
from fabledassistant.services.notifications import notify_group_added
|
||||
|
||||
groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups")
|
||||
|
||||
|
||||
@groups_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_groups():
|
||||
uid = get_current_user_id()
|
||||
return jsonify({"groups": await group_svc.list_groups(uid)})
|
||||
|
||||
|
||||
@groups_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_group():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "Name is required"}), 400
|
||||
try:
|
||||
group = await group_svc.create_group(uid, name, data.get("description"))
|
||||
except Exception:
|
||||
return jsonify({"error": "Group name already taken"}), 409
|
||||
return jsonify(group.to_dict()), 201
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_group(group_id: int):
|
||||
group = await group_svc.get_group(group_id)
|
||||
if not group:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
members = await group_svc.list_members(group_id)
|
||||
return jsonify({**group.to_dict(), "members": members})
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_group(group_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
updates = {k: v for k, v in data.items() if k in ("name", "description")}
|
||||
group = await group_svc.update_group(uid, group_id, g.user.role == "admin", **updates)
|
||||
if not group:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify(group.to_dict())
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_group(group_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await group_svc.delete_group(uid, group_id, g.user.role == "admin")
|
||||
if not deleted:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>/members", methods=["GET"])
|
||||
@login_required
|
||||
async def list_members(group_id: int):
|
||||
members = await group_svc.list_members(group_id)
|
||||
return jsonify({"members": members})
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>/members", methods=["POST"])
|
||||
@login_required
|
||||
async def add_member(group_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
target_uid = data.get("user_id")
|
||||
role = data.get("role", "member")
|
||||
if not target_uid:
|
||||
return jsonify({"error": "user_id required"}), 400
|
||||
if role not in ("owner", "member"):
|
||||
return jsonify({"error": "role must be owner or member"}), 400
|
||||
membership = await group_svc.add_member(uid, group_id, target_uid, role, g.user.role == "admin")
|
||||
if not membership:
|
||||
return jsonify({"error": "Forbidden, group not found, or user already a member"}), 403
|
||||
asyncio.create_task(notify_group_added(group_id, role, uid, target_uid))
|
||||
return jsonify(membership.to_dict()), 201
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_member(group_id: int, target_uid: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
role = data.get("role")
|
||||
if role not in ("owner", "member"):
|
||||
return jsonify({"error": "role must be owner or member"}), 400
|
||||
m = await group_svc.update_member_role(uid, group_id, target_uid, role, g.user.role == "admin")
|
||||
if not m:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify(m.to_dict())
|
||||
|
||||
|
||||
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def remove_member(group_id: int, target_uid: int):
|
||||
uid = get_current_user_id()
|
||||
removed = await group_svc.remove_member(uid, group_id, target_uid, g.user.role == "admin")
|
||||
if not removed:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
@@ -0,0 +1,44 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.services.notifications import (
|
||||
list_in_app_notifications,
|
||||
mark_all_notifications_read,
|
||||
mark_notification_read,
|
||||
unread_notification_count,
|
||||
)
|
||||
|
||||
notifications_bp = Blueprint("notifications", __name__, url_prefix="/api/notifications")
|
||||
|
||||
|
||||
@notifications_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_notifications():
|
||||
uid = get_current_user_id()
|
||||
all_flag = request.args.get("all", "false").lower() == "true"
|
||||
items = await list_in_app_notifications(uid, unread_only=not all_flag)
|
||||
return jsonify({"notifications": items})
|
||||
|
||||
|
||||
@notifications_bp.route("/count", methods=["GET"])
|
||||
@login_required
|
||||
async def get_count():
|
||||
uid = get_current_user_id()
|
||||
return jsonify({"count": await unread_notification_count(uid)})
|
||||
|
||||
|
||||
@notifications_bp.route("/<int:notif_id>/read", methods=["POST"])
|
||||
@login_required
|
||||
async def mark_read(notif_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await mark_notification_read(uid, notif_id):
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@notifications_bp.route("/read-all", methods=["POST"])
|
||||
@login_required
|
||||
async def mark_all_read():
|
||||
uid = get_current_user_id()
|
||||
count = await mark_all_notifications_read(uid)
|
||||
return jsonify({"status": "ok", "marked": count})
|
||||
@@ -26,6 +26,7 @@ from fabledassistant.services.notes import (
|
||||
get_backlinks,
|
||||
get_note,
|
||||
get_note_by_title,
|
||||
get_note_for_user,
|
||||
get_or_create_note_by_title,
|
||||
list_notes,
|
||||
update_note,
|
||||
@@ -61,6 +62,7 @@ async def list_notes_route():
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
milestone_id = request.args.get("milestone_id", type=int)
|
||||
parent_id = request.args.get("parent_id", type=int)
|
||||
no_project = request.args.get("no_project", "").lower() == "true"
|
||||
|
||||
# type= shorthand used by web frontend (?type=task or ?type=note)
|
||||
type_param = request.args.get("type")
|
||||
@@ -73,6 +75,7 @@ async def list_notes_route():
|
||||
uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order,
|
||||
limit=limit, offset=offset,
|
||||
project_id=project_id, milestone_id=milestone_id, parent_id=parent_id,
|
||||
no_project=no_project,
|
||||
)
|
||||
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
|
||||
|
||||
@@ -92,13 +95,20 @@ async def create_note_route():
|
||||
if isinstance(due_date, tuple):
|
||||
return due_date
|
||||
|
||||
project_id = data.get("project_id")
|
||||
if project_id is None and data.get("project"):
|
||||
from fabledassistant.services.projects import get_project_by_title as _gpbt
|
||||
proj = await _gpbt(uid, data["project"])
|
||||
if proj:
|
||||
project_id = proj.id
|
||||
|
||||
note = await create_note(
|
||||
uid,
|
||||
title=data.get("title", ""),
|
||||
body=body,
|
||||
tags=tags,
|
||||
parent_id=data.get("parent_id"),
|
||||
project_id=data.get("project_id"),
|
||||
project_id=project_id,
|
||||
milestone_id=data.get("milestone_id"),
|
||||
status=status,
|
||||
priority=priority,
|
||||
@@ -180,10 +190,13 @@ async def resolve_title_route():
|
||||
@login_required
|
||||
async def get_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
note = await get_note(uid, note_id)
|
||||
if note is None:
|
||||
result = await get_note_for_user(uid, note_id)
|
||||
if result is None:
|
||||
return not_found("Note")
|
||||
return jsonify(note.to_dict())
|
||||
note, permission = result
|
||||
data = note.to_dict()
|
||||
data["permission"] = permission
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>", methods=["PUT"])
|
||||
|
||||
@@ -11,8 +11,9 @@ from fabledassistant.services.projects import (
|
||||
create_project,
|
||||
delete_project,
|
||||
get_project,
|
||||
get_project_for_user,
|
||||
get_project_summary,
|
||||
list_projects,
|
||||
list_projects_for_user,
|
||||
update_project,
|
||||
)
|
||||
|
||||
@@ -26,8 +27,8 @@ projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
|
||||
async def list_projects_route():
|
||||
uid = get_current_user_id()
|
||||
status = request.args.get("status")
|
||||
projects = await list_projects(uid, status=status)
|
||||
return jsonify({"projects": [p.to_dict() for p in projects]})
|
||||
projects = await list_projects_for_user(uid, status=status)
|
||||
return jsonify({"projects": projects})
|
||||
|
||||
|
||||
@projects_bp.route("", methods=["POST"])
|
||||
@@ -51,12 +52,16 @@ async def create_project_route():
|
||||
@login_required
|
||||
async def get_project_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
summary = await get_project_summary(uid, project_id)
|
||||
project, permission = result
|
||||
# Summary uses the project owner's uid for stats when viewer is not the owner
|
||||
owner_uid = project.user_id or uid
|
||||
summary = await get_project_summary(owner_uid, project_id)
|
||||
data = project.to_dict()
|
||||
data["summary"] = summary
|
||||
data["permission"] = permission
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@@ -87,9 +92,10 @@ async def delete_project_route(project_id: int):
|
||||
@login_required
|
||||
async def get_project_notes_route(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
project = await get_project(uid, project_id)
|
||||
if project is None:
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
project, _ = result
|
||||
|
||||
# type filter: "note", "task", or None (both)
|
||||
type_filter = request.args.get("type")
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import asyncio
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.services import sharing as share_svc
|
||||
from fabledassistant.services.access import can_admin_project, can_write_note
|
||||
from fabledassistant.services.notifications import notify_note_shared, notify_project_shared
|
||||
from fabledassistant.services.sharing import list_shared_with_me
|
||||
|
||||
shares_bp = Blueprint("shares", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project shares
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["GET"])
|
||||
@login_required
|
||||
async def list_project_shares(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await can_admin_project(uid, project_id):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
return jsonify({"shares": await share_svc.list_project_shares(project_id)})
|
||||
|
||||
|
||||
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["POST"])
|
||||
@login_required
|
||||
async def create_project_share(project_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
share = await share_svc.share_project(
|
||||
uid, project_id,
|
||||
target_user_id=data.get("user_id"),
|
||||
target_group_id=data.get("group_id"),
|
||||
permission=data.get("permission", "viewer"),
|
||||
)
|
||||
if not share:
|
||||
return jsonify({"error": "Forbidden or invalid permission"}), 403
|
||||
asyncio.create_task(notify_project_shared(
|
||||
project_id, share.permission, uid,
|
||||
data.get("user_id"), data.get("group_id"),
|
||||
))
|
||||
return jsonify(share.to_dict()), 201
|
||||
|
||||
|
||||
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_project_share(project_id: int, share_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
share = await share_svc.update_project_share(uid, share_id, data.get("permission", ""))
|
||||
if not share:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify(share.to_dict())
|
||||
|
||||
|
||||
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def remove_project_share(project_id: int, share_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await share_svc.remove_project_share(uid, share_id):
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Note / task shares
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["GET"])
|
||||
@login_required
|
||||
async def list_note_shares(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await can_write_note(uid, note_id):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
return jsonify({"shares": await share_svc.list_note_shares(note_id)})
|
||||
|
||||
|
||||
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["POST"])
|
||||
@login_required
|
||||
async def create_note_share(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
share = await share_svc.share_note(
|
||||
uid, note_id,
|
||||
target_user_id=data.get("user_id"),
|
||||
target_group_id=data.get("group_id"),
|
||||
permission=data.get("permission", "viewer"),
|
||||
)
|
||||
if not share:
|
||||
return jsonify({"error": "Forbidden or invalid permission"}), 403
|
||||
asyncio.create_task(notify_note_shared(
|
||||
note_id, share.permission, uid,
|
||||
data.get("user_id"), data.get("group_id"),
|
||||
))
|
||||
return jsonify(share.to_dict()), 201
|
||||
|
||||
|
||||
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_note_share(note_id: int, share_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
share = await share_svc.update_note_share(uid, share_id, data.get("permission", ""))
|
||||
if not share:
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify(share.to_dict())
|
||||
|
||||
|
||||
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def remove_note_share(note_id: int, share_id: int):
|
||||
uid = get_current_user_id()
|
||||
if not await share_svc.remove_note_share(uid, share_id):
|
||||
return jsonify({"error": "Not found or forbidden"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared-with-me
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@shares_bp.route("/api/shared-with-me", methods=["GET"])
|
||||
@login_required
|
||||
async def shared_with_me():
|
||||
uid = get_current_user_id()
|
||||
return jsonify(await list_shared_with_me(uid))
|
||||
@@ -16,7 +16,7 @@ task_logs_bp = Blueprint("task_logs", __name__, url_prefix="/api/tasks")
|
||||
async def list_logs_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
logs = await list_logs(uid, task_id)
|
||||
return jsonify({"logs": [l.to_dict() for l in logs]})
|
||||
return jsonify({"logs": [log.to_dict() for log in logs]})
|
||||
|
||||
|
||||
@task_logs_bp.route("/<int:task_id>/logs", methods=["POST"])
|
||||
|
||||
@@ -7,6 +7,7 @@ from fabledassistant.services.notes import (
|
||||
create_note,
|
||||
delete_note,
|
||||
get_note,
|
||||
get_note_for_user,
|
||||
list_notes,
|
||||
update_note,
|
||||
)
|
||||
@@ -27,6 +28,9 @@ async def list_tasks_route():
|
||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
no_project = request.args.get("no_project", "").lower() == "true"
|
||||
|
||||
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
|
||||
if isinstance(due_before, tuple):
|
||||
return due_before
|
||||
@@ -43,6 +47,8 @@ async def list_tasks_route():
|
||||
priority=priority,
|
||||
due_before=due_before,
|
||||
due_after=due_after,
|
||||
project_id=project_id,
|
||||
no_project=no_project,
|
||||
sort=sort,
|
||||
order=order,
|
||||
limit=limit,
|
||||
@@ -68,6 +74,13 @@ async def create_task_route():
|
||||
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
|
||||
)
|
||||
|
||||
project_id = data.get("project_id")
|
||||
if project_id is None and data.get("project"):
|
||||
from fabledassistant.services.projects import get_project_by_title as _gpbt
|
||||
proj = await _gpbt(uid, data["project"])
|
||||
if proj:
|
||||
project_id = proj.id
|
||||
|
||||
task = await create_note(
|
||||
uid,
|
||||
title=data.get("title", ""),
|
||||
@@ -76,7 +89,7 @@ async def create_task_route():
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
tags=tags,
|
||||
project_id=data.get("project_id"),
|
||||
project_id=project_id,
|
||||
milestone_id=data.get("milestone_id"),
|
||||
parent_id=data.get("parent_id"),
|
||||
)
|
||||
@@ -87,10 +100,12 @@ async def create_task_route():
|
||||
@login_required
|
||||
async def get_task_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
task = await get_note(uid, task_id)
|
||||
if task is None:
|
||||
result = await get_note_for_user(uid, task_id)
|
||||
if result is None:
|
||||
return not_found("Task")
|
||||
task, permission = result
|
||||
data = task.to_dict()
|
||||
data["permission"] = permission
|
||||
if task.parent_id:
|
||||
parent = await get_note(uid, task.parent_id)
|
||||
data["parent_title"] = parent.title if parent else None
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.user import User
|
||||
|
||||
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
|
||||
|
||||
|
||||
@users_bp.route("/search", methods=["GET"])
|
||||
@login_required
|
||||
async def search_users():
|
||||
uid = get_current_user_id()
|
||||
q = (request.args.get("q") or "").strip()
|
||||
if len(q) < 2:
|
||||
return jsonify({"users": []})
|
||||
like = f"{q}%"
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(
|
||||
select(User).where(
|
||||
User.id != uid,
|
||||
or_(User.username.ilike(like), User.email.ilike(like)),
|
||||
).limit(10)
|
||||
)).scalars().all()
|
||||
return jsonify({"users": [
|
||||
{"id": u.id, "username": u.username, "email": u.email}
|
||||
for u in users
|
||||
]})
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Access control service.
|
||||
|
||||
Single source of truth for permission resolution on projects and notes.
|
||||
All human-facing routes that should honour shares call these functions.
|
||||
LLM tool routes continue to use owner-scoped service functions directly.
|
||||
|
||||
Permission rank: owner > admin > editor > viewer
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.group import GroupMembership
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.share import NoteShare, ProjectShare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERMISSION_RANK: dict[str, int] = {
|
||||
"viewer": 1,
|
||||
"editor": 2,
|
||||
"admin": 3,
|
||||
"owner": 4,
|
||||
}
|
||||
|
||||
|
||||
def _higher(a: str | None, b: str | None) -> str | None:
|
||||
"""Return the higher-ranked permission, or None if both are None."""
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return a if PERMISSION_RANK[a] >= PERMISSION_RANK[b] else b
|
||||
|
||||
|
||||
async def _user_group_ids(session, user_id: int) -> list[int]:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_project_permission(user_id: int, project_id: int) -> str | None:
|
||||
"""Return the effective permission string for user on project, or None."""
|
||||
async with async_session() as session:
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None:
|
||||
return None
|
||||
if project.user_id == user_id:
|
||||
return "owner"
|
||||
|
||||
# Direct share
|
||||
direct = (
|
||||
await session.execute(
|
||||
select(ProjectShare).where(
|
||||
ProjectShare.project_id == project_id,
|
||||
ProjectShare.shared_with_user_id == user_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
# Group shares
|
||||
group_ids = await _user_group_ids(session, user_id)
|
||||
group_perm: str | None = None
|
||||
if group_ids:
|
||||
group_shares = (
|
||||
await session.execute(
|
||||
select(ProjectShare).where(
|
||||
ProjectShare.project_id == project_id,
|
||||
ProjectShare.shared_with_group_id.in_(group_ids),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
for gs in group_shares:
|
||||
group_perm = _higher(group_perm, gs.permission)
|
||||
|
||||
return _higher(direct.permission if direct else None, group_perm)
|
||||
|
||||
|
||||
async def can_read_project(user_id: int, project_id: int) -> bool:
|
||||
return (await get_project_permission(user_id, project_id)) is not None
|
||||
|
||||
|
||||
async def can_write_project(user_id: int, project_id: int) -> bool:
|
||||
perm = await get_project_permission(user_id, project_id)
|
||||
return perm in ("editor", "admin", "owner")
|
||||
|
||||
|
||||
async def can_admin_project(user_id: int, project_id: int) -> bool:
|
||||
perm = await get_project_permission(user_id, project_id)
|
||||
return perm in ("admin", "owner")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Note / task permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_note_permission(user_id: int, note_id: int) -> str | None:
|
||||
"""Return the effective permission for user on a note/task, or None.
|
||||
|
||||
Resolution order:
|
||||
1. Ownership
|
||||
2. Direct note share
|
||||
3. Group-based note share
|
||||
4. Inherited from project share (if note belongs to a project)
|
||||
Highest rank wins.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
if note is None:
|
||||
return None
|
||||
if note.user_id == user_id:
|
||||
return "owner"
|
||||
|
||||
direct = (
|
||||
await session.execute(
|
||||
select(NoteShare).where(
|
||||
NoteShare.note_id == note_id,
|
||||
NoteShare.shared_with_user_id == user_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
group_ids = await _user_group_ids(session, user_id)
|
||||
group_perm: str | None = None
|
||||
if group_ids:
|
||||
group_shares = (
|
||||
await session.execute(
|
||||
select(NoteShare).where(
|
||||
NoteShare.note_id == note_id,
|
||||
NoteShare.shared_with_group_id.in_(group_ids),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
for gs in group_shares:
|
||||
group_perm = _higher(group_perm, gs.permission)
|
||||
|
||||
note_perm = _higher(direct.permission if direct else None, group_perm)
|
||||
|
||||
# Inherit from project if note belongs to one
|
||||
if note.project_id is not None:
|
||||
project_perm = await get_project_permission(user_id, note.project_id)
|
||||
note_perm = _higher(note_perm, project_perm)
|
||||
|
||||
return note_perm
|
||||
|
||||
|
||||
async def can_read_note(user_id: int, note_id: int) -> bool:
|
||||
return (await get_note_permission(user_id, note_id)) is not None
|
||||
|
||||
|
||||
async def can_write_note(user_id: int, note_id: int) -> bool:
|
||||
perm = await get_note_permission(user_id, note_id)
|
||||
return perm in ("editor", "admin", "owner")
|
||||
@@ -1,175 +1,369 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.note_draft import NoteDraft
|
||||
from fabledassistant.models.note_version import NoteVersion
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.push_subscription import PushSubscription
|
||||
from fabledassistant.models.setting import Setting
|
||||
from fabledassistant.models.task_log import TaskLog
|
||||
from fabledassistant.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _dt(val: str | None) -> datetime:
|
||||
return datetime.fromisoformat(val) if val else datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _d(val: str | None) -> date | None:
|
||||
return date.fromisoformat(val) if val else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def export_full_backup() -> dict:
|
||||
"""Export all users, notes, conversations+messages, and settings as JSON."""
|
||||
"""Export all data as version-2 JSON backup."""
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
projects = (await session.execute(select(Project))).scalars().all()
|
||||
milestones = (await session.execute(select(Milestone))).scalars().all()
|
||||
notes = (await session.execute(select(Note))).scalars().all()
|
||||
conversations = (
|
||||
await session.execute(
|
||||
select(Conversation).options(selectinload(Conversation.messages))
|
||||
)
|
||||
).scalars().all()
|
||||
task_logs = (await session.execute(select(TaskLog))).scalars().all()
|
||||
note_drafts = (await session.execute(select(NoteDraft))).scalars().all()
|
||||
note_versions = (await session.execute(
|
||||
select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id)
|
||||
)).scalars().all()
|
||||
conversations = (await session.execute(
|
||||
select(Conversation).options(selectinload(Conversation.messages))
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
push_subs = (await session.execute(select(PushSubscription))).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"scope": "full",
|
||||
"_security_notice": (
|
||||
"This backup contains hashed passwords. "
|
||||
"Store it securely and restrict access."
|
||||
),
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"password_hash": u.password_hash,
|
||||
"role": u.role,
|
||||
"created_at": u.created_at.isoformat(),
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"user_id": n.user_id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"tags": n.tags or [],
|
||||
"parent_id": n.parent_id,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"updated_at": n.updated_at.isoformat(),
|
||||
}
|
||||
for n in notes
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.id,
|
||||
"user_id": c.user_id,
|
||||
"title": c.title,
|
||||
"model": c.model,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"context_note_id": m.context_note_id,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in c.messages
|
||||
],
|
||||
}
|
||||
for c in conversations
|
||||
],
|
||||
"settings": [
|
||||
{
|
||||
"user_id": s.user_id,
|
||||
"key": s.key,
|
||||
"value": s.value,
|
||||
}
|
||||
for s in settings
|
||||
],
|
||||
}
|
||||
return {
|
||||
"version": 2,
|
||||
"scope": "full",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"_security_notice": (
|
||||
"This backup contains hashed passwords and push subscription keys. "
|
||||
"Store it securely and restrict access."
|
||||
),
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"password_hash": u.password_hash,
|
||||
"oauth_sub": u.oauth_sub,
|
||||
"role": u.role,
|
||||
"session_version": u.session_version,
|
||||
"created_at": u.created_at.isoformat(),
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"id": p.id,
|
||||
"user_id": p.user_id,
|
||||
"title": p.title,
|
||||
"description": p.description,
|
||||
"goal": p.goal,
|
||||
"status": p.status,
|
||||
"color": p.color,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
"updated_at": p.updated_at.isoformat(),
|
||||
}
|
||||
for p in projects
|
||||
],
|
||||
"milestones": [
|
||||
{
|
||||
"id": m.id,
|
||||
"user_id": m.user_id,
|
||||
"project_id": m.project_id,
|
||||
"title": m.title,
|
||||
"description": m.description,
|
||||
"status": m.status,
|
||||
"order_index": m.order_index,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"updated_at": m.updated_at.isoformat(),
|
||||
}
|
||||
for m in milestones
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"user_id": n.user_id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"tags": n.tags or [],
|
||||
"parent_id": n.parent_id,
|
||||
"project_id": n.project_id,
|
||||
"milestone_id": n.milestone_id,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"updated_at": n.updated_at.isoformat(),
|
||||
}
|
||||
for n in notes
|
||||
],
|
||||
"task_logs": [
|
||||
{
|
||||
"id": tl.id,
|
||||
"user_id": tl.user_id,
|
||||
"task_id": tl.task_id,
|
||||
"content": tl.content,
|
||||
"duration_minutes": tl.duration_minutes,
|
||||
"created_at": tl.created_at.isoformat(),
|
||||
"updated_at": tl.updated_at.isoformat(),
|
||||
}
|
||||
for tl in task_logs
|
||||
],
|
||||
"note_drafts": [
|
||||
{
|
||||
"id": nd.id,
|
||||
"user_id": nd.user_id,
|
||||
"note_id": nd.note_id,
|
||||
"proposed_body": nd.proposed_body,
|
||||
"original_body": nd.original_body,
|
||||
"instruction": nd.instruction,
|
||||
"scope": nd.scope,
|
||||
"created_at": nd.created_at.isoformat(),
|
||||
"updated_at": nd.updated_at.isoformat(),
|
||||
}
|
||||
for nd in note_drafts
|
||||
],
|
||||
"note_versions": [
|
||||
{
|
||||
"id": nv.id,
|
||||
"user_id": nv.user_id,
|
||||
"note_id": nv.note_id,
|
||||
"title": nv.title,
|
||||
"body": nv.body,
|
||||
"tags": nv.tags or [],
|
||||
"created_at": nv.created_at.isoformat(),
|
||||
}
|
||||
for nv in note_versions
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.id,
|
||||
"user_id": c.user_id,
|
||||
"title": c.title,
|
||||
"model": c.model,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"status": m.status,
|
||||
"context_note_id": m.context_note_id,
|
||||
"tool_calls": m.tool_calls,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in c.messages
|
||||
],
|
||||
}
|
||||
for c in conversations
|
||||
],
|
||||
"settings": [
|
||||
{"user_id": s.user_id, "key": s.key, "value": s.value}
|
||||
for s in settings
|
||||
],
|
||||
"push_subscriptions": [
|
||||
{
|
||||
"user_id": ps.user_id,
|
||||
"endpoint": ps.endpoint,
|
||||
"p256dh": ps.p256dh,
|
||||
"auth": ps.auth,
|
||||
"user_agent": ps.user_agent,
|
||||
"created_at": ps.created_at.isoformat(),
|
||||
}
|
||||
for ps in push_subs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def export_user_backup(user_id: int) -> dict:
|
||||
"""Export a single user's data as JSON."""
|
||||
"""Export a single user's data as version-2 JSON backup."""
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
notes = (
|
||||
await session.execute(select(Note).where(Note.user_id == user_id))
|
||||
).scalars().all()
|
||||
conversations = (
|
||||
await session.execute(
|
||||
select(Conversation)
|
||||
.options(selectinload(Conversation.messages))
|
||||
.where(Conversation.user_id == user_id)
|
||||
)
|
||||
).scalars().all()
|
||||
settings = (
|
||||
await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id)
|
||||
)
|
||||
).scalars().all()
|
||||
projects = (await session.execute(
|
||||
select(Project).where(Project.user_id == user_id)
|
||||
)).scalars().all()
|
||||
milestones = (await session.execute(
|
||||
select(Milestone).where(Milestone.user_id == user_id)
|
||||
)).scalars().all()
|
||||
notes = (await session.execute(
|
||||
select(Note).where(Note.user_id == user_id)
|
||||
)).scalars().all()
|
||||
task_logs = (await session.execute(
|
||||
select(TaskLog).where(TaskLog.user_id == user_id)
|
||||
)).scalars().all()
|
||||
note_drafts = (await session.execute(
|
||||
select(NoteDraft).where(NoteDraft.user_id == user_id)
|
||||
)).scalars().all()
|
||||
note_versions = (await session.execute(
|
||||
select(NoteVersion).where(NoteVersion.user_id == user_id)
|
||||
.order_by(NoteVersion.note_id, NoteVersion.id)
|
||||
)).scalars().all()
|
||||
conversations = (await session.execute(
|
||||
select(Conversation)
|
||||
.options(selectinload(Conversation.messages))
|
||||
.where(Conversation.user_id == user_id)
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id)
|
||||
)).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"scope": "user",
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
} if user else None,
|
||||
"notes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"tags": n.tags or [],
|
||||
"parent_id": n.parent_id,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"updated_at": n.updated_at.isoformat(),
|
||||
}
|
||||
for n in notes
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.id,
|
||||
"title": c.title,
|
||||
"model": c.model,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"context_note_id": m.context_note_id,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in c.messages
|
||||
],
|
||||
}
|
||||
for c in conversations
|
||||
],
|
||||
"settings": [
|
||||
{"key": s.key, "value": s.value}
|
||||
for s in settings
|
||||
],
|
||||
}
|
||||
return {
|
||||
"version": 2,
|
||||
"scope": "user",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
} if user else None,
|
||||
"projects": [
|
||||
{
|
||||
"id": p.id,
|
||||
"user_id": p.user_id,
|
||||
"title": p.title,
|
||||
"description": p.description,
|
||||
"goal": p.goal,
|
||||
"status": p.status,
|
||||
"color": p.color,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
"updated_at": p.updated_at.isoformat(),
|
||||
}
|
||||
for p in projects
|
||||
],
|
||||
"milestones": [
|
||||
{
|
||||
"id": m.id,
|
||||
"user_id": m.user_id,
|
||||
"project_id": m.project_id,
|
||||
"title": m.title,
|
||||
"description": m.description,
|
||||
"status": m.status,
|
||||
"order_index": m.order_index,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"updated_at": m.updated_at.isoformat(),
|
||||
}
|
||||
for m in milestones
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"tags": n.tags or [],
|
||||
"parent_id": n.parent_id,
|
||||
"project_id": n.project_id,
|
||||
"milestone_id": n.milestone_id,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"updated_at": n.updated_at.isoformat(),
|
||||
}
|
||||
for n in notes
|
||||
],
|
||||
"task_logs": [
|
||||
{
|
||||
"id": tl.id,
|
||||
"task_id": tl.task_id,
|
||||
"content": tl.content,
|
||||
"duration_minutes": tl.duration_minutes,
|
||||
"created_at": tl.created_at.isoformat(),
|
||||
"updated_at": tl.updated_at.isoformat(),
|
||||
}
|
||||
for tl in task_logs
|
||||
],
|
||||
"note_drafts": [
|
||||
{
|
||||
"id": nd.id,
|
||||
"note_id": nd.note_id,
|
||||
"proposed_body": nd.proposed_body,
|
||||
"original_body": nd.original_body,
|
||||
"instruction": nd.instruction,
|
||||
"scope": nd.scope,
|
||||
"created_at": nd.created_at.isoformat(),
|
||||
"updated_at": nd.updated_at.isoformat(),
|
||||
}
|
||||
for nd in note_drafts
|
||||
],
|
||||
"note_versions": [
|
||||
{
|
||||
"id": nv.id,
|
||||
"note_id": nv.note_id,
|
||||
"title": nv.title,
|
||||
"body": nv.body,
|
||||
"tags": nv.tags or [],
|
||||
"created_at": nv.created_at.isoformat(),
|
||||
}
|
||||
for nv in note_versions
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.id,
|
||||
"title": c.title,
|
||||
"model": c.model,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"status": m.status,
|
||||
"context_note_id": m.context_note_id,
|
||||
"tool_calls": m.tool_calls,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in c.messages
|
||||
],
|
||||
}
|
||||
for c in conversations
|
||||
],
|
||||
"settings": [
|
||||
{"key": s.key, "value": s.value}
|
||||
for s in settings
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def restore_full_backup(data: dict) -> dict:
|
||||
"""Restore from a full backup JSON. Returns stats about what was restored."""
|
||||
from datetime import date, datetime, timezone
|
||||
"""Restore from backup JSON. Dispatches by version."""
|
||||
version = data.get("version", 1)
|
||||
if version == 1:
|
||||
return await _restore_v1(data)
|
||||
return await _restore_v2(data)
|
||||
|
||||
|
||||
async def _restore_v1(data: dict) -> dict:
|
||||
"""Restore legacy v1 backup (original format)."""
|
||||
stats = {"users": 0, "notes": 0, "conversations": 0, "messages": 0, "settings": 0}
|
||||
|
||||
async with async_session() as session:
|
||||
# Restore users
|
||||
user_id_map: dict[int, int] = {}
|
||||
for u_data in data.get("users", []):
|
||||
old_id = u_data["id"]
|
||||
@@ -178,34 +372,30 @@ async def restore_full_backup(data: dict) -> dict:
|
||||
email=u_data.get("email"),
|
||||
password_hash=u_data["password_hash"],
|
||||
role=u_data.get("role", "user"),
|
||||
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
|
||||
created_at=_dt(u_data.get("created_at")),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
user_id_map[old_id] = user.id
|
||||
stats["users"] += 1
|
||||
|
||||
# Restore notes
|
||||
note_id_map: dict[int, int] = {}
|
||||
for n_data in data.get("notes", []):
|
||||
old_id = n_data.get("id")
|
||||
mapped_user_id = user_id_map.get(n_data.get("user_id", 0))
|
||||
if mapped_user_id is None:
|
||||
continue
|
||||
due = None
|
||||
if n_data.get("due_date"):
|
||||
due = date.fromisoformat(n_data["due_date"])
|
||||
note = Note(
|
||||
user_id=mapped_user_id,
|
||||
title=n_data.get("title", ""),
|
||||
body=n_data.get("body", ""),
|
||||
tags=n_data.get("tags", []),
|
||||
parent_id=n_data.get("parent_id"),
|
||||
parent_id=None, # patched below
|
||||
status=n_data.get("status"),
|
||||
priority=n_data.get("priority"),
|
||||
due_date=due,
|
||||
created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
due_date=_d(n_data.get("due_date")),
|
||||
created_at=_dt(n_data.get("created_at")),
|
||||
updated_at=_dt(n_data.get("updated_at")),
|
||||
)
|
||||
session.add(note)
|
||||
await session.flush()
|
||||
@@ -213,7 +403,15 @@ async def restore_full_backup(data: dict) -> dict:
|
||||
note_id_map[old_id] = note.id
|
||||
stats["notes"] += 1
|
||||
|
||||
# Restore conversations + messages
|
||||
# Patch parent_id now that all notes have new IDs
|
||||
for n_data in data.get("notes", []):
|
||||
old_id = n_data.get("id")
|
||||
old_parent = n_data.get("parent_id")
|
||||
if old_id and old_parent and old_id in note_id_map and old_parent in note_id_map:
|
||||
note_row = await session.get(Note, note_id_map[old_id])
|
||||
if note_row:
|
||||
note_row.parent_id = note_id_map[old_parent]
|
||||
|
||||
for c_data in data.get("conversations", []):
|
||||
mapped_user_id = user_id_map.get(c_data.get("user_id", 0))
|
||||
if mapped_user_id is None:
|
||||
@@ -222,38 +420,233 @@ async def restore_full_backup(data: dict) -> dict:
|
||||
user_id=mapped_user_id,
|
||||
title=c_data.get("title", ""),
|
||||
model=c_data.get("model", ""),
|
||||
created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
created_at=_dt(c_data.get("created_at")),
|
||||
updated_at=_dt(c_data.get("updated_at")),
|
||||
)
|
||||
session.add(conv)
|
||||
await session.flush()
|
||||
stats["conversations"] += 1
|
||||
|
||||
for m_data in c_data.get("messages", []):
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role=m_data["role"],
|
||||
content=m_data.get("content", ""),
|
||||
context_note_id=note_id_map.get(m_data.get("context_note_id")) if m_data.get("context_note_id") else None,
|
||||
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
|
||||
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
|
||||
created_at=_dt(m_data.get("created_at")),
|
||||
)
|
||||
session.add(msg)
|
||||
stats["messages"] += 1
|
||||
|
||||
# Restore settings
|
||||
for s_data in data.get("settings", []):
|
||||
mapped_user_id = user_id_map.get(s_data.get("user_id", 0))
|
||||
if mapped_user_id is None:
|
||||
continue
|
||||
setting = Setting(
|
||||
user_id=mapped_user_id,
|
||||
key=s_data["key"],
|
||||
value=s_data.get("value", ""),
|
||||
)
|
||||
session.add(setting)
|
||||
session.add(Setting(user_id=mapped_user_id, key=s_data["key"], value=s_data.get("value", "")))
|
||||
stats["settings"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored backup: %s", stats)
|
||||
logger.info("Restored v1 backup: %s", stats)
|
||||
return stats
|
||||
|
||||
|
||||
async def _restore_v2(data: dict) -> dict:
|
||||
"""Restore v2 backup with full FK re-mapping."""
|
||||
stats: dict[str, int] = {
|
||||
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
|
||||
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
|
||||
"conversations": 0, "messages": 0, "settings": 0,
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
user_id_map: dict[int, int] = {}
|
||||
project_id_map: dict[int, int] = {}
|
||||
milestone_id_map: dict[int, int] = {}
|
||||
note_id_map: dict[int, int] = {}
|
||||
|
||||
# 1. Users
|
||||
for u_data in data.get("users", []):
|
||||
old_id = u_data["id"]
|
||||
user = User(
|
||||
username=u_data["username"],
|
||||
email=u_data.get("email"),
|
||||
password_hash=u_data.get("password_hash"),
|
||||
oauth_sub=u_data.get("oauth_sub"),
|
||||
role=u_data.get("role", "user"),
|
||||
session_version=u_data.get("session_version", 1),
|
||||
created_at=_dt(u_data.get("created_at")),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
user_id_map[old_id] = user.id
|
||||
stats["users"] += 1
|
||||
|
||||
# 2. Projects
|
||||
for p_data in data.get("projects", []):
|
||||
mapped_uid = user_id_map.get(p_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
proj = Project(
|
||||
user_id=mapped_uid,
|
||||
title=p_data.get("title", ""),
|
||||
description=p_data.get("description", ""),
|
||||
goal=p_data.get("goal", ""),
|
||||
status=p_data.get("status", "active"),
|
||||
color=p_data.get("color"),
|
||||
created_at=_dt(p_data.get("created_at")),
|
||||
updated_at=_dt(p_data.get("updated_at")),
|
||||
)
|
||||
session.add(proj)
|
||||
await session.flush()
|
||||
project_id_map[p_data["id"]] = proj.id
|
||||
stats["projects"] += 1
|
||||
|
||||
# 3. Milestones
|
||||
for m_data in data.get("milestones", []):
|
||||
mapped_uid = user_id_map.get(m_data.get("user_id", 0))
|
||||
mapped_pid = project_id_map.get(m_data.get("project_id", 0))
|
||||
if mapped_uid is None or mapped_pid is None:
|
||||
continue
|
||||
ms = Milestone(
|
||||
user_id=mapped_uid,
|
||||
project_id=mapped_pid,
|
||||
title=m_data.get("title", ""),
|
||||
description=m_data.get("description"),
|
||||
status=m_data.get("status", "active"),
|
||||
order_index=m_data.get("order_index", 0),
|
||||
created_at=_dt(m_data.get("created_at")),
|
||||
updated_at=_dt(m_data.get("updated_at")),
|
||||
)
|
||||
session.add(ms)
|
||||
await session.flush()
|
||||
milestone_id_map[m_data["id"]] = ms.id
|
||||
stats["milestones"] += 1
|
||||
|
||||
# 4a. Notes — first pass (no parent_id yet)
|
||||
notes_with_parents: list[tuple[int, int]] = [] # (new_note_id, old_parent_id)
|
||||
for n_data in data.get("notes", []):
|
||||
mapped_uid = user_id_map.get(n_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
note = Note(
|
||||
user_id=mapped_uid,
|
||||
title=n_data.get("title", ""),
|
||||
body=n_data.get("body", ""),
|
||||
tags=n_data.get("tags", []),
|
||||
parent_id=None,
|
||||
project_id=project_id_map.get(n_data["project_id"]) if n_data.get("project_id") else None,
|
||||
milestone_id=milestone_id_map.get(n_data["milestone_id"]) if n_data.get("milestone_id") else None,
|
||||
status=n_data.get("status"),
|
||||
priority=n_data.get("priority"),
|
||||
due_date=_d(n_data.get("due_date")),
|
||||
created_at=_dt(n_data.get("created_at")),
|
||||
updated_at=_dt(n_data.get("updated_at")),
|
||||
)
|
||||
session.add(note)
|
||||
await session.flush()
|
||||
note_id_map[n_data["id"]] = note.id
|
||||
if n_data.get("parent_id"):
|
||||
notes_with_parents.append((note.id, n_data["parent_id"]))
|
||||
stats["notes"] += 1
|
||||
|
||||
# 4b. Patch parent_id
|
||||
for new_note_id, old_parent_id in notes_with_parents:
|
||||
new_parent_id = note_id_map.get(old_parent_id)
|
||||
if new_parent_id:
|
||||
note_row = await session.get(Note, new_note_id)
|
||||
if note_row:
|
||||
note_row.parent_id = new_parent_id
|
||||
|
||||
# 5. TaskLogs
|
||||
for tl_data in data.get("task_logs", []):
|
||||
mapped_uid = user_id_map.get(tl_data.get("user_id", 0))
|
||||
mapped_tid = note_id_map.get(tl_data.get("task_id", 0))
|
||||
if mapped_uid is None or mapped_tid is None:
|
||||
continue
|
||||
tl = TaskLog(
|
||||
user_id=mapped_uid,
|
||||
task_id=mapped_tid,
|
||||
content=tl_data.get("content", ""),
|
||||
duration_minutes=tl_data.get("duration_minutes"),
|
||||
created_at=_dt(tl_data.get("created_at")),
|
||||
updated_at=_dt(tl_data.get("updated_at")),
|
||||
)
|
||||
session.add(tl)
|
||||
stats["task_logs"] += 1
|
||||
|
||||
# 6. NoteDrafts
|
||||
for nd_data in data.get("note_drafts", []):
|
||||
mapped_uid = user_id_map.get(nd_data.get("user_id", 0))
|
||||
mapped_nid = note_id_map.get(nd_data.get("note_id", 0))
|
||||
if mapped_uid is None or mapped_nid is None:
|
||||
continue
|
||||
nd = NoteDraft(
|
||||
user_id=mapped_uid,
|
||||
note_id=mapped_nid,
|
||||
proposed_body=nd_data.get("proposed_body", ""),
|
||||
original_body=nd_data.get("original_body", ""),
|
||||
instruction=nd_data.get("instruction", ""),
|
||||
scope=nd_data.get("scope", "document"),
|
||||
created_at=_dt(nd_data.get("created_at")),
|
||||
updated_at=_dt(nd_data.get("updated_at")),
|
||||
)
|
||||
session.add(nd)
|
||||
stats["note_drafts"] += 1
|
||||
|
||||
# 7. NoteVersions
|
||||
for nv_data in data.get("note_versions", []):
|
||||
mapped_uid = user_id_map.get(nv_data.get("user_id", 0))
|
||||
mapped_nid = note_id_map.get(nv_data.get("note_id", 0))
|
||||
if mapped_uid is None or mapped_nid is None:
|
||||
continue
|
||||
nv = NoteVersion(
|
||||
user_id=mapped_uid,
|
||||
note_id=mapped_nid,
|
||||
title=nv_data.get("title", ""),
|
||||
body=nv_data.get("body", ""),
|
||||
tags=nv_data.get("tags", []),
|
||||
created_at=_dt(nv_data.get("created_at")),
|
||||
)
|
||||
session.add(nv)
|
||||
stats["note_versions"] += 1
|
||||
|
||||
# 8. Conversations + Messages
|
||||
for c_data in data.get("conversations", []):
|
||||
mapped_uid = user_id_map.get(c_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
conv = Conversation(
|
||||
user_id=mapped_uid,
|
||||
title=c_data.get("title", ""),
|
||||
model=c_data.get("model", ""),
|
||||
created_at=_dt(c_data.get("created_at")),
|
||||
updated_at=_dt(c_data.get("updated_at")),
|
||||
)
|
||||
session.add(conv)
|
||||
await session.flush()
|
||||
stats["conversations"] += 1
|
||||
for m_data in c_data.get("messages", []):
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role=m_data["role"],
|
||||
content=m_data.get("content", ""),
|
||||
status=m_data.get("status", "complete"),
|
||||
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
|
||||
tool_calls=m_data.get("tool_calls"),
|
||||
created_at=_dt(m_data.get("created_at")),
|
||||
)
|
||||
session.add(msg)
|
||||
stats["messages"] += 1
|
||||
|
||||
# 9. Settings
|
||||
for s_data in data.get("settings", []):
|
||||
mapped_uid = user_id_map.get(s_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
session.add(Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", "")))
|
||||
stats["settings"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2 backup: %s", stats)
|
||||
return stats
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Create and manage briefing conversations."""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_or_create_today_conversation(user_id: int, model: str) -> Conversation:
|
||||
"""
|
||||
Return today's briefing conversation, creating it if it doesn't exist.
|
||||
"""
|
||||
today = date.today()
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv is not None:
|
||||
return conv
|
||||
|
||||
conv = Conversation(
|
||||
user_id=user_id,
|
||||
title=f"Briefing — {today.isoformat()}",
|
||||
model=model,
|
||||
conversation_type="briefing",
|
||||
briefing_date=today,
|
||||
)
|
||||
session.add(conv)
|
||||
await session.commit()
|
||||
await session.refresh(conv)
|
||||
return conv
|
||||
|
||||
|
||||
async def post_message(conversation_id: int, role: str, content: str) -> Message:
|
||||
"""Append a message to a briefing conversation."""
|
||||
async with async_session() as session:
|
||||
msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
status="complete",
|
||||
)
|
||||
session.add(msg)
|
||||
# Bump conversation updated_at
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv:
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
async def list_briefing_conversations(user_id: int) -> list[dict]:
|
||||
"""Return briefing conversations newest first."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
).order_by(Conversation.briefing_date.desc().nullslast())
|
||||
.limit(30)
|
||||
)
|
||||
convs = list(result.scalars().all())
|
||||
return [c.to_dict() for c in convs]
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Briefing pipeline: parallel data gather + two-lane LLM synthesis.
|
||||
|
||||
Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (4pm)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
|
||||
|
||||
|
||||
def slot_greeting(slot: str) -> str:
|
||||
return {
|
||||
"compilation": "Good morning",
|
||||
"morning": "Good morning — you're at the office",
|
||||
"midday": "Midday check-in",
|
||||
"afternoon": "End of day wrap-up",
|
||||
}.get(slot, "Update")
|
||||
|
||||
|
||||
def format_task(task: dict) -> str:
|
||||
parts = [task.get("title", "Untitled")]
|
||||
if task.get("status"):
|
||||
parts.append(f"[{task['status'].replace('_', ' ').title()}]")
|
||||
if task.get("due_date"):
|
||||
parts.append(f"due {task['due_date']}")
|
||||
if task.get("priority") and task["priority"] not in ("none", ""):
|
||||
parts.append(f"priority: {task['priority']}")
|
||||
return " — ".join(parts)
|
||||
|
||||
|
||||
# ── Internal data gather ──────────────────────────────────────────────────────
|
||||
|
||||
async def _gather_internal(user_id: int) -> dict:
|
||||
"""Collect tasks, calendar events, and project data."""
|
||||
from fabledassistant.services.notes import list_notes
|
||||
from fabledassistant.services.projects import list_projects
|
||||
from fabledassistant.services.caldav import is_caldav_configured, list_events
|
||||
|
||||
today = date.today().isoformat()
|
||||
|
||||
# Tasks: overdue, due today, high priority in-progress
|
||||
try:
|
||||
all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100)
|
||||
all_tasks = [
|
||||
{
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"due_date": t.due_date.isoformat() if t.due_date else None,
|
||||
"priority": t.priority,
|
||||
}
|
||||
for t in all_task_objs
|
||||
]
|
||||
overdue = [
|
||||
format_task(t) for t in all_tasks
|
||||
if t.get("due_date") and t["due_date"] < today and t.get("status") != "done"
|
||||
]
|
||||
due_today = [
|
||||
format_task(t) for t in all_tasks
|
||||
if t.get("due_date") == today and t.get("status") != "done"
|
||||
]
|
||||
high_priority = [
|
||||
format_task(t) for t in all_tasks
|
||||
if t.get("priority") == "high" and t.get("status") not in ("done",) and
|
||||
format_task(t) not in overdue and format_task(t) not in due_today
|
||||
][:5]
|
||||
except Exception:
|
||||
logger.warning("Failed to gather tasks for briefing", exc_info=True)
|
||||
overdue, due_today, high_priority = [], [], []
|
||||
|
||||
# Calendar events today
|
||||
calendar_events = []
|
||||
try:
|
||||
if is_caldav_configured():
|
||||
events = await list_events(user_id, start=today, end=today)
|
||||
calendar_events = [
|
||||
f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
|
||||
for e in (events or [])
|
||||
]
|
||||
except Exception:
|
||||
logger.warning("Failed to gather calendar events for briefing", exc_info=True)
|
||||
|
||||
# Projects: active projects
|
||||
projects_summary = []
|
||||
try:
|
||||
projects = await list_projects(user_id)
|
||||
for p in projects[:5]:
|
||||
projects_summary.append(p.get("title", "Untitled project"))
|
||||
except Exception:
|
||||
logger.warning("Failed to gather projects for briefing", exc_info=True)
|
||||
|
||||
return {
|
||||
"date": today,
|
||||
"overdue_tasks": overdue,
|
||||
"due_today": due_today,
|
||||
"high_priority": high_priority,
|
||||
"calendar_events": calendar_events,
|
||||
"active_projects": projects_summary,
|
||||
}
|
||||
|
||||
|
||||
# ── External data gather ──────────────────────────────────────────────────────
|
||||
|
||||
async def _gather_external(user_id: int) -> dict:
|
||||
"""Collect RSS items and weather."""
|
||||
from fabledassistant.services.rss import get_recent_items
|
||||
from fabledassistant.services.weather import get_cached_weather
|
||||
|
||||
rss_items, weather = await asyncio.gather(
|
||||
get_recent_items(user_id, limit=20),
|
||||
get_cached_weather(user_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
return {
|
||||
"rss_items": rss_items if not isinstance(rss_items, Exception) else [],
|
||||
"weather": weather if not isinstance(weather, Exception) else [],
|
||||
}
|
||||
|
||||
|
||||
# ── LLM synthesis ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> str:
|
||||
"""Single non-streaming LLM call. Returns the assistant's response text."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
"options": {"num_ctx": 4096, "temperature": 0.4},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("message", {}).get("content", "").strip()
|
||||
except Exception:
|
||||
logger.warning("LLM synthesis failed", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def _internal_system_prompt(profile_body: str) -> str:
|
||||
return (
|
||||
"You are a personal briefing assistant. Your job is to give the user a clear, "
|
||||
"concise summary of their internal workload: tasks, calendar, and projects. "
|
||||
"Be direct and prioritised — lead with what's urgent. Use plain text with light "
|
||||
"markdown. Do not include weather or news.\n\n"
|
||||
+ (f"User profile:\n{profile_body}\n" if profile_body else "")
|
||||
)
|
||||
|
||||
|
||||
def _external_system_prompt() -> str:
|
||||
return (
|
||||
"You are a briefing assistant for external information. Your job is to summarise "
|
||||
"the user's RSS feed digest and weather forecast into a concise, engaging update. "
|
||||
"Group related news items. Note any significant weather changes. "
|
||||
"Be informative but brief. Do not discuss tasks, calendar, or work items."
|
||||
)
|
||||
|
||||
|
||||
def _internal_user_prompt(data: dict, slot: str) -> str:
|
||||
lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""]
|
||||
if data["overdue_tasks"]:
|
||||
lines.append(f"OVERDUE ({len(data['overdue_tasks'])}):")
|
||||
lines.extend(f" - {t}" for t in data["overdue_tasks"])
|
||||
lines.append("")
|
||||
if data["due_today"]:
|
||||
lines.append(f"DUE TODAY ({len(data['due_today'])}):")
|
||||
lines.extend(f" - {t}" for t in data["due_today"])
|
||||
lines.append("")
|
||||
if data["high_priority"]:
|
||||
lines.append("HIGH PRIORITY (in progress):")
|
||||
lines.extend(f" - {t}" for t in data["high_priority"])
|
||||
lines.append("")
|
||||
if data["calendar_events"]:
|
||||
lines.append("CALENDAR TODAY:")
|
||||
lines.extend(f" - {e}" for e in data["calendar_events"])
|
||||
lines.append("")
|
||||
if data["active_projects"]:
|
||||
lines.append(f"ACTIVE PROJECTS: {', '.join(data['active_projects'])}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _external_user_prompt(data: dict, slot: str) -> str:
|
||||
lines = [f"Briefing slot: {slot}", ""]
|
||||
if data["weather"]:
|
||||
lines.append("WEATHER:")
|
||||
for loc in data["weather"]:
|
||||
lines.append(f" {loc['location_label']}:")
|
||||
for day in loc["days"][:3]:
|
||||
lines.append(
|
||||
f" {day['date']}: {day['description']}, "
|
||||
f"{day['temp_min']}–{day['temp_max']}°C, {day['precip_mm']}mm rain"
|
||||
)
|
||||
if loc["changes_since_last_fetch"]:
|
||||
lines.append(" FORECAST CHANGES:")
|
||||
lines.extend(f" - {c}" for c in loc["changes_since_last_fetch"])
|
||||
lines.append("")
|
||||
if data["rss_items"]:
|
||||
lines.append(f"RSS DIGEST ({len(data['rss_items'])} items):")
|
||||
for item in data["rss_items"][:15]:
|
||||
lines.append(f" [{item.get('feed_title', 'Feed')}] {item['title']}")
|
||||
if item.get("content"):
|
||||
lines.append(f" {item['content'][:200]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Main entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
async def run_compilation(user_id: int, slot: str, model: str | None = None) -> str:
|
||||
"""
|
||||
Run the full two-lane briefing pipeline for a user and slot.
|
||||
Returns the combined briefing text to be posted as the opening assistant message.
|
||||
"""
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
from fabledassistant.services.briefing_profile import get_profile_body
|
||||
profile_body = await get_profile_body(user_id)
|
||||
|
||||
# Parallel gather
|
||||
internal_data, external_data = await asyncio.gather(
|
||||
_gather_internal(user_id),
|
||||
_gather_external(user_id),
|
||||
)
|
||||
|
||||
# Two-lane LLM synthesis (both calls run concurrently)
|
||||
internal_text, external_text = await asyncio.gather(
|
||||
_llm_synthesise(
|
||||
_internal_system_prompt(profile_body),
|
||||
_internal_user_prompt(internal_data, slot),
|
||||
model,
|
||||
),
|
||||
_llm_synthesise(
|
||||
_external_system_prompt(),
|
||||
_external_user_prompt(external_data, slot),
|
||||
model,
|
||||
),
|
||||
)
|
||||
|
||||
greeting = slot_greeting(slot)
|
||||
today = internal_data["date"]
|
||||
parts = [f"**{greeting} — {today}**", ""]
|
||||
if internal_text:
|
||||
parts += ["## Your Day", "", internal_text, ""]
|
||||
if external_text:
|
||||
parts += ["## The World", "", external_text]
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str:
|
||||
"""
|
||||
Lighter update for 8am/12pm/4pm — gathers fresh data and produces a slot-specific
|
||||
update prompt. Returns the text to inject as a new user→assistant exchange.
|
||||
"""
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
internal_data, external_data = await asyncio.gather(
|
||||
_gather_internal(user_id),
|
||||
_gather_external(user_id),
|
||||
)
|
||||
|
||||
system = (
|
||||
f"You are a briefing assistant providing a {slot} update. Be brief — "
|
||||
"the user has already seen the morning briefing. Focus on what's changed or new."
|
||||
)
|
||||
user_prompt = (
|
||||
f"Slot: {slot}\n\n"
|
||||
+ _internal_user_prompt(internal_data, slot)
|
||||
+ "\n\n"
|
||||
+ _external_user_prompt(external_data, slot)
|
||||
)
|
||||
return await _llm_synthesise(system, user_prompt, model)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Briefing profile note: stores learned user preferences for the briefing assistant."""
|
||||
|
||||
import logging
|
||||
|
||||
from fabledassistant.services.notes import create_note, list_notes, update_note
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROFILE_TAG = "briefing-profile"
|
||||
PROFILE_TITLE = "Briefing Profile"
|
||||
|
||||
|
||||
async def _find_profile_note(user_id: int) -> dict | None:
|
||||
"""Find the user's briefing profile note by tag."""
|
||||
notes, _total = await list_notes(user_id, tags=[PROFILE_TAG], limit=1)
|
||||
if not notes:
|
||||
return None
|
||||
note = notes[0]
|
||||
return {
|
||||
"id": note.id,
|
||||
"body": note.body or "",
|
||||
"title": note.title,
|
||||
}
|
||||
|
||||
|
||||
async def get_profile_body(user_id: int) -> str:
|
||||
"""Return the body of the briefing profile note, or '' if none exists."""
|
||||
note = await _find_profile_note(user_id)
|
||||
return note["body"] if note else ""
|
||||
|
||||
|
||||
async def get_profile_note_id(user_id: int) -> int | None:
|
||||
note = await _find_profile_note(user_id)
|
||||
return note["id"] if note else None
|
||||
|
||||
|
||||
async def ensure_profile_note(user_id: int) -> int:
|
||||
"""
|
||||
Get or create the briefing profile note.
|
||||
Returns the note id.
|
||||
"""
|
||||
note = await _find_profile_note(user_id)
|
||||
if note:
|
||||
return note["id"]
|
||||
created = await create_note(
|
||||
user_id=user_id,
|
||||
title=PROFILE_TITLE,
|
||||
body=(
|
||||
"# Briefing Profile\n\n"
|
||||
"This note is maintained by the briefing assistant. "
|
||||
"It stores your preferences, patterns, and work schedule.\n\n"
|
||||
"## Work Schedule\n\n"
|
||||
"Office days: (not yet configured)\n\n"
|
||||
"## Locations\n\n"
|
||||
"(configured via Settings → Briefing)\n\n"
|
||||
"## Preferences\n\n"
|
||||
"(the assistant will add observations here over time)\n"
|
||||
),
|
||||
tags=[PROFILE_TAG],
|
||||
)
|
||||
return created.id
|
||||
|
||||
|
||||
async def append_observations(user_id: int, observations: str) -> None:
|
||||
"""
|
||||
Append the assistant's end-of-day observations to the profile note.
|
||||
Creates the note if it doesn't exist.
|
||||
"""
|
||||
if not observations.strip():
|
||||
return
|
||||
note_id = await ensure_profile_note(user_id)
|
||||
note = await _find_profile_note(user_id)
|
||||
if not note:
|
||||
return
|
||||
current_body = note.get("body", "")
|
||||
from datetime import date
|
||||
date_str = date.today().isoformat()
|
||||
new_body = current_body.rstrip() + f"\n\n## Observations — {date_str}\n\n{observations.strip()}\n"
|
||||
await update_note(user_id, note_id, body=new_body)
|
||||
logger.info("Briefing profile updated for user %d", user_id)
|
||||
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
APScheduler-based briefing scheduler.
|
||||
|
||||
Uses a background thread scheduler (not async) because APScheduler 3.x's
|
||||
AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async functions
|
||||
wrapped with asyncio.run_coroutine_threadsafe().
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
|
||||
# Slot definitions: (name, hour, minute)
|
||||
SLOTS = [
|
||||
("compilation", 4, 0),
|
||||
("morning", 8, 0),
|
||||
("midday", 12, 0),
|
||||
("afternoon", 16, 0),
|
||||
]
|
||||
|
||||
|
||||
async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
|
||||
"""Return [(user_id, model)] for users with briefing enabled."""
|
||||
import json
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.key == "briefing_config")
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
enabled = []
|
||||
for row in rows:
|
||||
try:
|
||||
config = json.loads(row.value) if row.value else {}
|
||||
if config.get("enabled"):
|
||||
enabled.append((row.user_id, "")) # model resolved per-user at runtime
|
||||
except Exception:
|
||||
pass
|
||||
return enabled
|
||||
|
||||
|
||||
async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
||||
"""Execute one slot job for one user."""
|
||||
from fabledassistant.services.briefing_conversations import (
|
||||
get_or_create_today_conversation, post_message
|
||||
)
|
||||
from fabledassistant.services.briefing_pipeline import run_compilation, run_slot_injection
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.config import Config
|
||||
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
if slot == "compilation":
|
||||
# Refresh external data first
|
||||
try:
|
||||
from fabledassistant.services.rss import refresh_all_feeds
|
||||
import json
|
||||
config_raw = await get_setting(user_id, "briefing_config", "{}")
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
await refresh_all_feeds(user_id)
|
||||
# Refresh weather for configured locations
|
||||
from fabledassistant.services import weather as wx
|
||||
for key, loc in config.get("locations", {}).items():
|
||||
if loc.get("lat") and loc.get("lon"):
|
||||
await wx.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True)
|
||||
|
||||
# Run previous day's profile close-out
|
||||
await _run_profile_closeout(user_id, model)
|
||||
|
||||
# Create today's conversation and post opening message
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text = await run_compilation(user_id, slot, model)
|
||||
if text:
|
||||
await post_message(conv.id, "assistant", text)
|
||||
|
||||
else:
|
||||
# Inject slot update into today's conversation
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text = await run_slot_injection(user_id, slot, model)
|
||||
if text:
|
||||
# Post as a system-injected user prompt + assistant response pair
|
||||
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
|
||||
await post_message(conv.id, "assistant", text)
|
||||
|
||||
# Send push notification
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification
|
||||
slot_labels = {
|
||||
"compilation": "Morning briefing ready",
|
||||
"morning": "Office briefing ready",
|
||||
"midday": "Midday check-in",
|
||||
"afternoon": "End of day wrap-up",
|
||||
}
|
||||
await send_push_notification(
|
||||
user_id=user_id,
|
||||
title="Briefing",
|
||||
body=slot_labels.get(slot, "Briefing update"),
|
||||
url="/briefing",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Push notification failed for briefing slot %s", slot, exc_info=True)
|
||||
|
||||
logger.info("Briefing slot '%s' completed for user %d", slot, user_id)
|
||||
|
||||
|
||||
async def _run_profile_closeout(user_id: int, model: str) -> None:
|
||||
"""
|
||||
Read yesterday's briefing conversation, ask the LLM to extract preference
|
||||
observations, and append them to the briefing profile note.
|
||||
"""
|
||||
from fabledassistant.services.briefing_profile import append_observations
|
||||
from fabledassistant.services.briefing_pipeline import _llm_synthesise
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
|
||||
yesterday = (date.today() - timedelta(days=1))
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == yesterday,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if not conv:
|
||||
return
|
||||
msgs_result = await session.execute(
|
||||
select(Message).where(Message.conversation_id == conv.id).order_by(Message.created_at)
|
||||
)
|
||||
messages = list(msgs_result.scalars().all())
|
||||
|
||||
if len(messages) < 2:
|
||||
return # Nothing interesting to learn from
|
||||
|
||||
transcript = "\n".join(
|
||||
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:]
|
||||
)
|
||||
system = (
|
||||
"You are reviewing a day's briefing conversation to extract preference observations. "
|
||||
"Identify any patterns, preferences, or schedule facts the user revealed. "
|
||||
"Write 2-5 short bullet points. Be specific and factual. "
|
||||
"Example: '- User skipped news about finance', '- Prefers weather for home location first'. "
|
||||
"If nothing notable, output only: (nothing to note)"
|
||||
)
|
||||
observations = await _llm_synthesise(system, transcript, model)
|
||||
if observations and "(nothing to note)" not in observations.lower():
|
||||
await append_observations(user_id, observations)
|
||||
|
||||
|
||||
def _run_slot_sync(slot: str, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Synchronous wrapper called by APScheduler's background thread."""
|
||||
async def _job():
|
||||
users = await _get_briefing_enabled_users()
|
||||
for user_id, _ in users:
|
||||
try:
|
||||
await _run_slot_for_user(user_id, slot)
|
||||
except Exception:
|
||||
logger.exception("Briefing slot '%s' failed for user %d", slot, user_id)
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(_job(), loop)
|
||||
try:
|
||||
future.result(timeout=600) # 10 min max per slot run
|
||||
except Exception:
|
||||
logger.exception("Briefing slot '%s' job failed", slot)
|
||||
|
||||
|
||||
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""
|
||||
On startup, check if any slot was missed within the last 24 hours.
|
||||
Fire it once if so. Never backfill more than one slot per slot-name.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today = now.date()
|
||||
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
slot_time = datetime.combine(today, time(hour, minute), tzinfo=timezone.utc)
|
||||
if slot_time > now:
|
||||
continue # Hasn't happened yet today
|
||||
age = (now - slot_time).total_seconds()
|
||||
if age > 86400:
|
||||
continue # More than 24h ago — skip
|
||||
# Check if we already have a message for this slot today
|
||||
# Simple heuristic: if today's briefing conversation has messages posted
|
||||
# after the slot time, consider it covered
|
||||
users = await _get_briefing_enabled_users()
|
||||
for user_id, _ in users:
|
||||
async with async_session() as session:
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv:
|
||||
msgs = await session.execute(
|
||||
select(Message).where(
|
||||
Message.conversation_id == conv.id,
|
||||
Message.created_at >= slot_time,
|
||||
).limit(1)
|
||||
)
|
||||
if msgs.scalars().first():
|
||||
continue # Already covered
|
||||
# Fire the missed slot
|
||||
logger.info("Catching up missed briefing slot '%s' for user %d", slot_name, user_id)
|
||||
try:
|
||||
await _run_slot_for_user(user_id, slot_name)
|
||||
except Exception:
|
||||
logger.exception("Catch-up for slot '%s' user %d failed", slot_name, user_id)
|
||||
|
||||
|
||||
def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""
|
||||
Start the APScheduler background scheduler.
|
||||
Must be called from the app's before_serving hook with the running event loop.
|
||||
"""
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
return
|
||||
|
||||
_scheduler = BackgroundScheduler(timezone="UTC")
|
||||
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
_scheduler.add_job(
|
||||
_run_slot_sync,
|
||||
CronTrigger(hour=hour, minute=minute),
|
||||
args=[slot_name, loop],
|
||||
id=f"briefing_{slot_name}",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600, # Fire up to 1h late rather than skip
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info("Briefing scheduler started")
|
||||
|
||||
# Catch up missed slots in the background
|
||||
asyncio.run_coroutine_threadsafe(_catchup_missed_slots(loop), loop)
|
||||
|
||||
|
||||
def stop_briefing_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
@@ -46,12 +46,13 @@ async def get_conversation(
|
||||
|
||||
|
||||
async def list_conversations(
|
||||
user_id: int, limit: int = 50, offset: int = 0
|
||||
user_id: int, limit: int = 50, offset: int = 0, conv_type: str = "chat"
|
||||
) -> tuple[list[dict], int]:
|
||||
async with async_session() as session:
|
||||
total = await session.scalar(
|
||||
select(func.count(Conversation.id)).where(
|
||||
Conversation.user_id == user_id
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == conv_type,
|
||||
)
|
||||
) or 0
|
||||
|
||||
@@ -65,7 +66,7 @@ async def list_conversations(
|
||||
|
||||
result = await session.execute(
|
||||
select(Conversation, msg_count.label("message_count"))
|
||||
.where(Conversation.user_id == user_id)
|
||||
.where(Conversation.user_id == user_id, Conversation.conversation_type == conv_type)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
@@ -78,6 +79,8 @@ async def list_conversations(
|
||||
"id": conv.id,
|
||||
"title": conv.title,
|
||||
"model": conv.model,
|
||||
"conversation_type": conv.conversation_type,
|
||||
"briefing_date": conv.briefing_date.isoformat() if conv.briefing_date else None,
|
||||
"message_count": row[1],
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
"updated_at": conv.updated_at.isoformat(),
|
||||
|
||||
@@ -80,6 +80,7 @@ async def semantic_search_notes(
|
||||
limit: int = 8,
|
||||
threshold: float = _SIMILARITY_THRESHOLD,
|
||||
project_id: int | None = None,
|
||||
is_task: bool | None = None,
|
||||
) -> list[tuple[float, Note]]:
|
||||
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
||||
|
||||
@@ -102,6 +103,10 @@ async def semantic_search_notes(
|
||||
)
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(Note.project_id == project_id)
|
||||
if is_task is True:
|
||||
stmt = stmt.where(Note.status.isnot(None))
|
||||
elif is_task is False:
|
||||
stmt = stmt.where(Note.status.is_(None))
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
|
||||
@@ -260,7 +260,7 @@ async def run_generation(
|
||||
if tool_name == "research_topic":
|
||||
topic = arguments.get("topic", "")
|
||||
try:
|
||||
note = await run_research_pipeline(topic, user_id, model, buf)
|
||||
note = await run_research_pipeline(topic, user_id, model, buf, project_id=workspace_project_id)
|
||||
result = {
|
||||
"success": True,
|
||||
"type": "research_note",
|
||||
@@ -358,9 +358,18 @@ async def run_generation(
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification, vapid_enabled
|
||||
if vapid_enabled():
|
||||
preview = buf.content_so_far[:120].rstrip()
|
||||
if len(buf.content_so_far) > 120:
|
||||
preview += "…"
|
||||
text = buf.content_so_far.strip()
|
||||
if text:
|
||||
preview = text[:120].rstrip()
|
||||
if len(text) > 120:
|
||||
preview += "…"
|
||||
else:
|
||||
# Tool-only response — summarise what was done
|
||||
tool_names = [tc.get("function") for tc in all_tool_calls if tc.get("function")]
|
||||
if tool_names:
|
||||
preview = f"Completed: {', '.join(tool_names[:3])}"
|
||||
else:
|
||||
preview = "Action completed"
|
||||
asyncio.create_task(send_push_notification(
|
||||
user_id,
|
||||
title="Response ready",
|
||||
@@ -368,7 +377,7 @@ async def run_generation(
|
||||
url=f"/chat/{conv_id}",
|
||||
))
|
||||
except Exception:
|
||||
logger.debug("Failed to schedule push notification", exc_info=True)
|
||||
logger.warning("Failed to schedule push notification", exc_info=True)
|
||||
|
||||
# Title generation is non-critical — fire-and-forget so done fires immediately
|
||||
non_system = [m for m in messages if m["role"] != "system"]
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Group management service."""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.group import Group, GroupMembership
|
||||
from fabledassistant.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_group(
|
||||
user_id: int, name: str, description: str | None = None
|
||||
) -> Group:
|
||||
async with async_session() as session:
|
||||
group = Group(name=name, description=description, created_by=user_id)
|
||||
session.add(group)
|
||||
await session.flush()
|
||||
session.add(GroupMembership(group_id=group.id, user_id=user_id, role="owner"))
|
||||
await session.commit()
|
||||
await session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
async def list_groups(user_id: int) -> list[dict]:
|
||||
"""All users see all groups with member count and their own membership status."""
|
||||
async with async_session() as session:
|
||||
groups = (await session.execute(select(Group).order_by(Group.name))).scalars().all()
|
||||
user_group_ids = set(
|
||||
(await session.execute(
|
||||
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
||||
)).scalars().all()
|
||||
)
|
||||
result = []
|
||||
for g in groups:
|
||||
count = len(
|
||||
(await session.execute(
|
||||
select(GroupMembership).where(GroupMembership.group_id == g.id)
|
||||
)).scalars().all()
|
||||
)
|
||||
d = g.to_dict()
|
||||
d["member_count"] = count
|
||||
d["is_member"] = g.id in user_group_ids
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
async def get_group(group_id: int) -> Group | None:
|
||||
async with async_session() as session:
|
||||
return await session.get(Group, group_id)
|
||||
|
||||
|
||||
async def _is_group_owner(session, acting_user_id: int, group_id: int) -> bool:
|
||||
m = (await session.execute(
|
||||
select(GroupMembership).where(
|
||||
GroupMembership.group_id == group_id,
|
||||
GroupMembership.user_id == acting_user_id,
|
||||
GroupMembership.role == "owner",
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
return m is not None
|
||||
|
||||
|
||||
async def update_group(
|
||||
acting_user_id: int, group_id: int, is_site_admin: bool, **fields
|
||||
) -> Group | None:
|
||||
async with async_session() as session:
|
||||
group = await session.get(Group, group_id)
|
||||
if not group:
|
||||
return None
|
||||
if not is_site_admin and not await _is_group_owner(session, acting_user_id, group_id):
|
||||
return None
|
||||
for k, v in fields.items():
|
||||
if hasattr(group, k):
|
||||
setattr(group, k, v)
|
||||
await session.commit()
|
||||
await session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
async def delete_group(acting_user_id: int, group_id: int, is_site_admin: bool) -> bool:
|
||||
async with async_session() as session:
|
||||
group = await session.get(Group, group_id)
|
||||
if not group:
|
||||
return False
|
||||
if not is_site_admin and group.created_by != acting_user_id:
|
||||
return False
|
||||
await session.delete(group)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def list_members(group_id: int) -> list[dict]:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(GroupMembership, User)
|
||||
.join(User, User.id == GroupMembership.user_id)
|
||||
.where(GroupMembership.group_id == group_id)
|
||||
)).all()
|
||||
return [
|
||||
{**m.to_dict(), "username": u.username, "email": u.email}
|
||||
for m, u in rows
|
||||
]
|
||||
|
||||
|
||||
async def add_member(
|
||||
acting_user_id: int,
|
||||
group_id: int,
|
||||
target_user_id: int,
|
||||
role: str,
|
||||
is_site_admin: bool,
|
||||
) -> GroupMembership | None:
|
||||
async with async_session() as session:
|
||||
if not is_site_admin and not await _is_group_owner(session, acting_user_id, group_id):
|
||||
return None
|
||||
membership = GroupMembership(group_id=group_id, user_id=target_user_id, role=role)
|
||||
session.add(membership)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
return None
|
||||
await session.refresh(membership)
|
||||
return membership
|
||||
|
||||
|
||||
async def update_member_role(
|
||||
acting_user_id: int,
|
||||
group_id: int,
|
||||
target_user_id: int,
|
||||
role: str,
|
||||
is_site_admin: bool,
|
||||
) -> GroupMembership | None:
|
||||
async with async_session() as session:
|
||||
if not is_site_admin and not await _is_group_owner(session, acting_user_id, group_id):
|
||||
return None
|
||||
m = (await session.execute(
|
||||
select(GroupMembership).where(
|
||||
GroupMembership.group_id == group_id,
|
||||
GroupMembership.user_id == target_user_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not m:
|
||||
return None
|
||||
m.role = role
|
||||
await session.commit()
|
||||
await session.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
async def remove_member(
|
||||
acting_user_id: int,
|
||||
group_id: int,
|
||||
target_user_id: int,
|
||||
is_site_admin: bool,
|
||||
) -> bool:
|
||||
"""Group owner, site admin, or self-removal are all permitted."""
|
||||
async with async_session() as session:
|
||||
is_self = acting_user_id == target_user_id
|
||||
if not is_site_admin and not is_self:
|
||||
if not await _is_group_owner(session, acting_user_id, group_id):
|
||||
return False
|
||||
m = (await session.execute(
|
||||
select(GroupMembership).where(
|
||||
GroupMembership.group_id == group_id,
|
||||
GroupMembership.user_id == target_user_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not m:
|
||||
return False
|
||||
await session.delete(m)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_user_groups(user_id: int) -> list[Group]:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Group)
|
||||
.join(GroupMembership, GroupMembership.group_id == Group.id)
|
||||
.where(GroupMembership.user_id == user_id)
|
||||
)).scalars().all()
|
||||
return list(rows)
|
||||
@@ -1,15 +1,47 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note_version import NoteVersion
|
||||
|
||||
MAX_VERSIONS = 20
|
||||
# Maximum snapshots retained per note.
|
||||
MAX_VERSIONS = 50
|
||||
|
||||
# Minimum seconds between snapshots. Prevents autosave from consuming all
|
||||
# slots — with autosave at 60 s intervals, a 5-minute gate means at most
|
||||
# one snapshot per editing session segment rather than one per save tick.
|
||||
MIN_VERSION_INTERVAL_SECONDS = 300
|
||||
|
||||
|
||||
async def create_version(
|
||||
user_id: int, note_id: int, body: str, title: str, tags: list[str] | None = None
|
||||
) -> NoteVersion:
|
||||
) -> NoteVersion | None:
|
||||
async with async_session() as session:
|
||||
# Fetch the most recent snapshot once for both checks.
|
||||
recent = await session.execute(
|
||||
select(NoteVersion)
|
||||
.where(NoteVersion.note_id == note_id, NoteVersion.user_id == user_id)
|
||||
.order_by(NoteVersion.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last = recent.scalars().first()
|
||||
if last is not None:
|
||||
# Skip if content is identical — no change, no snapshot (git semantics).
|
||||
if (
|
||||
last.body == body
|
||||
and last.title == title
|
||||
and sorted(last.tags or []) == sorted(tags or [])
|
||||
):
|
||||
return None
|
||||
# Skip if within the minimum interval to prevent rapid-fire autosave snapshots.
|
||||
last_at = last.created_at
|
||||
if last_at.tzinfo is None:
|
||||
last_at = last_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - last_at).total_seconds()
|
||||
if age < MIN_VERSION_INTERVAL_SECONDS:
|
||||
return None
|
||||
|
||||
version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title, tags=tags or [])
|
||||
session.add(version)
|
||||
await session.commit()
|
||||
|
||||
@@ -85,6 +85,7 @@ async def list_notes(
|
||||
milestone_id: int | None = None,
|
||||
milestone_ids: list[int] | None = None,
|
||||
parent_id: int | None = None,
|
||||
no_project: bool = False,
|
||||
sort: str = "updated_at",
|
||||
order: str = "desc",
|
||||
limit: int = 50,
|
||||
@@ -152,6 +153,10 @@ async def list_notes(
|
||||
|
||||
if parent_id is not None:
|
||||
query = query.where(Note.parent_id == parent_id)
|
||||
|
||||
if no_project:
|
||||
query = query.where(Note.project_id.is_(None))
|
||||
count_query = count_query.where(Note.project_id.is_(None))
|
||||
count_query = count_query.where(Note.parent_id == parent_id)
|
||||
|
||||
sort_col = getattr(Note, sort, Note.updated_at)
|
||||
@@ -436,3 +441,20 @@ async def build_note_graph(
|
||||
edges.append({"source": nid, "target": tag_node_id, "type": "tag"})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared-access variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_note_for_user(
|
||||
accessing_user_id: int, note_id: int
|
||||
) -> tuple["Note", str] | None:
|
||||
"""Returns (note, permission) if user has any access, else None."""
|
||||
from fabledassistant.services.access import get_note_permission
|
||||
perm = await get_note_permission(accessing_user_id, note_id)
|
||||
if perm is None:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
return (note, perm) if note else None
|
||||
|
||||
@@ -249,3 +249,199 @@ def start_notification_loop() -> None:
|
||||
global _notification_task
|
||||
if _notification_task is None or _notification_task.done():
|
||||
_notification_task = asyncio.create_task(_notification_loop())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-app notifications (Notification model — shares, group events)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def create_in_app_notification(user_id: int, notif_type: str, payload: dict):
|
||||
"""Create an in-app Notification record."""
|
||||
from fabledassistant.models.notification import Notification
|
||||
async with async_session() as session:
|
||||
n = Notification(user_id=user_id, type=notif_type, payload=payload)
|
||||
session.add(n)
|
||||
await session.commit()
|
||||
await session.refresh(n)
|
||||
return n
|
||||
|
||||
|
||||
async def _fire_push_notif(user_id: int, title: str, body: str, url: str) -> None:
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification
|
||||
await send_push_notification(user_id, title, body, url=url)
|
||||
except Exception:
|
||||
logger.exception("Push notification failed for user %d", user_id)
|
||||
|
||||
|
||||
async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
|
||||
try:
|
||||
if not await is_smtp_configured():
|
||||
return
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if user and user.email:
|
||||
html = _email_html(subject, f"<p>{body_text.replace(chr(10), '<br>')}</p>")
|
||||
await send_email(user.email, subject, html)
|
||||
except Exception:
|
||||
logger.exception("Share email notification failed for user %d", user_id)
|
||||
|
||||
|
||||
async def _group_member_ids(group_id: int) -> list[int]:
|
||||
from fabledassistant.models.group import GroupMembership
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(GroupMembership.user_id).where(GroupMembership.group_id == group_id)
|
||||
)).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
|
||||
async def notify_project_shared(
|
||||
project_id: int,
|
||||
permission: str,
|
||||
invited_by_user_id: int,
|
||||
target_user_id: int | None,
|
||||
target_group_id: int | None,
|
||||
) -> None:
|
||||
from fabledassistant.models.project import Project
|
||||
async with async_session() as session:
|
||||
project = await session.get(Project, project_id)
|
||||
inviter = await session.get(User, invited_by_user_id)
|
||||
if not project or not inviter:
|
||||
return
|
||||
|
||||
recipients: list[int] = []
|
||||
if target_user_id:
|
||||
recipients = [target_user_id]
|
||||
elif target_group_id:
|
||||
recipients = [uid for uid in await _group_member_ids(target_group_id) if uid != invited_by_user_id]
|
||||
|
||||
url = f"/projects/{project_id}"
|
||||
msg = f"{inviter.username} shared \"{project.title}\" with you as {permission}"
|
||||
|
||||
for uid in recipients:
|
||||
await create_in_app_notification(uid, "project_shared", {
|
||||
"project_id": project_id,
|
||||
"project_title": project.title,
|
||||
"permission": permission,
|
||||
"invited_by": inviter.username,
|
||||
"url": url,
|
||||
})
|
||||
asyncio.create_task(_fire_push_notif(uid, "Project shared with you", msg, url))
|
||||
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a project with you", msg))
|
||||
|
||||
|
||||
async def notify_note_shared(
|
||||
note_id: int,
|
||||
permission: str,
|
||||
invited_by_user_id: int,
|
||||
target_user_id: int | None,
|
||||
target_group_id: int | None,
|
||||
) -> None:
|
||||
from fabledassistant.models.note import Note
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
inviter = await session.get(User, invited_by_user_id)
|
||||
if not note or not inviter:
|
||||
return
|
||||
|
||||
recipients: list[int] = []
|
||||
if target_user_id:
|
||||
recipients = [target_user_id]
|
||||
elif target_group_id:
|
||||
recipients = [uid for uid in await _group_member_ids(target_group_id) if uid != invited_by_user_id]
|
||||
|
||||
route = "tasks" if note.is_task else "notes"
|
||||
url = f"/{route}/{note_id}"
|
||||
msg = f"{inviter.username} shared \"{note.title}\" with you as {permission}"
|
||||
|
||||
for uid in recipients:
|
||||
await create_in_app_notification(uid, "note_shared", {
|
||||
"note_id": note_id,
|
||||
"note_title": note.title,
|
||||
"permission": permission,
|
||||
"invited_by": inviter.username,
|
||||
"url": url,
|
||||
})
|
||||
asyncio.create_task(_fire_push_notif(uid, "Note shared with you", msg, url))
|
||||
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a note with you", msg))
|
||||
|
||||
|
||||
async def notify_group_added(
|
||||
group_id: int, role: str, invited_by_user_id: int, target_user_id: int
|
||||
) -> None:
|
||||
from fabledassistant.models.group import Group
|
||||
async with async_session() as session:
|
||||
group = await session.get(Group, group_id)
|
||||
inviter = await session.get(User, invited_by_user_id)
|
||||
if not group or not inviter:
|
||||
return
|
||||
|
||||
url = f"/groups/{group_id}"
|
||||
msg = f"{inviter.username} added you to group \"{group.name}\" as {role}"
|
||||
|
||||
await create_in_app_notification(target_user_id, "group_added", {
|
||||
"group_id": group_id,
|
||||
"group_name": group.name,
|
||||
"role": role,
|
||||
"invited_by": inviter.username,
|
||||
"url": url,
|
||||
})
|
||||
asyncio.create_task(_fire_push_notif(target_user_id, "Added to a group", msg, url))
|
||||
asyncio.create_task(_fire_share_email(target_user_id, "[Fabled] You've been added to a group", msg))
|
||||
|
||||
|
||||
async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> list[dict]:
|
||||
from fabledassistant.models.notification import Notification
|
||||
async with async_session() as session:
|
||||
q = select(Notification).where(Notification.user_id == user_id)
|
||||
if unread_only:
|
||||
q = q.where(Notification.read_at.is_(None))
|
||||
q = q.order_by(Notification.created_at.desc()).limit(50)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
return [n.to_dict() for n in rows]
|
||||
|
||||
|
||||
async def unread_notification_count(user_id: int) -> int:
|
||||
from fabledassistant.models.notification import Notification
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(func.count()).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.read_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def mark_notification_read(user_id: int, notification_id: int) -> bool:
|
||||
from fabledassistant.models.notification import Notification
|
||||
from datetime import timezone as tz
|
||||
async with async_session() as session:
|
||||
n = (await session.execute(
|
||||
select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not n:
|
||||
return False
|
||||
from datetime import datetime
|
||||
n.read_at = datetime.now(tz.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def mark_all_notifications_read(user_id: int) -> int:
|
||||
from fabledassistant.models.notification import Notification
|
||||
from datetime import datetime, timezone as tz
|
||||
from sqlalchemy import update as sa_update
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
sa_update(Notification)
|
||||
.where(Notification.user_id == user_id, Notification.read_at.is_(None))
|
||||
.values(read_at=datetime.now(tz.utc))
|
||||
.returning(Notification.id)
|
||||
)
|
||||
await session.commit()
|
||||
return len(result.fetchall())
|
||||
|
||||
@@ -144,3 +144,78 @@ async def get_project_summary(user_id: int, project_id: int) -> dict:
|
||||
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
|
||||
"milestone_summary": milestone_summary,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared-access variants (honour ProjectShare in addition to ownership)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_project_for_user(
|
||||
accessing_user_id: int, project_id: int
|
||||
) -> tuple[Project, str] | None:
|
||||
"""Returns (project, permission) if user has any access, else None."""
|
||||
from fabledassistant.services.access import get_project_permission
|
||||
perm = await get_project_permission(accessing_user_id, project_id)
|
||||
if perm is None:
|
||||
return None
|
||||
async with async_session() as session:
|
||||
project = await session.get(Project, project_id)
|
||||
return (project, perm) if project else None
|
||||
|
||||
|
||||
async def list_projects_for_user(user_id: int, status: str | None = None) -> list[dict]:
|
||||
"""Owned projects + shared projects, each dict has 'permission' field."""
|
||||
from fabledassistant.models.group import GroupMembership
|
||||
from fabledassistant.models.share import ProjectShare
|
||||
from fabledassistant.services.access import PERMISSION_RANK
|
||||
|
||||
owned = await list_projects(user_id, status)
|
||||
owned_ids = {p.id for p in owned}
|
||||
|
||||
result = []
|
||||
for p in owned:
|
||||
d = p.to_dict() if hasattr(p, "to_dict") else {"id": p.id, "title": p.title}
|
||||
d["permission"] = "owner"
|
||||
result.append(d)
|
||||
|
||||
async with async_session() as session:
|
||||
user_group_ids = (await session.execute(
|
||||
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
||||
)).scalars().all()
|
||||
|
||||
shared_direct = (await session.execute(
|
||||
select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id)
|
||||
)).scalars().all()
|
||||
|
||||
shared_group: list[ProjectShare] = []
|
||||
if user_group_ids:
|
||||
shared_group = (await session.execute(
|
||||
select(ProjectShare).where(
|
||||
ProjectShare.shared_with_group_id.in_(user_group_ids)
|
||||
)
|
||||
)).scalars().all()
|
||||
|
||||
seen: dict[int, str] = {}
|
||||
for share in list(shared_direct) + list(shared_group):
|
||||
if share.project_id in owned_ids:
|
||||
continue
|
||||
prev = seen.get(share.project_id)
|
||||
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
|
||||
seen[share.project_id] = share.permission
|
||||
|
||||
for pid, perm in seen.items():
|
||||
if status:
|
||||
async with async_session() as session:
|
||||
proj = await session.get(Project, pid)
|
||||
if not proj or proj.status != status:
|
||||
continue
|
||||
else:
|
||||
async with async_session() as session:
|
||||
proj = await session.get(Project, pid)
|
||||
if proj:
|
||||
d = proj.to_dict() if hasattr(proj, "to_dict") else {"id": proj.id, "title": proj.title}
|
||||
d["permission"] = perm
|
||||
d["is_shared"] = True
|
||||
result.append(d)
|
||||
|
||||
return result
|
||||
|
||||
@@ -190,7 +190,7 @@ async def send_push_notification(
|
||||
)
|
||||
return response.status_code, sub.endpoint
|
||||
except Exception as e:
|
||||
logger.warning("Push send failed for sub %d: %s", sub.id, e)
|
||||
logger.error("Push send failed for sub %d: %s", sub.id, e, exc_info=True)
|
||||
return 0, sub.endpoint
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -199,9 +199,12 @@ async def send_push_notification(
|
||||
|
||||
for sub, result in zip(subscriptions, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error("Push gather exception for user %d: %s", user_id, result, exc_info=result)
|
||||
continue
|
||||
status_code, endpoint = result
|
||||
if status_code == 410:
|
||||
if status_code in (200, 201):
|
||||
logger.info("Push notification sent to sub %d (status %d)", sub.id, status_code)
|
||||
elif status_code == 410:
|
||||
asyncio.create_task(_remove_expired_subscription(user_id, endpoint))
|
||||
elif status_code and status_code not in (200, 201):
|
||||
logger.warning("Push returned status %d for user %d", status_code, user_id)
|
||||
elif status_code:
|
||||
logger.error("Push returned unexpected status %d for user %d sub %d", status_code, user_id, sub.id)
|
||||
|
||||
@@ -26,6 +26,7 @@ async def run_research_pipeline(
|
||||
user_id: int,
|
||||
model: str,
|
||||
buf=None,
|
||||
project_id: int | None = None,
|
||||
) -> Note:
|
||||
"""Full research pipeline: search → fetch → synthesize → create note.
|
||||
|
||||
@@ -112,6 +113,7 @@ async def run_research_pipeline(
|
||||
title=title,
|
||||
body=body,
|
||||
tags=["research"],
|
||||
project_id=project_id,
|
||||
)
|
||||
logger.info("Research: created note id=%d title='%s'", note.id, note.title)
|
||||
return note
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user