Initial release: Ironpad v0.1.0 - Local-first, file-based project and knowledge management system. Rust backend, Vue 3 frontend, Milkdown editor, Git integration, cross-platform builds. Built with AI using Open Method.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend (Rust)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
backend/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('backend/Cargo.lock') }}
|
||||
|
||||
- name: Check
|
||||
working-directory: backend
|
||||
run: cargo check
|
||||
|
||||
- name: Test
|
||||
working-directory: backend
|
||||
run: cargo test
|
||||
|
||||
- name: Format check
|
||||
working-directory: backend
|
||||
run: cargo fmt -- --check
|
||||
|
||||
frontend:
|
||||
name: Frontend (Vue)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
working-directory: frontend
|
||||
run: npx vue-tsc --noEmit
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
105
.github/workflows/release.yml
vendored
Normal file
105
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
archive: tar.gz
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
archive: tar.gz
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
archive: zip
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Copy frontend to static
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p backend/static
|
||||
cp -r frontend/dist/* backend/static/
|
||||
|
||||
- name: Build backend (release)
|
||||
working-directory: backend
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
shell: bash
|
||||
run: |
|
||||
BINARY_NAME="ironpad"
|
||||
RELEASE_DIR="ironpad-${{ github.ref_name }}-${{ matrix.target }}"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
cp "backend/target/${{ matrix.target }}/release/$BINARY_NAME" "$RELEASE_DIR/"
|
||||
cp README.md LICENSE "$RELEASE_DIR/"
|
||||
tar czf "$RELEASE_DIR.tar.gz" "$RELEASE_DIR"
|
||||
echo "ASSET=$RELEASE_DIR.tar.gz" >> $GITHUB_ENV
|
||||
|
||||
- name: Package (Windows)
|
||||
if: matrix.archive == 'zip'
|
||||
shell: bash
|
||||
run: |
|
||||
BINARY_NAME="ironpad.exe"
|
||||
RELEASE_DIR="ironpad-${{ github.ref_name }}-${{ matrix.target }}"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
cp "backend/target/${{ matrix.target }}/release/$BINARY_NAME" "$RELEASE_DIR/"
|
||||
cp README.md LICENSE "$RELEASE_DIR/"
|
||||
7z a "$RELEASE_DIR.zip" "$RELEASE_DIR"
|
||||
echo "ASSET=$RELEASE_DIR.zip" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ironpad-${{ matrix.target }}
|
||||
path: ${{ env.ASSET }}
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: artifacts/**/*
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# === OS ===
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# === Editors / IDEs ===
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# === Rust ===
|
||||
backend/target/
|
||||
|
||||
# === Node.js ===
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# === Build artifacts ===
|
||||
backend/static/
|
||||
*.tmp
|
||||
|
||||
# === User data (created at runtime) ===
|
||||
# The data/ directory is the user's local database.
|
||||
# We include only the skeleton structure (.gitkeep files),
|
||||
# not the actual notes, projects, tasks, or daily notes.
|
||||
data/.git/
|
||||
data/.gitignore
|
||||
data/inbox.md
|
||||
data/index.md
|
||||
data/archive/*.md
|
||||
data/daily/*.md
|
||||
data/notes/*.md
|
||||
data/notes/assets/*
|
||||
!data/notes/assets/.gitkeep
|
||||
data/projects/*/
|
||||
!data/projects/.gitkeep
|
||||
|
||||
# === Stray root lock file (frontend/package-lock.json is kept for CI) ===
|
||||
/package-lock.json
|
||||
|
||||
# === Generated images (article assets, not source) ===
|
||||
/assets/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ola Proeis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Ironpad
|
||||
|
||||
**A local-first, file-based project & knowledge management system.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Ironpad stores all your notes, projects, and tasks as plain Markdown files. No cloud services, no vendor lock-in -- your data stays on your machine in a format you can read and edit with any text editor. Every change is automatically versioned with Git.
|
||||
|
||||

|
||||
|
||||
> **v0.1.0 -- Early Release.** This is the first public release. It's functional and we use it daily, but expect rough edges. Bug reports and feature requests are welcome via [Issues](https://github.com/OlaProeis/ironPad/issues).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **File-based storage** -- All data stored as Markdown files with YAML frontmatter
|
||||
- **Local-first** -- Works fully offline, no internet required
|
||||
- **Git integration** -- Automatic version control with 60-second commit batching, full diff viewer, push/fetch
|
||||
- **WYSIWYG editing** -- Milkdown editor with real-time markdown rendering and formatting toolbar
|
||||
- **Project management** -- Organize tasks and notes by project with due dates, tags, subtasks, and recurrence
|
||||
- **Calendar view** -- Month grid showing tasks by due date with color-coded urgency
|
||||
- **Dashboard** -- Cross-project overview with active task summaries
|
||||
- **Daily notes** -- Quick capture with templates for daily journaling
|
||||
- **Real-time sync** -- WebSocket-based live updates; edit in VS Code, see changes in the browser instantly
|
||||
- **External editing** -- Full support for VS Code, Obsidian, Vim, or any text editor
|
||||
- **Search** -- ripgrep-powered full-text search across all files (Ctrl+K)
|
||||
- **Dark theme** -- Beautiful dark UI by default with light mode toggle
|
||||
- **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Download Release (Recommended)
|
||||
|
||||
1. Download the latest release for your platform from [Releases](https://github.com/OlaProeis/ironPad/releases)
|
||||
2. Extract and run the executable
|
||||
3. Your browser opens automatically -- start using Ironpad
|
||||
|
||||
Data is stored in a `data/` folder next to the executable. To use a custom location, set the `IRONPAD_DATA_DIR` environment variable.
|
||||
|
||||
### Option 2: Build From Source
|
||||
|
||||
**Prerequisites:** [Rust](https://rustup.rs/) (1.70+), [Node.js](https://nodejs.org/) (18+), [Git](https://git-scm.com/)
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/OlaProeis/ironPad.git
|
||||
cd ironPad
|
||||
|
||||
# Start the backend
|
||||
cd backend
|
||||
cargo run
|
||||
|
||||
# In a new terminal, start the frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 in your browser.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Backend | Rust, Axum 0.8, Tokio |
|
||||
| Frontend | Vue 3, Vite, TypeScript |
|
||||
| Editor | Milkdown (ProseMirror-based) |
|
||||
| State | Pinia |
|
||||
| Routing | Vue Router |
|
||||
| Data | Markdown + YAML frontmatter |
|
||||
| Version Control | Git (via git2) |
|
||||
| Search | ripgrep |
|
||||
|
||||
## Roadmap
|
||||
|
||||
Ironpad is under active development. Here's what's planned:
|
||||
|
||||
- [ ] UI polish and animations
|
||||
- [ ] Tag extraction and filtering across projects
|
||||
- [ ] Backlinks between notes
|
||||
- [ ] Graph view of note connections
|
||||
- [ ] Export to PDF / HTML
|
||||
- [ ] Custom themes
|
||||
- [ ] Global hotkey (Ctrl+Shift+Space)
|
||||
- [ ] System tray mode
|
||||
- [ ] Kanban board view for tasks
|
||||
|
||||
See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status.
|
||||
|
||||
## Built With AI
|
||||
|
||||
This entire application was built using AI-assisted development -- an approach we call **Open Method**. We share not just the code, but the complete process: the PRD, task breakdowns, handover documents, and workflow artifacts.
|
||||
|
||||
Read about the method:
|
||||
- [The AI Development Workflow I Actually Use](https://dev.to/olaproeis/the-ai-development-workflow-i-actually-use-549i) -- The original workflow article
|
||||
- [docs/ai-workflow/](docs/ai-workflow/) -- Documentation of the AI-assisted development process used to build Ironpad
|
||||
|
||||
**Tools used:** Cursor IDE, Claude Opus 4.5/4.6, Context7 MCP
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var |
|
||||
| Backend port | 3000 (auto-increments to 3010) | Dynamic port selection |
|
||||
| Auto-commit | Every 60 seconds | Git commits when changes exist |
|
||||
| Auto-save | 1 second debounce | Frontend saves after typing stops |
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [docs/API.md](docs/API.md) | Complete REST API reference |
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design and technical details |
|
||||
| [docs/ai-workflow/](docs/ai-workflow/) | AI development workflow and methodology |
|
||||
|
||||
## Contributing
|
||||
|
||||
This is an early release and contributions are welcome!
|
||||
|
||||
1. Check [Issues](https://github.com/OlaProeis/ironPad/issues) for open bugs and feature requests
|
||||
2. Create a branch for your feature/fix
|
||||
3. Follow the code style (`cargo fmt` for Rust)
|
||||
4. Test your changes thoroughly
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [Milkdown](https://milkdown.dev/) -- WYSIWYG Markdown editor
|
||||
- [Axum](https://github.com/tokio-rs/axum) -- Rust web framework
|
||||
- [Vue.js](https://vuejs.org/) -- Frontend framework
|
||||
- [Pinia](https://pinia.vuejs.org/) -- State management
|
||||
- [Anthropic Claude](https://www.anthropic.com/) -- AI-assisted development
|
||||
344
ai-context.md
Normal file
344
ai-context.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# AI Context — Ironpad
|
||||
|
||||
Paste this into every new AI chat for project context.
|
||||
|
||||
---
|
||||
|
||||
## What It Is
|
||||
|
||||
**Ironpad** is a local-first, file-based personal project & knowledge management system.
|
||||
|
||||
- **Backend**: Rust (Axum 0.8, Tokio)
|
||||
- **Frontend**: Vue 3 (Vite)
|
||||
- **Data**: Plain Markdown files with YAML frontmatter
|
||||
- **Versioning**: Local Git repository
|
||||
- **UI**: System browser (no Electron)
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Files are the database** — filesystem is source of truth
|
||||
2. **Local-first** — works fully offline
|
||||
3. **External editing supported** — VS Code, Obsidian, Vim all work
|
||||
4. **Backend owns metadata** — `id`, `created`, `updated` are auto-managed
|
||||
5. **Low ceremony** — minimal config, no manual metadata editing
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
ironpad/
|
||||
├── backend/ # Rust Axum server
|
||||
│ └── src/
|
||||
│ ├── main.rs # Server bootstrap, WebSocket, routes
|
||||
│ ├── routes/ # API endpoints
|
||||
│ ├── services/ # Business logic (filesystem, git, search)
|
||||
│ ├── models/ # Data structures
|
||||
│ ├── websocket.rs # Real-time sync
|
||||
│ └── watcher.rs # File system watching
|
||||
├── frontend/ # Vue 3 SPA
|
||||
│ └── src/
|
||||
│ ├── App.vue # Root component with router-view
|
||||
│ ├── main.ts # Entry point (Pinia + Vue Router)
|
||||
│ ├── router/ # Vue Router config
|
||||
│ ├── stores/ # Pinia stores (notes, projects, tasks, ui, websocket, git)
|
||||
│ ├── views/ # Route views (DashboardView, ProjectView, TasksView, CalendarView, DailyView)
|
||||
│ ├── components/ # Reusable components (Sidebar, MarkdownEditor, etc.)
|
||||
│ ├── composables/ # Vue composables (useWebSocket)
|
||||
│ ├── api/ # API client (client.ts)
|
||||
│ └── types/ # TypeScript types (index.ts)
|
||||
└── data/ # User data (separate git repo)
|
||||
├── notes/ # Standalone notes
|
||||
├── projects/ # Project folders
|
||||
│ └── {project}/
|
||||
│ ├── index.md # Project overview
|
||||
│ ├── notes/ # Project-specific notes
|
||||
│ └── tasks/ # Individual task files (task-YYYYMMDD-HHMMSS.md)
|
||||
├── daily/ # Daily notes (YYYY-MM-DD.md)
|
||||
├── archive/ # Archived items
|
||||
├── index.md # Landing page
|
||||
└── inbox.md # Quick capture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implemented Features
|
||||
|
||||
### Backend
|
||||
- API-only server (no frontend serving, no browser auto-open)
|
||||
- Dynamic port (3000-3010)
|
||||
- Notes CRUD with atomic writes
|
||||
- Frontmatter auto-management
|
||||
- WebSocket server for real-time sync + file locking
|
||||
- File watcher (filters own saves)
|
||||
- Search (ripgrep with fallback)
|
||||
- Git status + auto-commit (60s batching) + push + conflict detection
|
||||
- **Full Git panel** with commit history, diff viewer, custom commit messages
|
||||
- Git remote info (ahead/behind tracking), fetch support
|
||||
- Projects API with notes management
|
||||
- **File-based Tasks API** — each task is a markdown file with frontmatter
|
||||
- Fields: id, title, completed, section, priority, due_date, is_active, tags, parent_id, recurrence, recurrence_interval
|
||||
- Rich text descriptions with markdown support
|
||||
- Sorted by created date (stable ordering)
|
||||
- **Subtasks** — tasks with `parent_id` link to a parent task
|
||||
- **Tags** — YAML sequence in frontmatter, per-task labels for filtering
|
||||
- **Recurring tasks** — when completing a recurring task, auto-creates next instance with advanced due date
|
||||
- Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`)
|
||||
- Assets API (upload + serve)
|
||||
|
||||
### Frontend
|
||||
- Vue Router navigation
|
||||
- Pinia state management
|
||||
- **Milkdown WYSIWYG editor** — ProseMirror-based, renders markdown as you type
|
||||
- CommonMark + GFM support (tables, strikethrough, task lists)
|
||||
- **Toolbar** with formatting buttons (bold, italic, headings, links, images, code, lists, quotes)
|
||||
- **Image upload** — click image button, select file, auto-uploads and inserts markdown
|
||||
- History (undo/redo), clipboard, indentation plugins
|
||||
- **Dark theme by default** with toggle button (persists to localStorage)
|
||||
- Sidebar with Notes/Projects/Daily/Calendar sections + task counts
|
||||
- Search panel (Ctrl+K)
|
||||
- **Dashboard view** (home page) — all projects as cards with active task summaries
|
||||
- Click project to navigate, click task to open detail
|
||||
- Shows active/backlog/overdue counts per project
|
||||
- **Split-panel Task view** with:
|
||||
- Task list (Active/Backlog/Completed sections)
|
||||
- Task detail editor with markdown descriptions
|
||||
- Preview toggle for rendered markdown
|
||||
- Active/Backlog toggle button
|
||||
- **Due date picker** — inline date input to set/clear due dates
|
||||
- Due date display with color-coded urgency
|
||||
- **Tag system** — add/remove tags with autocomplete from project tags
|
||||
- **Tag filter bar** — click tags to filter task list
|
||||
- **Subtasks** — expandable subtasks under parent tasks, add subtask inline
|
||||
- **Recurrence picker** — set daily/weekly/monthly/yearly recurrence
|
||||
- Inline title editing (double-click)
|
||||
- **Calendar view** — month grid showing tasks by due date
|
||||
- Tasks with due dates plotted on calendar cells
|
||||
- Daily notes shown as blue dots
|
||||
- Color-coded urgency (overdue, today, soon)
|
||||
- Month navigation + Today button
|
||||
- Click task to navigate to detail, click date to open daily note
|
||||
- **Split-panel Notes view** (per project)
|
||||
- **Git panel** — slide-out panel with:
|
||||
- Commit history with expandable diffs
|
||||
- Working directory changes with line-by-line diff
|
||||
- Custom commit messages (Ctrl+Enter to commit)
|
||||
- Push/Fetch buttons with ahead/behind indicators
|
||||
- File status icons (added/modified/deleted/renamed)
|
||||
- Git status indicator in sidebar (click to open panel)
|
||||
- WebSocket real-time updates
|
||||
- File lock banners (read-only mode when locked)
|
||||
- Conflict warning banner
|
||||
- Fullscreen layout (uses all available space)
|
||||
|
||||
### API Endpoints
|
||||
```
|
||||
GET/POST /api/notes
|
||||
GET/PUT/DEL /api/notes/:id
|
||||
GET/POST /api/projects
|
||||
GET/PUT /api/projects/:id
|
||||
GET/PUT /api/projects/:id/content
|
||||
|
||||
# Project Notes (file-based)
|
||||
GET/POST /api/projects/:id/notes
|
||||
GET/PUT/DEL /api/projects/:id/notes/:note_id
|
||||
|
||||
# Project Tasks (file-based, each task is a .md file)
|
||||
GET/POST /api/projects/:id/tasks
|
||||
GET/PUT/DEL /api/projects/:id/tasks/:task_id
|
||||
PUT /api/projects/:id/tasks/:task_id/toggle
|
||||
PUT /api/projects/:id/tasks/:task_id/meta
|
||||
|
||||
GET /api/tasks # All tasks across projects
|
||||
GET /api/daily
|
||||
GET /api/daily/today
|
||||
GET/POST /api/daily/:date
|
||||
POST /api/assets/upload
|
||||
GET /api/assets/:project/:file
|
||||
GET /api/search?q=
|
||||
GET /api/git/status
|
||||
POST /api/git/commit
|
||||
POST /api/git/push
|
||||
GET /api/git/conflicts
|
||||
GET /api/git/log # Commit history (limit param)
|
||||
GET /api/git/diff # Working directory diff
|
||||
GET /api/git/diff/:commit_id # Diff for specific commit
|
||||
GET /api/git/remote # Remote repository info
|
||||
POST /api/git/fetch # Fetch from remote
|
||||
WS /ws
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implemented in Phase 3
|
||||
|
||||
- CodeMirror 6 editor with markdown syntax highlighting
|
||||
- Markdown preview with split view
|
||||
- Vue Router for navigation (`/`, `/projects/:id`, `/projects/:id/tasks`, `/calendar`, `/daily`)
|
||||
- Pinia state management (notes, projects, tasks, ui, websocket, git stores)
|
||||
- Project-specific task view with toggle and add functionality
|
||||
- File locking via WebSocket (Task View vs Editor)
|
||||
- Daily notes (`data/daily/`) with templates
|
||||
- Assets upload API (`/api/assets/upload`, `/api/assets/:project/:file`)
|
||||
- Git push and conflict detection (`/api/git/push`, `/api/git/conflicts`)
|
||||
|
||||
---
|
||||
|
||||
## Implemented in Phase 4
|
||||
|
||||
- **File-based tasks** — each task is a separate markdown file with YAML frontmatter
|
||||
- Supports rich text descriptions with images
|
||||
- New fields: `due_date`, `is_active`
|
||||
- Task files stored in `projects/{id}/tasks/task-YYYYMMDD-HHMMSS.md`
|
||||
- **Split-panel task view** — list on left, detail editor on right
|
||||
- **Markdown preview toggle** — side-by-side raw/rendered view
|
||||
- **Active/Backlog toggle** — button to move tasks between states
|
||||
- **Project notes** — separate notes folder per project with split-panel view
|
||||
- **Stable list sorting** — sorted by created date (not updated)
|
||||
- **Backend API-only mode** — no frontend serving, no browser auto-open
|
||||
- **Fullscreen layout** — uses all available browser space
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Technical Debt
|
||||
|
||||
1. **Project index note ID format**: Project notes use `{slug}-index` as their ID (e.g., `ferrite-index`). Projects created before this fix have incorrect IDs in frontmatter.
|
||||
|
||||
2. **Axum nested route limitation**: Path parameters from parent routes are NOT automatically available in nested route handlers. Project task routes use explicit routes instead of `.nest()`.
|
||||
|
||||
3. **Some warnings remain**: Unused methods in `locks.rs` and `git.rs` (reserved for future use).
|
||||
|
||||
---
|
||||
|
||||
## Implemented in Phase 5
|
||||
|
||||
- **Dashboard view** — cross-project home page with task summaries per project
|
||||
- **Tags system** — per-task tags stored in frontmatter, filter bar in task list, autocomplete
|
||||
- **Subtasks** — tasks with `parent_id`, grouped under parents in list, inline creation
|
||||
- **Recurring tasks** — daily/weekly/monthly/yearly with auto-creation on completion
|
||||
- **Calendar view** — month grid with tasks by due date + daily note indicators
|
||||
- **Due date picker** — inline date input in task detail panel
|
||||
- **Clickable app title** — "Ironpad" navigates to dashboard
|
||||
|
||||
---
|
||||
|
||||
## Not Yet Implemented (Phase 6+)
|
||||
|
||||
- UI polish and animations
|
||||
- Responsive sidebar
|
||||
- Global hotkey (Ctrl+Shift+Space)
|
||||
- System tray mode
|
||||
- Backlinks between notes
|
||||
- Graph view
|
||||
- Export (PDF / HTML)
|
||||
- Custom themes
|
||||
- Tantivy search (if >5000 notes)
|
||||
- Production packaging (Tauri or similar)
|
||||
- Task dependencies (blocked by)
|
||||
- Time estimates on tasks
|
||||
- Calendar drag-and-drop rescheduling
|
||||
- Week/day calendar views
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Data path | `../data` relative to backend |
|
||||
| Port | Dynamic 3000-3010 |
|
||||
| Auto-save | 1s debounce in frontend |
|
||||
| Git commits | 60s batch + manual button |
|
||||
| File watcher | notify crate, 500ms debounce |
|
||||
| Search | ripgrep CLI, fallback to manual |
|
||||
| Frontmatter | serde_yaml, auto-generated IDs |
|
||||
| Editor | Milkdown (WYSIWYG ProseMirror-based) |
|
||||
| Editor Legacy | CodeMirror 6 (MarkdownEditor.vue, kept for reference) |
|
||||
| State management | Pinia stores |
|
||||
| Routing | Vue Router (history mode) |
|
||||
| File locking | WebSocket-based, per-client locks |
|
||||
| Project note ID | `{slug}-index` format |
|
||||
| Task storage | Individual .md files in `tasks/` folder |
|
||||
| List sorting | By created date (stable, not affected by edits) |
|
||||
| Backend mode | API-only (no frontend serving) |
|
||||
| Theme | Dark by default, toggle to light, persists to localStorage |
|
||||
| Tags | YAML sequence in frontmatter, project-scoped filtering |
|
||||
| Subtasks | Separate task files with `parent_id` field linking to parent |
|
||||
| Recurring tasks | On completion, backend auto-creates next instance with advanced due date |
|
||||
| Calendar | Pure frontend month grid, tasks filtered by `due_date` presence |
|
||||
| Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary |
|
||||
|
||||
---
|
||||
|
||||
## Critical: Milkdown Editor Lifecycle
|
||||
|
||||
The Milkdown editor requires careful handling when switching between notes/tasks:
|
||||
|
||||
**Components:**
|
||||
- `MilkdownEditor.vue` — Wrapper with `:key` prop for recreation
|
||||
- `MilkdownEditorCore.vue` — Actual editor using `useEditor` hook from `@milkdown/vue`
|
||||
|
||||
**Pattern for switching content:**
|
||||
```javascript
|
||||
// Views use a separate editorKey ref (not the noteId/taskId directly)
|
||||
// Content MUST be set BEFORE updating editorKey
|
||||
|
||||
// CORRECT order:
|
||||
editorContent.value = loadedContent // Set content first
|
||||
editorKey.value = noteId // Then trigger editor recreation
|
||||
|
||||
// WRONG order (causes stale content):
|
||||
editorKey.value = noteId // Editor recreates with empty/stale defaultValue
|
||||
editorContent.value = loadedContent // Too late - editor already initialized
|
||||
```
|
||||
|
||||
**Why:** The editor uses `defaultValue` from props at creation time. If the key changes before content is set, the editor initializes with wrong content and `replaceAll()` updates may fail during the async initialization window.
|
||||
|
||||
**State in MilkdownEditorCore must be refs, not module-level variables** — ensures clean state on component recreation.
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Backend (from backend/)
|
||||
cargo run # API server on :3000 (no GUI)
|
||||
|
||||
# Frontend (from frontend/)
|
||||
npm run dev # Dev server on :5173 (connects to backend on :3000)
|
||||
npm run build # Build to dist/
|
||||
|
||||
# Development: Run both backend and frontend separately
|
||||
# Backend is API-only, does not serve frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed information, see:
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| `/README.md` | Project overview, quick start, installation |
|
||||
| `/frontend/README.md` | Frontend architecture, component structure, Milkdown editor patterns |
|
||||
| `/docs/ARCHITECTURE.md` | System design, service layer, data models, security considerations |
|
||||
| `/docs/API.md` | Complete REST API reference with examples |
|
||||
| `/HANDOVER.md` | Session handover notes, recent fixes, context for continuing work |
|
||||
| `/CHECKLIST.md` | Current implementation status and progress |
|
||||
| `/PRD.md` | Full product requirements document |
|
||||
|
||||
---
|
||||
|
||||
## Rules for AI
|
||||
|
||||
- Prefer incremental, verifiable changes
|
||||
- File system is source of truth
|
||||
- No databases, no cloud services
|
||||
- Windows + PowerShell environment
|
||||
- Rust 2021 edition
|
||||
- Check CHECKLIST.md for current status
|
||||
- Check PRD.md for full requirements
|
||||
- Check HANDOVER.md for recent session context
|
||||
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
2342
backend/Cargo.lock
generated
Normal file
2342
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
backend/Cargo.toml
Normal file
49
backend/Cargo.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "ironpad"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Web framework
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "normalize-path", "fs"] }
|
||||
|
||||
# Browser opening (production mode)
|
||||
webbrowser = "1.0"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# Markdown parsing (CommonMark)
|
||||
markdown = "1.0.0-alpha.22"
|
||||
|
||||
# Git operations
|
||||
git2 = "0.19"
|
||||
|
||||
|
||||
# File system watching
|
||||
notify = "6.1"
|
||||
notify-debouncer-full = "0.3"
|
||||
|
||||
# Search (ripgrep internals)
|
||||
grep = "0.3"
|
||||
walkdir = "2.4"
|
||||
|
||||
# Date/time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# WebSocket support
|
||||
futures-util = "0.3"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
|
||||
# Utilities
|
||||
lazy_static = "1.4"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
40
backend/src/config.rs
Normal file
40
backend/src/config.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Resolved data directory path.
|
||||
/// Priority: IRONPAD_DATA_DIR env var > auto-detect (production vs development).
|
||||
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Initialize the data directory path. Call once at startup.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `IRONPAD_DATA_DIR` environment variable (if set)
|
||||
/// 2. `./data` if `static/index.html` exists (production mode)
|
||||
/// 3. `../data` (development mode, binary runs from backend/)
|
||||
pub fn init_data_dir() {
|
||||
let path = if let Ok(custom) = std::env::var("IRONPAD_DATA_DIR") {
|
||||
tracing::info!("Using custom data directory from IRONPAD_DATA_DIR");
|
||||
PathBuf::from(custom)
|
||||
} else if Path::new("static/index.html").exists() {
|
||||
// Production mode: data/ is next to the binary
|
||||
PathBuf::from("data")
|
||||
} else {
|
||||
// Development mode: binary runs from backend/, data/ is one level up
|
||||
PathBuf::from("../data")
|
||||
};
|
||||
|
||||
// Create the data directory if it doesn't exist
|
||||
if !path.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(&path) {
|
||||
tracing::error!("Failed to create data directory {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Data directory: {}", path.display());
|
||||
DATA_DIR.set(path).expect("Data directory already initialized");
|
||||
}
|
||||
|
||||
/// Get the resolved data directory path.
|
||||
pub fn data_dir() -> &'static Path {
|
||||
DATA_DIR.get().expect("Data directory not initialized. Call config::init_data_dir() first.")
|
||||
}
|
||||
130
backend/src/main.rs
Normal file
130
backend/src/main.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub mod config;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod services;
|
||||
mod watcher;
|
||||
mod websocket;
|
||||
|
||||
/// Find an available port and return the bound listener.
|
||||
/// Avoids TOCTOU race by keeping the listener alive.
|
||||
async fn find_available_port() -> (TcpListener, u16) {
|
||||
for port in 3000..=3010 {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
if let Ok(listener) = TcpListener::bind(addr).await {
|
||||
return (listener, port);
|
||||
}
|
||||
}
|
||||
panic!("No available ports in range 3000–3010");
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Logging
|
||||
tracing_subscriber::fmt().init();
|
||||
|
||||
// Resolve data directory (production vs development mode)
|
||||
config::init_data_dir();
|
||||
|
||||
// Find port and bind (listener kept alive to avoid race condition)
|
||||
let (listener, port) = find_available_port().await;
|
||||
|
||||
// WebSocket state (shared across handlers)
|
||||
let ws_state = Arc::new(websocket::WsState::new());
|
||||
|
||||
// Start file watcher
|
||||
let ws_state_clone = ws_state.clone();
|
||||
if let Err(e) = watcher::start_watcher(ws_state_clone).await {
|
||||
warn!("File watcher failed to start: {}", e);
|
||||
}
|
||||
|
||||
// Initialize git repo if needed
|
||||
if let Err(e) = services::git::init_repo() {
|
||||
warn!("Git init skipped: {}", e);
|
||||
}
|
||||
|
||||
// Start auto-commit background task (tries to commit every 60s)
|
||||
services::git::start_auto_commit();
|
||||
|
||||
// CORS layer (permissive for local-only app)
|
||||
let cors = CorsLayer::permissive();
|
||||
|
||||
// API router
|
||||
let api_router = Router::new()
|
||||
// Notes CRUD
|
||||
.route(
|
||||
"/notes",
|
||||
get(routes::notes::list_notes).post(routes::notes::create_note),
|
||||
)
|
||||
.nest("/notes", routes::notes::router())
|
||||
// Tasks
|
||||
.nest("/tasks", routes::tasks::router())
|
||||
// Search
|
||||
.nest("/search", routes::search::router())
|
||||
// Git
|
||||
.nest("/git", routes::git::router())
|
||||
// Projects
|
||||
.nest("/projects", routes::projects::router())
|
||||
// Daily notes
|
||||
.nest("/daily", routes::daily::router())
|
||||
// Assets
|
||||
.nest("/assets", routes::assets::router());
|
||||
|
||||
// App router with WebSocket state
|
||||
let mut app = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route(
|
||||
"/ws",
|
||||
get({
|
||||
let ws = ws_state.clone();
|
||||
move |upgrade: axum::extract::WebSocketUpgrade| {
|
||||
websocket::ws_handler(upgrade, axum::extract::State(ws))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.nest("/api", api_router)
|
||||
.layer(cors);
|
||||
|
||||
// Check for embedded frontend (production mode)
|
||||
let static_dir = Path::new("static");
|
||||
let has_frontend = static_dir.join("index.html").exists();
|
||||
|
||||
if has_frontend {
|
||||
// Production mode: serve frontend from static/ and use SPA fallback
|
||||
info!("Production mode: serving frontend from static/");
|
||||
let serve_dir = ServeDir::new("static")
|
||||
.fallback(tower_http::services::ServeFile::new("static/index.html"));
|
||||
app = app.fallback_service(serve_dir);
|
||||
} else {
|
||||
// Development mode: API-only
|
||||
app = app.fallback(|| async {
|
||||
"Ironpad API server running. Use 'npm run dev' in frontend/ for the GUI."
|
||||
});
|
||||
}
|
||||
|
||||
// Start server
|
||||
info!("🚀 Ironpad running on http://localhost:{port}");
|
||||
|
||||
// Auto-open browser in production mode
|
||||
if has_frontend {
|
||||
let url = format!("http://localhost:{}", port);
|
||||
tokio::spawn(async move {
|
||||
// Small delay to ensure server is ready
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
if let Err(e) = webbrowser::open(&url) {
|
||||
tracing::warn!("Failed to open browser: {}. Open http://localhost:{} manually.", e, port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
axum::serve(listener, app).await.expect("Server failed");
|
||||
}
|
||||
3
backend/src/models/mod.rs
Normal file
3
backend/src/models/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod note;
|
||||
pub mod project;
|
||||
pub mod task;
|
||||
23
backend/src/models/note.rs
Normal file
23
backend/src/models/note.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use serde::Serialize;
|
||||
|
||||
/// Lightweight note representation for list views.
|
||||
/// Read-only, derived from filesystem + frontmatter.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NoteSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
pub note_type: String,
|
||||
pub updated: Option<String>,
|
||||
}
|
||||
|
||||
/// Full note payload for editor view.
|
||||
/// Returned by GET /api/notes/:id
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub note_type: String,
|
||||
pub frontmatter: serde_yaml::Mapping,
|
||||
pub content: String,
|
||||
}
|
||||
3
backend/src/models/project.rs
Normal file
3
backend/src/models/project.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Project structs are defined inline in routes/projects.rs
|
||||
// because they are tightly coupled to the API response shape.
|
||||
// This module is kept as a placeholder for future shared types.
|
||||
3
backend/src/models/task.rs
Normal file
3
backend/src/models/task.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Task structs are defined inline in routes/tasks.rs
|
||||
// because they are tightly coupled to the API response shape.
|
||||
// This module is kept as a placeholder for future shared types.
|
||||
265
backend/src/routes/assets.rs
Normal file
265
backend/src/routes/assets.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Multipart, Path, Query},
|
||||
http::{header, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path as StdPath;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::config;
|
||||
const MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UploadQuery {
|
||||
pub project: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UploadResponse {
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/upload", post(upload_asset))
|
||||
.route("/{project}/{filename}", get(get_asset))
|
||||
}
|
||||
|
||||
async fn upload_asset(
|
||||
Query(query): Query<UploadQuery>,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
// Determine target directory
|
||||
let assets_dir = if let Some(project_id) = &query.project {
|
||||
config::data_dir()
|
||||
.join("projects")
|
||||
.join(project_id)
|
||||
.join("assets")
|
||||
} else {
|
||||
config::data_dir().join("notes").join("assets")
|
||||
};
|
||||
|
||||
// Create assets directory if it doesn't exist
|
||||
if !assets_dir.exists() {
|
||||
if let Err(e) = fs::create_dir_all(&assets_dir) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create assets directory: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Process uploaded file
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or("file").to_string();
|
||||
if name != "file" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let original_filename = field
|
||||
.file_name()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("upload_{}", chrono::Utc::now().timestamp()));
|
||||
|
||||
// Validate file type (images only for now)
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !is_allowed_content_type(&content_type) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Unsupported file type: {}. Only images are allowed.", content_type),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Read file data
|
||||
let data = match field.bytes().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Failed to read file data: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Check file size
|
||||
if data.len() > MAX_FILE_SIZE {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("File too large. Maximum size is {} MB.", MAX_FILE_SIZE / 1024 / 1024),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Generate unique filename if needed
|
||||
let filename = generate_unique_filename(&assets_dir, &original_filename);
|
||||
let file_path = assets_dir.join(&filename);
|
||||
|
||||
// Write file
|
||||
let mut file = match fs::File::create(&file_path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create file: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = file.write_all(&data) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to write file: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Build response URL
|
||||
let project_part = query.project.as_deref().unwrap_or("notes");
|
||||
let url = format!("/api/assets/{}/{}", project_part, filename);
|
||||
|
||||
return (
|
||||
StatusCode::CREATED,
|
||||
Json(UploadResponse {
|
||||
url,
|
||||
filename,
|
||||
size: data.len(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(StatusCode::BAD_REQUEST, "No file provided").into_response()
|
||||
}
|
||||
|
||||
/// Validate that a path component doesn't contain directory traversal
|
||||
fn validate_path_component(component: &str) -> Result<(), String> {
|
||||
if component.contains("..") || component.contains('/') || component.contains('\\') || component.is_empty() {
|
||||
return Err("Invalid path component".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_asset(Path((project, filename)): Path<(String, String)>) -> impl IntoResponse {
|
||||
// Validate path components to prevent directory traversal
|
||||
if validate_path_component(&project).is_err() || validate_path_component(&filename).is_err() {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||
}
|
||||
|
||||
// Determine file path
|
||||
let file_path = if project == "notes" {
|
||||
config::data_dir()
|
||||
.join("notes")
|
||||
.join("assets")
|
||||
.join(&filename)
|
||||
} else {
|
||||
config::data_dir()
|
||||
.join("projects")
|
||||
.join(&project)
|
||||
.join("assets")
|
||||
.join(&filename)
|
||||
};
|
||||
|
||||
// Check if file exists
|
||||
if !file_path.exists() {
|
||||
return (StatusCode::NOT_FOUND, "Asset not found").into_response();
|
||||
}
|
||||
|
||||
// Read file
|
||||
let file = match tokio::fs::File::open(&file_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to open file: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Determine content type
|
||||
let content_type = get_content_type(&filename);
|
||||
|
||||
// Stream file response
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, content_type)],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn is_allowed_content_type(content_type: &str) -> bool {
|
||||
matches!(
|
||||
content_type,
|
||||
"image/jpeg"
|
||||
| "image/png"
|
||||
| "image/gif"
|
||||
| "image/webp"
|
||||
| "image/svg+xml"
|
||||
| "application/pdf"
|
||||
)
|
||||
}
|
||||
|
||||
fn get_content_type(filename: &str) -> &'static str {
|
||||
let ext = filename
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
match ext.as_str() {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"svg" => "image/svg+xml",
|
||||
"pdf" => "application/pdf",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_unique_filename(dir: &StdPath, original: &str) -> String {
|
||||
// Extract name and extension
|
||||
let (name, ext) = if let Some(dot_idx) = original.rfind('.') {
|
||||
(&original[..dot_idx], &original[dot_idx..])
|
||||
} else {
|
||||
(original, "")
|
||||
};
|
||||
|
||||
// Sanitize filename
|
||||
let sanitized_name: String = name
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
|
||||
let base_filename = format!("{}{}", sanitized_name, ext);
|
||||
let target_path = dir.join(&base_filename);
|
||||
|
||||
// If file doesn't exist, use original name
|
||||
if !target_path.exists() {
|
||||
return base_filename;
|
||||
}
|
||||
|
||||
// Otherwise, add timestamp
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
format!("{}_{}{}", sanitized_name, timestamp, ext)
|
||||
}
|
||||
319
backend/src/routes/daily.rs
Normal file
319
backend/src/routes/daily.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::Path,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
use crate::services::filesystem;
|
||||
use crate::config;
|
||||
use crate::services::frontmatter;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DailyNote {
|
||||
pub id: String,
|
||||
pub date: String,
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub frontmatter: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DailyNoteSummary {
|
||||
pub id: String,
|
||||
pub date: String,
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(list_daily_notes))
|
||||
.route("/today", get(get_or_create_today))
|
||||
.route("/{date}", get(get_daily_note).post(create_daily_note).put(update_daily_note))
|
||||
}
|
||||
|
||||
/// List all daily notes
|
||||
async fn list_daily_notes() -> impl IntoResponse {
|
||||
match list_daily_notes_impl() {
|
||||
Ok(notes) => Json(notes).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to list daily notes: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_daily_notes_impl() -> Result<Vec<DailyNoteSummary>, String> {
|
||||
let daily_dir = config::data_dir().join("daily");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if !daily_dir.exists() {
|
||||
fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut notes = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&daily_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
|
||||
// Validate date format
|
||||
if NaiveDate::parse_from_str(filename, "%Y-%m-%d").is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let title = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| filename.to_string());
|
||||
|
||||
notes.push(DailyNoteSummary {
|
||||
id: format!("daily-{}", filename),
|
||||
date: filename.to_string(),
|
||||
path: format!("daily/{}.md", filename),
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
notes.sort_by(|a, b| b.date.cmp(&a.date));
|
||||
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
/// Get or create today's daily note
|
||||
async fn get_or_create_today() -> impl IntoResponse {
|
||||
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||
|
||||
match get_daily_note_impl(&today) {
|
||||
Ok(note) => Json(note).into_response(),
|
||||
Err(_) => {
|
||||
// Note doesn't exist, create it with default template
|
||||
match create_daily_note_impl(&today, None) {
|
||||
Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create today's note: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a daily note by date
|
||||
async fn get_daily_note(Path(date): Path<String>) -> impl IntoResponse {
|
||||
// Validate date format
|
||||
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response();
|
||||
}
|
||||
|
||||
match get_daily_note_impl(&date) {
|
||||
Ok(note) => Json(note).into_response(),
|
||||
Err(err) if err.contains("not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get daily note: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_daily_note_impl(date: &str) -> Result<DailyNote, String> {
|
||||
let daily_dir = config::data_dir().join("daily");
|
||||
let note_path = daily_dir.join(format!("{}.md", date));
|
||||
|
||||
if !note_path.exists() {
|
||||
return Err(format!("Daily note not found: {}", date));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(¬e_path).map_err(|e| e.to_string())?;
|
||||
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
Ok(DailyNote {
|
||||
id: format!("daily-{}", date),
|
||||
date: date.to_string(),
|
||||
path: format!("daily/{}.md", date),
|
||||
content: body,
|
||||
frontmatter: fm,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDailyNoteRequest {
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
/// Create a daily note (optionally with content)
|
||||
async fn create_daily_note(
|
||||
Path(date): Path<String>,
|
||||
body: Option<Json<CreateDailyNoteRequest>>,
|
||||
) -> impl IntoResponse {
|
||||
// Validate date format
|
||||
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response();
|
||||
}
|
||||
|
||||
let content = body.and_then(|b| b.content.clone());
|
||||
|
||||
match create_daily_note_impl(&date, content.as_deref()) {
|
||||
Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
|
||||
Err(err) if err.contains("already exists") => {
|
||||
(StatusCode::CONFLICT, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create daily note: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_daily_note_impl(date: &str, initial_content: Option<&str>) -> Result<DailyNote, String> {
|
||||
let daily_dir = config::data_dir().join("daily");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if !daily_dir.exists() {
|
||||
fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let note_path = daily_dir.join(format!("{}.md", date));
|
||||
|
||||
if note_path.exists() {
|
||||
return Err(format!("Daily note already exists: {}", date));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
// Parse date for display
|
||||
let parsed_date = NaiveDate::parse_from_str(date, "%Y-%m-%d")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let display_date = parsed_date.format("%A, %B %d, %Y").to_string();
|
||||
|
||||
// Create frontmatter
|
||||
let mut fm = serde_yaml::Mapping::new();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("id"),
|
||||
serde_yaml::Value::from(format!("daily-{}", date)),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("type"),
|
||||
serde_yaml::Value::from("daily"),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("title"),
|
||||
serde_yaml::Value::from(display_date.clone()),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("date"),
|
||||
serde_yaml::Value::from(date),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("created"),
|
||||
serde_yaml::Value::from(now.clone()),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now),
|
||||
);
|
||||
|
||||
// Use provided content or default template
|
||||
let body = initial_content
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"# {}\n\n## Today's Focus\n\n- \n\n## Notes\n\n\n\n## Tasks\n\n- [ ] \n",
|
||||
display_date
|
||||
)
|
||||
});
|
||||
|
||||
let content = frontmatter::serialize_frontmatter(&fm, &body)?;
|
||||
|
||||
filesystem::atomic_write(¬e_path, content.as_bytes())?;
|
||||
|
||||
Ok(DailyNote {
|
||||
id: format!("daily-{}", date),
|
||||
date: date.to_string(),
|
||||
path: format!("daily/{}.md", date),
|
||||
content: body,
|
||||
frontmatter: fm,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update a daily note's content
|
||||
async fn update_daily_note(
|
||||
Path(date): Path<String>,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
// Validate date format
|
||||
if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response();
|
||||
}
|
||||
|
||||
let content = String::from_utf8_lossy(&body).to_string();
|
||||
|
||||
match update_daily_note_impl(&date, &content) {
|
||||
Ok(note) => Json(note).into_response(),
|
||||
Err(err) if err.contains("not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to update daily note: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_daily_note_impl(date: &str, new_content: &str) -> Result<DailyNote, String> {
|
||||
let daily_dir = config::data_dir().join("daily");
|
||||
let note_path = daily_dir.join(format!("{}.md", date));
|
||||
|
||||
if !note_path.exists() {
|
||||
return Err(format!("Daily note not found: {}", date));
|
||||
}
|
||||
|
||||
// Read existing file to preserve frontmatter
|
||||
let existing_content = fs::read_to_string(¬e_path).map_err(|e| e.to_string())?;
|
||||
let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing_content);
|
||||
|
||||
// Update the 'updated' timestamp
|
||||
let now = Utc::now().to_rfc3339();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now),
|
||||
);
|
||||
|
||||
// Serialize with updated frontmatter and new content (atomic write)
|
||||
let file_content = frontmatter::serialize_frontmatter(&fm, new_content)?;
|
||||
|
||||
filesystem::atomic_write(¬e_path, file_content.as_bytes())?;
|
||||
|
||||
Ok(DailyNote {
|
||||
id: format!("daily-{}", date),
|
||||
date: date.to_string(),
|
||||
path: format!("daily/{}.md", date),
|
||||
content: new_content.to_string(),
|
||||
frontmatter: fm,
|
||||
})
|
||||
}
|
||||
184
backend/src/routes/git.rs
Normal file
184
backend/src/routes/git.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::services::git;
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/status", get(get_status))
|
||||
.route("/commit", post(commit))
|
||||
.route("/init", post(init_repo))
|
||||
.route("/conflicts", get(get_conflicts))
|
||||
.route("/push", post(push))
|
||||
.route("/log", get(get_log))
|
||||
.route("/diff", get(get_working_diff))
|
||||
.route("/diff/{commit_id}", get(get_commit_diff))
|
||||
.route("/remote", get(get_remote))
|
||||
.route("/fetch", post(fetch))
|
||||
}
|
||||
|
||||
async fn get_status() -> impl IntoResponse {
|
||||
match git::get_status() {
|
||||
Ok(status) => Json(status).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get git status: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommitRequest {
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
async fn commit(Json(payload): Json<CommitRequest>) -> impl IntoResponse {
|
||||
match git::commit_all(payload.message.as_deref()) {
|
||||
Ok(info) => (StatusCode::CREATED, Json(info)).into_response(),
|
||||
Err(err) => (StatusCode::BAD_REQUEST, err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn init_repo() -> impl IntoResponse {
|
||||
match git::init_repo() {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to init repo: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_conflicts() -> impl IntoResponse {
|
||||
match git::check_conflicts() {
|
||||
Ok(conflicts) => Json(conflicts).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to check conflicts: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PushResponse {
|
||||
success: bool,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn push() -> impl IntoResponse {
|
||||
// Check if remote is configured
|
||||
if !git::has_remote() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(PushResponse {
|
||||
success: false,
|
||||
message: "No remote repository configured. Add a remote with: git remote add origin <url>".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match git::push_to_remote() {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(PushResponse {
|
||||
success: true,
|
||||
message: "Successfully pushed to remote".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(PushResponse {
|
||||
success: false,
|
||||
message: err,
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LogQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
async fn get_log(Query(query): Query<LogQuery>) -> impl IntoResponse {
|
||||
match git::get_log(query.limit) {
|
||||
Ok(commits) => Json(commits).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get git log: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_working_diff() -> impl IntoResponse {
|
||||
match git::get_working_diff() {
|
||||
Ok(diff) => Json(diff).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get diff: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_commit_diff(Path(commit_id): Path<String>) -> impl IntoResponse {
|
||||
match git::get_commit_diff(&commit_id) {
|
||||
Ok(diff) => Json(diff).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get commit diff: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_remote() -> impl IntoResponse {
|
||||
match git::get_remote_info() {
|
||||
Ok(info) => Json(info).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get remote info: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FetchResponse {
|
||||
success: bool,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn fetch() -> impl IntoResponse {
|
||||
match git::fetch_from_remote() {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(FetchResponse {
|
||||
success: true,
|
||||
message: "Successfully fetched from remote".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(FetchResponse {
|
||||
success: false,
|
||||
message: err,
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
7
backend/src/routes/mod.rs
Normal file
7
backend/src/routes/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod assets;
|
||||
pub mod daily;
|
||||
pub mod git;
|
||||
pub mod notes;
|
||||
pub mod projects;
|
||||
pub mod search;
|
||||
pub mod tasks;
|
||||
82
backend/src/routes/notes.rs
Normal file
82
backend/src/routes/notes.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::models::note::{Note, NoteSummary};
|
||||
use crate::services::filesystem;
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/{id}", get(get_note).put(update_note).delete(delete_note))
|
||||
}
|
||||
|
||||
pub async fn list_notes() -> impl IntoResponse {
|
||||
match filesystem::list_notes() {
|
||||
Ok(notes) => Json::<Vec<NoteSummary>>(notes).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to list notes: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_note(Path(id): Path<String>) -> impl IntoResponse {
|
||||
match filesystem::read_note_by_id(&id) {
|
||||
Ok(note) => Json::<Note>(note).into_response(),
|
||||
Err(err) if err.starts_with("Note not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read note: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_note() -> impl IntoResponse {
|
||||
match filesystem::create_note() {
|
||||
Ok(note) => (StatusCode::CREATED, Json::<Note>(note)).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create note: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_note(
|
||||
Path(id): Path<String>,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
match filesystem::update_note(&id, &body) {
|
||||
Ok(note) => Json::<Note>(note).into_response(),
|
||||
Err(err) if err.starts_with("Note not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to update note: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_note(Path(id): Path<String>) -> impl IntoResponse {
|
||||
match filesystem::archive_note(&id) {
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) if err.starts_with("Note not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to archive note: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
860
backend/src/routes/projects.rs
Normal file
860
backend/src/routes/projects.rs
Normal file
@@ -0,0 +1,860 @@
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
use crate::routes::tasks::{
|
||||
CreateTaskRequest, UpdateTaskMetaRequest,
|
||||
list_project_tasks_handler, create_task_handler, get_task_handler,
|
||||
update_task_content_handler, toggle_task_handler, update_task_meta_handler,
|
||||
delete_task_handler,
|
||||
};
|
||||
use crate::services::filesystem;
|
||||
use crate::config;
|
||||
use crate::services::frontmatter;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProjectWithContent {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub created: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProjectContentRequest {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateProjectRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProjectNote {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
pub project_id: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProjectNoteWithContent {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
pub project_id: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateNoteRequest {
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(list_projects).post(create_project))
|
||||
.route("/{id}", get(get_project))
|
||||
.route("/{id}/content", get(get_project_content).put(update_project_content))
|
||||
// Task routes (file-based)
|
||||
.route("/{id}/tasks", get(get_project_tasks).post(create_project_task))
|
||||
.route("/{id}/tasks/{task_id}", get(get_project_task).put(update_project_task).delete(delete_project_task))
|
||||
.route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task))
|
||||
.route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta))
|
||||
// Note routes
|
||||
.route("/{id}/notes", get(list_project_notes).post(create_project_note))
|
||||
.route("/{id}/notes/{note_id}", get(get_project_note).put(update_project_note).delete(delete_project_note))
|
||||
}
|
||||
|
||||
// ============ Task Handlers ============
|
||||
|
||||
async fn get_project_tasks(Path(id): Path<String>) -> impl IntoResponse {
|
||||
list_project_tasks_handler(id).await
|
||||
}
|
||||
|
||||
async fn create_project_task(
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<CreateTaskRequest>,
|
||||
) -> impl IntoResponse {
|
||||
create_task_handler(id, payload).await
|
||||
}
|
||||
|
||||
async fn get_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse {
|
||||
get_task_handler(id, task_id).await
|
||||
}
|
||||
|
||||
async fn update_project_task(
|
||||
Path((id, task_id)): Path<(String, String)>,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
update_task_content_handler(id, task_id, body).await
|
||||
}
|
||||
|
||||
async fn toggle_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse {
|
||||
toggle_task_handler(id, task_id).await
|
||||
}
|
||||
|
||||
async fn update_project_task_meta(
|
||||
Path((id, task_id)): Path<(String, String)>,
|
||||
Json(payload): Json<UpdateTaskMetaRequest>,
|
||||
) -> impl IntoResponse {
|
||||
update_task_meta_handler(id, task_id, payload).await
|
||||
}
|
||||
|
||||
async fn delete_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse {
|
||||
delete_task_handler(id, task_id).await
|
||||
}
|
||||
|
||||
async fn list_projects() -> impl IntoResponse {
|
||||
match list_projects_impl() {
|
||||
Ok(projects) => Json(projects).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to list projects: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_projects_impl() -> Result<Vec<Project>, String> {
|
||||
let projects_dir = config::data_dir().join("projects");
|
||||
|
||||
if !projects_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut projects = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&projects_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let index_path = path.join("index.md");
|
||||
if !index_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&index_path).map_err(|e| e.to_string())?;
|
||||
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let id = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let name = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
let created = fm
|
||||
.get(&serde_yaml::Value::from("created"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
projects.push(Project {
|
||||
id: id.clone(),
|
||||
name,
|
||||
path: format!("projects/{}", id),
|
||||
created,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
async fn get_project(Path(id): Path<String>) -> impl IntoResponse {
|
||||
let projects_dir = config::data_dir().join("projects").join(&id);
|
||||
let index_path = projects_dir.join("index.md");
|
||||
|
||||
if !index_path.exists() {
|
||||
return (StatusCode::NOT_FOUND, "Project not found").into_response();
|
||||
}
|
||||
|
||||
match fs::read_to_string(&index_path) {
|
||||
Ok(content) => {
|
||||
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let name = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
let created = fm
|
||||
.get(&serde_yaml::Value::from("created"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(Project {
|
||||
id: id.clone(),
|
||||
name,
|
||||
path: format!("projects/{}", id),
|
||||
created,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read project: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_project(Json(payload): Json<CreateProjectRequest>) -> impl IntoResponse {
|
||||
match create_project_impl(&payload.name) {
|
||||
Ok(project) => (StatusCode::CREATED, Json(project)).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create project: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_project_impl(name: &str) -> Result<Project, String> {
|
||||
use chrono::Utc;
|
||||
|
||||
// Create slug from name
|
||||
let slug = name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
|
||||
if slug.is_empty() {
|
||||
return Err("Invalid project name".to_string());
|
||||
}
|
||||
|
||||
let projects_dir = config::data_dir().join("projects");
|
||||
let project_dir = projects_dir.join(&slug);
|
||||
|
||||
if project_dir.exists() {
|
||||
return Err("Project already exists".to_string());
|
||||
}
|
||||
|
||||
// Create directories
|
||||
fs::create_dir_all(&project_dir).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(project_dir.join("assets")).map_err(|e| e.to_string())?;
|
||||
|
||||
// Create index.md
|
||||
let index_path = project_dir.join("index.md");
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let mut fm = serde_yaml::Mapping::new();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("id"),
|
||||
serde_yaml::Value::from(format!("{}-index", slug)),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("type"),
|
||||
serde_yaml::Value::from("project"),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("title"),
|
||||
serde_yaml::Value::from(name),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("created"),
|
||||
serde_yaml::Value::from(now.clone()),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now.clone()),
|
||||
);
|
||||
|
||||
let content = frontmatter::serialize_frontmatter(&fm, &format!("# {}\n\n", name))?;
|
||||
|
||||
filesystem::atomic_write(&index_path, content.as_bytes())?;
|
||||
|
||||
// Also create notes directory for project-scoped notes
|
||||
fs::create_dir_all(project_dir.join("notes")).map_err(|e| e.to_string())?;
|
||||
|
||||
// Create tasks directory for file-based tasks
|
||||
fs::create_dir_all(project_dir.join("tasks")).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Project {
|
||||
id: slug.clone(),
|
||||
name: name.to_string(),
|
||||
path: format!("projects/{}", slug),
|
||||
created: now,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_project_content(Path(id): Path<String>) -> impl IntoResponse {
|
||||
let index_path = config::data_dir()
|
||||
.join("projects")
|
||||
.join(&id)
|
||||
.join("index.md");
|
||||
|
||||
if !index_path.exists() {
|
||||
return (StatusCode::NOT_FOUND, "Project not found").into_response();
|
||||
}
|
||||
|
||||
match fs::read_to_string(&index_path) {
|
||||
Ok(content) => {
|
||||
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let name = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
let created = fm
|
||||
.get(&serde_yaml::Value::from("created"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(ProjectWithContent {
|
||||
id: id.clone(),
|
||||
name,
|
||||
path: format!("projects/{}", id),
|
||||
created,
|
||||
content: body,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read project: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_project_content(
|
||||
Path(id): Path<String>,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
let index_path = config::data_dir()
|
||||
.join("projects")
|
||||
.join(&id)
|
||||
.join("index.md");
|
||||
|
||||
if !index_path.exists() {
|
||||
return (StatusCode::NOT_FOUND, "Project not found").into_response();
|
||||
}
|
||||
|
||||
// Read existing file to get frontmatter
|
||||
let existing = match fs::read_to_string(&index_path) {
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read project: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing);
|
||||
|
||||
// Update the timestamp
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now),
|
||||
);
|
||||
|
||||
// Serialize with new content
|
||||
let new_content = match frontmatter::serialize_frontmatter(&fm, &body) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to serialize: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Write back (atomic to prevent corruption)
|
||||
if let Err(err) = filesystem::atomic_write(&index_path, new_content.as_bytes()) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to write file: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let name = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
let created = fm
|
||||
.get(&serde_yaml::Value::from("created"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(ProjectWithContent {
|
||||
id: id.clone(),
|
||||
name,
|
||||
path: format!("projects/{}", id),
|
||||
created,
|
||||
content: body,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ============ Project Notes Handlers ============
|
||||
|
||||
async fn list_project_notes(Path(project_id): Path<String>) -> impl IntoResponse {
|
||||
let notes_dir = config::data_dir()
|
||||
.join("projects")
|
||||
.join(&project_id)
|
||||
.join("notes");
|
||||
|
||||
// Create notes directory if it doesn't exist
|
||||
if !notes_dir.exists() {
|
||||
if let Err(e) = fs::create_dir_all(¬es_dir) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create notes directory: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let mut notes = Vec::new();
|
||||
|
||||
let entries = match fs::read_dir(¬es_dir) {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read notes directory: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let filename = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let id = fm
|
||||
.get(&serde_yaml::Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| filename.clone());
|
||||
|
||||
let title = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| filename.clone());
|
||||
|
||||
let created = fm
|
||||
.get(&serde_yaml::Value::from("created"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
let updated = fm
|
||||
.get(&serde_yaml::Value::from("updated"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
notes.push(ProjectNote {
|
||||
id,
|
||||
title,
|
||||
path: format!("projects/{}/notes/{}.md", project_id, filename),
|
||||
project_id: project_id.clone(),
|
||||
created,
|
||||
updated,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by updated date descending
|
||||
// Sort by created date (stable ordering - won't change when note is viewed/edited)
|
||||
notes.sort_by(|a, b| b.created.cmp(&a.created));
|
||||
|
||||
Json(notes).into_response()
|
||||
}
|
||||
|
||||
async fn create_project_note(
|
||||
Path(project_id): Path<String>,
|
||||
Json(payload): Json<CreateNoteRequest>,
|
||||
) -> impl IntoResponse {
|
||||
use chrono::Utc;
|
||||
|
||||
let notes_dir = config::data_dir()
|
||||
.join("projects")
|
||||
.join(&project_id)
|
||||
.join("notes");
|
||||
|
||||
// Create notes directory if it doesn't exist
|
||||
if let Err(e) = fs::create_dir_all(¬es_dir) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create notes directory: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Generate filename from timestamp
|
||||
let now = Utc::now();
|
||||
let filename = now.format("%Y%m%d-%H%M%S").to_string();
|
||||
let note_path = notes_dir.join(format!("{}.md", filename));
|
||||
|
||||
let title = payload.title.unwrap_or_else(|| "Untitled".to_string());
|
||||
let now_str = now.to_rfc3339();
|
||||
|
||||
let mut fm = serde_yaml::Mapping::new();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("id"),
|
||||
serde_yaml::Value::from(format!("{}-{}", project_id, filename)),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("type"),
|
||||
serde_yaml::Value::from("note"),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("title"),
|
||||
serde_yaml::Value::from(title.clone()),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("project_id"),
|
||||
serde_yaml::Value::from(project_id.clone()),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("created"),
|
||||
serde_yaml::Value::from(now_str.clone()),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now_str.clone()),
|
||||
);
|
||||
|
||||
let body = format!("# {}\n\n", title);
|
||||
let content = match frontmatter::serialize_frontmatter(&fm, &body) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to serialize frontmatter: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = filesystem::atomic_write(¬e_path, content.as_bytes()) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to write note file: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(ProjectNoteWithContent {
|
||||
id: format!("{}-{}", project_id, filename),
|
||||
title,
|
||||
path: format!("projects/{}/notes/{}.md", project_id, filename),
|
||||
project_id,
|
||||
created: now_str.clone(),
|
||||
updated: now_str,
|
||||
content: body,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn get_project_note(Path((project_id, note_id)): Path<(String, String)>) -> impl IntoResponse {
|
||||
let notes_dir = config::data_dir()
|
||||
.join("projects")
|
||||
.join(&project_id)
|
||||
.join("notes");
|
||||
|
||||
// Try to find the note by ID (which might be the filename)
|
||||
let note_path = notes_dir.join(format!("{}.md", note_id));
|
||||
|
||||
if !note_path.exists() {
|
||||
// Try to find by searching all notes for matching ID
|
||||
if let Ok(entries) = fs::read_dir(¬es_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let file_id = fm
|
||||
.get(&serde_yaml::Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
if file_id.as_deref() == Some(¬e_id) {
|
||||
let title = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
let created = fm
|
||||
.get(&serde_yaml::Value::from("created"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
let updated = fm
|
||||
.get(&serde_yaml::Value::from("updated"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
let filename = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
return Json(ProjectNoteWithContent {
|
||||
id: note_id,
|
||||
title,
|
||||
path: format!("projects/{}/notes/{}.md", project_id, filename),
|
||||
project_id,
|
||||
created,
|
||||
updated,
|
||||
content: body,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (StatusCode::NOT_FOUND, "Note not found").into_response();
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(¬e_path) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read note: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let id = fm
|
||||
.get(&serde_yaml::Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| note_id.clone());
|
||||
|
||||
let title = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
let created = fm
|
||||
.get(&serde_yaml::Value::from("created"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
let updated = fm
|
||||
.get(&serde_yaml::Value::from("updated"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(ProjectNoteWithContent {
|
||||
id,
|
||||
title,
|
||||
path: format!("projects/{}/notes/{}.md", project_id, note_id),
|
||||
project_id,
|
||||
created,
|
||||
updated,
|
||||
content: body,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn update_project_note(
|
||||
Path((project_id, note_id)): Path<(String, String)>,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
let notes_dir = config::data_dir()
|
||||
.join("projects")
|
||||
.join(&project_id)
|
||||
.join("notes");
|
||||
|
||||
let note_path = notes_dir.join(format!("{}.md", note_id));
|
||||
|
||||
if !note_path.exists() {
|
||||
return (StatusCode::NOT_FOUND, "Note not found").into_response();
|
||||
}
|
||||
|
||||
// Read existing content for frontmatter
|
||||
let existing = match fs::read_to_string(¬e_path) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to read note: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing);
|
||||
|
||||
// Update timestamp
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now.clone()),
|
||||
);
|
||||
|
||||
// Serialize with new content
|
||||
let new_content = match frontmatter::serialize_frontmatter(&fm, &body) {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to serialize: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = filesystem::atomic_write(¬e_path, new_content.as_bytes()) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to write file: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let id = fm
|
||||
.get(&serde_yaml::Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| note_id.clone());
|
||||
|
||||
let title = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
let created = fm
|
||||
.get(&serde_yaml::Value::from("created"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(ProjectNoteWithContent {
|
||||
id,
|
||||
title,
|
||||
path: format!("projects/{}/notes/{}.md", project_id, note_id),
|
||||
project_id,
|
||||
created,
|
||||
updated: now,
|
||||
content: body,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn delete_project_note(
|
||||
Path((project_id, note_id)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let notes_dir = config::data_dir()
|
||||
.join("projects")
|
||||
.join(&project_id)
|
||||
.join("notes");
|
||||
|
||||
let note_path = notes_dir.join(format!("{}.md", note_id));
|
||||
|
||||
if !note_path.exists() {
|
||||
return (StatusCode::NOT_FOUND, "Note not found").into_response();
|
||||
}
|
||||
|
||||
// Move to archive instead of deleting
|
||||
let archive_dir = config::data_dir().join("archive");
|
||||
if let Err(e) = fs::create_dir_all(&archive_dir) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create archive directory: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let archive_path = archive_dir.join(format!("{}-{}.md", project_id, note_id));
|
||||
|
||||
if let Err(err) = fs::rename(¬e_path, &archive_path) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to archive note: {}", err),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
30
backend/src/routes/search.rs
Normal file
30
backend/src/routes/search.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::services::search;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
q: String,
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new().route("/", get(search_notes))
|
||||
}
|
||||
|
||||
async fn search_notes(Query(params): Query<SearchQuery>) -> impl IntoResponse {
|
||||
match search::search_notes(¶ms.q) {
|
||||
Ok(results) => Json(results).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Search failed: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
835
backend/src/routes/tasks.rs
Normal file
835
backend/src/routes/tasks.rs
Normal file
@@ -0,0 +1,835 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path as StdPath;
|
||||
|
||||
use crate::services::filesystem;
|
||||
use crate::config;
|
||||
use crate::services::frontmatter;
|
||||
|
||||
/// Task summary for list views
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Task {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub completed: bool,
|
||||
pub section: String,
|
||||
pub priority: Option<String>,
|
||||
pub due_date: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub tags: Vec<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub recurrence: Option<String>,
|
||||
pub recurrence_interval: Option<u32>,
|
||||
pub project_id: String,
|
||||
pub path: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
}
|
||||
|
||||
/// Task with full content for detail view
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TaskWithContent {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub completed: bool,
|
||||
pub section: String,
|
||||
pub priority: Option<String>,
|
||||
pub due_date: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub tags: Vec<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub recurrence: Option<String>,
|
||||
pub recurrence_interval: Option<u32>,
|
||||
pub project_id: String,
|
||||
pub path: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTaskRequest {
|
||||
pub title: String,
|
||||
pub section: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateTaskMetaRequest {
|
||||
pub title: Option<String>,
|
||||
pub section: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub due_date: Option<String>,
|
||||
pub is_active: Option<bool>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub recurrence: Option<String>,
|
||||
pub recurrence_interval: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(list_all_tasks_handler))
|
||||
}
|
||||
|
||||
// ============ Handler Functions (called from projects.rs) ============
|
||||
|
||||
/// List all tasks for a project
|
||||
pub async fn list_project_tasks_handler(project_id: String) -> impl IntoResponse {
|
||||
match list_project_tasks_impl(&project_id) {
|
||||
Ok(tasks) => Json(tasks).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to list tasks: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new task
|
||||
pub async fn create_task_handler(
|
||||
project_id: String,
|
||||
payload: CreateTaskRequest,
|
||||
) -> impl IntoResponse {
|
||||
match create_task_impl(&project_id, &payload.title, payload.section.as_deref(), payload.parent_id.as_deref()) {
|
||||
Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create task: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a task with content
|
||||
pub async fn get_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
|
||||
match get_task_impl(&project_id, &task_id) {
|
||||
Ok(task) => Json(task).into_response(),
|
||||
Err(err) if err.contains("not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get task: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update task content (markdown body)
|
||||
pub async fn update_task_content_handler(
|
||||
project_id: String,
|
||||
task_id: String,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
match update_task_content_impl(&project_id, &task_id, &body) {
|
||||
Ok(task) => Json(task).into_response(),
|
||||
Err(err) if err.contains("not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to update task: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle task completion
|
||||
pub async fn toggle_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
|
||||
match toggle_task_impl(&project_id, &task_id) {
|
||||
Ok(task) => Json(task).into_response(),
|
||||
Err(err) if err.contains("not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to toggle task: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update task metadata (title, section, priority)
|
||||
pub async fn update_task_meta_handler(
|
||||
project_id: String,
|
||||
task_id: String,
|
||||
payload: UpdateTaskMetaRequest,
|
||||
) -> impl IntoResponse {
|
||||
match update_task_meta_impl(&project_id, &task_id, payload) {
|
||||
Ok(task) => Json(task).into_response(),
|
||||
Err(err) if err.contains("not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to update task metadata: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete (archive) a task
|
||||
pub async fn delete_task_handler(project_id: String, task_id: String) -> impl IntoResponse {
|
||||
match delete_task_impl(&project_id, &task_id) {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) if err.contains("not found") => {
|
||||
(StatusCode::NOT_FOUND, err).into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to delete task: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Implementation Functions ============
|
||||
|
||||
fn get_tasks_dir(project_id: &str) -> std::path::PathBuf {
|
||||
config::data_dir()
|
||||
.join("projects")
|
||||
.join(project_id)
|
||||
.join("tasks")
|
||||
}
|
||||
|
||||
fn ensure_tasks_dir(project_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
let tasks_dir = get_tasks_dir(project_id);
|
||||
if !tasks_dir.exists() {
|
||||
fs::create_dir_all(&tasks_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(tasks_dir)
|
||||
}
|
||||
|
||||
fn list_project_tasks_impl(project_id: &str) -> Result<Vec<Task>, String> {
|
||||
let tasks_dir = ensure_tasks_dir(project_id)?;
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
let entries = match fs::read_dir(&tasks_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(Vec::new()), // No tasks directory yet
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(task) = parse_task_file(&content, &path, project_id) {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updated date descending (most recent first)
|
||||
// Sort by created date (stable ordering - won't change when task is viewed/edited)
|
||||
tasks.sort_by(|a, b| b.created.cmp(&a.created));
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
/// Shared helper: extract common task fields from frontmatter.
|
||||
/// Eliminates duplication between parse_task_file and parse_task_with_content.
|
||||
fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &str) -> Task {
|
||||
let filename = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Task {
|
||||
id: frontmatter::get_str_or(fm, "id", &filename),
|
||||
title: frontmatter::get_str_or(fm, "title", "Untitled"),
|
||||
completed: frontmatter::get_bool_or(fm, "completed", false),
|
||||
section: frontmatter::get_str_or(fm, "section", "Active"),
|
||||
priority: frontmatter::get_str(fm, "priority"),
|
||||
due_date: frontmatter::get_str(fm, "due_date"),
|
||||
is_active: frontmatter::get_bool_or(fm, "is_active", true),
|
||||
tags: frontmatter::get_string_seq(fm, "tags"),
|
||||
parent_id: frontmatter::get_str(fm, "parent_id"),
|
||||
recurrence: frontmatter::get_str(fm, "recurrence"),
|
||||
recurrence_interval: frontmatter::get_u64(fm, "recurrence_interval").map(|v| v as u32),
|
||||
project_id: project_id.to_string(),
|
||||
path: format!("projects/{}/tasks/{}.md", project_id, filename),
|
||||
created: frontmatter::get_str_or(fm, "created", ""),
|
||||
updated: frontmatter::get_str_or(fm, "updated", ""),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_task_file(content: &str, path: &StdPath, project_id: &str) -> Option<Task> {
|
||||
let (fm, _, _) = frontmatter::parse_frontmatter(content);
|
||||
Some(extract_task_fields(&fm, path, project_id))
|
||||
}
|
||||
|
||||
fn create_task_impl(
|
||||
project_id: &str,
|
||||
title: &str,
|
||||
section: Option<&str>,
|
||||
parent_id: Option<&str>,
|
||||
) -> Result<TaskWithContent, String> {
|
||||
use chrono::Utc;
|
||||
|
||||
let tasks_dir = ensure_tasks_dir(project_id)?;
|
||||
|
||||
// Generate filename from timestamp
|
||||
let now = Utc::now();
|
||||
let filename = format!("task-{}", now.format("%Y%m%d-%H%M%S"));
|
||||
let task_path = tasks_dir.join(format!("{}.md", filename));
|
||||
|
||||
let section = section.unwrap_or("Active").to_string();
|
||||
let now_str = now.to_rfc3339();
|
||||
let id = format!("{}-{}", project_id, filename);
|
||||
|
||||
let mut fm = serde_yaml::Mapping::new();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("id"),
|
||||
serde_yaml::Value::from(id.clone()),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("type"),
|
||||
serde_yaml::Value::from("task"),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("title"),
|
||||
serde_yaml::Value::from(title),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("completed"),
|
||||
serde_yaml::Value::from(false),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("section"),
|
||||
serde_yaml::Value::from(section.clone()),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("priority"),
|
||||
serde_yaml::Value::from("normal"),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("is_active"),
|
||||
serde_yaml::Value::from(true),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("project_id"),
|
||||
serde_yaml::Value::from(project_id),
|
||||
);
|
||||
if let Some(pid) = parent_id {
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("parent_id"),
|
||||
serde_yaml::Value::from(pid),
|
||||
);
|
||||
}
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("created"),
|
||||
serde_yaml::Value::from(now_str.clone()),
|
||||
);
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now_str.clone()),
|
||||
);
|
||||
|
||||
let body = format!("# {}\n\n", title);
|
||||
let content = frontmatter::serialize_frontmatter(&fm, &body)?;
|
||||
|
||||
filesystem::atomic_write(&task_path, content.as_bytes())?;
|
||||
|
||||
Ok(TaskWithContent {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
completed: false,
|
||||
section,
|
||||
priority: Some("normal".to_string()),
|
||||
due_date: None,
|
||||
is_active: true,
|
||||
tags: Vec::new(),
|
||||
parent_id: parent_id.map(String::from),
|
||||
recurrence: None,
|
||||
recurrence_interval: None,
|
||||
project_id: project_id.to_string(),
|
||||
path: format!("projects/{}/tasks/{}.md", project_id, filename),
|
||||
created: now_str.clone(),
|
||||
updated: now_str,
|
||||
content: body,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_task_impl(project_id: &str, task_id: &str) -> Result<TaskWithContent, String> {
|
||||
let tasks_dir = get_tasks_dir(project_id);
|
||||
|
||||
// Try direct filename match first
|
||||
let task_path = tasks_dir.join(format!("{}.md", task_id));
|
||||
|
||||
if task_path.exists() {
|
||||
return read_task_with_content(&task_path, project_id);
|
||||
}
|
||||
|
||||
// Search by ID in frontmatter
|
||||
if let Ok(entries) = fs::read_dir(&tasks_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let file_id = fm
|
||||
.get(&serde_yaml::Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
if file_id.as_deref() == Some(task_id) {
|
||||
return parse_task_with_content(&fm, &body, &path, project_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Task not found".to_string())
|
||||
}
|
||||
|
||||
fn read_task_with_content(path: &StdPath, project_id: &str) -> Result<TaskWithContent, String> {
|
||||
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let (fm, body, _) = frontmatter::parse_frontmatter(&content);
|
||||
parse_task_with_content(&fm, &body, path, project_id)
|
||||
}
|
||||
|
||||
fn parse_task_with_content(
|
||||
fm: &serde_yaml::Mapping,
|
||||
body: &str,
|
||||
path: &StdPath,
|
||||
project_id: &str,
|
||||
) -> Result<TaskWithContent, String> {
|
||||
let task = extract_task_fields(fm, path, project_id);
|
||||
Ok(TaskWithContent {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
completed: task.completed,
|
||||
section: task.section,
|
||||
priority: task.priority,
|
||||
due_date: task.due_date,
|
||||
is_active: task.is_active,
|
||||
tags: task.tags,
|
||||
parent_id: task.parent_id,
|
||||
recurrence: task.recurrence,
|
||||
recurrence_interval: task.recurrence_interval,
|
||||
project_id: task.project_id,
|
||||
path: task.path,
|
||||
created: task.created,
|
||||
updated: task.updated,
|
||||
content: body.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn update_task_content_impl(
|
||||
project_id: &str,
|
||||
task_id: &str,
|
||||
new_body: &str,
|
||||
) -> Result<TaskWithContent, String> {
|
||||
let task_path = find_task_path(project_id, task_id)?;
|
||||
|
||||
// Read existing content
|
||||
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
|
||||
let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing);
|
||||
|
||||
// Update timestamp
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now),
|
||||
);
|
||||
|
||||
// Serialize with new content (atomic write to prevent corruption)
|
||||
let new_content = frontmatter::serialize_frontmatter(&fm, new_body)?;
|
||||
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
|
||||
|
||||
parse_task_with_content(&fm, new_body, &task_path, project_id)
|
||||
}
|
||||
|
||||
fn toggle_task_impl(project_id: &str, task_id: &str) -> Result<Task, String> {
|
||||
let task_path = find_task_path(project_id, task_id)?;
|
||||
|
||||
// Read existing content
|
||||
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
|
||||
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
|
||||
|
||||
// Toggle completed
|
||||
let current_completed = fm
|
||||
.get(&serde_yaml::Value::from("completed"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let new_completed = !current_completed;
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("completed"),
|
||||
serde_yaml::Value::from(new_completed),
|
||||
);
|
||||
|
||||
// Update section based on completion status
|
||||
let new_section = if new_completed {
|
||||
"Completed"
|
||||
} else {
|
||||
"Active"
|
||||
};
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("section"),
|
||||
serde_yaml::Value::from(new_section),
|
||||
);
|
||||
|
||||
// Update timestamp
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now),
|
||||
);
|
||||
|
||||
// Serialize and write (atomic to prevent corruption)
|
||||
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
|
||||
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
|
||||
|
||||
// If completing a recurring task, create the next instance
|
||||
if new_completed {
|
||||
let recurrence = fm
|
||||
.get(&serde_yaml::Value::from("recurrence"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
if let Some(rec) = recurrence {
|
||||
let interval = fm
|
||||
.get(&serde_yaml::Value::from("recurrence_interval"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(1) as i64;
|
||||
|
||||
let title = fm
|
||||
.get(&serde_yaml::Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Untitled")
|
||||
.to_string();
|
||||
|
||||
let due_date = fm
|
||||
.get(&serde_yaml::Value::from("due_date"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let tags = fm
|
||||
.get(&serde_yaml::Value::from("tags"))
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Calculate next due date
|
||||
let next_due = calculate_next_due_date(due_date.as_deref(), &rec, interval);
|
||||
|
||||
// Create the next recurring task
|
||||
let _ = create_recurring_task_impl(
|
||||
project_id,
|
||||
&title,
|
||||
next_due.as_deref(),
|
||||
&rec,
|
||||
interval as u32,
|
||||
&tags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated task
|
||||
let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id)
|
||||
.ok_or_else(|| "Failed to parse updated task".to_string())?;
|
||||
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
fn calculate_next_due_date(current_due: Option<&str>, recurrence: &str, interval: i64) -> Option<String> {
|
||||
use chrono::{NaiveDate, Duration, Utc, Months};
|
||||
|
||||
let base_date = if let Some(due_str) = current_due {
|
||||
NaiveDate::parse_from_str(due_str, "%Y-%m-%d").unwrap_or_else(|_| Utc::now().date_naive())
|
||||
} else {
|
||||
Utc::now().date_naive()
|
||||
};
|
||||
|
||||
let next = match recurrence {
|
||||
"daily" => Some(base_date + Duration::days(interval)),
|
||||
"weekly" => Some(base_date + Duration::weeks(interval)),
|
||||
"monthly" => base_date.checked_add_months(Months::new(interval as u32)),
|
||||
"yearly" => base_date.checked_add_months(Months::new((interval * 12) as u32)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
next.map(|d| d.format("%Y-%m-%d").to_string())
|
||||
}
|
||||
|
||||
fn create_recurring_task_impl(
|
||||
project_id: &str,
|
||||
title: &str,
|
||||
due_date: Option<&str>,
|
||||
recurrence: &str,
|
||||
interval: u32,
|
||||
tags: &[String],
|
||||
) -> Result<TaskWithContent, String> {
|
||||
use chrono::Utc;
|
||||
|
||||
let tasks_dir = ensure_tasks_dir(project_id)?;
|
||||
let now = Utc::now();
|
||||
// Add a small suffix to avoid filename collision with completed task
|
||||
let filename = format!("task-{}-r", now.format("%Y%m%d-%H%M%S"));
|
||||
let task_path = tasks_dir.join(format!("{}.md", filename));
|
||||
|
||||
let now_str = now.to_rfc3339();
|
||||
let id = format!("{}-{}", project_id, filename);
|
||||
|
||||
let mut fm = serde_yaml::Mapping::new();
|
||||
fm.insert(serde_yaml::Value::from("id"), serde_yaml::Value::from(id.clone()));
|
||||
fm.insert(serde_yaml::Value::from("type"), serde_yaml::Value::from("task"));
|
||||
fm.insert(serde_yaml::Value::from("title"), serde_yaml::Value::from(title));
|
||||
fm.insert(serde_yaml::Value::from("completed"), serde_yaml::Value::from(false));
|
||||
fm.insert(serde_yaml::Value::from("section"), serde_yaml::Value::from("Active"));
|
||||
fm.insert(serde_yaml::Value::from("priority"), serde_yaml::Value::from("normal"));
|
||||
fm.insert(serde_yaml::Value::from("is_active"), serde_yaml::Value::from(true));
|
||||
fm.insert(serde_yaml::Value::from("project_id"), serde_yaml::Value::from(project_id));
|
||||
fm.insert(serde_yaml::Value::from("recurrence"), serde_yaml::Value::from(recurrence));
|
||||
fm.insert(serde_yaml::Value::from("recurrence_interval"), serde_yaml::Value::from(interval as u64));
|
||||
|
||||
if let Some(due) = due_date {
|
||||
fm.insert(serde_yaml::Value::from("due_date"), serde_yaml::Value::from(due));
|
||||
}
|
||||
|
||||
if !tags.is_empty() {
|
||||
let yaml_tags: Vec<serde_yaml::Value> = tags.iter().map(|t| serde_yaml::Value::from(t.as_str())).collect();
|
||||
fm.insert(serde_yaml::Value::from("tags"), serde_yaml::Value::Sequence(yaml_tags));
|
||||
}
|
||||
|
||||
fm.insert(serde_yaml::Value::from("created"), serde_yaml::Value::from(now_str.clone()));
|
||||
fm.insert(serde_yaml::Value::from("updated"), serde_yaml::Value::from(now_str.clone()));
|
||||
|
||||
let body = format!("# {}\n\n", title);
|
||||
let content = frontmatter::serialize_frontmatter(&fm, &body)?;
|
||||
|
||||
filesystem::atomic_write(&task_path, content.as_bytes())?;
|
||||
|
||||
Ok(TaskWithContent {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
completed: false,
|
||||
section: "Active".to_string(),
|
||||
priority: Some("normal".to_string()),
|
||||
due_date: due_date.map(String::from),
|
||||
is_active: true,
|
||||
tags: tags.to_vec(),
|
||||
parent_id: None,
|
||||
recurrence: Some(recurrence.to_string()),
|
||||
recurrence_interval: Some(interval),
|
||||
project_id: project_id.to_string(),
|
||||
path: format!("projects/{}/tasks/{}.md", project_id, filename),
|
||||
created: now_str.clone(),
|
||||
updated: now_str,
|
||||
content: body,
|
||||
})
|
||||
}
|
||||
|
||||
fn update_task_meta_impl(
|
||||
project_id: &str,
|
||||
task_id: &str,
|
||||
meta: UpdateTaskMetaRequest,
|
||||
) -> Result<Task, String> {
|
||||
let task_path = find_task_path(project_id, task_id)?;
|
||||
|
||||
// Read existing content
|
||||
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
|
||||
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
|
||||
|
||||
// Update fields if provided
|
||||
if let Some(title) = meta.title {
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("title"),
|
||||
serde_yaml::Value::from(title),
|
||||
);
|
||||
}
|
||||
if let Some(section) = meta.section {
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("section"),
|
||||
serde_yaml::Value::from(section),
|
||||
);
|
||||
}
|
||||
if let Some(priority) = meta.priority {
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("priority"),
|
||||
serde_yaml::Value::from(priority),
|
||||
);
|
||||
}
|
||||
if let Some(due_date) = meta.due_date {
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("due_date"),
|
||||
serde_yaml::Value::from(due_date),
|
||||
);
|
||||
}
|
||||
if let Some(is_active) = meta.is_active {
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("is_active"),
|
||||
serde_yaml::Value::from(is_active),
|
||||
);
|
||||
}
|
||||
if let Some(tags) = meta.tags {
|
||||
let yaml_tags: Vec<serde_yaml::Value> =
|
||||
tags.into_iter().map(serde_yaml::Value::from).collect();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("tags"),
|
||||
serde_yaml::Value::Sequence(yaml_tags),
|
||||
);
|
||||
}
|
||||
if let Some(recurrence) = meta.recurrence {
|
||||
if recurrence.is_empty() {
|
||||
fm.remove(&serde_yaml::Value::from("recurrence"));
|
||||
fm.remove(&serde_yaml::Value::from("recurrence_interval"));
|
||||
} else {
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("recurrence"),
|
||||
serde_yaml::Value::from(recurrence),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(interval) = meta.recurrence_interval {
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("recurrence_interval"),
|
||||
serde_yaml::Value::from(interval as u64),
|
||||
);
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
fm.insert(
|
||||
serde_yaml::Value::from("updated"),
|
||||
serde_yaml::Value::from(now),
|
||||
);
|
||||
|
||||
// Serialize and write (atomic to prevent corruption)
|
||||
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
|
||||
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
|
||||
|
||||
// Return updated task
|
||||
let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id)
|
||||
.ok_or_else(|| "Failed to parse updated task".to_string())?;
|
||||
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> {
|
||||
let task_path = find_task_path(project_id, task_id)?;
|
||||
|
||||
// Move to archive
|
||||
let archive_dir = config::data_dir().join("archive");
|
||||
fs::create_dir_all(&archive_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let filename = task_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("task.md");
|
||||
|
||||
let archive_path = archive_dir.join(format!("{}-{}", project_id, filename));
|
||||
fs::rename(&task_path, &archive_path).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_task_path(project_id: &str, task_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
let tasks_dir = get_tasks_dir(project_id);
|
||||
|
||||
// Try direct filename match
|
||||
let direct_path = tasks_dir.join(format!("{}.md", task_id));
|
||||
if direct_path.exists() {
|
||||
return Ok(direct_path);
|
||||
}
|
||||
|
||||
// Search by ID in frontmatter
|
||||
if let Ok(entries) = fs::read_dir(&tasks_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let file_id = fm
|
||||
.get(&serde_yaml::Value::from("id"))
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
if file_id == Some(task_id) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Task not found".to_string())
|
||||
}
|
||||
|
||||
// ============ Legacy/Global Task Listing ============
|
||||
|
||||
async fn list_all_tasks_handler() -> impl IntoResponse {
|
||||
match list_all_tasks_impl() {
|
||||
Ok(tasks) => Json(tasks).into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to list tasks: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_all_tasks_impl() -> Result<Vec<Task>, String> {
|
||||
let projects_dir = config::data_dir().join("projects");
|
||||
|
||||
if !projects_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut all_tasks = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&projects_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let project_path = entry.path();
|
||||
|
||||
if !project_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let project_id = project_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if let Ok(tasks) = list_project_tasks_impl(&project_id) {
|
||||
all_tasks.extend(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all tasks by updated date descending
|
||||
// Sort by created date (stable ordering)
|
||||
all_tasks.sort_by(|a, b| b.created.cmp(&a.created));
|
||||
|
||||
Ok(all_tasks)
|
||||
}
|
||||
349
backend/src/services/filesystem.rs
Normal file
349
backend/src/services/filesystem.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_yaml::Value;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::models::note::{Note, NoteSummary};
|
||||
use crate::services::frontmatter;
|
||||
|
||||
use crate::config;
|
||||
|
||||
/// List all notes in the filesystem (read-only).
|
||||
pub fn list_notes() -> Result<Vec<NoteSummary>, String> {
|
||||
let mut notes = Vec::new();
|
||||
let root = config::data_dir();
|
||||
|
||||
for entry in WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_ignored(e.path()))
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only include notes and project index files
|
||||
if !is_note_file(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match parse_note_summary(path) {
|
||||
Ok(note) => notes.push(note),
|
||||
Err(err) => {
|
||||
tracing::warn!("Skipping file {:?}: {}", path, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
fn is_ignored(path: &Path) -> bool {
|
||||
path.components().any(|c| {
|
||||
matches!(
|
||||
c.as_os_str().to_str(),
|
||||
Some(".git") | Some("assets") | Some("archive")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_note_file(path: &Path) -> bool {
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
// data/notes/**/*.md (handles both forward and back slashes)
|
||||
if path_str.contains("notes") && !path_str.contains("archive") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// data/projects/*/index.md
|
||||
if path_str.contains("projects") && path.file_name().and_then(|s| s.to_str()) == Some("index.md") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Root-level files (index.md, inbox.md) - parent is the data dir
|
||||
if let Some(parent) = path.parent() {
|
||||
if parent == config::data_dir() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_note_summary(path: &Path) -> Result<NoteSummary, String> {
|
||||
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let (fm, _body, _has_fm) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let id = fm
|
||||
.get(&Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| frontmatter::derive_id_from_path(path));
|
||||
|
||||
let title = fm
|
||||
.get(&Value::from("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Untitled")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let note_type = fm
|
||||
.get(&Value::from("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("note")
|
||||
.to_string();
|
||||
|
||||
let updated = fm
|
||||
.get(&Value::from("updated"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
Ok(NoteSummary {
|
||||
id,
|
||||
title,
|
||||
path: normalize_path(path),
|
||||
note_type,
|
||||
updated,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_path(path: &Path) -> String {
|
||||
// Strip the data directory prefix and normalize separators
|
||||
let path_str = path.to_string_lossy();
|
||||
let stripped = if let Some(idx) = path_str.find("data") {
|
||||
&path_str[idx + 5..] // Skip "data" + separator
|
||||
} else {
|
||||
&path_str
|
||||
};
|
||||
stripped.replace('\\', "/").trim_start_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// Read a full note by deterministic ID.
|
||||
pub fn read_note_by_id(note_id: &str) -> Result<Note, String> {
|
||||
let root = config::data_dir();
|
||||
|
||||
for entry in WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_ignored(e.path()))
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !is_note_file(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let (fm, body, _has_fm) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let derived_id = fm
|
||||
.get(&Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| frontmatter::derive_id_from_path(path));
|
||||
|
||||
if derived_id != note_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
let note_type = fm
|
||||
.get(&Value::from("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("note")
|
||||
.to_string();
|
||||
|
||||
return Ok(Note {
|
||||
id: derived_id,
|
||||
path: normalize_path(path),
|
||||
note_type,
|
||||
frontmatter: fm,
|
||||
content: body.trim_start().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(format!("Note not found: {}", note_id))
|
||||
}
|
||||
|
||||
/// Create a new empty note in data/notes/.
|
||||
pub fn create_note() -> Result<Note, String> {
|
||||
use chrono::Utc;
|
||||
|
||||
let dir = config::data_dir().join("notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let filename = format!("{}.md", Utc::now().format("%Y%m%d-%H%M%S"));
|
||||
let path = dir.join(&filename);
|
||||
|
||||
let fm = frontmatter::generate_frontmatter(&path, "note");
|
||||
let content = frontmatter::serialize_frontmatter(&fm, "")?;
|
||||
|
||||
// Atomic write: write to temp file, then rename
|
||||
atomic_write(&path, content.as_bytes())?;
|
||||
|
||||
let id = fm
|
||||
.get(&Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(Note {
|
||||
id,
|
||||
path: normalize_path(&path),
|
||||
note_type: "note".to_string(),
|
||||
frontmatter: fm,
|
||||
content: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Update an existing note by ID with full markdown payload.
|
||||
/// Handles notes with or without existing frontmatter.
|
||||
/// Preserves user-defined fields, updates backend-owned fields.
|
||||
pub fn update_note(note_id: &str, new_content: &str) -> Result<Note, String> {
|
||||
let root = config::data_dir();
|
||||
|
||||
for entry in WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_ignored(e.path()))
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !is_note_file(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let (mut fm, _old_body, has_fm) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let derived_id = fm
|
||||
.get(&Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| frontmatter::derive_id_from_path(path));
|
||||
|
||||
if derived_id != note_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure frontmatter has all required fields
|
||||
// This handles files without frontmatter or with incomplete frontmatter
|
||||
if !has_fm || !frontmatter::is_frontmatter_complete(&fm) {
|
||||
frontmatter::ensure_frontmatter(&mut fm, path);
|
||||
} else {
|
||||
// Just update the timestamp
|
||||
frontmatter::update_frontmatter(&mut fm);
|
||||
}
|
||||
|
||||
// Rebuild file content
|
||||
let rebuilt = frontmatter::serialize_frontmatter(&fm, new_content.trim_start())?;
|
||||
|
||||
// Atomic write
|
||||
atomic_write(path, rebuilt.as_bytes())?;
|
||||
|
||||
let note_type = fm
|
||||
.get(&Value::from("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("note")
|
||||
.to_string();
|
||||
|
||||
return Ok(Note {
|
||||
id: derived_id,
|
||||
path: normalize_path(path),
|
||||
note_type,
|
||||
frontmatter: fm,
|
||||
content: new_content.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(format!("Note not found: {}", note_id))
|
||||
}
|
||||
|
||||
/// Archive a note by ID (move to data/archive/).
|
||||
pub fn archive_note(note_id: &str) -> Result<(), String> {
|
||||
let root = config::data_dir();
|
||||
let archive_dir = config::data_dir().join("archive");
|
||||
|
||||
fs::create_dir_all(&archive_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
for entry in WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_ignored(e.path()))
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !is_note_file(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let (fm, _, _) = frontmatter::parse_frontmatter(&content);
|
||||
|
||||
let derived_id = fm
|
||||
.get(&Value::from("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| frontmatter::derive_id_from_path(path));
|
||||
|
||||
if derived_id != note_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_name().ok_or("Invalid filename")?;
|
||||
let target = archive_dir.join(filename);
|
||||
|
||||
fs::rename(path, target).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!("Note not found: {}", note_id))
|
||||
}
|
||||
|
||||
/// Atomic write: write to temp file, then rename.
|
||||
/// This prevents data loss on crash or power failure.
|
||||
/// Also marks the file as recently saved to avoid triggering external edit notifications.
|
||||
pub fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> {
|
||||
let parent = path.parent().ok_or("Invalid path")?;
|
||||
let temp_name = format!(
|
||||
".{}.tmp",
|
||||
path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("file")
|
||||
);
|
||||
let temp_path = parent.join(temp_name);
|
||||
|
||||
// Mark this file as being saved by us (to avoid triggering external edit notification)
|
||||
let normalized = normalize_path(path);
|
||||
crate::watcher::mark_file_saved(&normalized);
|
||||
|
||||
// Write to temp file
|
||||
let mut file = fs::File::create(&temp_path).map_err(|e| e.to_string())?;
|
||||
file.write_all(contents).map_err(|e| e.to_string())?;
|
||||
file.sync_all().map_err(|e| e.to_string())?;
|
||||
drop(file);
|
||||
|
||||
// Rename temp file to target (atomic on most filesystems)
|
||||
fs::rename(&temp_path, path).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
191
backend/src/services/frontmatter.rs
Normal file
191
backend/src/services/frontmatter.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_yaml::{Mapping, Value};
|
||||
|
||||
/// Derive deterministic ID from file path.
|
||||
/// Matches filesystem ID logic: strips data directory prefix and folder name.
|
||||
pub fn derive_id_from_path(path: &Path) -> String {
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
// Find "data" in the path and strip everything before and including it
|
||||
let rel_str = if let Some(idx) = path_str.find("data") {
|
||||
&path_str[idx + 5..] // Skip "data" + separator
|
||||
} else {
|
||||
&path_str
|
||||
};
|
||||
|
||||
// Split by both forward and back slashes, filter empty parts
|
||||
let mut parts: Vec<String> = rel_str
|
||||
.split(['/', '\\'])
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.replace(".md", ""))
|
||||
.collect();
|
||||
|
||||
// Drop top-level folder name (notes, projects, etc.) if we have multiple parts
|
||||
if parts.len() > 1 {
|
||||
parts.remove(0);
|
||||
}
|
||||
|
||||
parts.join("-")
|
||||
}
|
||||
|
||||
/// Parse frontmatter from file content.
|
||||
/// Returns (frontmatter mapping, body content, has_frontmatter flag).
|
||||
pub fn parse_frontmatter(content: &str) -> (Mapping, String, bool) {
|
||||
if !content.starts_with("---") {
|
||||
return (Mapping::new(), content.to_string(), false);
|
||||
}
|
||||
|
||||
let mut parts = content.splitn(3, "---");
|
||||
parts.next(); // empty before first ---
|
||||
let yaml = parts.next().unwrap_or("");
|
||||
let body = parts.next().unwrap_or("");
|
||||
|
||||
let fm: Value = serde_yaml::from_str(yaml).unwrap_or(Value::Null);
|
||||
let map = fm.as_mapping().cloned().unwrap_or_default();
|
||||
|
||||
(map, body.to_string(), true)
|
||||
}
|
||||
|
||||
/// Serialize frontmatter and body back to markdown string.
|
||||
pub fn serialize_frontmatter(frontmatter: &Mapping, body: &str) -> Result<String, String> {
|
||||
let yaml = serde_yaml::to_string(frontmatter).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("---\n");
|
||||
content.push_str(&yaml);
|
||||
content.push_str("---\n\n");
|
||||
content.push_str(body.trim_start());
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Generate initial frontmatter for a newly created file.
|
||||
/// Sets backend-owned fields only.
|
||||
pub fn generate_frontmatter(path: &Path, note_type: &str) -> Mapping {
|
||||
let mut map = Mapping::new();
|
||||
|
||||
let id = derive_id_from_path(path);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
map.insert(Value::from("id"), Value::from(id));
|
||||
map.insert(Value::from("type"), Value::from(note_type));
|
||||
map.insert(Value::from("created"), Value::from(now.clone()));
|
||||
map.insert(Value::from("updated"), Value::from(now));
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
/// Ensure frontmatter has all required backend-owned fields.
|
||||
/// - If `id` is missing, derive from path
|
||||
/// - If `created` is missing, set to now
|
||||
/// - Always updates `updated` timestamp
|
||||
/// - Preserves all user-defined fields (title, tags, status, etc.)
|
||||
pub fn ensure_frontmatter(existing: &mut Mapping, path: &Path) {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
// Ensure ID exists (derive from path if missing)
|
||||
if !existing.contains_key(&Value::from("id")) {
|
||||
let id = derive_id_from_path(path);
|
||||
existing.insert(Value::from("id"), Value::from(id));
|
||||
}
|
||||
|
||||
// Ensure created timestamp exists (set once, never overwritten)
|
||||
if !existing.contains_key(&Value::from("created")) {
|
||||
existing.insert(Value::from("created"), Value::from(now.clone()));
|
||||
}
|
||||
|
||||
// Always update the updated timestamp
|
||||
existing.insert(Value::from("updated"), Value::from(now));
|
||||
}
|
||||
|
||||
/// Update frontmatter on save.
|
||||
/// Only updates `updated` timestamp, preserves all other fields.
|
||||
pub fn update_frontmatter(existing: &mut Mapping) {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
existing.insert(Value::from("updated"), Value::from(now));
|
||||
}
|
||||
|
||||
/// Check if frontmatter has all required backend-owned fields.
|
||||
pub fn is_frontmatter_complete(frontmatter: &Mapping) -> bool {
|
||||
frontmatter.contains_key(&Value::from("id"))
|
||||
&& frontmatter.contains_key(&Value::from("created"))
|
||||
&& frontmatter.contains_key(&Value::from("updated"))
|
||||
}
|
||||
|
||||
// ============ Helper functions for cleaner frontmatter field access ============
|
||||
|
||||
/// Get a string value from frontmatter by key.
|
||||
pub fn get_str(fm: &Mapping, key: &str) -> Option<String> {
|
||||
fm.get(&Value::from(key))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Get a string value from frontmatter, with a default fallback.
|
||||
pub fn get_str_or(fm: &Mapping, key: &str, default: &str) -> String {
|
||||
get_str(fm, key).unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
/// Get a bool value from frontmatter by key.
|
||||
pub fn get_bool(fm: &Mapping, key: &str) -> Option<bool> {
|
||||
fm.get(&Value::from(key)).and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
/// Get a bool value from frontmatter, with a default fallback.
|
||||
pub fn get_bool_or(fm: &Mapping, key: &str, default: bool) -> bool {
|
||||
get_bool(fm, key).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Get a u64 value from frontmatter by key.
|
||||
pub fn get_u64(fm: &Mapping, key: &str) -> Option<u64> {
|
||||
fm.get(&Value::from(key)).and_then(|v| v.as_u64())
|
||||
}
|
||||
|
||||
/// Get a string sequence (tags, etc.) from frontmatter by key.
|
||||
pub fn get_string_seq(fm: &Mapping, key: &str) -> Vec<String> {
|
||||
fm.get(&Value::from(key))
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_frontmatter_with_frontmatter() {
|
||||
let content = "---\nid: test\ntitle: Test Note\n---\n\nBody content";
|
||||
let (fm, body, has_fm) = parse_frontmatter(content);
|
||||
|
||||
assert!(has_fm);
|
||||
assert_eq!(fm.get(&Value::from("id")).unwrap().as_str().unwrap(), "test");
|
||||
assert_eq!(fm.get(&Value::from("title")).unwrap().as_str().unwrap(), "Test Note");
|
||||
assert!(body.contains("Body content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_frontmatter_without_frontmatter() {
|
||||
let content = "Just some content without frontmatter";
|
||||
let (fm, body, has_fm) = parse_frontmatter(content);
|
||||
|
||||
assert!(!has_fm);
|
||||
assert!(fm.is_empty());
|
||||
assert_eq!(body, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_id_from_path() {
|
||||
let path = Path::new("data/notes/my-note.md");
|
||||
assert_eq!(derive_id_from_path(path), "my-note");
|
||||
|
||||
let path = Path::new("data/projects/myproject/index.md");
|
||||
assert_eq!(derive_id_from_path(path), "myproject-index");
|
||||
}
|
||||
}
|
||||
655
backend/src/services/git.rs
Normal file
655
backend/src/services/git.rs
Normal file
@@ -0,0 +1,655 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use git2::{Repository, Signature, StatusOptions};
|
||||
use serde::Serialize;
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::config;
|
||||
|
||||
/// Git status for a file
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FileStatus {
|
||||
pub path: String,
|
||||
pub status: String, // "new", "modified", "deleted", "renamed", "untracked"
|
||||
}
|
||||
|
||||
/// Overall repository status
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RepoStatus {
|
||||
pub is_repo: bool,
|
||||
pub branch: Option<String>,
|
||||
pub files: Vec<FileStatus>,
|
||||
pub has_changes: bool,
|
||||
pub last_commit: Option<CommitInfo>,
|
||||
}
|
||||
|
||||
/// Commit information
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CommitInfo {
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// Extended commit info for history
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CommitDetail {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub message: String,
|
||||
pub author: String,
|
||||
pub timestamp: String,
|
||||
pub files_changed: usize,
|
||||
}
|
||||
|
||||
/// Diff information
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DiffInfo {
|
||||
pub files: Vec<FileDiff>,
|
||||
pub stats: DiffStats,
|
||||
}
|
||||
|
||||
/// File diff
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileDiff {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
pub additions: usize,
|
||||
pub deletions: usize,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
|
||||
/// Diff hunk (section of changes)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DiffHunk {
|
||||
pub header: String,
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
|
||||
/// Single diff line
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DiffLine {
|
||||
pub origin: char,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Diff statistics
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DiffStats {
|
||||
pub files_changed: usize,
|
||||
pub insertions: usize,
|
||||
pub deletions: usize,
|
||||
}
|
||||
|
||||
/// Remote repository information
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RemoteInfo {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub has_upstream: bool,
|
||||
pub ahead: usize,
|
||||
pub behind: usize,
|
||||
}
|
||||
|
||||
/// Auto-commit is enabled by default.
|
||||
/// The background task simply tries to commit every interval;
|
||||
/// commit_all() already handles "no changes" gracefully.
|
||||
|
||||
/// Get repository status
|
||||
pub fn get_status() -> Result<RepoStatus, String> {
|
||||
let data_path = config::data_dir();
|
||||
|
||||
// Try to open as git repo
|
||||
let repo = match Repository::open(data_path) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
return Ok(RepoStatus {
|
||||
is_repo: false,
|
||||
branch: None,
|
||||
files: Vec::new(),
|
||||
has_changes: false,
|
||||
last_commit: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get current branch
|
||||
let branch = repo
|
||||
.head()
|
||||
.ok()
|
||||
.and_then(|h| h.shorthand().map(String::from));
|
||||
|
||||
// Get file statuses
|
||||
let mut opts = StatusOptions::new();
|
||||
opts.include_untracked(true)
|
||||
.recurse_untracked_dirs(true)
|
||||
.exclude_submodules(true);
|
||||
|
||||
let statuses = repo.statuses(Some(&mut opts)).map_err(|e| e.to_string())?;
|
||||
|
||||
let files: Vec<FileStatus> = statuses
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path()?.to_string();
|
||||
let status = entry.status();
|
||||
|
||||
let status_str = if status.is_index_new() || status.is_wt_new() {
|
||||
"new"
|
||||
} else if status.is_index_modified() || status.is_wt_modified() {
|
||||
"modified"
|
||||
} else if status.is_index_deleted() || status.is_wt_deleted() {
|
||||
"deleted"
|
||||
} else if status.is_index_renamed() || status.is_wt_renamed() {
|
||||
"renamed"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(FileStatus {
|
||||
path,
|
||||
status: status_str.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let has_changes = !files.is_empty();
|
||||
|
||||
// Get last commit info
|
||||
let last_commit = repo.head().ok().and_then(|head| {
|
||||
let commit = head.peel_to_commit().ok()?;
|
||||
Some(CommitInfo {
|
||||
id: commit.id().to_string()[..8].to_string(),
|
||||
message: commit.message()?.trim().to_string(),
|
||||
timestamp: chrono::DateTime::from_timestamp(commit.time().seconds(), 0)?
|
||||
.to_rfc3339(),
|
||||
})
|
||||
});
|
||||
|
||||
Ok(RepoStatus {
|
||||
is_repo: true,
|
||||
branch,
|
||||
files,
|
||||
has_changes,
|
||||
last_commit,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a commit with all changes
|
||||
pub fn commit_all(message: Option<&str>) -> Result<CommitInfo, String> {
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
|
||||
// Stage all changes
|
||||
let mut index = repo.index().map_err(|e| e.to_string())?;
|
||||
index
|
||||
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
index.write().map_err(|e| e.to_string())?;
|
||||
|
||||
// Check if there are changes to commit
|
||||
let tree_id = index.write_tree().map_err(|e| e.to_string())?;
|
||||
let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?;
|
||||
|
||||
// Get parent commit (if any)
|
||||
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
|
||||
|
||||
// Check if tree is different from parent
|
||||
if let Some(ref p) = parent {
|
||||
if p.tree().map(|t| t.id()) == Ok(tree_id) {
|
||||
return Err("No changes to commit".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Create signature
|
||||
let sig = Signature::now("Ironpad", "ironpad@local").map_err(|e| e.to_string())?;
|
||||
|
||||
// Generate commit message
|
||||
let msg = message.unwrap_or_else(|| "Auto-save");
|
||||
let timestamp = Utc::now().format("%Y-%m-%d %H:%M");
|
||||
let full_message = format!("{} ({})", msg, timestamp);
|
||||
|
||||
// Create commit
|
||||
let parents: Vec<&git2::Commit> = parent.as_ref().map(|p| vec![p]).unwrap_or_default();
|
||||
let commit_id = repo
|
||||
.commit(Some("HEAD"), &sig, &sig, &full_message, &tree, &parents)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(CommitInfo {
|
||||
id: commit_id.to_string()[..8].to_string(),
|
||||
message: full_message,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize data directory as a git repository if not already
|
||||
pub fn init_repo() -> Result<(), String> {
|
||||
let data_path = config::data_dir();
|
||||
|
||||
if Repository::open(data_path).is_ok() {
|
||||
return Ok(()); // Already a repo
|
||||
}
|
||||
|
||||
Repository::init(data_path).map_err(|e| format!("Failed to init repo: {}", e))?;
|
||||
|
||||
// Create initial .gitignore
|
||||
let gitignore_path = data_path.join(".gitignore");
|
||||
if !gitignore_path.exists() {
|
||||
std::fs::write(&gitignore_path, "*.tmp\n.DS_Store\n")
|
||||
.map_err(|e| format!("Failed to create .gitignore: {}", e))?;
|
||||
}
|
||||
|
||||
// Initial commit
|
||||
commit_all(Some("Initial commit"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check for merge conflicts
|
||||
pub fn check_conflicts() -> Result<Vec<String>, String> {
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// Check for .git/index.lock (another git operation in progress)
|
||||
let lock_path = data_path.join(".git").join("index.lock");
|
||||
if lock_path.exists() {
|
||||
// This isn't a conflict per se, but indicates git is busy
|
||||
tracing::warn!("Git index.lock exists - another operation may be in progress");
|
||||
}
|
||||
|
||||
// Check status for conflicted files
|
||||
let mut opts = StatusOptions::new();
|
||||
opts.include_untracked(false);
|
||||
|
||||
let statuses = repo.statuses(Some(&mut opts)).map_err(|e| e.to_string())?;
|
||||
|
||||
for entry in statuses.iter() {
|
||||
let status = entry.status();
|
||||
// Check for conflict status flags
|
||||
if status.is_conflicted() {
|
||||
if let Some(path) = entry.path() {
|
||||
conflicts.push(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check the index for conflicts
|
||||
let index = repo.index().map_err(|e| e.to_string())?;
|
||||
if index.has_conflicts() {
|
||||
for conflict in index.conflicts().map_err(|e| e.to_string())? {
|
||||
if let Ok(conflict) = conflict {
|
||||
if let Some(ancestor) = conflict.ancestor {
|
||||
if let Some(path) = std::str::from_utf8(&ancestor.path).ok() {
|
||||
if !conflicts.contains(&path.to_string()) {
|
||||
conflicts.push(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(conflicts)
|
||||
}
|
||||
|
||||
/// Push to remote repository
|
||||
pub fn push_to_remote() -> Result<(), String> {
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
|
||||
// Get the current branch
|
||||
let head = repo.head().map_err(|e| e.to_string())?;
|
||||
let branch_name = head
|
||||
.shorthand()
|
||||
.ok_or_else(|| "Could not get branch name".to_string())?;
|
||||
|
||||
// Find the remote (default to "origin")
|
||||
let mut remote = repo
|
||||
.find_remote("origin")
|
||||
.map_err(|e| format!("Remote 'origin' not found: {}", e))?;
|
||||
|
||||
// Check if remote URL is configured
|
||||
let remote_url = remote.url().ok_or_else(|| "No remote URL configured".to_string())?;
|
||||
if remote_url.is_empty() {
|
||||
return Err("No remote URL configured".to_string());
|
||||
}
|
||||
|
||||
// Create callbacks for authentication
|
||||
let mut callbacks = git2::RemoteCallbacks::new();
|
||||
|
||||
// Try to use credential helper from git config
|
||||
callbacks.credentials(|_url, username_from_url, _allowed_types| {
|
||||
// Try SSH agent first
|
||||
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
|
||||
});
|
||||
|
||||
// Set up push options
|
||||
let mut push_options = git2::PushOptions::new();
|
||||
push_options.remote_callbacks(callbacks);
|
||||
|
||||
// Push the current branch
|
||||
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
|
||||
remote
|
||||
.push(&[&refspec], Some(&mut push_options))
|
||||
.map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?;
|
||||
|
||||
tracing::info!("Successfully pushed to origin/{}", branch_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if remote is configured
|
||||
pub fn has_remote() -> bool {
|
||||
let data_path = config::data_dir();
|
||||
if let Ok(repo) = Repository::open(data_path) {
|
||||
if let Ok(remote) = repo.find_remote("origin") {
|
||||
return remote.url().is_some();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Start auto-commit background task.
|
||||
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
|
||||
pub fn start_auto_commit() {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = interval(Duration::from_secs(60));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match commit_all(Some("Auto-save")) {
|
||||
Ok(info) => {
|
||||
tracing::info!("Auto-commit: {} - {}", info.id, info.message);
|
||||
}
|
||||
Err(e) => {
|
||||
if !e.contains("No changes") {
|
||||
tracing::warn!("Auto-commit failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Get commit history (most recent first)
|
||||
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
|
||||
let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?;
|
||||
revwalk.push_head().map_err(|e| e.to_string())?;
|
||||
revwalk
|
||||
.set_sorting(git2::Sort::TIME)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let max_commits = limit.unwrap_or(50);
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for (i, oid_result) in revwalk.enumerate() {
|
||||
if i >= max_commits {
|
||||
break;
|
||||
}
|
||||
|
||||
let oid = oid_result.map_err(|e| e.to_string())?;
|
||||
let commit = repo.find_commit(oid).map_err(|e| e.to_string())?;
|
||||
|
||||
// Count files changed in this commit
|
||||
let files_changed = if commit.parent_count() > 0 {
|
||||
let parent = commit.parent(0).ok();
|
||||
let parent_tree = parent.as_ref().and_then(|p| p.tree().ok());
|
||||
let commit_tree = commit.tree().ok();
|
||||
|
||||
if let (Some(pt), Some(ct)) = (parent_tree, commit_tree) {
|
||||
let diff = repo
|
||||
.diff_tree_to_tree(Some(&pt), Some(&ct), None)
|
||||
.ok();
|
||||
diff.map(|d| d.deltas().count()).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
// Initial commit - count all files
|
||||
commit
|
||||
.tree()
|
||||
.ok()
|
||||
.map(|t| count_tree_entries(&t))
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let timestamp =
|
||||
chrono::DateTime::from_timestamp(commit.time().seconds(), 0)
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
commits.push(CommitDetail {
|
||||
id: oid.to_string(),
|
||||
short_id: oid.to_string()[..8].to_string(),
|
||||
message: commit.message().unwrap_or("").trim().to_string(),
|
||||
author: commit.author().name().unwrap_or("Unknown").to_string(),
|
||||
timestamp,
|
||||
files_changed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// Helper to count entries in a tree recursively
|
||||
fn count_tree_entries(tree: &git2::Tree) -> usize {
|
||||
tree.iter()
|
||||
.filter(|entry| entry.kind() == Some(git2::ObjectType::Blob))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Get working directory diff (uncommitted changes)
|
||||
pub fn get_working_diff() -> Result<DiffInfo, String> {
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
|
||||
// Get HEAD tree (or empty tree if no commits)
|
||||
let head_tree = repo
|
||||
.head()
|
||||
.ok()
|
||||
.and_then(|h| h.peel_to_tree().ok());
|
||||
|
||||
// Diff against working directory
|
||||
let diff = repo
|
||||
.diff_tree_to_workdir_with_index(head_tree.as_ref(), None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
parse_diff(&diff)
|
||||
}
|
||||
|
||||
/// Get diff for a specific commit
|
||||
pub fn get_commit_diff(commit_id: &str) -> Result<DiffInfo, String> {
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
|
||||
let oid = git2::Oid::from_str(commit_id).map_err(|e| format!("Invalid commit ID: {}", e))?;
|
||||
let commit = repo
|
||||
.find_commit(oid)
|
||||
.map_err(|e| format!("Commit not found: {}", e))?;
|
||||
|
||||
let commit_tree = commit.tree().map_err(|e| e.to_string())?;
|
||||
|
||||
let parent_tree = if commit.parent_count() > 0 {
|
||||
commit.parent(0).ok().and_then(|p| p.tree().ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let diff = repo
|
||||
.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
parse_diff(&diff)
|
||||
}
|
||||
|
||||
/// Parse a git2::Diff into our DiffInfo structure
|
||||
fn parse_diff(diff: &git2::Diff) -> Result<DiffInfo, String> {
|
||||
let stats = diff.stats().map_err(|e| e.to_string())?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
for delta_idx in 0..diff.deltas().count() {
|
||||
let delta = diff.get_delta(delta_idx).ok_or("Missing delta")?;
|
||||
|
||||
let path = delta
|
||||
.new_file()
|
||||
.path()
|
||||
.or_else(|| delta.old_file().path())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let status = match delta.status() {
|
||||
git2::Delta::Added => "added",
|
||||
git2::Delta::Deleted => "deleted",
|
||||
git2::Delta::Modified => "modified",
|
||||
git2::Delta::Renamed => "renamed",
|
||||
git2::Delta::Copied => "copied",
|
||||
_ => "unknown",
|
||||
};
|
||||
|
||||
let mut hunks = Vec::new();
|
||||
let mut additions = 0;
|
||||
let mut deletions = 0;
|
||||
|
||||
// Get patch for this file
|
||||
if let Ok(patch) = git2::Patch::from_diff(diff, delta_idx) {
|
||||
if let Some(p) = patch {
|
||||
for hunk_idx in 0..p.num_hunks() {
|
||||
if let Ok((hunk, _)) = p.hunk(hunk_idx) {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for line_idx in 0..p.num_lines_in_hunk(hunk_idx).unwrap_or(0) {
|
||||
if let Ok(line) = p.line_in_hunk(hunk_idx, line_idx) {
|
||||
let origin = line.origin();
|
||||
let content = std::str::from_utf8(line.content())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
match origin {
|
||||
'+' => additions += 1,
|
||||
'-' => deletions += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
lines.push(DiffLine { origin, content });
|
||||
}
|
||||
}
|
||||
|
||||
hunks.push(DiffHunk {
|
||||
header: std::str::from_utf8(hunk.header())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string(),
|
||||
lines,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.push(FileDiff {
|
||||
path,
|
||||
status: status.to_string(),
|
||||
additions,
|
||||
deletions,
|
||||
hunks,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(DiffInfo {
|
||||
files,
|
||||
stats: DiffStats {
|
||||
files_changed: stats.files_changed(),
|
||||
insertions: stats.insertions(),
|
||||
deletions: stats.deletions(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Get remote repository information
|
||||
pub fn get_remote_info() -> Result<Option<RemoteInfo>, String> {
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
|
||||
let remote = match repo.find_remote("origin") {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
let url = remote.url().unwrap_or("").to_string();
|
||||
if url.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
let head = match repo.head() {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return Ok(Some(RemoteInfo {
|
||||
name: "origin".to_string(),
|
||||
url,
|
||||
has_upstream: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let branch_name = head.shorthand().unwrap_or("HEAD");
|
||||
|
||||
// Try to find upstream branch
|
||||
let local_branch = repo.find_branch(branch_name, git2::BranchType::Local).ok();
|
||||
let upstream = local_branch.as_ref().and_then(|b| b.upstream().ok());
|
||||
|
||||
let (ahead, behind) = if let Some(ref up) = upstream {
|
||||
// Calculate ahead/behind
|
||||
let local_oid = head.target().unwrap_or_else(git2::Oid::zero);
|
||||
let upstream_oid = up
|
||||
.get()
|
||||
.target()
|
||||
.unwrap_or_else(git2::Oid::zero);
|
||||
|
||||
repo.graph_ahead_behind(local_oid, upstream_oid)
|
||||
.unwrap_or((0, 0))
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
Ok(Some(RemoteInfo {
|
||||
name: "origin".to_string(),
|
||||
url,
|
||||
has_upstream: upstream.is_some(),
|
||||
ahead,
|
||||
behind,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch from remote
|
||||
pub fn fetch_from_remote() -> Result<(), String> {
|
||||
let data_path = config::data_dir();
|
||||
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
|
||||
|
||||
let mut remote = repo
|
||||
.find_remote("origin")
|
||||
.map_err(|e| format!("Remote 'origin' not found: {}", e))?;
|
||||
|
||||
// Create callbacks for authentication
|
||||
let mut callbacks = git2::RemoteCallbacks::new();
|
||||
callbacks.credentials(|_url, username_from_url, _allowed_types| {
|
||||
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
|
||||
});
|
||||
|
||||
let mut fetch_options = git2::FetchOptions::new();
|
||||
fetch_options.remote_callbacks(callbacks);
|
||||
|
||||
remote
|
||||
.fetch(&[] as &[&str], Some(&mut fetch_options), None)
|
||||
.map_err(|e| format!("Fetch failed: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
149
backend/src/services/locks.rs
Normal file
149
backend/src/services/locks.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Type of lock held on a file
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LockType {
|
||||
Editor,
|
||||
TaskView,
|
||||
}
|
||||
|
||||
/// Information about a file lock
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LockInfo {
|
||||
pub path: String,
|
||||
pub client_id: String,
|
||||
pub lock_type: LockType,
|
||||
pub acquired_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Error type for lock operations
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum LockError {
|
||||
AlreadyLocked { holder: String, lock_type: LockType },
|
||||
NotLocked,
|
||||
NotOwner,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LockError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LockError::AlreadyLocked { holder, lock_type } => {
|
||||
write!(f, "File already locked by {} ({:?})", holder, lock_type)
|
||||
}
|
||||
LockError::NotLocked => write!(f, "File is not locked"),
|
||||
LockError::NotOwner => write!(f, "You do not own this lock"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages file locks across the application
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileLockManager {
|
||||
locks: Arc<RwLock<HashMap<String, LockInfo>>>,
|
||||
}
|
||||
|
||||
impl FileLockManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to acquire a lock on a file
|
||||
pub async fn acquire(
|
||||
&self,
|
||||
path: &str,
|
||||
client_id: &str,
|
||||
lock_type: LockType,
|
||||
) -> Result<LockInfo, LockError> {
|
||||
let mut locks = self.locks.write().await;
|
||||
|
||||
// Check if already locked
|
||||
if let Some(existing) = locks.get(path) {
|
||||
if existing.client_id != client_id {
|
||||
return Err(LockError::AlreadyLocked {
|
||||
holder: existing.client_id.clone(),
|
||||
lock_type: existing.lock_type,
|
||||
});
|
||||
}
|
||||
// Same client - update lock type
|
||||
}
|
||||
|
||||
let lock_info = LockInfo {
|
||||
path: path.to_string(),
|
||||
client_id: client_id.to_string(),
|
||||
lock_type,
|
||||
acquired_at: Utc::now(),
|
||||
};
|
||||
|
||||
locks.insert(path.to_string(), lock_info.clone());
|
||||
Ok(lock_info)
|
||||
}
|
||||
|
||||
/// Release a lock on a file
|
||||
pub async fn release(&self, path: &str, client_id: &str) -> Result<(), LockError> {
|
||||
let mut locks = self.locks.write().await;
|
||||
|
||||
if let Some(existing) = locks.get(path) {
|
||||
if existing.client_id != client_id {
|
||||
return Err(LockError::NotOwner);
|
||||
}
|
||||
locks.remove(path);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(LockError::NotLocked)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a file is locked
|
||||
pub async fn is_locked(&self, path: &str) -> Option<LockInfo> {
|
||||
let locks = self.locks.read().await;
|
||||
locks.get(path).cloned()
|
||||
}
|
||||
|
||||
/// Check if a file is locked by someone other than the given client
|
||||
pub async fn is_locked_by_other(&self, path: &str, client_id: &str) -> Option<LockInfo> {
|
||||
let locks = self.locks.read().await;
|
||||
locks.get(path).and_then(|lock| {
|
||||
if lock.client_id != client_id {
|
||||
Some(lock.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Release all locks held by a client (used on disconnect)
|
||||
pub async fn release_all_for_client(&self, client_id: &str) -> Vec<String> {
|
||||
let mut locks = self.locks.write().await;
|
||||
let paths_to_remove: Vec<String> = locks
|
||||
.iter()
|
||||
.filter(|(_, lock)| lock.client_id == client_id)
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect();
|
||||
|
||||
for path in &paths_to_remove {
|
||||
locks.remove(path);
|
||||
}
|
||||
|
||||
paths_to_remove
|
||||
}
|
||||
|
||||
/// Get all current locks (for debugging/monitoring)
|
||||
pub async fn get_all_locks(&self) -> Vec<LockInfo> {
|
||||
let locks = self.locks.read().await;
|
||||
locks.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FileLockManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
0
backend/src/services/markdown.rs
Normal file
0
backend/src/services/markdown.rs
Normal file
6
backend/src/services/mod.rs
Normal file
6
backend/src/services/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod filesystem;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod locks;
|
||||
pub mod markdown;
|
||||
pub mod search;
|
||||
188
backend/src/services/search.rs
Normal file
188
backend/src/services/search.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::Serialize;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::config;
|
||||
|
||||
/// Search result item
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub matches: Vec<SearchMatch>,
|
||||
}
|
||||
|
||||
/// Individual match within a file
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchMatch {
|
||||
pub line_number: u32,
|
||||
pub line_content: String,
|
||||
}
|
||||
|
||||
/// Search notes using simple string matching
|
||||
/// Falls back to manual search if ripgrep is not available
|
||||
pub fn search_notes(query: &str) -> Result<Vec<SearchResult>, String> {
|
||||
if query.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Try ripgrep first (faster)
|
||||
match search_with_ripgrep(query) {
|
||||
Ok(results) => return Ok(results),
|
||||
Err(e) => {
|
||||
tracing::debug!("ripgrep not available, falling back to manual search: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to manual search
|
||||
search_manual(query)
|
||||
}
|
||||
|
||||
/// Search using ripgrep (rg)
|
||||
fn search_with_ripgrep(query: &str) -> Result<Vec<SearchResult>, String> {
|
||||
let data_dir_str = config::data_dir().to_string_lossy();
|
||||
let output = Command::new("rg")
|
||||
.args([
|
||||
"--json", // JSON output for parsing
|
||||
"--ignore-case", // Case insensitive
|
||||
"--type", "md", // Only markdown files
|
||||
"--max-count", "5", // Max 5 matches per file
|
||||
query,
|
||||
&data_dir_str,
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run ripgrep: {}", e))?;
|
||||
|
||||
if !output.status.success() && output.stdout.is_empty() {
|
||||
// No matches found or error
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
parse_ripgrep_output(&output.stdout)
|
||||
}
|
||||
|
||||
/// Parse ripgrep JSON output
|
||||
fn parse_ripgrep_output(output: &[u8]) -> Result<Vec<SearchResult>, String> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let output_str = String::from_utf8_lossy(output);
|
||||
let mut results_map: HashMap<String, SearchResult> = HashMap::new();
|
||||
|
||||
for line in output_str.lines() {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if json["type"] == "match" {
|
||||
let data = &json["data"];
|
||||
let path_str = data["path"]["text"].as_str().unwrap_or("");
|
||||
let line_number = data["line_number"].as_u64().unwrap_or(0) as u32;
|
||||
let line_content = data["lines"]["text"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let normalized_path = normalize_path(path_str);
|
||||
let title = extract_title_from_path(&normalized_path);
|
||||
|
||||
let result = results_map.entry(normalized_path.clone()).or_insert_with(|| {
|
||||
SearchResult {
|
||||
path: normalized_path,
|
||||
title,
|
||||
matches: Vec::new(),
|
||||
}
|
||||
});
|
||||
|
||||
result.matches.push(SearchMatch {
|
||||
line_number,
|
||||
line_content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results_map.into_values().collect())
|
||||
}
|
||||
|
||||
/// Manual search fallback (no external dependencies)
|
||||
fn search_manual(query: &str) -> Result<Vec<SearchResult>, String> {
|
||||
let query_lower = query.to_lowercase();
|
||||
let root = config::data_dir();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_ignored(e.path()))
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let mut matches = Vec::new();
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
if line.to_lowercase().contains(&query_lower) {
|
||||
matches.push(SearchMatch {
|
||||
line_number: (i + 1) as u32,
|
||||
line_content: line.trim().to_string(),
|
||||
});
|
||||
|
||||
// Limit matches per file
|
||||
if matches.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !matches.is_empty() {
|
||||
let normalized_path = normalize_path(&path.to_string_lossy());
|
||||
let title = extract_title_from_path(&normalized_path);
|
||||
|
||||
results.push(SearchResult {
|
||||
path: normalized_path,
|
||||
title,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn is_ignored(path: &Path) -> bool {
|
||||
path.components().any(|c| {
|
||||
matches!(
|
||||
c.as_os_str().to_str(),
|
||||
Some(".git") | Some("assets") | Some("archive")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
if let Some(idx) = path.find("data") {
|
||||
let stripped = &path[idx + 5..];
|
||||
return stripped
|
||||
.replace('\\', "/")
|
||||
.trim_start_matches('/')
|
||||
.to_string();
|
||||
}
|
||||
path.replace('\\', "/")
|
||||
}
|
||||
|
||||
fn extract_title_from_path(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Untitled")
|
||||
.to_string()
|
||||
}
|
||||
161
backend/src/watcher.rs
Normal file
161
backend/src/watcher.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
use notify_debouncer_full::{new_debouncer, DebouncedEvent};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::config;
|
||||
use crate::websocket::{WsMessage, WsState};
|
||||
|
||||
/// Start the file watcher in a background task
|
||||
pub async fn start_watcher(ws_state: Arc<WsState>) -> Result<(), String> {
|
||||
let (tx, mut rx) = mpsc::channel::<Vec<DebouncedEvent>>(100);
|
||||
|
||||
// Create debouncer with 500ms debounce time
|
||||
let debouncer = new_debouncer(
|
||||
Duration::from_millis(500),
|
||||
None,
|
||||
move |result: Result<Vec<DebouncedEvent>, Vec<notify::Error>>| {
|
||||
if let Ok(events) = result {
|
||||
let _ = tx.blocking_send(events);
|
||||
}
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to create file watcher: {}", e))?;
|
||||
|
||||
// Watch the data directory
|
||||
let data_path = config::data_dir();
|
||||
if !data_path.exists() {
|
||||
return Err(format!("Data directory does not exist: {}", data_path.display()));
|
||||
}
|
||||
|
||||
// We need to keep the debouncer alive, so we'll store it
|
||||
let debouncer = Arc::new(tokio::sync::Mutex::new(debouncer));
|
||||
|
||||
{
|
||||
let mut d = debouncer.lock().await;
|
||||
d.watcher().watch(data_path, RecursiveMode::Recursive)
|
||||
.map_err(|e| format!("Failed to watch directory: {}", e))?;
|
||||
}
|
||||
|
||||
tracing::info!("File watcher started for: {}", data_path.display());
|
||||
|
||||
// Spawn task to process file events
|
||||
let ws_state_clone = ws_state.clone();
|
||||
tokio::spawn(async move {
|
||||
// Keep debouncer alive
|
||||
let _debouncer = debouncer;
|
||||
|
||||
while let Some(events) = rx.recv().await {
|
||||
for event in events {
|
||||
process_event(&event, &ws_state_clone);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Track recent saves to avoid notifying about our own changes
|
||||
use std::sync::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref RECENT_SAVES: Mutex<HashMap<String, Instant>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Mark a file as recently saved by us (call this before saving)
|
||||
pub fn mark_file_saved(path: &str) {
|
||||
if let Ok(mut saves) = RECENT_SAVES.lock() {
|
||||
saves.insert(path.to_string(), Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single debounced file event
|
||||
fn process_event(event: &DebouncedEvent, ws_state: &WsState) {
|
||||
use notify::EventKind;
|
||||
|
||||
// Only process markdown files
|
||||
let paths: Vec<_> = event
|
||||
.paths
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e == "md")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip temporary files (used for atomic writes)
|
||||
if paths.iter().any(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.') && n.ends_with(".tmp"))
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip archive and .git directories
|
||||
if paths.iter().any(|p| {
|
||||
let s = p.to_string_lossy();
|
||||
s.contains("archive") || s.contains(".git")
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
|
||||
let path_str = normalize_path(&paths[0]);
|
||||
|
||||
// Check if this was a recent save by us (within last 2 seconds)
|
||||
if let Ok(mut saves) = RECENT_SAVES.lock() {
|
||||
// Clean up old entries
|
||||
saves.retain(|_, t| t.elapsed().as_secs() < 5);
|
||||
|
||||
if let Some(saved_at) = saves.get(&path_str) {
|
||||
if saved_at.elapsed().as_secs() < 2 {
|
||||
return; // Skip - this was our own save
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let msg = match &event.kind {
|
||||
EventKind::Create(_) => {
|
||||
tracing::info!("External file created: {}", path_str);
|
||||
Some(WsMessage::FileCreated { path: path_str })
|
||||
}
|
||||
EventKind::Modify(_) => {
|
||||
tracing::info!("External file modified: {}", path_str);
|
||||
Some(WsMessage::FileModified { path: path_str })
|
||||
}
|
||||
EventKind::Remove(_) => {
|
||||
tracing::info!("External file deleted: {}", path_str);
|
||||
Some(WsMessage::FileDeleted { path: path_str })
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(msg) = msg {
|
||||
ws_state.broadcast(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize path for client consumption
|
||||
fn normalize_path(path: &Path) -> String {
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
// Find "data" in the path and strip everything before and including it
|
||||
if let Some(idx) = path_str.find("data") {
|
||||
let stripped = &path_str[idx + 5..]; // Skip "data" + separator
|
||||
return stripped.replace('\\', "/").trim_start_matches('/').to_string();
|
||||
}
|
||||
|
||||
path_str.replace('\\', "/")
|
||||
}
|
||||
230
backend/src/websocket.rs
Normal file
230
backend/src/websocket.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
State, WebSocketUpgrade,
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
use crate::services::locks::{FileLockManager, LockType};
|
||||
|
||||
/// WebSocket message types sent to clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "payload")]
|
||||
pub enum WsMessage {
|
||||
/// A file was created
|
||||
FileCreated { path: String },
|
||||
/// A file was modified
|
||||
FileModified { path: String },
|
||||
/// A file was deleted
|
||||
FileDeleted { path: String },
|
||||
/// A file was renamed
|
||||
FileRenamed { from: String, to: String },
|
||||
/// A file was locked
|
||||
FileLocked {
|
||||
path: String,
|
||||
client_id: String,
|
||||
lock_type: String,
|
||||
},
|
||||
/// A file was unlocked
|
||||
FileUnlocked { path: String },
|
||||
/// Git conflict detected
|
||||
GitConflict { files: Vec<String> },
|
||||
/// Server is sending a ping
|
||||
Ping,
|
||||
/// Client connection confirmed
|
||||
Connected { client_id: String },
|
||||
/// Error message
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// Client message types received from clients
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ClientMessage {
|
||||
/// Request to lock a file
|
||||
#[serde(rename = "lock_file")]
|
||||
LockFile { path: String, lock_type: String },
|
||||
/// Request to unlock a file
|
||||
#[serde(rename = "unlock_file")]
|
||||
UnlockFile { path: String },
|
||||
/// Ping response
|
||||
#[serde(rename = "pong")]
|
||||
Pong,
|
||||
}
|
||||
|
||||
/// Shared state for WebSocket connections
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WsState {
|
||||
/// Broadcast channel for sending messages to all clients
|
||||
pub tx: broadcast::Sender<WsMessage>,
|
||||
/// Set of connected client IDs
|
||||
pub clients: Arc<RwLock<HashSet<String>>>,
|
||||
/// File lock manager
|
||||
pub lock_manager: FileLockManager,
|
||||
}
|
||||
|
||||
impl WsState {
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(100);
|
||||
Self {
|
||||
tx,
|
||||
clients: Arc::new(RwLock::new(HashSet::new())),
|
||||
lock_manager: FileLockManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast a message to all connected clients
|
||||
pub fn broadcast(&self, msg: WsMessage) {
|
||||
// Ignore send errors (no receivers)
|
||||
let _ = self.tx.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WsState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket upgrade handler
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<WsState>>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
/// Handle individual WebSocket connection
|
||||
async fn handle_socket(socket: WebSocket, state: Arc<WsState>) {
|
||||
let client_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Add client to set
|
||||
{
|
||||
let mut clients = state.clients.write().await;
|
||||
clients.insert(client_id.clone());
|
||||
}
|
||||
|
||||
tracing::info!("WebSocket client connected: {}", client_id);
|
||||
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Subscribe to broadcast channel
|
||||
let mut rx = state.tx.subscribe();
|
||||
|
||||
// Send connected message
|
||||
let connected_msg = WsMessage::Connected {
|
||||
client_id: client_id.clone(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&connected_msg) {
|
||||
let _ = sender.send(Message::Text(json.into())).await;
|
||||
}
|
||||
|
||||
// Spawn task to forward broadcast messages to this client
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Ok(msg) = rx.recv().await {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming messages from client
|
||||
let state_clone = state.clone();
|
||||
let client_id_clone = client_id.clone();
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
if let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) {
|
||||
handle_client_message(&state_clone, &client_id_clone, client_msg).await;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"Unknown message from {}: {}",
|
||||
client_id_clone,
|
||||
text
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either task to complete
|
||||
tokio::select! {
|
||||
_ = send_task => {},
|
||||
_ = recv_task => {},
|
||||
}
|
||||
|
||||
// Clean up on disconnect
|
||||
// Release all locks held by this client
|
||||
let released_paths = state.lock_manager.release_all_for_client(&client_id).await;
|
||||
for path in released_paths {
|
||||
state.broadcast(WsMessage::FileUnlocked { path });
|
||||
}
|
||||
|
||||
// Remove client from set
|
||||
{
|
||||
let mut clients = state.clients.write().await;
|
||||
clients.remove(&client_id);
|
||||
}
|
||||
|
||||
tracing::info!("WebSocket client disconnected: {}", client_id);
|
||||
}
|
||||
|
||||
/// Handle a message from a client
|
||||
async fn handle_client_message(state: &Arc<WsState>, client_id: &str, msg: ClientMessage) {
|
||||
match msg {
|
||||
ClientMessage::LockFile { path, lock_type } => {
|
||||
let lock_type = match lock_type.as_str() {
|
||||
"editor" => LockType::Editor,
|
||||
"task_view" => LockType::TaskView,
|
||||
_ => {
|
||||
tracing::warn!("Unknown lock type: {}", lock_type);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match state.lock_manager.acquire(&path, client_id, lock_type).await {
|
||||
Ok(lock_info) => {
|
||||
let lock_type_str = match lock_info.lock_type {
|
||||
LockType::Editor => "editor",
|
||||
LockType::TaskView => "task_view",
|
||||
};
|
||||
state.broadcast(WsMessage::FileLocked {
|
||||
path: lock_info.path,
|
||||
client_id: lock_info.client_id,
|
||||
lock_type: lock_type_str.to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to acquire lock: {}", e);
|
||||
// Could send error back to specific client if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientMessage::UnlockFile { path } => {
|
||||
match state.lock_manager.release(&path, client_id).await {
|
||||
Ok(()) => {
|
||||
state.broadcast(WsMessage::FileUnlocked { path });
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to release lock: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientMessage::Pong => {
|
||||
// Heartbeat response, no action needed
|
||||
}
|
||||
}
|
||||
}
|
||||
0
data/archive/.gitkeep
Normal file
0
data/archive/.gitkeep
Normal file
0
data/daily/.gitkeep
Normal file
0
data/daily/.gitkeep
Normal file
0
data/notes/.gitkeep
Normal file
0
data/notes/.gitkeep
Normal file
0
data/notes/assets/.gitkeep
Normal file
0
data/notes/assets/.gitkeep
Normal file
0
data/projects/.gitkeep
Normal file
0
data/projects/.gitkeep
Normal file
659
docs/API.md
Normal file
659
docs/API.md
Normal file
@@ -0,0 +1,659 @@
|
||||
# Ironpad API Reference
|
||||
|
||||
Base URL: `http://localhost:3000`
|
||||
|
||||
## Notes
|
||||
|
||||
### List Notes
|
||||
|
||||
```http
|
||||
GET /api/notes
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "20260205-123456",
|
||||
"title": "My Note",
|
||||
"path": "notes/20260205-123456.md",
|
||||
"created": "2026-02-05T12:34:56Z",
|
||||
"updated": "2026-02-05T12:34:56Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Create Note
|
||||
|
||||
```http
|
||||
POST /api/notes
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "Optional Title",
|
||||
"content": "# My Note\n\nContent here"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"id": "20260205-123456",
|
||||
"title": "Optional Title",
|
||||
"path": "notes/20260205-123456.md",
|
||||
"content": "# My Note\n\nContent here",
|
||||
"created": "2026-02-05T12:34:56Z",
|
||||
"updated": "2026-02-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Note
|
||||
|
||||
```http
|
||||
GET /api/notes/:id
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "20260205-123456",
|
||||
"title": "My Note",
|
||||
"path": "notes/20260205-123456.md",
|
||||
"content": "# My Note\n\nFull content...",
|
||||
"created": "2026-02-05T12:34:56Z",
|
||||
"updated": "2026-02-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Update Note
|
||||
|
||||
```http
|
||||
PUT /api/notes/:id
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"content": "# Updated Content\n\nNew content here"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "20260205-123456",
|
||||
"title": "Updated Content",
|
||||
"path": "notes/20260205-123456.md",
|
||||
"content": "# Updated Content\n\nNew content here",
|
||||
"created": "2026-02-05T12:34:56Z",
|
||||
"updated": "2026-02-05T12:35:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Delete (Archive) Note
|
||||
|
||||
```http
|
||||
DELETE /api/notes/:id
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
Note: The note is moved to `archive/`, not permanently deleted.
|
||||
|
||||
---
|
||||
|
||||
## Projects
|
||||
|
||||
### List Projects
|
||||
|
||||
```http
|
||||
GET /api/projects
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "ferrite",
|
||||
"title": "Ferrite",
|
||||
"description": "A Rust project",
|
||||
"path": "projects/ferrite",
|
||||
"created": "2026-02-04T10:00:00Z",
|
||||
"updated": "2026-02-05T12:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Create Project
|
||||
|
||||
```http
|
||||
POST /api/projects
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "New Project",
|
||||
"description": "Project description"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"id": "new-project",
|
||||
"title": "New Project",
|
||||
"description": "Project description",
|
||||
"path": "projects/new-project",
|
||||
"created": "2026-02-05T12:34:56Z",
|
||||
"updated": "2026-02-05T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Project
|
||||
|
||||
```http
|
||||
GET /api/projects/:id
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "ferrite",
|
||||
"title": "Ferrite",
|
||||
"description": "A Rust project",
|
||||
"path": "projects/ferrite",
|
||||
"created": "2026-02-04T10:00:00Z",
|
||||
"updated": "2026-02-05T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Project Content
|
||||
|
||||
```http
|
||||
GET /api/projects/:id/content
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"content": "# Ferrite\n\nProject overview content..."
|
||||
}
|
||||
```
|
||||
|
||||
### Update Project Content
|
||||
|
||||
```http
|
||||
PUT /api/projects/:id/content
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"content": "# Updated Overview\n\nNew content..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Notes
|
||||
|
||||
### List Project Notes
|
||||
|
||||
```http
|
||||
GET /api/projects/:id/notes
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "20260205-123456",
|
||||
"title": "Project Note",
|
||||
"path": "projects/ferrite/notes/20260205-123456.md",
|
||||
"created": "2026-02-05T12:34:56Z",
|
||||
"updated": "2026-02-05T12:34:56Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Create Project Note
|
||||
|
||||
```http
|
||||
POST /api/projects/:id/notes
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "New Note",
|
||||
"content": "Note content..."
|
||||
}
|
||||
```
|
||||
|
||||
### Get Project Note
|
||||
|
||||
```http
|
||||
GET /api/projects/:id/notes/:noteId
|
||||
```
|
||||
|
||||
### Update Project Note
|
||||
|
||||
```http
|
||||
PUT /api/projects/:id/notes/:noteId
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"content": "Updated content..."
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Project Note
|
||||
|
||||
```http
|
||||
DELETE /api/projects/:id/notes/:noteId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Tasks
|
||||
|
||||
### List Project Tasks
|
||||
|
||||
```http
|
||||
GET /api/projects/:id/tasks
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "task-20260205-123456",
|
||||
"title": "Implement feature X",
|
||||
"completed": false,
|
||||
"section": "Active",
|
||||
"priority": "high",
|
||||
"due_date": "2026-02-10",
|
||||
"is_active": true,
|
||||
"content": "## Requirements\n\n- Item 1\n- Item 2",
|
||||
"path": "projects/ferrite/tasks/task-20260205-123456.md",
|
||||
"created": "2026-02-05T12:34:56Z",
|
||||
"updated": "2026-02-05T12:34:56Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Create Task
|
||||
|
||||
```http
|
||||
POST /api/projects/:id/tasks
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "New Task",
|
||||
"content": "Task description..."
|
||||
}
|
||||
```
|
||||
|
||||
### Get Task
|
||||
|
||||
```http
|
||||
GET /api/projects/:id/tasks/:taskId
|
||||
```
|
||||
|
||||
### Update Task Content
|
||||
|
||||
```http
|
||||
PUT /api/projects/:id/tasks/:taskId
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"content": "Updated task description..."
|
||||
}
|
||||
```
|
||||
|
||||
### Update Task Metadata
|
||||
|
||||
```http
|
||||
PUT /api/projects/:id/tasks/:taskId/meta
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "New Title",
|
||||
"is_active": false,
|
||||
"section": "Backlog",
|
||||
"priority": "low",
|
||||
"due_date": "2026-02-15"
|
||||
}
|
||||
```
|
||||
|
||||
### Toggle Task Completion
|
||||
|
||||
```http
|
||||
PUT /api/projects/:id/tasks/:taskId/toggle
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"completed": true
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Task
|
||||
|
||||
```http
|
||||
DELETE /api/projects/:id/tasks/:taskId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## All Tasks
|
||||
|
||||
### List All Tasks (across projects)
|
||||
|
||||
```http
|
||||
GET /api/tasks
|
||||
```
|
||||
|
||||
Returns tasks from all projects, useful for global task views.
|
||||
|
||||
---
|
||||
|
||||
## Daily Notes
|
||||
|
||||
### List Daily Notes
|
||||
|
||||
```http
|
||||
GET /api/daily
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"date": "2026-02-05",
|
||||
"path": "daily/2026-02-05.md",
|
||||
"created": "2026-02-05T08:00:00Z",
|
||||
"updated": "2026-02-05T12:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Get Today's Note
|
||||
|
||||
```http
|
||||
GET /api/daily/today
|
||||
```
|
||||
|
||||
Creates the daily note if it doesn't exist.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"date": "2026-02-05",
|
||||
"content": "# 2026-02-05\n\n## Todo\n\n- [ ] Task 1",
|
||||
"path": "daily/2026-02-05.md",
|
||||
"created": "2026-02-05T08:00:00Z",
|
||||
"updated": "2026-02-05T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Get/Create Daily Note by Date
|
||||
|
||||
```http
|
||||
GET /api/daily/:date
|
||||
POST /api/daily/:date
|
||||
```
|
||||
|
||||
Date format: `YYYY-MM-DD`
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
### Upload Asset
|
||||
|
||||
```http
|
||||
POST /api/assets/upload
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
project: ferrite
|
||||
file: (binary data)
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"url": "/api/assets/ferrite/image-20260205-123456.png",
|
||||
"filename": "image-20260205-123456.png"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Asset
|
||||
|
||||
```http
|
||||
GET /api/assets/:project/:filename
|
||||
```
|
||||
|
||||
Returns the binary file with appropriate Content-Type header.
|
||||
|
||||
---
|
||||
|
||||
## Search
|
||||
|
||||
### Search Content
|
||||
|
||||
```http
|
||||
GET /api/search?q=search+term
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"path": "notes/20260205-123456.md",
|
||||
"title": "My Note",
|
||||
"matches": [
|
||||
{
|
||||
"line": 5,
|
||||
"text": "This is a **search term** example"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Git Operations
|
||||
|
||||
### Get Status
|
||||
|
||||
```http
|
||||
GET /api/git/status
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"branch": "main",
|
||||
"ahead": 2,
|
||||
"behind": 0,
|
||||
"staged": [],
|
||||
"modified": ["notes/20260205-123456.md"],
|
||||
"untracked": [],
|
||||
"has_conflicts": false
|
||||
}
|
||||
```
|
||||
|
||||
### Commit Changes
|
||||
|
||||
```http
|
||||
POST /api/git/commit
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "Update notes"
|
||||
}
|
||||
```
|
||||
|
||||
### Push to Remote
|
||||
|
||||
```http
|
||||
POST /api/git/push
|
||||
```
|
||||
|
||||
### Fetch from Remote
|
||||
|
||||
```http
|
||||
POST /api/git/fetch
|
||||
```
|
||||
|
||||
### Get Commit Log
|
||||
|
||||
```http
|
||||
GET /api/git/log?limit=20
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "abc123...",
|
||||
"message": "Update notes",
|
||||
"author": "User Name",
|
||||
"date": "2026-02-05T12:34:56Z",
|
||||
"files_changed": 3
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Get Working Directory Diff
|
||||
|
||||
```http
|
||||
GET /api/git/diff
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"diff": "diff --git a/notes/... "
|
||||
}
|
||||
```
|
||||
|
||||
### Get Commit Diff
|
||||
|
||||
```http
|
||||
GET /api/git/diff/:commitId
|
||||
```
|
||||
|
||||
### Get Remote Info
|
||||
|
||||
```http
|
||||
GET /api/git/remote
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"name": "origin",
|
||||
"url": "git@github.com:user/repo.git",
|
||||
"ahead": 2,
|
||||
"behind": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Check for Conflicts
|
||||
|
||||
```http
|
||||
GET /api/git/conflicts
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"has_conflicts": false,
|
||||
"files": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket
|
||||
|
||||
### Connect
|
||||
|
||||
```
|
||||
WS /ws
|
||||
```
|
||||
|
||||
### Messages (Client → Server)
|
||||
|
||||
**Lock File:**
|
||||
```json
|
||||
{
|
||||
"type": "lock_file",
|
||||
"path": "notes/20260205-123456.md",
|
||||
"lock_type": "editor"
|
||||
}
|
||||
```
|
||||
|
||||
**Unlock File:**
|
||||
```json
|
||||
{
|
||||
"type": "unlock_file",
|
||||
"path": "notes/20260205-123456.md"
|
||||
}
|
||||
```
|
||||
|
||||
### Messages (Server → Client)
|
||||
|
||||
**File Locked:**
|
||||
```json
|
||||
{
|
||||
"type": "file_locked",
|
||||
"path": "notes/20260205-123456.md",
|
||||
"client_id": "client-123"
|
||||
}
|
||||
```
|
||||
|
||||
**File Unlocked:**
|
||||
```json
|
||||
{
|
||||
"type": "file_unlocked",
|
||||
"path": "notes/20260205-123456.md"
|
||||
}
|
||||
```
|
||||
|
||||
**File Modified (broadcast):**
|
||||
```json
|
||||
{
|
||||
"type": "file_modified",
|
||||
"path": "notes/20260205-123456.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Git Status Update:**
|
||||
```json
|
||||
{
|
||||
"type": "git_status",
|
||||
"status": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return errors in this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Human-readable error message",
|
||||
"code": "ERROR_CODE"
|
||||
}
|
||||
```
|
||||
|
||||
### Common Error Codes
|
||||
|
||||
| Code | HTTP Status | Description |
|
||||
|------|-------------|-------------|
|
||||
| `NOT_FOUND` | 404 | Resource doesn't exist |
|
||||
| `BAD_REQUEST` | 400 | Invalid request data |
|
||||
| `CONFLICT` | 409 | Resource conflict (e.g., Git) |
|
||||
| `INTERNAL_ERROR` | 500 | Server error |
|
||||
416
docs/ARCHITECTURE.md
Normal file
416
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Ironpad Architecture
|
||||
|
||||
This document describes the technical architecture of Ironpad.
|
||||
|
||||
## System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||
│ │ Vue 3 SPA ││
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ ││
|
||||
│ │ │ Views │ │Components│ │ Stores │ │ Composables │ ││
|
||||
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘ ││
|
||||
│ │ │ │ │ │ ││
|
||||
│ │ └─────────────┴──────┬──────┴───────────────┘ ││
|
||||
│ │ │ ││
|
||||
│ │ ┌───────▼───────┐ ││
|
||||
│ │ │ API Client │ ││
|
||||
│ └────────────────────┴───────┬───────┴────────────────────────┘│
|
||||
└───────────────────────────────┼─────────────────────────────────┘
|
||||
│
|
||||
HTTP REST │ WebSocket
|
||||
│
|
||||
┌───────────────────────────────┼─────────────────────────────────┐
|
||||
│ │ │
|
||||
│ ┌────────────────────────────▼────────────────────────────────┐│
|
||||
│ │ Axum Router ││
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐││
|
||||
│ │ │ Routes │││
|
||||
│ │ │ /api/notes /api/projects /api/tasks /api/git /ws │││
|
||||
│ │ └───────────────────────────┬─────────────────────────────┘││
|
||||
│ └──────────────────────────────┼───────────────────────────────┘│
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────▼───────────────────────────────┐│
|
||||
│ │ Services ││
|
||||
│ │ ┌──────────┐ ┌───────────┐ ┌──────┐ ┌───────┐ ┌──────┐ ││
|
||||
│ │ │Filesystem│ │Frontmatter│ │ Git │ │Search │ │Locks │ ││
|
||||
│ │ └────┬─────┘ └─────┬─────┘ └──┬───┘ └───┬───┘ └──┬───┘ ││
|
||||
│ └───────┼──────────────┼───────────┼──────────┼─────────┼──────┘│
|
||||
│ │ │ │ │ │ │
|
||||
│ └──────────────┴─────┬─────┴──────────┴─────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ File System │ │
|
||||
│ │ (data/) │ │
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
│ Rust Backend │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Files Are the Database
|
||||
|
||||
All data is stored as Markdown files with YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: note-20260205-123456
|
||||
title: My Note
|
||||
created: 2026-02-05T12:34:56Z
|
||||
updated: 2026-02-05T12:34:56Z
|
||||
---
|
||||
|
||||
# My Note
|
||||
|
||||
Content goes here...
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Portable — files can be copied, backed up, synced
|
||||
- Editable — any text editor works
|
||||
- Versionable — Git tracks all changes
|
||||
- Debuggable — human-readable format
|
||||
|
||||
### 2. Backend Owns Metadata
|
||||
|
||||
The backend automatically manages:
|
||||
- `id` — Generated from timestamp (YYYYMMDD-HHMMSS)
|
||||
- `created` — Set once when file is created
|
||||
- `updated` — Updated on every save
|
||||
|
||||
Clients send content; backend handles metadata consistency.
|
||||
|
||||
### 3. Local-First
|
||||
|
||||
The application works fully offline:
|
||||
- No cloud dependencies
|
||||
- No external API calls
|
||||
- Git push is optional
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Rust** — Memory safety, performance
|
||||
- **Axum 0.8** — Async web framework
|
||||
- **Tokio** — Async runtime
|
||||
- **serde/serde_yaml** — Serialization
|
||||
- **notify** — File system watching
|
||||
|
||||
### Service Layer
|
||||
|
||||
```
|
||||
services/
|
||||
├── filesystem.rs # File read/write operations
|
||||
├── frontmatter.rs # YAML parsing/generation
|
||||
├── git.rs # Git CLI wrapper
|
||||
├── locks.rs # File locking state
|
||||
├── markdown.rs # Markdown utilities
|
||||
└── search.rs # ripgrep integration
|
||||
```
|
||||
|
||||
#### Filesystem Service
|
||||
|
||||
Handles all file operations with atomic writes:
|
||||
|
||||
```rust
|
||||
// Atomic write pattern
|
||||
fn write_note(path: &Path, content: &str) -> Result<()> {
|
||||
let temp = path.with_extension("tmp");
|
||||
fs::write(&temp, content)?;
|
||||
fs::rename(temp, path)?; // Atomic on most filesystems
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontmatter Service
|
||||
|
||||
Parses and generates YAML frontmatter:
|
||||
|
||||
```rust
|
||||
struct Frontmatter {
|
||||
id: String,
|
||||
title: Option<String>,
|
||||
created: DateTime<Utc>,
|
||||
updated: DateTime<Utc>,
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
#### Git Service
|
||||
|
||||
Wraps Git CLI commands:
|
||||
|
||||
```rust
|
||||
impl GitService {
|
||||
fn status(&self) -> Result<GitStatus>;
|
||||
fn commit(&self, message: &str) -> Result<()>;
|
||||
fn push(&self) -> Result<()>;
|
||||
fn log(&self, limit: usize) -> Result<Vec<Commit>>;
|
||||
fn diff(&self, commit: Option<&str>) -> Result<String>;
|
||||
}
|
||||
```
|
||||
|
||||
Auto-commit runs every 60 seconds when changes exist.
|
||||
|
||||
### WebSocket System
|
||||
|
||||
Real-time updates via WebSocket:
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
│──── connect ─────────▶│
|
||||
│◀─── accepted ─────────│
|
||||
│ │
|
||||
│──── lock_file ───────▶│
|
||||
│◀─── file_locked ──────│
|
||||
│ │
|
||||
│ │ (file changed on disk)
|
||||
│◀─── file_modified ────│
|
||||
│ │
|
||||
│──── unlock_file ─────▶│
|
||||
│◀─── file_unlocked ────│
|
||||
```
|
||||
|
||||
**Message Types:**
|
||||
- `lock_file` / `unlock_file` — File locking for concurrent editing
|
||||
- `file_modified` — Broadcast when files change on disk
|
||||
- `git_status` — Git status updates
|
||||
|
||||
### File Watcher
|
||||
|
||||
Uses `notify` crate to watch the data directory:
|
||||
|
||||
```rust
|
||||
// Debounce: 500ms to batch rapid changes
|
||||
// Filter: Ignores changes from own writes
|
||||
watcher.watch(data_path, RecursiveMode::Recursive)?;
|
||||
```
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Vue 3** — Composition API
|
||||
- **TypeScript** — Type safety
|
||||
- **Vite** — Build tooling
|
||||
- **Pinia** — State management
|
||||
- **Vue Router** — Navigation
|
||||
- **Milkdown** — WYSIWYG editor
|
||||
|
||||
### Component Hierarchy
|
||||
|
||||
```
|
||||
App.vue
|
||||
├── Sidebar.vue
|
||||
│ ├── NoteList.vue
|
||||
│ ├── ProjectList.vue
|
||||
│ └── GitStatus.vue
|
||||
├── TopBar.vue
|
||||
├── SearchPanel.vue
|
||||
├── GitPanel.vue
|
||||
└── <router-view>
|
||||
├── NotesView.vue
|
||||
├── ProjectView.vue
|
||||
├── ProjectNotesView.vue
|
||||
├── TasksView.vue
|
||||
└── DailyView.vue
|
||||
```
|
||||
|
||||
### State Management (Pinia)
|
||||
|
||||
Each domain has a dedicated store:
|
||||
|
||||
```typescript
|
||||
// Example: notesStore
|
||||
export const useNotesStore = defineStore('notes', () => {
|
||||
const notes = ref<Note[]>([])
|
||||
const currentNote = ref<NoteWithContent | null>(null)
|
||||
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
|
||||
async function loadNote(id: string) { ... }
|
||||
async function saveNote(content: string) { ... }
|
||||
|
||||
return { notes, currentNote, saveStatus, loadNote, saveNote }
|
||||
})
|
||||
```
|
||||
|
||||
### Milkdown Editor Integration
|
||||
|
||||
The editor uses a two-component architecture:
|
||||
|
||||
```
|
||||
MilkdownEditor.vue (wrapper)
|
||||
└── MilkdownEditorCore.vue (actual editor)
|
||||
```
|
||||
|
||||
**Critical Lifecycle:**
|
||||
|
||||
1. `MilkdownProvider` provides Vue context
|
||||
2. `useEditor` hook creates `Crepe` instance
|
||||
3. `Crepe.editor` is the ProseMirror editor
|
||||
4. `editor.action(replaceAll(content))` updates content
|
||||
|
||||
**Key Pattern:** Content must be set BEFORE the editor key changes:
|
||||
|
||||
```javascript
|
||||
// View component
|
||||
watch(noteId, async (newId) => {
|
||||
const note = await api.getNote(newId)
|
||||
|
||||
// CORRECT ORDER:
|
||||
editorContent.value = note.content // 1. Set content
|
||||
editorKey.value = newId // 2. Recreate editor
|
||||
})
|
||||
```
|
||||
|
||||
### Auto-save System
|
||||
|
||||
Smart auto-save that prevents unnecessary saves:
|
||||
|
||||
```javascript
|
||||
// Track original content
|
||||
const lastSavedContent = ref<string | null>(null)
|
||||
|
||||
// Only save when content differs
|
||||
watch(editorContent, (newContent) => {
|
||||
if (lastSavedContent.value !== null &&
|
||||
newContent !== lastSavedContent.value) {
|
||||
scheduleAutoSave() // 1-second debounce
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### Note
|
||||
|
||||
```typescript
|
||||
interface Note {
|
||||
id: string // e.g., "20260205-123456"
|
||||
title?: string
|
||||
path: string // e.g., "notes/20260205-123456.md"
|
||||
created: string // ISO 8601
|
||||
updated: string
|
||||
}
|
||||
|
||||
interface NoteWithContent extends Note {
|
||||
content: string // Markdown body
|
||||
}
|
||||
```
|
||||
|
||||
### Project
|
||||
|
||||
```typescript
|
||||
interface Project {
|
||||
id: string // e.g., "ferrite" (slug)
|
||||
title: string
|
||||
description?: string
|
||||
path: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
```
|
||||
|
||||
### Task
|
||||
|
||||
```typescript
|
||||
interface Task {
|
||||
id: string // e.g., "task-20260205-123456"
|
||||
title: string
|
||||
completed: boolean
|
||||
section?: string // "Active" | "Backlog"
|
||||
priority?: string
|
||||
due_date?: string
|
||||
is_active: boolean
|
||||
content: string // Markdown description
|
||||
path: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
```
|
||||
|
||||
## API Design
|
||||
|
||||
### REST Conventions
|
||||
|
||||
- `GET /api/resource` — List all
|
||||
- `POST /api/resource` — Create new
|
||||
- `GET /api/resource/:id` — Get one
|
||||
- `PUT /api/resource/:id` — Update
|
||||
- `DELETE /api/resource/:id` — Delete (usually archives)
|
||||
|
||||
### Error Handling
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Note not found",
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
```
|
||||
|
||||
HTTP status codes:
|
||||
- `200` — Success
|
||||
- `201` — Created
|
||||
- `400` — Bad request
|
||||
- `404` — Not found
|
||||
- `500` — Server error
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Current State
|
||||
|
||||
Ironpad is designed for **local, single-user** operation:
|
||||
|
||||
- No authentication (local access assumed)
|
||||
- No HTTPS (localhost only)
|
||||
- No input sanitization for XSS (trusted user)
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For multi-user or remote deployment:
|
||||
|
||||
1. Add authentication (JWT, session-based)
|
||||
2. Enable HTTPS
|
||||
3. Sanitize markdown output
|
||||
4. Rate limit API endpoints
|
||||
5. Validate file paths to prevent directory traversal
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Backend
|
||||
|
||||
- **Atomic writes** — Prevent corruption on crash
|
||||
- **File caching** — Read once, cache in memory (not yet implemented)
|
||||
- **Ripgrep search** — Fast full-text search
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Virtual scrolling** — For large note lists (not yet needed)
|
||||
- **Debounced saves** — 1-second delay batches rapid edits
|
||||
- **Lazy loading** — Routes loaded on demand
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Scalability
|
||||
|
||||
Current design handles ~5000 files comfortably. For larger datasets:
|
||||
|
||||
- Add Tantivy full-text search index
|
||||
- Implement pagination for note lists
|
||||
- Add lazy loading for project trees
|
||||
|
||||
### Features
|
||||
|
||||
See `ai-context.md` for planned features:
|
||||
|
||||
- Tag extraction and filtering
|
||||
- Backlinks between notes
|
||||
- Graph view
|
||||
- Export (PDF/HTML)
|
||||
- Custom themes
|
||||
221
docs/ai-workflow/CHECKLIST.md
Normal file
221
docs/ai-workflow/CHECKLIST.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Ironpad — Implementation Checklist
|
||||
|
||||
This checklist tracks **what is planned vs what is done**, based on the PRD.
|
||||
It is the authoritative execution status for the project.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Preparation ✅ (COMPLETED)
|
||||
|
||||
### Repository & Tooling
|
||||
- [x] Create project root structure
|
||||
- [x] Initialize Rust backend (`ironpad`, edition 2021)
|
||||
- [x] Add backend dependencies (Axum, Tokio, notify, git2, etc.)
|
||||
- [x] Verify backend builds (`cargo check`)
|
||||
|
||||
### Backend Scaffolding
|
||||
- [x] Create `routes/`, `services/`, `models/` modules
|
||||
- [x] Create placeholder files for all planned backend components
|
||||
- [x] Prepare WebSocket and file watcher modules
|
||||
|
||||
### Data Layer
|
||||
- [x] Create `data/` directory structure
|
||||
- [x] Create initial files (`index.md`, `inbox.md`)
|
||||
- [x] Initialize `data/` as its own Git repository
|
||||
|
||||
### Project Meta
|
||||
- [x] Create `ai-context.md`
|
||||
- [x] Create implementation checklist
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — MVP ✅ (COMPLETED)
|
||||
|
||||
### Backend Core
|
||||
- [x] Implement `main.rs` (Axum server bootstrap)
|
||||
- [x] Dynamic port selection (3000–3010)
|
||||
- [x] Auto-open system browser on startup
|
||||
- [x] Serve static frontend files (production path)
|
||||
|
||||
### Notes (CRUD)
|
||||
- [x] List notes from filesystem
|
||||
- [x] Read markdown file by ID
|
||||
- [x] Create new note with auto-generated frontmatter
|
||||
- [x] Update note with auto-save + timestamp update
|
||||
- [x] Archive note on delete (move to `data/archive/`)
|
||||
|
||||
### Frontmatter Automation
|
||||
- [x] Parse/serialize frontmatter
|
||||
- [x] Deterministic ID from path
|
||||
- [x] Auto-manage `created`/`updated` timestamps
|
||||
- [x] Preserve user-defined fields
|
||||
|
||||
### Frontend (Basic)
|
||||
- [x] Vue 3 + Vite setup
|
||||
- [x] Note list sidebar
|
||||
- [x] Note viewer/editor (textarea)
|
||||
- [x] Create/archive note actions
|
||||
- [x] Auto-save on edit
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Core Daily Driver ✅ (COMPLETED)
|
||||
|
||||
### Real-Time Sync
|
||||
- [x] File system watching (`notify` crate)
|
||||
- [x] WebSocket server for real-time updates
|
||||
- [x] External edit detection + UI notifications
|
||||
- [x] Filter out own saves from notifications
|
||||
|
||||
### Search
|
||||
- [x] Full-text search (ripgrep with fallback)
|
||||
- [x] Search endpoint (`GET /api/search?q=`)
|
||||
- [x] UI search integration (Ctrl+K)
|
||||
|
||||
### Git Integration
|
||||
- [x] Git status endpoint
|
||||
- [x] Auto-commit (60-second batching)
|
||||
- [x] Manual commit button
|
||||
- [x] Git status indicator in UI
|
||||
|
||||
### Projects
|
||||
- [x] Project creation (folder + `index.md` + `assets/`)
|
||||
- [x] List projects API
|
||||
- [x] Project task file creation (`tasks.md`)
|
||||
|
||||
### Tasks (Basic)
|
||||
- [x] Task parsing from markdown checkboxes
|
||||
- [x] Tasks API (list all tasks)
|
||||
- [x] Task view in sidebar
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Full PRD Compliance ✅ (COMPLETED)
|
||||
|
||||
### Projects & Tasks (Per PRD Section 7.2, 7.3)
|
||||
- [x] Project-specific task endpoint (`GET /api/projects/:id/tasks`)
|
||||
- [x] Task toggle endpoint (update checkbox state)
|
||||
- [x] Add task via UI (append to `tasks.md`)
|
||||
- [x] Task sections: Active, Completed, Backlog
|
||||
- [x] Project task view at `/projects/:id/tasks` route
|
||||
|
||||
### File Locking (Per PRD Section 7.7)
|
||||
- [x] Backend tracks open files via WebSocket
|
||||
- [x] File lock when Task View opens
|
||||
- [x] Editor shows "Read-Only" if file locked
|
||||
- [x] Auto-unlock when view closes
|
||||
|
||||
### Daily Notes (Per PRD Section 6)
|
||||
- [x] Create `data/daily/` directory
|
||||
- [x] Daily note endpoint (create/get today's note)
|
||||
- [x] Daily note templates
|
||||
- [x] Daily notes in sidebar
|
||||
|
||||
### CodeMirror 6 Editor (Per PRD Section 9.3)
|
||||
- [x] Install CodeMirror 6 dependencies
|
||||
- [x] Replace textarea with CodeMirror
|
||||
- [x] Markdown syntax highlighting
|
||||
- [x] Line numbers
|
||||
- [x] Keyboard shortcuts
|
||||
|
||||
### Markdown Preview (Per PRD Section 5)
|
||||
- [x] Split view (editor + preview)
|
||||
- [x] Markdown-it rendering
|
||||
- [x] CommonMark consistency
|
||||
|
||||
### Assets API (Per PRD Section 8)
|
||||
- [x] `POST /api/assets/upload` endpoint
|
||||
- [x] `GET /api/assets/:project/:file` endpoint
|
||||
- [x] Image upload UI in editor
|
||||
|
||||
### Git Advanced (Per PRD Section 7.5)
|
||||
- [x] Git conflict detection
|
||||
- [x] Conflict warning banner in UI
|
||||
- [x] `POST /api/git/push` endpoint
|
||||
- [x] `GET /api/git/conflicts` endpoint
|
||||
|
||||
### Frontend Architecture (Per PRD Section 14)
|
||||
- [x] Vue Router for navigation
|
||||
- [x] Pinia state management
|
||||
- [x] Separate view components (NotesView, TasksView, ProjectView)
|
||||
- [x] WebSocket composable
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Enhanced Task System ✅ (COMPLETED)
|
||||
|
||||
### Dashboard
|
||||
- [x] Cross-project dashboard as home page (`/`)
|
||||
- [x] Project cards with active task counts and summaries
|
||||
- [x] Click-through to project or task detail
|
||||
- [x] Clickable "Ironpad" title navigates to dashboard
|
||||
|
||||
### Tags
|
||||
- [x] Tags field in task frontmatter (YAML sequence)
|
||||
- [x] Backend parses/writes tags on task CRUD
|
||||
- [x] Tag pills displayed on task list items
|
||||
- [x] Tag filter bar — click to filter tasks by tag
|
||||
- [x] Tag editor in task detail panel with autocomplete
|
||||
- [x] `projectTags` computed getter for all unique tags in project
|
||||
|
||||
### Subtasks
|
||||
- [x] `parent_id` field in task frontmatter
|
||||
- [x] Backend accepts `parent_id` on task creation
|
||||
- [x] Task list groups subtasks under parent (indented)
|
||||
- [x] Subtask count badge on parent tasks (completed/total)
|
||||
- [x] Subtask panel in task detail with inline add
|
||||
- [x] Subtasks clickable to view/edit
|
||||
|
||||
### Recurring Tasks
|
||||
- [x] `recurrence` and `recurrence_interval` fields in frontmatter
|
||||
- [x] Backend auto-creates next instance on recurring task completion
|
||||
- [x] Due date advanced by interval (daily/weekly/monthly/yearly)
|
||||
- [x] Recurrence picker (dropdown) in task detail panel
|
||||
- [x] Recurrence indicator on task list items
|
||||
|
||||
### Calendar View
|
||||
- [x] Month grid calendar at `/calendar`
|
||||
- [x] Tasks with due dates plotted on day cells
|
||||
- [x] Daily notes shown as blue dots
|
||||
- [x] Color-coded urgency (overdue/today/soon)
|
||||
- [x] Month navigation (prev/next) + Today button
|
||||
- [x] Click task → navigate to detail, click date → daily note
|
||||
- [x] Calendar link in sidebar navigation
|
||||
|
||||
### Due Date
|
||||
- [x] Inline date picker in task detail panel
|
||||
- [x] Clear due date button
|
||||
- [x] Due date display with color-coded urgency on task items
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Polish
|
||||
|
||||
- [ ] UI polish and animations
|
||||
- [ ] Responsive sidebar
|
||||
- [ ] Better error handling/messages
|
||||
- [ ] Loading states
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Future / Optional
|
||||
|
||||
- [ ] Global hotkey (Ctrl+Shift+Space)
|
||||
- [ ] System tray mode
|
||||
- [ ] Backlinks between notes
|
||||
- [ ] Graph view
|
||||
- [ ] Export (PDF / HTML)
|
||||
- [ ] Custom themes
|
||||
- [ ] Tantivy search (if >5000 notes)
|
||||
- [ ] Task dependencies (blocked by)
|
||||
- [ ] Time estimates on tasks
|
||||
- [ ] Calendar drag-and-drop rescheduling
|
||||
- [ ] Week/day calendar views
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- No item is marked complete unless it is implemented and verified.
|
||||
- New features must be added to this checklist before implementation.
|
||||
- If it's not on this list, it's out of scope.
|
||||
128
docs/ai-workflow/HANDOVER.md
Normal file
128
docs/ai-workflow/HANDOVER.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Ironpad - Chat Handover Document
|
||||
|
||||
**Date:** 2026-02-05
|
||||
**Context:** See `ai-context.md` for full project overview
|
||||
|
||||
---
|
||||
|
||||
## Session Summary
|
||||
|
||||
This session focused on fixing a critical bug where **notes and tasks displayed stale/wrong content** when switching between items. The issue caused data loss as the wrong content was being saved to the wrong files.
|
||||
|
||||
---
|
||||
|
||||
## What Was Fixed
|
||||
|
||||
### Problem: Stale Content When Switching Notes/Tasks
|
||||
|
||||
**Symptoms:**
|
||||
- Click note A → shows content correctly
|
||||
- Click note B → still shows note A's content
|
||||
- Refresh sometimes fixes it, sometimes shows blank
|
||||
- Auto-save then overwrites note B with note A's content (DATA LOSS)
|
||||
|
||||
**Root Cause:**
|
||||
The Milkdown WYSIWYG editor wasn't properly recreating when switching items. Two issues:
|
||||
|
||||
1. **Module-level variables in `MilkdownEditorCore.vue`** - State like `currentContent` was persisting across component recreations because they were `let` variables instead of Vue `ref`s.
|
||||
|
||||
2. **Race condition in view components** - The editor key was changing BEFORE content was loaded:
|
||||
```
|
||||
noteId changes → editor recreates with empty content → content loads → too late
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
1. Converted module-level `let` variables to `ref`s in `MilkdownEditorCore.vue`
|
||||
2. Added retry mechanism for applying pending content
|
||||
3. Introduced separate `editorKey` ref in all view components that only updates AFTER content is loaded
|
||||
4. Added guards to prevent emitting stale content
|
||||
|
||||
**Files Modified:**
|
||||
- `frontend/src/components/MilkdownEditorCore.vue`
|
||||
- `frontend/src/components/MilkdownEditor.vue`
|
||||
- `frontend/src/views/ProjectNotesView.vue`
|
||||
- `frontend/src/views/TasksView.vue`
|
||||
- `frontend/src/views/NotesView.vue`
|
||||
|
||||
---
|
||||
|
||||
## Outstanding Issues
|
||||
|
||||
All major issues from this session have been resolved:
|
||||
|
||||
1. **Auto-save aggressiveness** - FIXED: Now tracks "last saved content" and only saves when actual changes are made
|
||||
2. **Documentation** - FIXED: Added README.md, docs/ARCHITECTURE.md, docs/API.md
|
||||
|
||||
---
|
||||
|
||||
## Technical Context for Future Sessions
|
||||
|
||||
### Milkdown Editor Lifecycle (Critical Knowledge)
|
||||
|
||||
The Milkdown editor (WYSIWYG markdown) has a complex lifecycle:
|
||||
|
||||
1. `MilkdownProvider` provides Vue context
|
||||
2. `useEditor` hook creates the `Crepe` instance
|
||||
3. `Crepe.editor` is the actual Milkdown Editor
|
||||
4. `editor.action(replaceAll(content))` updates content
|
||||
5. BUT `editor.action` isn't immediately available after `useEditor` returns
|
||||
|
||||
**Key Pattern:** Always set content BEFORE changing the editor key:
|
||||
```javascript
|
||||
// CORRECT
|
||||
editorContent.value = newContent
|
||||
editorKey.value = newId // Editor recreates with correct defaultValue
|
||||
|
||||
// WRONG
|
||||
editorKey.value = newId // Editor recreates with stale/empty content
|
||||
editorContent.value = newContent // Too late!
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
ironpad/
|
||||
├── backend/ # Rust Axum server (API only)
|
||||
├── frontend/ # Vue 3 SPA
|
||||
│ └── src/
|
||||
│ ├── components/
|
||||
│ │ ├── MilkdownEditor.vue # Wrapper component
|
||||
│ │ └── MilkdownEditorCore.vue # Actual editor (key file!)
|
||||
│ ├── views/
|
||||
│ │ ├── NotesView.vue # Standalone notes
|
||||
│ │ ├── ProjectNotesView.vue # Project-specific notes
|
||||
│ │ └── TasksView.vue # Project tasks
|
||||
│ └── stores/ # Pinia state management
|
||||
└── data/ # Markdown files (source of truth)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. ~~**Fix auto-save aggressiveness**~~ - DONE: Uses `lastSavedContent` to track actual changes
|
||||
2. ~~**Create proper README.md**~~ - DONE: See `/README.md`, `/frontend/README.md`
|
||||
3. ~~**Add developer documentation**~~ - DONE: See `/docs/ARCHITECTURE.md`, `/docs/API.md`
|
||||
4. **Consider adding tests** - At minimum, test the content switching logic
|
||||
|
||||
---
|
||||
|
||||
## Commands Reference
|
||||
|
||||
```bash
|
||||
# Backend (from backend/)
|
||||
cargo run # API server on :3000
|
||||
|
||||
# Frontend (from frontend/)
|
||||
npm run dev # Dev server on :5173
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Windows + PowerShell environment
|
||||
- Files are the database (no SQL)
|
||||
- Git auto-commits every 60 seconds
|
||||
- See `ai-context.md` for full feature list and API endpoints
|
||||
1004
docs/ai-workflow/PRD.md
Normal file
1004
docs/ai-workflow/PRD.md
Normal file
File diff suppressed because it is too large
Load Diff
22
docs/ai-workflow/README.md
Normal file
22
docs/ai-workflow/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# AI-Assisted Development: How Ironpad Was Built
|
||||
|
||||
Ironpad was built entirely using AI-assisted development. Not just the code -- the architecture, the PRD, the task breakdowns, and the debugging were all done through structured collaboration with AI models.
|
||||
|
||||
We share this process openly as part of the **Open Method** philosophy: not just open source code, but open development process.
|
||||
|
||||
## What's in This Folder
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [method.md](method.md) | The complete AI development workflow: PRD-first, task decomposition, handover system |
|
||||
| [tools.md](tools.md) | Every tool used, how it fits in, and why |
|
||||
| [lessons-learned.md](lessons-learned.md) | What worked, what didn't, and the 200K to 1M context window shift |
|
||||
|
||||
## Articles
|
||||
|
||||
- [The AI Development Workflow I Actually Use](https://dev.to/olaproeis/the-ai-development-workflow-i-actually-use-549i) -- The original workflow article describing the method
|
||||
- [From 200K to 1M: How Claude Opus 4.6 Changed My AI Development Workflow](docs/article-opus-4.6-workflow.md) -- Follow-up on the context window leap
|
||||
|
||||
## Key Takeaway
|
||||
|
||||
The tools keep getting better, but **the process of using them well still matters**. A 1 million token context window doesn't help if you don't know what to ask for. Structure your work, be specific, maintain context, verify results.
|
||||
133
docs/ai-workflow/lessons-learned.md
Normal file
133
docs/ai-workflow/lessons-learned.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Lessons Learned
|
||||
|
||||
What worked, what didn't, and what changed when the context window went from 200K to 1M tokens.
|
||||
|
||||
## What Worked Well
|
||||
|
||||
### 1. PRD-First Development
|
||||
|
||||
Writing a detailed PRD before any code was the single highest-leverage activity. The AI produces dramatically better code when it knows exactly what success looks like.
|
||||
|
||||
Ironpad's PRD went through 3 versions. Each round of multi-AI review caught issues that would have been expensive to fix later:
|
||||
- Concurrent editing race conditions (file locking needed)
|
||||
- File watcher integration (external editor support)
|
||||
- Git conflict handling (graceful degradation, not crashes)
|
||||
- Frontmatter automation (users shouldn't manually edit metadata)
|
||||
|
||||
**Lesson:** Time spent on the PRD pays off 10x during implementation.
|
||||
|
||||
### 2. Rust's Strict Compiler
|
||||
|
||||
Rust stands out for AI-assisted development because the compiler is extraordinarily strict. It catches:
|
||||
- Memory safety issues
|
||||
- Type mismatches
|
||||
- Lifetime problems
|
||||
- Unused variables and imports
|
||||
- Missing error handling
|
||||
|
||||
With dynamic languages, bugs hide until runtime. With Rust, the AI gets immediate, precise feedback on what's broken. The feedback loop is tighter and more reliable.
|
||||
|
||||
`cargo check` became the primary verification tool. If it compiles, a large category of bugs is already eliminated.
|
||||
|
||||
**Lesson:** A strict compiler is a massive advantage for AI-generated code.
|
||||
|
||||
### 3. The ai-context.md Pattern
|
||||
|
||||
Maintaining a lean (~100 line) architectural reference that tells the AI how to write code for this specific project eliminated a whole class of "doesn't fit the codebase" problems.
|
||||
|
||||
Without it, the AI would invent new patterns, use different naming conventions, or structure code differently from the existing codebase. With it, code consistently matched existing patterns.
|
||||
|
||||
**Lesson:** A small context document is worth more than a large architecture doc. The AI needs a cheat sheet, not a textbook.
|
||||
|
||||
### 4. Fresh Chats Over Long Conversations
|
||||
|
||||
Context accumulates noise. By the third task in a single chat, the AI references irrelevant earlier context. Starting fresh with a focused handover produced consistently better results.
|
||||
|
||||
**Lesson:** Shorter, focused sessions beat long wandering ones.
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
### 1. Trusting "This Should Work"
|
||||
|
||||
The AI confidently says "this should work" when it doesn't. Every single time. Without exception.
|
||||
|
||||
Early on, I'd take the AI's word and move on. Then things would break two features later when the untested code interacted with something else.
|
||||
|
||||
**Fix:** Test everything yourself. Run the feature. Click the buttons. Try the edge cases. The AI writes code; you verify the product.
|
||||
|
||||
### 2. Vague Requirements
|
||||
|
||||
"Add search" produces mediocre results. "Add full-text search with ripgrep, triggered by Ctrl+K, showing filename and matching line with context, limited to 5 matches per file, falling back to manual string search if ripgrep isn't available" produces excellent results.
|
||||
|
||||
**Fix:** Be specific. The more precise the requirement, the better the code.
|
||||
|
||||
### 3. Over-Engineering
|
||||
|
||||
The AI tends to add abstractions, patterns, and generalization that aren't needed yet. It builds for a future that may never come.
|
||||
|
||||
**Fix:** Explicitly state YAGNI in the context. Call out when something is over-engineered. The AI responds well to "simplify this."
|
||||
|
||||
### 4. Ignoring the Editor Lifecycle
|
||||
|
||||
The Milkdown WYSIWYG editor had a complex initialization lifecycle that the AI didn't fully understand. This caused a critical bug where switching between notes showed stale content, leading to data loss.
|
||||
|
||||
**Fix:** Document critical component lifecycles in ai-context.md. The "Milkdown Editor Lifecycle" section was added after this bug and prevented similar issues.
|
||||
|
||||
## The 200K to 1M Context Shift
|
||||
|
||||
This was the most significant change in the project's development workflow.
|
||||
|
||||
### Before: 200K Tokens (Claude Opus 4.5)
|
||||
|
||||
| Aspect | Reality |
|
||||
|--------|---------|
|
||||
| Files in context | ~3-5 at once |
|
||||
| Task granularity | Must split features into 3-5 micro-tasks |
|
||||
| Handovers | Required between every task |
|
||||
| Cross-file bugs | Hard to find (AI can't see all files) |
|
||||
| Refactors | Multi-session, risk of inconsistency |
|
||||
| Overhead per task | ~15-20 min (handover + context setup) |
|
||||
|
||||
### After: 1M Tokens (Claude Opus 4.6)
|
||||
|
||||
| Aspect | Reality |
|
||||
|--------|---------|
|
||||
| Files in context | Entire codebase (80+ files) |
|
||||
| Task granularity | Full features in one session |
|
||||
| Handovers | Only needed between days/sessions |
|
||||
| Cross-file bugs | Found automatically (AI sees everything) |
|
||||
| Refactors | Single session, guaranteed consistency |
|
||||
| Overhead per task | ~0 min |
|
||||
|
||||
### The Codebase Audit
|
||||
|
||||
The clearest demonstration of the shift: loading the entire Ironpad codebase into a single context and asking "what's wrong?"
|
||||
|
||||
The AI found 16 issues, including:
|
||||
- **Auto-commit silently broken** -- A flag that was never set to `true` anywhere in the codebase. Finding this required reading `main.rs`, `git.rs`, and every route handler simultaneously.
|
||||
- **Operator precedence bug** -- `0 > 0` evaluated before `??` due to JavaScript precedence rules. Subtle and easy to miss.
|
||||
- **Missing atomic writes** -- Only one of eight write paths used the safe atomic write pattern.
|
||||
|
||||
14 of 16 issues were fixed in a single session. Zero compilation errors introduced.
|
||||
|
||||
This type of comprehensive audit was not practical at 200K tokens.
|
||||
|
||||
### What Didn't Change
|
||||
|
||||
The 1M context window doesn't change everything:
|
||||
- **PRDs are still essential.** More context doesn't substitute for clear requirements.
|
||||
- **Testing is still essential.** The AI still says "this should work" when it doesn't.
|
||||
- **Specificity still matters.** Vague asks still produce vague results.
|
||||
- **Handovers still matter** between sessions (sleep, context switches, etc.)
|
||||
|
||||
The context window is a force multiplier, not a replacement for process.
|
||||
|
||||
## Advice for Others
|
||||
|
||||
1. **Start with the PRD.** It's the highest-leverage activity.
|
||||
2. **Use a strict language if you can.** Rust, TypeScript (strict mode), Go -- anything with a compiler that catches bugs.
|
||||
3. **Maintain ai-context.md.** Keep it under 100 lines. Update it when patterns change.
|
||||
4. **Test everything.** Don't read code. Run the thing.
|
||||
5. **Use multiple AI models.** They have different blind spots.
|
||||
6. **Be specific.** The more precise the requirement, the better the result.
|
||||
7. **Keep sessions focused.** One task, one chat (at 200K). One feature, one chat (at 1M).
|
||||
119
docs/ai-workflow/method.md
Normal file
119
docs/ai-workflow/method.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# The Development Method
|
||||
|
||||
This document describes the AI-assisted development workflow used to build Ironpad from scratch.
|
||||
|
||||
## Overview
|
||||
|
||||
The method has six phases, applied iteratively for each feature:
|
||||
|
||||
1. **Multi-AI Consultation** -- Get perspectives from multiple AI models before coding
|
||||
2. **PRD Creation** -- Write a detailed product requirements document
|
||||
3. **Task Decomposition** -- Break the PRD into ordered, dependency-aware tasks
|
||||
4. **Context Loading** -- Feed the AI current documentation and project context
|
||||
5. **Implementation** -- Build features in focused sessions with handovers
|
||||
6. **Verification** -- Test everything yourself; don't trust "this should work"
|
||||
|
||||
## Phase 1: Multi-AI Consultation
|
||||
|
||||
Before writing any code, discuss the idea with different AI assistants:
|
||||
|
||||
- **Claude** for architecture and code design
|
||||
- **Perplexity** for research on libraries, crate versions, and known issues
|
||||
- **Gemini** for alternative perspectives and catching blind spots
|
||||
|
||||
Each AI has different strengths and blind spots. Five minutes getting multiple opinions saves hours of rework later.
|
||||
|
||||
**Example from Ironpad:** When designing the task system, one model suggested storing tasks as checkboxes in a single `tasks.md` file. Another pointed out that individual task files with frontmatter would be more flexible and avoid concurrent edit conflicts. We went with individual files, which turned out to be the right call.
|
||||
|
||||
## Phase 2: PRD Creation
|
||||
|
||||
Task Master (and AI in general) produces dramatically better results when it knows exactly what success looks like. The PRD captures:
|
||||
|
||||
- Problem statement and goals
|
||||
- Detailed feature specifications
|
||||
- Technical architecture decisions
|
||||
- API design
|
||||
- Data model
|
||||
- Edge cases and error handling
|
||||
- Non-goals (equally important)
|
||||
|
||||
After drafting, run the PRD through other AIs for review. Iterate until it's tight.
|
||||
|
||||
**Ironpad's PRD** went through 3 versions, incorporating feedback about concurrency control, file watching, git conflict handling, and frontmatter automation -- all before a single line of code was written.
|
||||
|
||||
## Phase 3: Task Decomposition
|
||||
|
||||
Use Task Master to parse the PRD into structured tasks with dependencies. Each task should have:
|
||||
|
||||
- Clear inputs (what files/context are needed)
|
||||
- Clear outputs (what gets created/changed)
|
||||
- Explicit dependencies (what must be done first)
|
||||
- Acceptance criteria (how to verify it works)
|
||||
|
||||
You don't need Task Master specifically. What matters is having explicit, ordered tasks rather than vague goals like "add search."
|
||||
|
||||
## Phase 4: Context Loading
|
||||
|
||||
AI models have training cutoffs. The library docs they know might be outdated.
|
||||
|
||||
- **Context7** (MCP tool) pulls current documentation into context for any library
|
||||
- **ai-context.md** is a lean architectural reference (~100 lines) telling the AI how to write code that fits the codebase
|
||||
- **Handover documents** carry context between sessions
|
||||
|
||||
### The ai-context.md Pattern
|
||||
|
||||
This file tells the AI *how* to write code that belongs in this project:
|
||||
|
||||
- Module structure and naming conventions
|
||||
- Key types and their relationships
|
||||
- Framework idioms (Axum patterns, Vue composition API patterns)
|
||||
- Critical gotchas that cause bugs (e.g., Milkdown editor lifecycle)
|
||||
- Current implementation status
|
||||
|
||||
It's not a full architecture doc. It's a cheat sheet for the AI.
|
||||
|
||||
## Phase 5: Implementation
|
||||
|
||||
### The Handover System (200K Context)
|
||||
|
||||
With 200K token models, each task gets a fresh chat:
|
||||
|
||||
1. Open new chat
|
||||
2. Paste handover document with: rules, relevant files, current task
|
||||
3. Work on task until done
|
||||
4. AI updates the handover document for the next task
|
||||
5. Close chat, repeat
|
||||
|
||||
**Why fresh chats?** Context accumulates noise. Three tasks in, the AI references irrelevant stuff from earlier. Starting clean with a focused handover produces better results.
|
||||
|
||||
### The Full-Context Approach (1M Context)
|
||||
|
||||
With 1M token models (Claude Opus 4.6), the workflow simplifies:
|
||||
|
||||
1. Load the entire codebase into context
|
||||
2. Work on features directly -- the AI sees everything
|
||||
3. Use handovers only for session boundaries (end of day, sleep, etc.)
|
||||
|
||||
The handover system doesn't disappear -- it shifts from "required between every task" to "useful between sessions."
|
||||
|
||||
## Phase 6: Verification
|
||||
|
||||
The AI writes the code. You verify the product.
|
||||
|
||||
- Run the feature and see if it works
|
||||
- Test edge cases manually
|
||||
- Check that nothing else broke
|
||||
- Use compiler output (`cargo check`) and linters as mechanical verification
|
||||
|
||||
Don't read code line by line. Run the thing and see if it works. When something's wrong, describe the problem clearly. The AI debugs from there.
|
||||
|
||||
## How This Played Out for Ironpad
|
||||
|
||||
| Phase | What Happened |
|
||||
|-------|---------------|
|
||||
| 1-3 | PRD v3.0 with architecture decisions, reviewed by multiple AIs |
|
||||
| 4 | ai-context.md maintained throughout, Context7 for Axum/Vue/Milkdown docs |
|
||||
| 5 | Phases 1-3 built with Opus 4.5 (200K), phases 4-5 with Opus 4.6 (1M) |
|
||||
| 6 | Every feature manually tested in the browser |
|
||||
|
||||
Total development time: approximately 2 weeks from PRD to working application with dashboard, calendar, git panel, WYSIWYG editor, subtasks, recurring tasks, and real-time sync.
|
||||
116
docs/ai-workflow/tools.md
Normal file
116
docs/ai-workflow/tools.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Tools Used
|
||||
|
||||
Every tool in the Ironpad development workflow and how it fits in.
|
||||
|
||||
## IDE
|
||||
|
||||
### Cursor IDE
|
||||
|
||||
The primary development environment. Cursor is a fork of VS Code with built-in AI integration.
|
||||
|
||||
- **Why:** Direct integration with Claude models, inline code editing, multi-file context
|
||||
- **How used:** All coding, file editing, terminal commands, and AI conversations happen in Cursor
|
||||
- **Alternative:** VS Code + Copilot, Windsurf, or any editor with AI integration
|
||||
|
||||
## AI Models
|
||||
|
||||
### Claude Opus 4.5 (Anthropic)
|
||||
|
||||
Used for the majority of Ironpad's development (Phases 1-3 of the implementation).
|
||||
|
||||
- **Context window:** 200K tokens
|
||||
- **Strengths:** Excellent at Rust code, understands Axum patterns well, good at architecture
|
||||
- **Limitation:** Can only hold ~5 files at once; required task splitting and handover documents
|
||||
- **How used:** Feature implementation, debugging, code review
|
||||
|
||||
### Claude Opus 4.6 (Anthropic)
|
||||
|
||||
Used for later phases and the full codebase audit.
|
||||
|
||||
- **Context window:** 1M tokens (5x increase)
|
||||
- **Strengths:** Can hold the entire codebase at once; finds cross-file bugs; handles complex refactors in one session
|
||||
- **How used:** Codebase audit (found 16 issues), cross-cutting refactors, feature implementation without task splitting
|
||||
|
||||
### Perplexity AI
|
||||
|
||||
Used for research before coding.
|
||||
|
||||
- **Why:** Has internet access, provides current information with citations
|
||||
- **How used:** Checking library versions, finding known issues with crates, researching approaches
|
||||
- **Example:** Verified that `serde_yaml` was deprecated before we chose to use it (accepted risk for v1)
|
||||
|
||||
### Google Gemini
|
||||
|
||||
Used as a second opinion on architecture.
|
||||
|
||||
- **Why:** Different training data and perspective from Claude
|
||||
- **How used:** PRD review, architecture review, catching blind spots
|
||||
- **Example:** Flagged the need for file locking between Task View and Editor (race condition that Claude's initial design missed)
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### Task Master
|
||||
|
||||
A Model Context Protocol (MCP) tool for structured task management.
|
||||
|
||||
- **What it does:** Parses a PRD and generates ordered task lists with dependencies
|
||||
- **How used:** Fed the PRD to Task Master to generate the implementation plan; tasks tracked through completion
|
||||
- **Why it matters:** Turns a document into actionable, sequenced work items
|
||||
|
||||
### Context7
|
||||
|
||||
A Model Context Protocol (MCP) tool for pulling current library documentation.
|
||||
|
||||
- **What it does:** Fetches up-to-date documentation for any library and loads it into the AI's context
|
||||
- **How used:** Pulled current Axum 0.8 docs, Vue 3 Composition API docs, Milkdown editor API docs
|
||||
- **Why it matters:** Eliminates bugs caused by the AI using outdated API knowledge
|
||||
|
||||
## Build Tools
|
||||
|
||||
### Rust / Cargo
|
||||
|
||||
- Backend language and build system
|
||||
- `cargo check` for fast compilation checks
|
||||
- `cargo build --release` for production binaries
|
||||
- Strict compiler catches entire categories of bugs before runtime
|
||||
|
||||
### Node.js / Vite
|
||||
|
||||
- Frontend build tooling
|
||||
- `npm run dev` for development with hot reload
|
||||
- `npm run build` for production static files
|
||||
- Vue SFC compilation via `@vitejs/plugin-vue`
|
||||
|
||||
### Git
|
||||
|
||||
- Version control for all data files
|
||||
- `git2` crate for programmatic access from Rust
|
||||
- Automatic 60-second commit batching
|
||||
- Full diff viewer in the UI
|
||||
|
||||
## The Tool Stack in Practice
|
||||
|
||||
```
|
||||
Idea
|
||||
|
|
||||
v
|
||||
[Perplexity] -- Research libraries, check feasibility
|
||||
[Gemini] -- Second opinion on approach
|
||||
|
|
||||
v
|
||||
[Claude] -- Draft PRD
|
||||
[Gemini] -- Review PRD
|
||||
|
|
||||
v
|
||||
[Task Master] -- Generate ordered task list
|
||||
|
|
||||
v
|
||||
[Cursor + Claude] -- Implement each task
|
||||
[Context7] -- Current docs when needed
|
||||
|
|
||||
v
|
||||
[Manual Testing] -- Verify in browser
|
||||
[cargo check] -- Compiler verification
|
||||
```
|
||||
|
||||
No single tool does everything. The value is in how they compose.
|
||||
BIN
docs/screenshot.jpg
Normal file
BIN
docs/screenshot.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
143
frontend/README.md
Normal file
143
frontend/README.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Ironpad Frontend
|
||||
|
||||
Vue 3 single-page application for Ironpad.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 (requires backend running on port 3000).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Vue 3** with Composition API and `<script setup>`
|
||||
- **TypeScript** for type safety
|
||||
- **Vite** for fast development and builds
|
||||
- **Pinia** for state management
|
||||
- **Vue Router** for navigation
|
||||
- **Milkdown** for WYSIWYG Markdown editing
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API client for backend communication
|
||||
│ └── client.ts
|
||||
├── components/ # Reusable Vue components
|
||||
│ ├── MilkdownEditor.vue # Editor wrapper
|
||||
│ ├── MilkdownEditorCore.vue # Core editor logic
|
||||
│ ├── Sidebar.vue # Navigation sidebar
|
||||
│ ├── GitPanel.vue # Git operations panel
|
||||
│ └── ...
|
||||
├── composables/ # Vue composables
|
||||
│ └── useWebSocket.ts
|
||||
├── router/ # Vue Router configuration
|
||||
│ └── index.ts
|
||||
├── stores/ # Pinia stores
|
||||
│ ├── notes.ts # Notes state
|
||||
│ ├── projects.ts # Projects state
|
||||
│ ├── tasks.ts # Tasks state
|
||||
│ ├── git.ts # Git state
|
||||
│ ├── theme.ts # Theme state
|
||||
│ ├── ui.ts # UI state
|
||||
│ ├── websocket.ts # WebSocket state
|
||||
│ └── workspace.ts # Workspace state
|
||||
├── types/ # TypeScript type definitions
|
||||
│ └── index.ts
|
||||
├── views/ # Route views
|
||||
│ ├── DashboardView.vue # Home page with project cards + task summaries
|
||||
│ ├── ProjectView.vue # Project overview with editor
|
||||
│ ├── ProjectNotesView.vue # Project notes split view
|
||||
│ ├── ProjectsView.vue # Projects management list
|
||||
│ ├── TasksView.vue # Task split view (list + detail)
|
||||
│ ├── CalendarView.vue # Month grid calendar
|
||||
│ └── DailyView.vue # Daily notes
|
||||
├── App.vue # Root component
|
||||
├── main.ts # Entry point
|
||||
└── style.css # Global styles
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### Milkdown Editor
|
||||
|
||||
The editor consists of two components:
|
||||
|
||||
- **MilkdownEditor.vue** — Wrapper component that accepts a `:key` prop for recreation
|
||||
- **MilkdownEditorCore.vue** — Core editor using the `@milkdown/vue` integration
|
||||
|
||||
**Critical Pattern**: When switching between notes/tasks, content MUST be set BEFORE updating the editor key:
|
||||
|
||||
```javascript
|
||||
// CORRECT order:
|
||||
editorContent.value = newContent // Set content first
|
||||
editorKey.value = noteId // Then trigger editor recreation
|
||||
|
||||
// WRONG order (causes stale content):
|
||||
editorKey.value = noteId // Editor recreates with wrong content
|
||||
editorContent.value = newContent // Too late!
|
||||
```
|
||||
|
||||
### Task System Features
|
||||
|
||||
The task view (`TasksView.vue`) includes:
|
||||
|
||||
- **Tag system** — tags stored in YAML frontmatter, filterable via tag bar, autocomplete from project tags
|
||||
- **Subtasks** — tasks with `parent_id` grouped under parents, inline creation, count badges
|
||||
- **Recurring tasks** — daily/weekly/monthly/yearly, auto-creates next on completion
|
||||
- **Due date picker** — inline date input, clearable, color-coded urgency display
|
||||
- **Active/Backlog toggle** — move tasks between states
|
||||
|
||||
### Dashboard (`DashboardView.vue`)
|
||||
|
||||
Cross-project home page showing all projects as cards with:
|
||||
- Active task count, backlog count, overdue count
|
||||
- Top 5 active tasks per project with tags and due dates
|
||||
- Click-through to project or individual task
|
||||
|
||||
### Calendar (`CalendarView.vue`)
|
||||
|
||||
Month grid calendar showing:
|
||||
- Tasks plotted by `due_date` (only tasks with dates appear)
|
||||
- Daily notes shown as blue dots
|
||||
- Color-coded urgency (overdue=red, today=red, soon=yellow)
|
||||
- Navigation: prev/next month, Today button
|
||||
|
||||
### State Management
|
||||
|
||||
Each domain has its own Pinia store:
|
||||
|
||||
- `notesStore` — Standalone notes CRUD
|
||||
- `projectsStore` — Projects list and details
|
||||
- `tasksStore` — Project tasks with active/backlog sections, tag filtering, subtask helpers
|
||||
- `gitStore` — Git status, commits, push/pull
|
||||
- `themeStore` — Dark/light mode
|
||||
- `uiStore` — Search panel, modals
|
||||
- `websocketStore` — Real-time connection state
|
||||
- `workspaceStore` — Active project tracking
|
||||
|
||||
### Auto-save Behavior
|
||||
|
||||
Views implement smart auto-save that:
|
||||
1. Tracks the "last saved content" when a note/task loads
|
||||
2. Only saves when content differs from last saved
|
||||
3. Uses 1-second debounce to batch rapid edits
|
||||
4. Prevents unnecessary saves when just opening items
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server (hot reload)
|
||||
npm run build # Production build to dist/
|
||||
npm run preview # Preview production build
|
||||
npm run lint # Run ESLint
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
The frontend expects the backend API at `http://localhost:3000`. This is configured in `src/api/client.ts`.
|
||||
|
||||
For production, build the frontend and serve from any static host, configuring the API URL as needed.
|
||||
38
frontend/index.html
Normal file
38
frontend/index.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>Ironpad</title>
|
||||
<script>
|
||||
// Apply saved theme immediately to prevent flash
|
||||
(function() {
|
||||
var saved = localStorage.getItem('ironpad-theme');
|
||||
if (saved === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
// Default is dark (already set on html tag)
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
[data-theme="light"] body {
|
||||
background: #ffffff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
4025
frontend/package-lock.json
generated
Normal file
4025
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.12",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
"@milkdown/vue": "^7.18.0",
|
||||
"@prosemirror-adapter/vue": "^0.4.6",
|
||||
"codemirror": "^6.0.2",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.15.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.7.3",
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.10"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
324
frontend/src/App.vue
Normal file
324
frontend/src/App.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
useNotesStore,
|
||||
useProjectsStore,
|
||||
useTasksStore,
|
||||
useUiStore,
|
||||
useGitStore,
|
||||
useWebSocketStore,
|
||||
useWorkspaceStore,
|
||||
useThemeStore
|
||||
} from './stores'
|
||||
import { useWebSocket } from './composables/useWebSocket'
|
||||
import TopBar from './components/TopBar.vue'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import ConflictBanner from './components/ConflictBanner.vue'
|
||||
|
||||
const notesStore = useNotesStore()
|
||||
const projectsStore = useProjectsStore()
|
||||
const tasksStore = useTasksStore()
|
||||
const uiStore = useUiStore()
|
||||
const gitStore = useGitStore()
|
||||
const wsStore = useWebSocketStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// Non-blocking external edit notification (replaces blocking confirm())
|
||||
const externalEditPath = ref<string | null>(null)
|
||||
|
||||
function reloadExternalEdit() {
|
||||
if (notesStore.currentNote && externalEditPath.value) {
|
||||
notesStore.loadNote(notesStore.currentNote.id)
|
||||
}
|
||||
externalEditPath.value = null
|
||||
}
|
||||
|
||||
function dismissExternalEdit() {
|
||||
externalEditPath.value = null
|
||||
}
|
||||
|
||||
// Initialize theme immediately (before mount for no flash)
|
||||
themeStore.init()
|
||||
|
||||
// WebSocket connection with handlers
|
||||
const { connected, clientId } = useWebSocket({
|
||||
onFileCreated: () => {
|
||||
notesStore.loadNotes()
|
||||
projectsStore.loadProjects()
|
||||
},
|
||||
onFileModified: (path) => {
|
||||
notesStore.loadNotes()
|
||||
// Non-blocking notification if current note was modified externally
|
||||
if (notesStore.currentNote?.path === path) {
|
||||
externalEditPath.value = path
|
||||
}
|
||||
},
|
||||
onFileDeleted: () => {
|
||||
notesStore.loadNotes()
|
||||
projectsStore.loadProjects()
|
||||
},
|
||||
onFileLocked: (path, lockClientId, lockType) => {
|
||||
wsStore.addFileLock({
|
||||
path,
|
||||
client_id: lockClientId,
|
||||
lock_type: lockType as 'editor' | 'task_view'
|
||||
})
|
||||
},
|
||||
onFileUnlocked: (path) => {
|
||||
wsStore.removeFileLock(path)
|
||||
},
|
||||
onGitConflict: (files) => {
|
||||
wsStore.setGitConflicts(files)
|
||||
}
|
||||
})
|
||||
|
||||
// Sync WebSocket state to store
|
||||
import { watch } from 'vue'
|
||||
// Note: watch imported separately to maintain original code structure
|
||||
watch(connected, (val) => wsStore.setConnected(val))
|
||||
watch(clientId, (val) => wsStore.setClientId(val))
|
||||
|
||||
// Keyboard shortcuts
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ctrl/Cmd + K for search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
uiStore.toggleSearch()
|
||||
}
|
||||
// Ctrl/Cmd + S to save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (notesStore.currentNote) {
|
||||
// Trigger save - the view will handle it
|
||||
const event = new CustomEvent('save-note')
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
// Escape to close panels
|
||||
if (e.key === 'Escape') {
|
||||
uiStore.closeSearch()
|
||||
uiStore.closeTasks()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Load initial data
|
||||
await Promise.all([
|
||||
notesStore.loadNotes(),
|
||||
projectsStore.loadProjects(),
|
||||
tasksStore.loadAllTasks(),
|
||||
gitStore.loadStatus(),
|
||||
gitStore.loadRemote()
|
||||
])
|
||||
|
||||
// Load saved active project
|
||||
await workspaceStore.loadSavedProject()
|
||||
|
||||
// Check for git conflicts and remote status periodically
|
||||
setInterval(() => {
|
||||
gitStore.checkConflicts()
|
||||
gitStore.loadRemote()
|
||||
}, 60000)
|
||||
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app-layout">
|
||||
<TopBar />
|
||||
<div id="app-container">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<ConflictBanner />
|
||||
<div v-if="externalEditPath" class="external-edit-banner">
|
||||
File modified externally.
|
||||
<button @click="reloadExternalEdit" class="primary" style="margin-left: 8px">Reload</button>
|
||||
<button @click="dismissExternalEdit" style="margin-left: 4px">Dismiss</button>
|
||||
</div>
|
||||
<div v-if="uiStore.globalError" class="error-message">
|
||||
{{ uiStore.globalError }}
|
||||
<button @click="uiStore.clearGlobalError" style="margin-left: 12px">Dismiss</button>
|
||||
</div>
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
--sidebar-width: 280px;
|
||||
--header-height: 52px;
|
||||
--font-mono: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
|
||||
/* Light theme as base (will be overridden by dark) */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-secondary: #f8f9fa;
|
||||
--color-bg-hover: #f1f3f5;
|
||||
--color-border: #e1e4e8;
|
||||
--color-text: #24292e;
|
||||
--color-text-secondary: #586069;
|
||||
--color-primary: #0366d6;
|
||||
--color-danger: #cb2431;
|
||||
--color-success: #28a745;
|
||||
--color-warning: #f0ad4e;
|
||||
}
|
||||
|
||||
/* Light theme explicit */
|
||||
[data-theme="light"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-secondary: #f8f9fa;
|
||||
--color-bg-hover: #f1f3f5;
|
||||
--color-border: #e1e4e8;
|
||||
--color-text: #24292e;
|
||||
--color-text-secondary: #586069;
|
||||
--color-primary: #0366d6;
|
||||
--color-danger: #cb2431;
|
||||
--color-success: #28a745;
|
||||
--color-warning: #f0ad4e;
|
||||
}
|
||||
|
||||
/* Dark theme - overrides light when data-theme="dark" */
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #1a1a1a;
|
||||
--color-bg-secondary: #232323;
|
||||
--color-bg-hover: #2d2d2d;
|
||||
--color-border: #3c3c3c;
|
||||
--color-text: #e0e0e0;
|
||||
--color-text-secondary: #999999;
|
||||
--color-primary: #58a6ff;
|
||||
--color-danger: #f85149;
|
||||
--color-success: #56d364;
|
||||
--color-warning: #d29922;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.external-edit-banner {
|
||||
padding: 10px 16px;
|
||||
background: var(--color-warning);
|
||||
color: #1a1a1a;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="search"] {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="search"]:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
240
frontend/src/api/client.ts
Normal file
240
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// API client for Ironpad backend
|
||||
|
||||
import type {
|
||||
Note,
|
||||
NoteSummary,
|
||||
Project,
|
||||
ProjectWithContent,
|
||||
ProjectNote,
|
||||
ProjectNoteWithContent,
|
||||
Task,
|
||||
TaskWithContent,
|
||||
SearchResult,
|
||||
GitStatus,
|
||||
CommitInfo,
|
||||
CommitDetail,
|
||||
DiffInfo,
|
||||
RemoteInfo,
|
||||
DailyNote
|
||||
} from '../types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${url}`, options)
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
// Handle empty responses
|
||||
const contentType = res.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
return res.json()
|
||||
}
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
// Notes API
|
||||
export const notesApi = {
|
||||
list: () => request<NoteSummary[]>('/notes'),
|
||||
|
||||
get: (id: string) => request<Note>(`/notes/${encodeURIComponent(id)}`),
|
||||
|
||||
create: () => request<Note>('/notes', { method: 'POST' }),
|
||||
|
||||
update: (id: string, content: string) =>
|
||||
request<Note>(`/notes/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
request<void>(`/notes/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// Projects API
|
||||
export const projectsApi = {
|
||||
list: () => request<Project[]>('/projects'),
|
||||
|
||||
get: (id: string) => request<Project>(`/projects/${encodeURIComponent(id)}`),
|
||||
|
||||
getContent: (id: string) =>
|
||||
request<ProjectWithContent>(`/projects/${encodeURIComponent(id)}/content`),
|
||||
|
||||
updateContent: (id: string, content: string) =>
|
||||
request<ProjectWithContent>(`/projects/${encodeURIComponent(id)}/content`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
}),
|
||||
|
||||
create: (name: string) =>
|
||||
request<Project>('/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
}),
|
||||
|
||||
// Project Notes
|
||||
listNotes: (projectId: string) =>
|
||||
request<ProjectNote[]>(`/projects/${encodeURIComponent(projectId)}/notes`),
|
||||
|
||||
getNote: (projectId: string, noteId: string) =>
|
||||
request<ProjectNoteWithContent>(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`),
|
||||
|
||||
createNote: (projectId: string, title?: string) =>
|
||||
request<ProjectNoteWithContent>(`/projects/${encodeURIComponent(projectId)}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title })
|
||||
}),
|
||||
|
||||
updateNote: (projectId: string, noteId: string, content: string) =>
|
||||
request<ProjectNoteWithContent>(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
}),
|
||||
|
||||
deleteNote: (projectId: string, noteId: string) =>
|
||||
request<void>(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Tasks API (file-based tasks)
|
||||
export const tasksApi = {
|
||||
// List all tasks across all projects
|
||||
listAll: () => request<Task[]>('/tasks'),
|
||||
|
||||
// List tasks for a specific project
|
||||
list: (projectId: string) =>
|
||||
request<Task[]>(`/projects/${encodeURIComponent(projectId)}/tasks`),
|
||||
|
||||
// Get a single task with content
|
||||
get: (projectId: string, taskId: string) =>
|
||||
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`),
|
||||
|
||||
// Create a new task
|
||||
create: (projectId: string, title: string, section?: string, parentId?: string) =>
|
||||
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, section, parent_id: parentId || undefined })
|
||||
}),
|
||||
|
||||
// Update task content (markdown body)
|
||||
updateContent: (projectId: string, taskId: string, content: string) =>
|
||||
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
}),
|
||||
|
||||
// Toggle task completion
|
||||
toggle: (projectId: string, taskId: string) =>
|
||||
request<Task>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/toggle`, {
|
||||
method: 'PUT'
|
||||
}),
|
||||
|
||||
// Update task metadata
|
||||
updateMeta: (projectId: string, taskId: string, meta: { title?: string; section?: string; priority?: string; due_date?: string; is_active?: boolean; tags?: string[]; recurrence?: string; recurrence_interval?: number }) =>
|
||||
request<Task>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/meta`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(meta)
|
||||
}),
|
||||
|
||||
// Delete (archive) a task
|
||||
delete: (projectId: string, taskId: string) =>
|
||||
request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Search API
|
||||
export const searchApi = {
|
||||
search: (query: string) =>
|
||||
request<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
// Git API
|
||||
export const gitApi = {
|
||||
status: () => request<GitStatus>('/git/status'),
|
||||
|
||||
commit: (message?: string) =>
|
||||
request<CommitInfo>('/git/commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message })
|
||||
}),
|
||||
|
||||
push: () => request<{ success: boolean; message: string }>('/git/push', { method: 'POST' }),
|
||||
|
||||
conflicts: () => request<string[]>('/git/conflicts'),
|
||||
|
||||
// Commit history
|
||||
log: (limit?: number) =>
|
||||
request<CommitDetail[]>(`/git/log${limit ? `?limit=${limit}` : ''}`),
|
||||
|
||||
// Working directory diff (uncommitted changes)
|
||||
diff: () => request<DiffInfo>('/git/diff'),
|
||||
|
||||
// Diff for a specific commit
|
||||
commitDiff: (commitId: string) =>
|
||||
request<DiffInfo>(`/git/diff/${encodeURIComponent(commitId)}`),
|
||||
|
||||
// Remote repository info
|
||||
remote: () => request<RemoteInfo | null>('/git/remote'),
|
||||
|
||||
// Fetch from remote
|
||||
fetch: () => request<{ success: boolean; message: string }>('/git/fetch', { method: 'POST' })
|
||||
}
|
||||
|
||||
// Daily Notes API
|
||||
export const dailyApi = {
|
||||
list: () => request<DailyNote[]>('/daily'),
|
||||
|
||||
today: () => request<DailyNote>('/daily/today'),
|
||||
|
||||
get: (date: string) => request<DailyNote>(`/daily/${date}`),
|
||||
|
||||
create: (date: string, content?: string) =>
|
||||
request<DailyNote>(`/daily/${date}`, {
|
||||
method: 'POST',
|
||||
headers: content ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: content ? JSON.stringify({ content }) : undefined
|
||||
}),
|
||||
|
||||
update: (date: string, content: string) =>
|
||||
request<DailyNote>(`/daily/${date}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
})
|
||||
}
|
||||
|
||||
// Assets API
|
||||
export const assetsApi = {
|
||||
upload: async (file: File, projectId?: string): Promise<{ url: string; filename: string }> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const params = projectId ? `?project=${encodeURIComponent(projectId)}` : ''
|
||||
const res = await fetch(`${API_BASE}/assets/upload${params}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
},
|
||||
|
||||
getUrl: (project: string, filename: string) =>
|
||||
`${API_BASE}/assets/${encodeURIComponent(project)}/${encodeURIComponent(filename)}`
|
||||
}
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
65
frontend/src/components/ConflictBanner.vue
Normal file
65
frontend/src/components/ConflictBanner.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { useGitStore } from '../stores'
|
||||
|
||||
const gitStore = useGitStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="gitStore.hasConflicts" class="conflict-banner">
|
||||
<span class="icon">⚠️</span>
|
||||
<span class="message">
|
||||
Git conflicts detected in {{ gitStore.conflicts.length }} file(s).
|
||||
Please resolve conflicts manually using git or an external tool.
|
||||
</span>
|
||||
<details class="conflict-files">
|
||||
<summary>Show files</summary>
|
||||
<ul>
|
||||
<li v-for="file in gitStore.conflicts" :key="file">{{ file }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conflict-banner {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.conflict-files {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.conflict-files summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conflict-files ul {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.conflict-files li {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
322
frontend/src/components/EditorToolbar.vue
Normal file
322
frontend/src/components/EditorToolbar.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'format': [type: string, extra?: string]
|
||||
'insert-image': [file: File]
|
||||
'insert-link': []
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Formatting actions
|
||||
function bold() { emit('format', 'bold') }
|
||||
function italic() { emit('format', 'italic') }
|
||||
function strikethrough() { emit('format', 'strikethrough') }
|
||||
function heading(level: number) { emit('format', 'heading', String(level)) }
|
||||
function link() { emit('insert-link') }
|
||||
function code() { emit('format', 'code') }
|
||||
function codeBlock() { emit('format', 'codeblock') }
|
||||
function quote() { emit('format', 'quote') }
|
||||
function bulletList() { emit('format', 'bullet') }
|
||||
function numberedList() { emit('format', 'numbered') }
|
||||
function taskList() { emit('format', 'task') }
|
||||
function horizontalRule() { emit('format', 'hr') }
|
||||
|
||||
// Image handling
|
||||
function triggerImageUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
emit('insert-image', file)
|
||||
// Reset input so same file can be selected again
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Heading dropdown
|
||||
const showHeadingDropdown = ref(false)
|
||||
|
||||
function toggleHeadingDropdown() {
|
||||
showHeadingDropdown.value = !showHeadingDropdown.value
|
||||
}
|
||||
|
||||
function selectHeading(level: number) {
|
||||
heading(level)
|
||||
showHeadingDropdown.value = false
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function closeDropdowns() {
|
||||
showHeadingDropdown.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-toolbar" @click.stop>
|
||||
<!-- Hidden file input for image upload -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- Text formatting group -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="bold"
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<span class="icon">B</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="italic"
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<span class="icon italic">I</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="strikethrough"
|
||||
title="Strikethrough"
|
||||
>
|
||||
<span class="icon strikethrough">S</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="code"
|
||||
title="Inline code (Ctrl+`)"
|
||||
>
|
||||
<span class="icon mono"></></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="toolbar-group">
|
||||
<div class="dropdown-container">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="toggleHeadingDropdown"
|
||||
title="Heading"
|
||||
>
|
||||
<span class="icon">H</span>
|
||||
<span class="dropdown-arrow">▾</span>
|
||||
</button>
|
||||
<div v-if="showHeadingDropdown" class="dropdown-menu" @click.stop>
|
||||
<button @click="selectHeading(1)">Heading 1</button>
|
||||
<button @click="selectHeading(2)">Heading 2</button>
|
||||
<button @click="selectHeading(3)">Heading 3</button>
|
||||
<button @click="selectHeading(4)">Heading 4</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Insert group -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="link"
|
||||
title="Insert link"
|
||||
>
|
||||
<span class="icon">🔗</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="triggerImageUpload"
|
||||
title="Insert image"
|
||||
>
|
||||
<span class="icon">🖼️</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="codeBlock"
|
||||
title="Code block"
|
||||
>
|
||||
<span class="icon mono">{}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- List group -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="bulletList"
|
||||
title="Bullet list"
|
||||
>
|
||||
<span class="icon">•</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="numberedList"
|
||||
title="Numbered list"
|
||||
>
|
||||
<span class="icon">1.</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="taskList"
|
||||
title="Task list"
|
||||
>
|
||||
<span class="icon">☐</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Block group -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="quote"
|
||||
title="Quote"
|
||||
>
|
||||
<span class="icon">"</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="horizontalRule"
|
||||
title="Horizontal rule"
|
||||
>
|
||||
<span class="icon">—</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close dropdowns -->
|
||||
<div
|
||||
v-if="showHeadingDropdown"
|
||||
class="dropdown-overlay"
|
||||
@click="closeDropdowns"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.toolbar-btn:active {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.toolbar-btn .icon {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbar-btn .icon.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.toolbar-btn .icon.strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.toolbar-btn .icon.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolbar-btn .dropdown-arrow {
|
||||
font-size: 8px;
|
||||
margin-left: 2px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--color-border);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
min-width: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dropdown-menu button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.dropdown-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
</style>
|
||||
879
frontend/src/components/GitPanel.vue
Normal file
879
frontend/src/components/GitPanel.vue
Normal file
@@ -0,0 +1,879 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useGitStore } from '../stores'
|
||||
|
||||
const gitStore = useGitStore()
|
||||
|
||||
// Local state
|
||||
const commitMessage = ref('')
|
||||
const activeTab = ref<'changes' | 'history'>('changes')
|
||||
const expandedFiles = ref<Set<string>>(new Set())
|
||||
|
||||
// Computed
|
||||
const canCommit = computed(() =>
|
||||
gitStore.hasChanges && commitMessage.value.trim().length > 0
|
||||
)
|
||||
|
||||
const remoteDisplay = computed(() => {
|
||||
if (!gitStore.remote) return null
|
||||
const url = gitStore.remote.url
|
||||
// Extract repo name from URL (handles both https and ssh)
|
||||
const match = url.match(/[:/]([^/]+\/[^/.]+)(?:\.git)?$/)
|
||||
return match ? match[1] : url
|
||||
})
|
||||
|
||||
// Actions
|
||||
async function doCommit() {
|
||||
if (!canCommit.value) return
|
||||
const result = await gitStore.commit(commitMessage.value.trim())
|
||||
if (result) {
|
||||
commitMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function doPush() {
|
||||
try {
|
||||
await gitStore.push()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
async function doFetch() {
|
||||
try {
|
||||
await gitStore.fetchRemote()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
function selectCommit(commitId: string) {
|
||||
if (gitStore.selectedCommitId === commitId) {
|
||||
gitStore.clearSelectedCommit()
|
||||
} else {
|
||||
gitStore.loadCommitDiff(commitId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFile(path: string) {
|
||||
if (expandedFiles.value.has(path)) {
|
||||
expandedFiles.value.delete(path)
|
||||
} else {
|
||||
expandedFiles.value.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
case 'added': return '+'
|
||||
case 'modified': return '~'
|
||||
case 'deleted': return '-'
|
||||
case 'renamed': return '→'
|
||||
default: return '?'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
case 'added': return 'status-added'
|
||||
case 'modified': return 'status-modified'
|
||||
case 'deleted': return 'status-deleted'
|
||||
case 'renamed': return 'status-renamed'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Close panel on escape
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
gitStore.togglePanel()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for panel open to load data
|
||||
watch(() => gitStore.panelOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
gitStore.clearSelectedCommit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="panel">
|
||||
<div v-if="gitStore.panelOpen" class="git-panel-overlay" @click.self="gitStore.togglePanel()">
|
||||
<div class="git-panel">
|
||||
<!-- Header -->
|
||||
<header class="panel-header">
|
||||
<div class="header-left">
|
||||
<span class="branch-icon">⎇</span>
|
||||
<span class="branch-name">{{ gitStore.branch }}</span>
|
||||
<span v-if="gitStore.hasChanges" class="changes-badge">
|
||||
{{ gitStore.changedFilesCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div v-if="gitStore.remote" class="remote-info">
|
||||
<span class="remote-name" :title="gitStore.remote.url">{{ remoteDisplay }}</span>
|
||||
<span v-if="gitStore.remote.ahead > 0" class="ahead">↑{{ gitStore.remote.ahead }}</span>
|
||||
<span v-if="gitStore.remote.behind > 0" class="behind">↓{{ gitStore.remote.behind }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="gitStore.togglePanel()" title="Close (Esc)">×</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Error Banner -->
|
||||
<div v-if="gitStore.error" class="error-banner">
|
||||
{{ gitStore.error }}
|
||||
<button @click="gitStore.clearError()">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
:class="['tab', { active: activeTab === 'changes' }]"
|
||||
@click="activeTab = 'changes'"
|
||||
>
|
||||
Changes
|
||||
<span v-if="gitStore.hasChanges" class="tab-badge">{{ gitStore.changedFilesCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { active: activeTab === 'history' }]"
|
||||
@click="activeTab = 'history'"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="panel-content">
|
||||
<!-- Changes Tab -->
|
||||
<div v-if="activeTab === 'changes'" class="changes-tab">
|
||||
<!-- Commit Form -->
|
||||
<div class="commit-form">
|
||||
<textarea
|
||||
v-model="commitMessage"
|
||||
placeholder="Commit message..."
|
||||
class="commit-input"
|
||||
rows="2"
|
||||
@keydown.ctrl.enter="doCommit"
|
||||
></textarea>
|
||||
<div class="commit-actions">
|
||||
<button
|
||||
class="commit-btn"
|
||||
:disabled="!canCommit || gitStore.committing"
|
||||
@click="doCommit"
|
||||
>
|
||||
{{ gitStore.committing ? 'Committing...' : 'Commit' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="gitStore.hasRemote"
|
||||
class="push-btn"
|
||||
:disabled="gitStore.pushing"
|
||||
@click="doPush"
|
||||
:title="gitStore.canPush ? `Push ${gitStore.remote?.ahead} commits` : 'Push to remote'"
|
||||
>
|
||||
{{ gitStore.pushing ? '...' : '↑ Push' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="gitStore.hasRemote"
|
||||
class="fetch-btn"
|
||||
:disabled="gitStore.fetching"
|
||||
@click="doFetch"
|
||||
title="Fetch from remote"
|
||||
>
|
||||
{{ gitStore.fetching ? '...' : '↓ Fetch' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changed Files List -->
|
||||
<div v-if="gitStore.hasChanges" class="files-list">
|
||||
<div class="section-header">
|
||||
<span>Staged Changes</span>
|
||||
<span class="stats" v-if="gitStore.workingDiff">
|
||||
<span class="insertions">+{{ gitStore.workingDiff.stats.insertions }}</span>
|
||||
<span class="deletions">-{{ gitStore.workingDiff.stats.deletions }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="gitStore.diffLoading && !gitStore.workingDiff" class="loading">
|
||||
Loading diff...
|
||||
</div>
|
||||
|
||||
<div v-else-if="gitStore.workingDiff" class="diff-files">
|
||||
<div
|
||||
v-for="file in gitStore.workingDiff.files"
|
||||
:key="file.path"
|
||||
class="diff-file"
|
||||
>
|
||||
<div
|
||||
class="file-header"
|
||||
@click="toggleFile(file.path)"
|
||||
>
|
||||
<span :class="['status-icon', getStatusClass(file.status)]">
|
||||
{{ getStatusIcon(file.status) }}
|
||||
</span>
|
||||
<span class="file-path">{{ file.path }}</span>
|
||||
<span class="file-stats">
|
||||
<span v-if="file.additions" class="insertions">+{{ file.additions }}</span>
|
||||
<span v-if="file.deletions" class="deletions">-{{ file.deletions }}</span>
|
||||
</span>
|
||||
<span class="expand-icon">{{ expandedFiles.has(file.path) ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedFiles.has(file.path)" class="file-diff">
|
||||
<div v-for="(hunk, i) in file.hunks" :key="i" class="diff-hunk">
|
||||
<div class="hunk-header">{{ hunk.header }}</div>
|
||||
<div class="diff-lines">
|
||||
<div
|
||||
v-for="(line, j) in hunk.lines"
|
||||
:key="j"
|
||||
:class="['diff-line', {
|
||||
'line-add': line.origin === '+',
|
||||
'line-del': line.origin === '-',
|
||||
'line-ctx': line.origin === ' '
|
||||
}]"
|
||||
>
|
||||
<span class="line-origin">{{ line.origin }}</span>
|
||||
<span class="line-content">{{ line.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-changes">
|
||||
<span class="icon">✓</span>
|
||||
<span>No changes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Tab -->
|
||||
<div v-if="activeTab === 'history'" class="history-tab">
|
||||
<div v-if="gitStore.historyLoading && !gitStore.history.length" class="loading">
|
||||
Loading history...
|
||||
</div>
|
||||
|
||||
<div v-else-if="gitStore.history.length === 0" class="no-history">
|
||||
No commits yet
|
||||
</div>
|
||||
|
||||
<div v-else class="commit-list">
|
||||
<div
|
||||
v-for="commit in gitStore.history"
|
||||
:key="commit.id"
|
||||
:class="['commit-item', { selected: gitStore.selectedCommitId === commit.id }]"
|
||||
@click="selectCommit(commit.id)"
|
||||
>
|
||||
<div class="commit-header">
|
||||
<span class="commit-id">{{ commit.short_id }}</span>
|
||||
<span class="commit-time">{{ formatTimestamp(commit.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="commit-message">{{ commit.message }}</div>
|
||||
<div class="commit-meta">
|
||||
<span class="commit-author">{{ commit.author }}</span>
|
||||
<span v-if="commit.files_changed" class="commit-files">
|
||||
{{ commit.files_changed }} file{{ commit.files_changed !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Commit Diff (expanded) -->
|
||||
<div
|
||||
v-if="gitStore.selectedCommitId === commit.id && gitStore.selectedCommitDiff"
|
||||
class="commit-diff"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-for="file in gitStore.selectedCommitDiff.files"
|
||||
:key="file.path"
|
||||
class="diff-file"
|
||||
>
|
||||
<div
|
||||
class="file-header"
|
||||
@click="toggleFile(`${commit.id}:${file.path}`)"
|
||||
>
|
||||
<span :class="['status-icon', getStatusClass(file.status)]">
|
||||
{{ getStatusIcon(file.status) }}
|
||||
</span>
|
||||
<span class="file-path">{{ file.path }}</span>
|
||||
<span class="file-stats">
|
||||
<span v-if="file.additions" class="insertions">+{{ file.additions }}</span>
|
||||
<span v-if="file.deletions" class="deletions">-{{ file.deletions }}</span>
|
||||
</span>
|
||||
<span class="expand-icon">
|
||||
{{ expandedFiles.has(`${commit.id}:${file.path}`) ? '▼' : '▶' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedFiles.has(`${commit.id}:${file.path}`)" class="file-diff">
|
||||
<div v-for="(hunk, i) in file.hunks" :key="i" class="diff-hunk">
|
||||
<div class="hunk-header">{{ hunk.header }}</div>
|
||||
<div class="diff-lines">
|
||||
<div
|
||||
v-for="(line, j) in hunk.lines"
|
||||
:key="j"
|
||||
:class="['diff-line', {
|
||||
'line-add': line.origin === '+',
|
||||
'line-del': line.origin === '-',
|
||||
'line-ctx': line.origin === ' '
|
||||
}]"
|
||||
>
|
||||
<span class="line-origin">{{ line.origin }}</span>
|
||||
<span class="line-content">{{ line.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="gitStore.selectedCommitId === commit.id && gitStore.diffLoading"
|
||||
class="loading"
|
||||
>
|
||||
Loading diff...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Panel Overlay */
|
||||
.git-panel-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.git-panel {
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.panel-enter-active,
|
||||
.panel-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.panel-enter-active .git-panel,
|
||||
.panel-leave-active .git-panel {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.panel-enter-from,
|
||||
.panel-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.panel-enter-from .git-panel,
|
||||
.panel-leave-to .git-panel {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.branch-icon {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.branch-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.changes-badge {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.remote-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.remote-name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ahead {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.behind {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-banner button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Changes Tab */
|
||||
.changes-tab {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.commit-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.commit-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.commit-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.commit-input::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.commit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.commit-btn,
|
||||
.push-btn,
|
||||
.fetch-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.commit-btn {
|
||||
flex: 1;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.commit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.push-btn,
|
||||
.fetch-btn {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.push-btn:hover:not(:disabled),
|
||||
.fetch-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.push-btn:disabled,
|
||||
.fetch-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Files List */
|
||||
.files-list {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.insertions {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.deletions {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Diff Files */
|
||||
.diff-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.diff-file {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-header:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-added { color: var(--color-success); }
|
||||
.status-modified { color: var(--color-warning); }
|
||||
.status-deleted { color: var(--color-danger); }
|
||||
.status-renamed { color: var(--color-primary); }
|
||||
|
||||
.file-path {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-stats {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* File Diff Content */
|
||||
.file-diff {
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.diff-hunk {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hunk-header {
|
||||
padding: 4px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.diff-lines {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: flex;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.line-origin {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
padding-right: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.line-add {
|
||||
background: rgba(46, 160, 67, 0.15);
|
||||
}
|
||||
|
||||
.line-add .line-origin {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.line-del {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
}
|
||||
|
||||
.line-del .line-origin {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* No Changes */
|
||||
.no-changes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-secondary);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.no-changes .icon {
|
||||
font-size: 32px;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* History Tab */
|
||||
.history-tab {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.commit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.commit-item {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.commit-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.commit-item.selected {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.commit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.commit-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.commit-time {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commit-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.commit-diff {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.no-history {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
123
frontend/src/components/GitStatus.vue
Normal file
123
frontend/src/components/GitStatus.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { useGitStore } from '../stores'
|
||||
import GitPanel from './GitPanel.vue'
|
||||
|
||||
const gitStore = useGitStore()
|
||||
|
||||
function openPanel() {
|
||||
gitStore.togglePanel()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Git Panel (slides in from right) -->
|
||||
<GitPanel />
|
||||
|
||||
<div v-if="gitStore.isRepo" class="git-status" @click="openPanel" title="Open Git panel">
|
||||
<div class="git-info">
|
||||
<span :class="['git-indicator', { 'has-changes': gitStore.hasChanges }]">●</span>
|
||||
<span class="git-branch">{{ gitStore.branch }}</span>
|
||||
<span v-if="gitStore.hasChanges" class="git-changes">
|
||||
{{ gitStore.changedFilesCount }} changes
|
||||
</span>
|
||||
</div>
|
||||
<div class="git-sync-status">
|
||||
<span v-if="gitStore.remote?.ahead" class="sync-ahead" title="Commits ahead">
|
||||
↑{{ gitStore.remote.ahead }}
|
||||
</span>
|
||||
<span v-if="gitStore.remote?.behind" class="sync-behind" title="Commits behind">
|
||||
↓{{ gitStore.remote.behind }}
|
||||
</span>
|
||||
<span class="expand-hint">⋯</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflict Warning -->
|
||||
<div v-if="gitStore.hasConflicts" class="git-conflicts" @click="openPanel">
|
||||
⚠️ Git conflicts detected ({{ gitStore.conflicts.length }} files)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.git-status {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.git-status:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.git-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.git-indicator {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.git-indicator.has-changes {
|
||||
color: var(--color-primary);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.git-branch {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.git-changes {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.git-sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sync-ahead {
|
||||
color: var(--color-success);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sync-behind {
|
||||
color: var(--color-warning);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expand-hint {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.git-conflicts {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-conflicts:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
</style>
|
||||
533
frontend/src/components/MarkdownEditor.vue
Normal file
533
frontend/src/components/MarkdownEditor.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, shallowRef } from 'vue'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { useThemeStore } from '../stores'
|
||||
import { assetsApi } from '../api/client'
|
||||
import EditorToolbar from './EditorToolbar.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
placeholder?: string
|
||||
projectId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const editorContainer = ref<HTMLDivElement | null>(null)
|
||||
const editorView = shallowRef<EditorView | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
// Check if dark mode is active
|
||||
function isDarkMode(): boolean {
|
||||
return themeStore.getEffectiveTheme() === 'dark'
|
||||
}
|
||||
|
||||
// Create custom theme for light mode
|
||||
const lightTheme = EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'var(--color-bg)',
|
||||
color: 'var(--color-text)'
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: "'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace",
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
padding: '16px 0'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-bg-secondary)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid var(--color-border)'
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--color-border)'
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.03)'
|
||||
},
|
||||
'&.cm-focused .cm-cursor': {
|
||||
borderLeftColor: 'var(--color-primary)'
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'rgba(3, 102, 214, 0.2)'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
// Create dark theme extension
|
||||
const darkTheme = EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'var(--color-bg)',
|
||||
color: 'var(--color-text)'
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: "'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace",
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
padding: '16px 0'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-bg-secondary)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid var(--color-border)'
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--color-border)'
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)'
|
||||
},
|
||||
'&.cm-focused .cm-cursor': {
|
||||
borderLeftColor: 'var(--color-primary)'
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'rgba(79, 195, 247, 0.2)'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto'
|
||||
}
|
||||
}, { dark: true })
|
||||
|
||||
// Markdown formatting keybindings
|
||||
function toggleBold(view: EditorView): boolean {
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
|
||||
if (selectedText) {
|
||||
// Check if already bold
|
||||
const isBold = selectedText.startsWith('**') && selectedText.endsWith('**')
|
||||
let newText: string
|
||||
|
||||
if (isBold) {
|
||||
newText = selectedText.slice(2, -2)
|
||||
} else {
|
||||
newText = `**${selectedText}**`
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: newText }
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleItalic(view: EditorView): boolean {
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
|
||||
if (selectedText) {
|
||||
// Check if already italic (single asterisk, not bold)
|
||||
const isItalic = selectedText.startsWith('*') && selectedText.endsWith('*') &&
|
||||
!selectedText.startsWith('**')
|
||||
let newText: string
|
||||
|
||||
if (isItalic) {
|
||||
newText = selectedText.slice(1, -1)
|
||||
} else {
|
||||
newText = `*${selectedText}*`
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: newText }
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleCode(view: EditorView): boolean {
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
|
||||
if (selectedText) {
|
||||
const isCode = selectedText.startsWith('`') && selectedText.endsWith('`')
|
||||
let newText: string
|
||||
|
||||
if (isCode) {
|
||||
newText = selectedText.slice(1, -1)
|
||||
} else {
|
||||
newText = `\`${selectedText}\``
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: newText }
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const markdownKeymap = keymap.of([
|
||||
{ key: 'Mod-b', run: toggleBold },
|
||||
{ key: 'Mod-i', run: toggleItalic },
|
||||
{ key: 'Mod-`', run: toggleCode }
|
||||
])
|
||||
|
||||
// Toolbar format handler
|
||||
function handleFormat(type: string, extra?: string) {
|
||||
const view = editorView.value
|
||||
if (!view) return
|
||||
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
const line = view.state.doc.lineAt(from)
|
||||
const lineStart = line.from
|
||||
const lineText = line.text
|
||||
|
||||
let insert = ''
|
||||
let newFrom = from
|
||||
let newTo = to
|
||||
|
||||
switch (type) {
|
||||
case 'bold':
|
||||
if (selectedText) {
|
||||
insert = `**${selectedText}**`
|
||||
} else {
|
||||
insert = '**bold**'
|
||||
newFrom = from + 2
|
||||
newTo = from + 6
|
||||
}
|
||||
break
|
||||
|
||||
case 'italic':
|
||||
if (selectedText) {
|
||||
insert = `*${selectedText}*`
|
||||
} else {
|
||||
insert = '*italic*'
|
||||
newFrom = from + 1
|
||||
newTo = from + 7
|
||||
}
|
||||
break
|
||||
|
||||
case 'strikethrough':
|
||||
if (selectedText) {
|
||||
insert = `~~${selectedText}~~`
|
||||
} else {
|
||||
insert = '~~strikethrough~~'
|
||||
newFrom = from + 2
|
||||
newTo = from + 15
|
||||
}
|
||||
break
|
||||
|
||||
case 'code':
|
||||
if (selectedText) {
|
||||
insert = `\`${selectedText}\``
|
||||
} else {
|
||||
insert = '`code`'
|
||||
newFrom = from + 1
|
||||
newTo = from + 5
|
||||
}
|
||||
break
|
||||
|
||||
case 'codeblock':
|
||||
if (selectedText) {
|
||||
insert = `\n\`\`\`\n${selectedText}\n\`\`\`\n`
|
||||
} else {
|
||||
insert = '\n```\ncode\n```\n'
|
||||
newFrom = from + 5
|
||||
newTo = from + 9
|
||||
}
|
||||
break
|
||||
|
||||
case 'heading':
|
||||
const level = parseInt(extra || '2')
|
||||
const prefix = '#'.repeat(level) + ' '
|
||||
// Check if line already has heading
|
||||
const headingMatch = lineText.match(/^(#{1,6})\s/)
|
||||
if (headingMatch) {
|
||||
// Replace existing heading
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart + headingMatch[0].length, insert: prefix }
|
||||
})
|
||||
return
|
||||
} else {
|
||||
// Insert at line start
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: prefix }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case 'quote':
|
||||
// Add > at start of each selected line
|
||||
if (selectedText.includes('\n')) {
|
||||
insert = selectedText.split('\n').map(l => `> ${l}`).join('\n')
|
||||
} else if (selectedText) {
|
||||
insert = `> ${selectedText}`
|
||||
} else {
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: '> ' }
|
||||
})
|
||||
return
|
||||
}
|
||||
break
|
||||
|
||||
case 'bullet':
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: '- ' }
|
||||
})
|
||||
return
|
||||
|
||||
case 'numbered':
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: '1. ' }
|
||||
})
|
||||
return
|
||||
|
||||
case 'task':
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: '- [ ] ' }
|
||||
})
|
||||
return
|
||||
|
||||
case 'hr':
|
||||
insert = '\n---\n'
|
||||
break
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: newFrom, head: newTo }
|
||||
})
|
||||
view.focus()
|
||||
}
|
||||
|
||||
// Link insertion with prompt
|
||||
function handleInsertLink() {
|
||||
const view = editorView.value
|
||||
if (!view) return
|
||||
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
|
||||
const url = prompt('Enter URL:', 'https://')
|
||||
if (!url) return
|
||||
|
||||
const linkText = selectedText || 'link text'
|
||||
const insert = `[${linkText}](${url})`
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert }
|
||||
})
|
||||
view.focus()
|
||||
}
|
||||
|
||||
// Image upload and insertion
|
||||
async function handleInsertImage(file: File) {
|
||||
const view = editorView.value
|
||||
if (!view) return
|
||||
|
||||
uploading.value = true
|
||||
|
||||
try {
|
||||
// Upload via assets API
|
||||
const result = await assetsApi.upload(file, props.projectId)
|
||||
|
||||
// Insert markdown image
|
||||
const { from, to } = view.state.selection.main
|
||||
const altText = file.name.replace(/\.[^/.]+$/, '') // filename without extension
|
||||
const insert = ``
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert }
|
||||
})
|
||||
view.focus()
|
||||
} catch (err) {
|
||||
console.error('Failed to upload image:', err)
|
||||
alert('Failed to upload image: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function createEditorState(content: string): EditorState {
|
||||
const dark = isDarkMode()
|
||||
|
||||
const extensions = [
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
markdown({ base: markdownLanguage }),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
dark ? darkTheme : lightTheme,
|
||||
dark ? oneDark : [],
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...historyKeymap
|
||||
]),
|
||||
markdownKeymap,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
emit('update:modelValue', update.state.doc.toString())
|
||||
}
|
||||
}),
|
||||
EditorState.readOnly.of(props.readonly ?? false),
|
||||
EditorView.editable.of(!(props.readonly ?? false))
|
||||
].flat()
|
||||
|
||||
return EditorState.create({
|
||||
doc: content,
|
||||
extensions
|
||||
})
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
if (!editorContainer.value) return
|
||||
|
||||
const state = createEditorState(props.modelValue)
|
||||
|
||||
editorView.value = new EditorView({
|
||||
state,
|
||||
parent: editorContainer.value
|
||||
})
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
if (editorView.value) {
|
||||
editorView.value.destroy()
|
||||
editorView.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for external content changes
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (!editorView.value) return
|
||||
|
||||
const currentValue = editorView.value.state.doc.toString()
|
||||
if (newValue !== currentValue) {
|
||||
editorView.value.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editorView.value.state.doc.length,
|
||||
insert: newValue
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for readonly changes
|
||||
watch(() => props.readonly, () => {
|
||||
if (!editorView.value) return
|
||||
|
||||
// Recreate editor with new readonly state
|
||||
const content = editorView.value.state.doc.toString()
|
||||
destroyEditor()
|
||||
initEditor()
|
||||
|
||||
// Restore content
|
||||
if (editorView.value && content) {
|
||||
editorView.value.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editorView.value.state.doc.length,
|
||||
insert: content
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for theme changes
|
||||
function handleThemeChange() {
|
||||
if (!editorView.value) return
|
||||
destroyEditor()
|
||||
initEditor()
|
||||
// Content will be restored via props.modelValue
|
||||
}
|
||||
|
||||
// Watch theme store changes
|
||||
watch(() => themeStore.getEffectiveTheme(), handleThemeChange)
|
||||
|
||||
onMounted(() => {
|
||||
initEditor()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyEditor()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-editor-wrapper" :class="{ readonly, uploading }">
|
||||
<!-- Toolbar (shows when not readonly) -->
|
||||
<EditorToolbar
|
||||
v-if="!readonly"
|
||||
@format="handleFormat"
|
||||
@insert-link="handleInsertLink"
|
||||
@insert-image="handleInsertImage"
|
||||
/>
|
||||
|
||||
<!-- Upload indicator -->
|
||||
<div v-if="uploading" class="upload-indicator">
|
||||
Uploading image...
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div ref="editorContainer" class="markdown-editor"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-editor-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-editor :deep(.cm-editor) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-editor :deep(.cm-scroller) {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.markdown-editor-wrapper.readonly .markdown-editor :deep(.cm-editor) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.upload-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.markdown-editor-wrapper.uploading .markdown-editor {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
204
frontend/src/components/MarkdownPreview.vue
Normal file
204
frontend/src/components/MarkdownPreview.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
// Initialize markdown-it with CommonMark preset
|
||||
const md = ref<MarkdownIt | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
md.value = new MarkdownIt({
|
||||
html: false, // Disable HTML tags in source
|
||||
xhtmlOut: true, // Use '/' to close single tags (<br />)
|
||||
breaks: true, // Convert '\n' in paragraphs into <br>
|
||||
linkify: true, // Autoconvert URL-like text to links
|
||||
typographer: true, // Enable smartquotes and other typographic replacements
|
||||
})
|
||||
})
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
if (!md.value) return ''
|
||||
|
||||
try {
|
||||
return md.value.render(props.content)
|
||||
} catch (e) {
|
||||
console.error('Markdown rendering error:', e)
|
||||
return `<pre>${props.content}</pre>`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-preview">
|
||||
<div class="preview-content" v-html="renderedHtml"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-preview {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
max-width: 800px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Markdown styling */
|
||||
.preview-content :deep(h1) {
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
margin: 0.67em 0;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.preview-content :deep(h2) {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.5em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.preview-content :deep(h3) {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.5em;
|
||||
}
|
||||
|
||||
.preview-content :deep(h4),
|
||||
.preview-content :deep(h5),
|
||||
.preview-content :deep(h6) {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.5em;
|
||||
}
|
||||
|
||||
.preview-content :deep(p) {
|
||||
margin: 0.5em 0 1em;
|
||||
}
|
||||
|
||||
.preview-content :deep(ul),
|
||||
.preview-content :deep(ol) {
|
||||
margin: 0.5em 0 1em;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.preview-content :deep(li) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.preview-content :deep(li > ul),
|
||||
.preview-content :deep(li > ol) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.preview-content :deep(blockquote) {
|
||||
margin: 1em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 4px solid var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.preview-content :deep(blockquote p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-content :deep(code) {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.2em 0.4em;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-content :deep(pre) {
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.preview-content :deep(pre code) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-content :deep(hr) {
|
||||
margin: 2em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.preview-content :deep(a) {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.preview-content :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.preview-content :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-content :deep(table) {
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.preview-content :deep(th),
|
||||
.preview-content :deep(td) {
|
||||
padding: 0.5em 1em;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.preview-content :deep(th) {
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-content :deep(tr:nth-child(even)) {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* Task list styling */
|
||||
.preview-content :deep(input[type="checkbox"]) {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.preview-content :deep(li.task-list-item) {
|
||||
list-style: none;
|
||||
margin-left: -1.5em;
|
||||
}
|
||||
|
||||
/* Strong and emphasis */
|
||||
.preview-content :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-content :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.preview-content :deep(del) {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/components/MilkdownEditor.vue
Normal file
108
frontend/src/components/MilkdownEditor.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { MilkdownProvider } from '@milkdown/vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { useThemeStore } from '../stores'
|
||||
import { assetsApi } from '../api/client'
|
||||
import MilkdownEditorCore from './MilkdownEditorCore.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
placeholder?: string
|
||||
projectId?: string
|
||||
editorKey?: string | number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const uploading = ref(false)
|
||||
const editorInstance = ref<Crepe | null>(null)
|
||||
|
||||
const isDarkMode = computed(() => themeStore.getEffectiveTheme() === 'dark')
|
||||
|
||||
// Handle content updates from the core editor
|
||||
function handleContentUpdate(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// Store editor instance when ready
|
||||
function handleEditorReady(crepe: Crepe) {
|
||||
editorInstance.value = crepe
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="milkdown-editor-wrapper"
|
||||
:class="{
|
||||
readonly,
|
||||
uploading,
|
||||
'dark-mode': isDarkMode
|
||||
}"
|
||||
>
|
||||
<!-- Upload indicator -->
|
||||
<div v-if="uploading" class="upload-indicator">
|
||||
Uploading image...
|
||||
</div>
|
||||
|
||||
<!-- Milkdown Editor with Crepe (includes built-in toolbar) -->
|
||||
<!-- CRITICAL: Key the entire container to force full remount when switching notes/tasks -->
|
||||
<!-- This ensures the Milkdown editor instance is completely recreated, not just updated -->
|
||||
<div :key="editorKey" class="milkdown-container" :class="{ 'is-readonly': readonly }">
|
||||
<MilkdownProvider>
|
||||
<MilkdownEditorCore
|
||||
:model-value="modelValue"
|
||||
:readonly="readonly"
|
||||
@update:model-value="handleContentUpdate"
|
||||
@editor-ready="handleEditorReady"
|
||||
/>
|
||||
</MilkdownProvider>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.milkdown-editor-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.milkdown-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.milkdown-container.is-readonly {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.upload-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.milkdown-editor-wrapper.uploading .milkdown-container {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
442
frontend/src/components/MilkdownEditorCore.vue
Normal file
442
frontend/src/components/MilkdownEditorCore.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, computed, ref, onUnmounted } from 'vue'
|
||||
import { Milkdown, useEditor } from '@milkdown/vue'
|
||||
import { Crepe, CrepeFeature } from '@milkdown/crepe'
|
||||
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'
|
||||
import { replaceAll } from '@milkdown/kit/utils'
|
||||
import { useThemeStore } from '../stores'
|
||||
|
||||
// Import Crepe common styles (layout, components)
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
|
||||
// Import the frame theme (light) - we override for dark mode via CSS
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'editor-ready': [editor: Crepe]
|
||||
}>()
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
// Use mode directly for reactivity, then compute effective theme
|
||||
const isDarkMode = computed(() => {
|
||||
const mode = themeStore.mode
|
||||
if (mode === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return mode === 'dark'
|
||||
})
|
||||
|
||||
// CRITICAL: Use refs for instance-scoped state that must reset when component recreates
|
||||
// These were previously module-level lets which caused stale content bugs when switching notes/tasks
|
||||
const isExternalUpdate = ref(false)
|
||||
const currentContent = ref(props.modelValue)
|
||||
const pendingContent = ref<string | null>(null)
|
||||
const editorReady = ref(false)
|
||||
|
||||
// Cleanup any pending timeouts/intervals on unmount
|
||||
let externalUpdateTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingRetryInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onUnmounted(() => {
|
||||
if (externalUpdateTimeout) {
|
||||
clearTimeout(externalUpdateTimeout)
|
||||
externalUpdateTimeout = null
|
||||
}
|
||||
if (pendingRetryInterval) {
|
||||
clearInterval(pendingRetryInterval)
|
||||
pendingRetryInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
// Try to apply pending content - called when editor might be ready
|
||||
function tryApplyPendingContent() {
|
||||
if (pendingContent.value === null) return false
|
||||
|
||||
const crepe = get()
|
||||
if (!crepe) return false
|
||||
|
||||
try {
|
||||
const editor = crepe.editor
|
||||
if (!editor || typeof editor.action !== 'function') return false
|
||||
|
||||
console.log('[MilkdownEditorCore] Applying pending content, length:', pendingContent.value.length)
|
||||
isExternalUpdate.value = true
|
||||
editor.action(replaceAll(pendingContent.value))
|
||||
currentContent.value = pendingContent.value
|
||||
pendingContent.value = null
|
||||
editorReady.value = true
|
||||
|
||||
if (externalUpdateTimeout) clearTimeout(externalUpdateTimeout)
|
||||
externalUpdateTimeout = setTimeout(() => { isExternalUpdate.value = false }, 50)
|
||||
|
||||
// Stop retry interval if running
|
||||
if (pendingRetryInterval) {
|
||||
clearInterval(pendingRetryInterval)
|
||||
pendingRetryInterval = null
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[MilkdownEditorCore] Failed to apply pending content:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const { get, loading } = useEditor((root) => {
|
||||
const crepe = new Crepe({
|
||||
root,
|
||||
defaultValue: props.modelValue,
|
||||
features: {
|
||||
[CrepeFeature.CodeMirror]: true,
|
||||
[CrepeFeature.ListItem]: true,
|
||||
[CrepeFeature.LinkTooltip]: true,
|
||||
[CrepeFeature.Cursor]: true,
|
||||
[CrepeFeature.ImageBlock]: true,
|
||||
[CrepeFeature.BlockEdit]: true,
|
||||
[CrepeFeature.Toolbar]: true,
|
||||
[CrepeFeature.Placeholder]: true,
|
||||
[CrepeFeature.Table]: true,
|
||||
[CrepeFeature.Latex]: false, // Disable LaTeX for now
|
||||
},
|
||||
featureConfigs: {
|
||||
[CrepeFeature.Placeholder]: {
|
||||
text: 'Start writing...',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Add listener plugin for content changes
|
||||
crepe.editor
|
||||
.config((ctx) => {
|
||||
const listenerHandler = ctx.get(listenerCtx)
|
||||
listenerHandler.markdownUpdated((ctx, markdown, prevMarkdown) => {
|
||||
// CRITICAL: Only emit content changes if:
|
||||
// 1. Content actually changed
|
||||
// 2. We're not in the middle of an external update
|
||||
// 3. Editor is ready (not still applying pending content)
|
||||
// 4. No pending content waiting to be applied (prevents emitting stale content)
|
||||
if (markdown !== prevMarkdown && !isExternalUpdate.value && editorReady.value && pendingContent.value === null) {
|
||||
console.log('[MilkdownEditorCore] User edit, emitting content length:', markdown.length)
|
||||
currentContent.value = markdown
|
||||
emit('update:modelValue', markdown)
|
||||
} else if (markdown !== prevMarkdown) {
|
||||
console.log('[MilkdownEditorCore] Content changed but not emitting:', {
|
||||
isExternalUpdate: isExternalUpdate.value,
|
||||
editorReady: editorReady.value,
|
||||
hasPendingContent: pendingContent.value !== null
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.use(listener)
|
||||
|
||||
return crepe
|
||||
})
|
||||
|
||||
// Emit editor instance when ready, and apply any pending content
|
||||
watch(loading, (isLoading) => {
|
||||
if (!isLoading) {
|
||||
const crepe = get()
|
||||
if (crepe) {
|
||||
emit('editor-ready', crepe)
|
||||
|
||||
// Try to apply pending content - might need retries if editor not fully ready
|
||||
if (pendingContent.value !== null) {
|
||||
if (!tryApplyPendingContent()) {
|
||||
// Editor not ready yet, start retry interval
|
||||
console.log('[MilkdownEditorCore] Editor not ready after loading, starting retry')
|
||||
startPendingRetry()
|
||||
}
|
||||
} else {
|
||||
editorReady.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Start a retry interval for applying pending content
|
||||
function startPendingRetry() {
|
||||
if (pendingRetryInterval) return // Already retrying
|
||||
|
||||
let retryCount = 0
|
||||
const maxRetries = 20 // 2 seconds max
|
||||
|
||||
pendingRetryInterval = setInterval(() => {
|
||||
retryCount++
|
||||
console.log('[MilkdownEditorCore] Retry attempt', retryCount, 'to apply pending content')
|
||||
|
||||
if (tryApplyPendingContent()) {
|
||||
// Success - interval cleared in tryApplyPendingContent
|
||||
return
|
||||
}
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
console.error('[MilkdownEditorCore] Failed to apply pending content after', maxRetries, 'retries')
|
||||
if (pendingRetryInterval) {
|
||||
clearInterval(pendingRetryInterval)
|
||||
pendingRetryInterval = null
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// Watch for external content changes
|
||||
watch(() => props.modelValue, async (newValue) => {
|
||||
console.log('[MilkdownEditorCore] modelValue changed, length:', newValue?.length, 'loading:', loading.value, 'currentContent length:', currentContent.value?.length, 'editorReady:', editorReady.value)
|
||||
|
||||
// If editor is still loading, store the content to apply after load
|
||||
if (loading.value) {
|
||||
console.log('[MilkdownEditorCore] Editor loading, storing as pending content')
|
||||
pendingContent.value = newValue
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if content hasn't actually changed
|
||||
if (newValue === currentContent.value) {
|
||||
console.log('[MilkdownEditorCore] Content unchanged, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
// Store new content as pending and try to apply
|
||||
pendingContent.value = newValue
|
||||
|
||||
if (!tryApplyPendingContent()) {
|
||||
// Editor not ready, start retry mechanism
|
||||
console.log('[MilkdownEditorCore] Editor not ready, starting retry for new content')
|
||||
startPendingRetry()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['crepe-editor', { 'dark-theme': isDarkMode }]">
|
||||
<Milkdown />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Dark theme override for Milkdown Crepe
|
||||
* When .dark-theme is applied, override Crepe's light theme variables
|
||||
*/
|
||||
.crepe-editor.dark-theme .milkdown {
|
||||
--crepe-color-background: #1a1a1a;
|
||||
--crepe-color-on-background: #e0e0e0;
|
||||
--crepe-color-surface: #232323;
|
||||
--crepe-color-surface-low: #1a1a1a;
|
||||
--crepe-color-on-surface: #e0e0e0;
|
||||
--crepe-color-on-surface-variant: #999999;
|
||||
--crepe-color-outline: #3c3c3c;
|
||||
--crepe-color-primary: #58a6ff;
|
||||
--crepe-color-secondary: #232323;
|
||||
--crepe-color-on-secondary: #e0e0e0;
|
||||
--crepe-color-inverse: #e0e0e0;
|
||||
--crepe-color-on-inverse: #1a1a1a;
|
||||
--crepe-color-inline-code: #58a6ff;
|
||||
--crepe-color-error: #f85149;
|
||||
--crepe-color-hover: #2d2d2d;
|
||||
--crepe-color-selected: #3c3c3c;
|
||||
--crepe-color-inline-area: #2d2d2d;
|
||||
--crepe-shadow-1: 0px 1px 2px 0px rgba(0, 0, 0, 0.5), 0px 1px 3px 1px rgba(0, 0, 0, 0.3);
|
||||
--crepe-shadow-2: 0px 1px 2px 0px rgba(0, 0, 0, 0.5), 0px 2px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Editor container layout */
|
||||
.crepe-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Dark theme background for container */
|
||||
.crepe-editor.dark-theme {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor .milkdown {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The actual editor area */
|
||||
.crepe-editor .ProseMirror {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
outline: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Dark theme for ProseMirror content area */
|
||||
.crepe-editor.dark-theme .ProseMirror {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Toolbar styling - dark theme */
|
||||
.crepe-editor.dark-theme .milkdown-toolbar,
|
||||
.crepe-editor.dark-theme milkdown-toolbar {
|
||||
background: #232323;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme .milkdown-toolbar button,
|
||||
.crepe-editor.dark-theme milkdown-toolbar button {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme .milkdown-toolbar button:hover,
|
||||
.crepe-editor.dark-theme milkdown-toolbar button:hover {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
/* Block handle and menus - dark theme */
|
||||
.crepe-editor.dark-theme [data-block-handle],
|
||||
.crepe-editor.dark-theme .slash-menu,
|
||||
.crepe-editor.dark-theme .link-tooltip,
|
||||
.crepe-editor.dark-theme milkdown-slash-menu,
|
||||
.crepe-editor.dark-theme milkdown-link-tooltip {
|
||||
background: #232323;
|
||||
border: 1px solid #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Menu items - dark theme */
|
||||
.crepe-editor.dark-theme .slash-menu-item,
|
||||
.crepe-editor.dark-theme [role="menuitem"] {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme .slash-menu-item:hover,
|
||||
.crepe-editor.dark-theme [role="menuitem"]:hover {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
/* Code blocks - dark theme */
|
||||
.crepe-editor.dark-theme pre {
|
||||
background: #232323;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme pre code {
|
||||
background: transparent;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor code {
|
||||
font-family: var(--font-mono, 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Inline code - dark theme */
|
||||
.crepe-editor.dark-theme :not(pre) > code {
|
||||
background: #2d2d2d;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
/* Blockquote - dark theme */
|
||||
.crepe-editor.dark-theme blockquote {
|
||||
border-left: 3px solid #3c3c3c;
|
||||
padding-left: 16px;
|
||||
margin-left: 0;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
/* Tables - dark theme */
|
||||
.crepe-editor table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme th,
|
||||
.crepe-editor.dark-theme td {
|
||||
border: 1px solid #3c3c3c;
|
||||
padding: 8px 12px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme th {
|
||||
background: #232323;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Links - dark theme */
|
||||
.crepe-editor.dark-theme a {
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Horizontal rule - dark theme */
|
||||
.crepe-editor.dark-theme hr {
|
||||
border: none;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
/* Task list */
|
||||
.crepe-editor li[data-task-list-item] {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.crepe-editor li[data-task-list-item]::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Headings - dark theme */
|
||||
.crepe-editor.dark-theme h1,
|
||||
.crepe-editor.dark-theme h2,
|
||||
.crepe-editor.dark-theme h3,
|
||||
.crepe-editor.dark-theme h4,
|
||||
.crepe-editor.dark-theme h5,
|
||||
.crepe-editor.dark-theme h6 {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Lists - dark theme */
|
||||
.crepe-editor.dark-theme ul,
|
||||
.crepe-editor.dark-theme ol,
|
||||
.crepe-editor.dark-theme li {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Placeholder - dark theme */
|
||||
.crepe-editor.dark-theme .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: #999999;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Selection - dark theme */
|
||||
.crepe-editor.dark-theme .ProseMirror ::selection {
|
||||
background: #58a6ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Focus state */
|
||||
.crepe-editor .ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Image blocks */
|
||||
.crepe-editor img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
102
frontend/src/components/NoteList.vue
Normal file
102
frontend/src/components/NoteList.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useNotesStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const notesStore = useNotesStore()
|
||||
|
||||
const selectedNoteId = computed(() => route.params.id as string | undefined)
|
||||
|
||||
function selectNote(id: string) {
|
||||
router.push({ name: 'note', params: { id } })
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="notesStore.loading" class="loading">Loading notes...</div>
|
||||
<div v-else-if="notesStore.sortedNotes.length === 0" class="empty">No notes yet</div>
|
||||
<ul v-else class="note-list">
|
||||
<li
|
||||
v-for="note in notesStore.sortedNotes"
|
||||
:key="note.id"
|
||||
:class="['note-item', { active: note.id === selectedNoteId }]"
|
||||
@click="selectNote(note.id)"
|
||||
>
|
||||
<div class="note-item-title">{{ note.title }}</div>
|
||||
<div class="note-item-meta">
|
||||
<span class="type-badge">{{ note.note_type }}</span>
|
||||
<span v-if="note.updated"> · {{ formatDate(note.updated) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.note-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.note-item.active {
|
||||
background: var(--color-border);
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.note-item-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
124
frontend/src/components/ProjectList.vue
Normal file
124
frontend/src/components/ProjectList.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useProjectsStore } from '../stores'
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const projectsStore = useProjectsStore()
|
||||
|
||||
const selectedProjectId = computed(() => route.params.id as string | undefined)
|
||||
|
||||
function selectProject(id: string) {
|
||||
router.push({ name: 'project', params: { id } })
|
||||
}
|
||||
|
||||
function goToTasks(id: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
router.push({ name: 'project-tasks', params: { id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-list-container">
|
||||
<div class="project-actions">
|
||||
<button class="create-btn" @click="emit('create')">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<div v-if="projectsStore.loading" class="loading">Loading projects...</div>
|
||||
<div v-else-if="projectsStore.sortedProjects.length === 0" class="empty">No projects yet</div>
|
||||
<ul v-else class="project-list">
|
||||
<li
|
||||
v-for="project in projectsStore.sortedProjects"
|
||||
:key="project.id"
|
||||
:class="['project-item', { active: project.id === selectedProjectId }]"
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
<div class="project-item-content">
|
||||
<div class="project-item-name">{{ project.name }}</div>
|
||||
<button
|
||||
class="tasks-btn"
|
||||
@click="goToTasks(project.id, $event)"
|
||||
title="View Tasks"
|
||||
>
|
||||
☑
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background: var(--color-border);
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.project-item-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-item-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasks-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.project-item:hover .tasks-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
32
frontend/src/components/ReadOnlyBanner.vue
Normal file
32
frontend/src/components/ReadOnlyBanner.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="read-only-banner">
|
||||
<span class="icon">🔒</span>
|
||||
<span class="message">{{ message || 'This file is being edited elsewhere. Read-only mode.' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-only-banner {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
127
frontend/src/components/SearchPanel.vue
Normal file
127
frontend/src/components/SearchPanel.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotesStore, useUiStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const notesStore = useNotesStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const localQuery = ref('')
|
||||
let searchTimeout: number | null = null
|
||||
|
||||
watch(localQuery, (query) => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = window.setTimeout(() => {
|
||||
uiStore.search(query)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function openSearchResult(result: { path: string }) {
|
||||
// Find note by path
|
||||
const note = notesStore.notes.find(n => n.path === result.path)
|
||||
if (note) {
|
||||
router.push({ name: 'note', params: { id: note.id } })
|
||||
uiStore.closeSearch()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-panel">
|
||||
<input
|
||||
v-model="localQuery"
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
class="search-input"
|
||||
autofocus
|
||||
/>
|
||||
<div v-if="uiStore.isSearching" class="loading">Searching...</div>
|
||||
<div v-else-if="uiStore.searchResults.length" class="search-results">
|
||||
<div
|
||||
v-for="result in uiStore.searchResults"
|
||||
:key="result.path"
|
||||
class="search-result"
|
||||
@click="openSearchResult(result)"
|
||||
>
|
||||
<div class="search-result-title">{{ result.title }}</div>
|
||||
<div
|
||||
v-for="match in result.matches.slice(0, 2)"
|
||||
:key="match.line_number"
|
||||
class="search-result-match"
|
||||
>
|
||||
<span class="line-num">{{ match.line_number }}:</span>
|
||||
{{ match.line_content.slice(0, 80) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="localQuery.length >= 2" class="no-results">No results found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-panel {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 12px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-result:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.search-result-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.search-result-match {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-num {
|
||||
color: var(--color-primary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.no-results,
|
||||
.loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
338
frontend/src/components/Sidebar.vue
Normal file
338
frontend/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useWorkspaceStore, useTasksStore, useUiStore } from '../stores'
|
||||
import SearchPanel from './SearchPanel.vue'
|
||||
import TaskPanel from './TaskPanel.vue'
|
||||
import GitStatus from './GitStatus.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const tasksStore = useTasksStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const activeProject = computed(() => workspaceStore.activeProject)
|
||||
const activeProjectId = computed(() => workspaceStore.activeProjectId)
|
||||
|
||||
// Load tasks when project changes
|
||||
watch(activeProjectId, async (id) => {
|
||||
if (id) {
|
||||
await tasksStore.loadProjectTasks(id)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const activeTasks = computed(() => tasksStore.activeTasks)
|
||||
const completedTasks = computed(() => tasksStore.completedTasks)
|
||||
|
||||
function goToProjectOverview() {
|
||||
if (activeProjectId.value) {
|
||||
router.push({ name: 'project', params: { id: activeProjectId.value } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToProjectTasks() {
|
||||
if (activeProjectId.value) {
|
||||
router.push({ name: 'project-tasks', params: { id: activeProjectId.value } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToProjectNotes() {
|
||||
if (activeProjectId.value) {
|
||||
router.push({ name: 'project-notes', params: { id: activeProjectId.value } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToDaily() {
|
||||
router.push({ name: 'daily' })
|
||||
}
|
||||
|
||||
function goToCalendar() {
|
||||
router.push({ name: 'calendar' })
|
||||
}
|
||||
|
||||
function goToProjects() {
|
||||
router.push({ name: 'projects' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<!-- Search Panel (overlay) -->
|
||||
<SearchPanel v-if="uiStore.showSearch" />
|
||||
|
||||
<!-- Tasks Panel (overlay) -->
|
||||
<TaskPanel v-else-if="uiStore.showTasks" />
|
||||
|
||||
<!-- Main Content -->
|
||||
<template v-else>
|
||||
<!-- No Project Selected -->
|
||||
<div v-if="!activeProject" class="no-project">
|
||||
<div class="no-project-content">
|
||||
<h3>No Project Selected</h3>
|
||||
<p>Select a project from the dropdown above to get started.</p>
|
||||
<button class="primary" @click="goToProjects">Browse Projects</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Content -->
|
||||
<template v-else>
|
||||
<!-- Project Navigation -->
|
||||
<nav class="project-nav">
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'project' }]"
|
||||
@click="goToProjectOverview"
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'project-notes' }]"
|
||||
@click="goToProjectNotes"
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'project-tasks' }]"
|
||||
@click="goToProjectTasks"
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'daily' || route.name === 'daily-note' }]"
|
||||
@click="goToDaily"
|
||||
>
|
||||
Daily
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'calendar' }]"
|
||||
@click="goToCalendar"
|
||||
>
|
||||
Calendar
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="quick-stats">
|
||||
<div class="stat-item" @click="goToProjectTasks">
|
||||
<span class="stat-value">{{ activeTasks.length }}</span>
|
||||
<span class="stat-label">Active Tasks</span>
|
||||
</div>
|
||||
<div class="stat-item" @click="goToProjectTasks">
|
||||
<span class="stat-value">{{ completedTasks.length }}</span>
|
||||
<span class="stat-label">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Tasks Preview -->
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header">
|
||||
<h4>Active Tasks</h4>
|
||||
<button class="link-btn" @click="goToProjectTasks">View All</button>
|
||||
</div>
|
||||
<div v-if="activeTasks.length === 0" class="empty-section">
|
||||
No active tasks
|
||||
</div>
|
||||
<ul v-else class="task-preview-list">
|
||||
<li v-for="task in activeTasks.slice(0, 5)" :key="task.id" class="task-preview-item">
|
||||
<span class="task-checkbox">☐</span>
|
||||
<span class="task-text">{{ task.title }}</span>
|
||||
</li>
|
||||
<li v-if="activeTasks.length > 5" class="task-more">
|
||||
+{{ activeTasks.length - 5 }} more...
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Git Status -->
|
||||
<GitStatus />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
max-width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-project {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.no-project-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-project-content h3 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.no-project-content p {
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
min-width: 70px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-preview-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-preview-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.task-preview-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-more {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
156
frontend/src/components/TaskPanel.vue
Normal file
156
frontend/src/components/TaskPanel.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTasksStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const tasksStore = useTasksStore()
|
||||
|
||||
const taskFilter = ref<'all' | 'pending' | 'completed'>('pending')
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
const tasks = tasksStore.allTasks
|
||||
if (taskFilter.value === 'all') return tasks
|
||||
if (taskFilter.value === 'pending') return tasks.filter(t => !t.completed)
|
||||
return tasks.filter(t => t.completed)
|
||||
})
|
||||
|
||||
function goToTask(task: { id: string; project_id: string }) {
|
||||
router.push({
|
||||
name: 'project-tasks',
|
||||
params: { id: task.project_id, taskId: task.id }
|
||||
})
|
||||
}
|
||||
|
||||
async function toggleTask(task: { id: string; project_id: string }) {
|
||||
try {
|
||||
await tasksStore.toggleTask(task.project_id, task.id)
|
||||
// Refresh global task list
|
||||
await tasksStore.loadAllTasks()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tasksStore.loadAllTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tasks-panel">
|
||||
<div class="task-filters">
|
||||
<button :class="{ active: taskFilter === 'pending' }" @click="taskFilter = 'pending'">
|
||||
Pending
|
||||
</button>
|
||||
<button :class="{ active: taskFilter === 'all' }" @click="taskFilter = 'all'">
|
||||
All
|
||||
</button>
|
||||
<button :class="{ active: taskFilter === 'completed' }" @click="taskFilter = 'completed'">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="tasksStore.loading" class="loading">Loading tasks...</div>
|
||||
<div v-else-if="filteredTasks.length === 0" class="no-tasks">No tasks</div>
|
||||
<div v-else class="task-list">
|
||||
<div
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="task-item"
|
||||
:class="{ completed: task.completed }"
|
||||
@click="goToTask(task)"
|
||||
>
|
||||
<button class="task-checkbox" @click.stop="toggleTask(task)">
|
||||
{{ task.completed ? '☑' : '☐' }}
|
||||
</button>
|
||||
<span class="task-text">{{ task.title }}</span>
|
||||
<span class="task-source">
|
||||
{{ task.project_id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasks-panel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.task-filters button {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-filters button.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.task-item.completed .task-text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-source {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.task-source:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.no-tasks,
|
||||
.loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
316
frontend/src/components/TopBar.vue
Normal file
316
frontend/src/components/TopBar.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectsStore, useWorkspaceStore, useUiStore, useThemeStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const uiStore = useUiStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const isDark = computed(() => themeStore.getEffectiveTheme() === 'dark')
|
||||
|
||||
const dropdownOpen = ref(false)
|
||||
const showNewProjectInput = ref(false)
|
||||
const newProjectName = ref('')
|
||||
|
||||
const activeProject = computed(() => workspaceStore.activeProject)
|
||||
const projects = computed(() => projectsStore.sortedProjects)
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
showNewProjectInput.value = false
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen.value = false
|
||||
showNewProjectInput.value = false
|
||||
newProjectName.value = ''
|
||||
}
|
||||
|
||||
async function selectProject(projectId: string) {
|
||||
await workspaceStore.setActiveProject(projectId)
|
||||
closeDropdown()
|
||||
router.push({ name: 'project', params: { id: projectId } })
|
||||
}
|
||||
|
||||
function showCreateProject() {
|
||||
showNewProjectInput.value = true
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!newProjectName.value.trim()) return
|
||||
|
||||
try {
|
||||
const project = await projectsStore.createProject(newProjectName.value.trim())
|
||||
await workspaceStore.setActiveProject(project.id)
|
||||
closeDropdown()
|
||||
router.push({ name: 'project', params: { id: project.id } })
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
function goToProjects() {
|
||||
closeDropdown()
|
||||
router.push({ name: 'projects' })
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">Ironpad</h1>
|
||||
|
||||
<div class="project-selector">
|
||||
<button class="project-button" @click="toggleDropdown">
|
||||
<span class="project-name">
|
||||
{{ activeProject?.name ?? 'Select Project' }}
|
||||
</span>
|
||||
<span class="dropdown-arrow">{{ dropdownOpen ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="dropdownOpen" class="dropdown-menu" @click.stop>
|
||||
<div v-if="!showNewProjectInput" class="dropdown-content">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:class="['dropdown-item', { active: project.id === activeProject?.id }]"
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
{{ project.name }}
|
||||
</div>
|
||||
|
||||
<div v-if="projects.length === 0" class="dropdown-empty">
|
||||
No projects yet
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<div class="dropdown-item action" @click="showCreateProject">
|
||||
+ New Project
|
||||
</div>
|
||||
|
||||
<div class="dropdown-item action" @click="goToProjects">
|
||||
Manage Projects
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="new-project-form">
|
||||
<input
|
||||
v-model="newProjectName"
|
||||
type="text"
|
||||
placeholder="Project name..."
|
||||
class="new-project-input"
|
||||
@keyup.enter="createProject"
|
||||
@keyup.escape="closeDropdown"
|
||||
autofocus
|
||||
/>
|
||||
<div class="form-buttons">
|
||||
<button class="primary" @click="createProject" :disabled="!newProjectName.trim()">
|
||||
Create
|
||||
</button>
|
||||
<button @click="closeDropdown">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<button
|
||||
@click="uiStore.toggleSearch()"
|
||||
:class="{ active: uiStore.showSearch }"
|
||||
title="Search (Ctrl+K)"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
@click="uiStore.toggleTasks()"
|
||||
:class="{ active: uiStore.showTasks }"
|
||||
title="Tasks"
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
class="theme-toggle"
|
||||
@click="themeStore.toggleTheme()"
|
||||
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
>
|
||||
{{ isDark ? '☀️' : '🌙' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Click outside to close dropdown -->
|
||||
<div v-if="dropdownOpen" class="dropdown-overlay" @click="closeDropdown"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
height: var(--header-height);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.project-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.project-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.project-button:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 220px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dropdown-item.action {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.new-project-form {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.new-project-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.new-project-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-right button {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topbar-right button.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
font-size: 16px;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.dropdown-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
</style>
|
||||
148
frontend/src/composables/useWebSocket.ts
Normal file
148
frontend/src/composables/useWebSocket.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { WsMessage, WsConnectedPayload } from '../types'
|
||||
|
||||
export interface UseWebSocketOptions {
|
||||
onFileCreated?: (path: string) => void
|
||||
onFileModified?: (path: string) => void
|
||||
onFileDeleted?: (path: string) => void
|
||||
onFileRenamed?: (from: string, to: string) => void
|
||||
onFileLocked?: (path: string, clientId: string, lockType: string) => void
|
||||
onFileUnlocked?: (path: string) => void
|
||||
onGitConflict?: (files: string[]) => void
|
||||
}
|
||||
|
||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||
const connected = ref(false)
|
||||
const clientId = ref<string | null>(null)
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimeout: number | null = null
|
||||
let reconnectAttempts = 0
|
||||
const MAX_RECONNECT_DELAY = 30000 // 30 seconds max
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
reconnectAttempts = 0 // Reset backoff on successful connection
|
||||
console.log('WebSocket connected')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WsMessage
|
||||
handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
connected.value = false
|
||||
clientId.value = null
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY)
|
||||
reconnectAttempts++
|
||||
console.log(`WebSocket disconnected, reconnecting in ${delay / 1000}s...`)
|
||||
reconnectTimeout = window.setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('WebSocket error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(msg: WsMessage) {
|
||||
switch (msg.type) {
|
||||
case 'Connected': {
|
||||
const payload = msg.payload as WsConnectedPayload
|
||||
clientId.value = payload.client_id
|
||||
console.log('WebSocket client ID:', payload.client_id)
|
||||
break
|
||||
}
|
||||
case 'FileCreated': {
|
||||
const payload = msg.payload as { path: string }
|
||||
options.onFileCreated?.(payload.path)
|
||||
break
|
||||
}
|
||||
case 'FileModified': {
|
||||
const payload = msg.payload as { path: string }
|
||||
options.onFileModified?.(payload.path)
|
||||
break
|
||||
}
|
||||
case 'FileDeleted': {
|
||||
const payload = msg.payload as { path: string }
|
||||
options.onFileDeleted?.(payload.path)
|
||||
break
|
||||
}
|
||||
case 'FileRenamed': {
|
||||
const payload = msg.payload as { from: string; to: string }
|
||||
options.onFileRenamed?.(payload.from, payload.to)
|
||||
break
|
||||
}
|
||||
case 'FileLocked': {
|
||||
const payload = msg.payload as { path: string; client_id: string; lock_type: string }
|
||||
options.onFileLocked?.(payload.path, payload.client_id, payload.lock_type)
|
||||
break
|
||||
}
|
||||
case 'FileUnlocked': {
|
||||
const payload = msg.payload as { path: string }
|
||||
options.onFileUnlocked?.(payload.path)
|
||||
break
|
||||
}
|
||||
case 'GitConflict': {
|
||||
const payload = msg.payload as { files: string[] }
|
||||
options.onGitConflict?.(payload.files)
|
||||
break
|
||||
}
|
||||
case 'Ping':
|
||||
// Heartbeat, no action needed
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function send(message: object) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message))
|
||||
}
|
||||
}
|
||||
|
||||
function lockFile(path: string, lockType: 'editor' | 'task_view') {
|
||||
send({ type: 'lock_file', path, lock_type: lockType })
|
||||
}
|
||||
|
||||
function unlockFile(path: string) {
|
||||
send({ type: 'unlock_file', path })
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connected,
|
||||
clientId,
|
||||
send,
|
||||
lockFile,
|
||||
unlockFile,
|
||||
disconnect
|
||||
}
|
||||
}
|
||||
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
53
frontend/src/router/index.ts
Normal file
53
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
name: 'projects',
|
||||
component: () => import('../views/ProjectsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
name: 'project',
|
||||
component: () => import('../views/ProjectView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/projects/:id/notes/:noteId?',
|
||||
name: 'project-notes',
|
||||
component: () => import('../views/ProjectNotesView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/projects/:id/tasks/:taskId?',
|
||||
name: 'project-tasks',
|
||||
component: () => import('../views/TasksView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('../views/CalendarView.vue')
|
||||
},
|
||||
{
|
||||
path: '/daily',
|
||||
name: 'daily',
|
||||
component: () => import('../views/DailyView.vue')
|
||||
},
|
||||
{
|
||||
path: '/daily/:date',
|
||||
name: 'daily-note',
|
||||
component: () => import('../views/DailyView.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
218
frontend/src/stores/git.ts
Normal file
218
frontend/src/stores/git.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { GitStatus, CommitInfo, CommitDetail, DiffInfo, RemoteInfo } from '../types'
|
||||
import { gitApi } from '../api/client'
|
||||
|
||||
export const useGitStore = defineStore('git', () => {
|
||||
// State
|
||||
const status = ref<GitStatus | null>(null)
|
||||
const loading = ref(false)
|
||||
const committing = ref(false)
|
||||
const pushing = ref(false)
|
||||
const fetching = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const conflicts = ref<string[]>([])
|
||||
|
||||
// New state for expanded git features
|
||||
const history = ref<CommitDetail[]>([])
|
||||
const historyLoading = ref(false)
|
||||
const workingDiff = ref<DiffInfo | null>(null)
|
||||
const diffLoading = ref(false)
|
||||
const selectedCommitDiff = ref<DiffInfo | null>(null)
|
||||
const selectedCommitId = ref<string | null>(null)
|
||||
const remote = ref<RemoteInfo | null>(null)
|
||||
const panelOpen = ref(false)
|
||||
|
||||
// Getters
|
||||
const hasChanges = computed(() => status.value?.has_changes ?? false)
|
||||
const hasConflicts = computed(() => conflicts.value.length > 0)
|
||||
const branch = computed(() => status.value?.branch ?? 'main')
|
||||
const isRepo = computed(() => status.value?.is_repo ?? false)
|
||||
const changedFilesCount = computed(() => status.value?.files.length ?? 0)
|
||||
const hasRemote = computed(() => remote.value !== null)
|
||||
const canPush = computed(() => (remote.value?.ahead ?? 0) > 0)
|
||||
const canPull = computed(() => (remote.value?.behind ?? 0) > 0)
|
||||
|
||||
// Actions
|
||||
async function loadStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
status.value = await gitApi.status()
|
||||
} catch (err) {
|
||||
error.value = `Failed to load git status: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(message?: string): Promise<CommitInfo | null> {
|
||||
try {
|
||||
committing.value = true
|
||||
error.value = null
|
||||
const result = await gitApi.commit(message)
|
||||
await loadStatus()
|
||||
// Refresh history and diff after commit
|
||||
await Promise.all([loadHistory(), loadWorkingDiff(), loadRemote()])
|
||||
return result
|
||||
} catch (err) {
|
||||
error.value = `Commit failed: ${err}`
|
||||
return null
|
||||
} finally {
|
||||
committing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function push() {
|
||||
try {
|
||||
pushing.value = true
|
||||
error.value = null
|
||||
const result = await gitApi.push()
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
await Promise.all([loadStatus(), loadRemote()])
|
||||
} catch (err) {
|
||||
error.value = `Push failed: ${err}`
|
||||
throw err
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote() {
|
||||
try {
|
||||
fetching.value = true
|
||||
error.value = null
|
||||
const result = await gitApi.fetch()
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
await loadRemote()
|
||||
} catch (err) {
|
||||
error.value = `Fetch failed: ${err}`
|
||||
throw err
|
||||
} finally {
|
||||
fetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConflicts() {
|
||||
try {
|
||||
error.value = null
|
||||
conflicts.value = await gitApi.conflicts()
|
||||
} catch (err) {
|
||||
// Conflicts endpoint might not exist yet, ignore error
|
||||
conflicts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory(limit?: number) {
|
||||
try {
|
||||
historyLoading.value = true
|
||||
history.value = await gitApi.log(limit)
|
||||
} catch (err) {
|
||||
console.error('Failed to load git history:', err)
|
||||
history.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkingDiff() {
|
||||
try {
|
||||
diffLoading.value = true
|
||||
workingDiff.value = await gitApi.diff()
|
||||
} catch (err) {
|
||||
console.error('Failed to load working diff:', err)
|
||||
workingDiff.value = null
|
||||
} finally {
|
||||
diffLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommitDiff(commitId: string) {
|
||||
try {
|
||||
diffLoading.value = true
|
||||
selectedCommitId.value = commitId
|
||||
selectedCommitDiff.value = await gitApi.commitDiff(commitId)
|
||||
} catch (err) {
|
||||
console.error('Failed to load commit diff:', err)
|
||||
selectedCommitDiff.value = null
|
||||
} finally {
|
||||
diffLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemote() {
|
||||
try {
|
||||
remote.value = await gitApi.remote()
|
||||
} catch (err) {
|
||||
console.error('Failed to load remote info:', err)
|
||||
remote.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelectedCommit() {
|
||||
selectedCommitId.value = null
|
||||
selectedCommitDiff.value = null
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen.value = !panelOpen.value
|
||||
if (panelOpen.value) {
|
||||
// Load all data when opening panel
|
||||
Promise.all([
|
||||
loadStatus(),
|
||||
loadHistory(),
|
||||
loadWorkingDiff(),
|
||||
loadRemote()
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
status,
|
||||
loading,
|
||||
committing,
|
||||
pushing,
|
||||
fetching,
|
||||
error,
|
||||
conflicts,
|
||||
history,
|
||||
historyLoading,
|
||||
workingDiff,
|
||||
diffLoading,
|
||||
selectedCommitDiff,
|
||||
selectedCommitId,
|
||||
remote,
|
||||
panelOpen,
|
||||
// Getters
|
||||
hasChanges,
|
||||
hasConflicts,
|
||||
branch,
|
||||
isRepo,
|
||||
changedFilesCount,
|
||||
hasRemote,
|
||||
canPush,
|
||||
canPull,
|
||||
// Actions
|
||||
loadStatus,
|
||||
commit,
|
||||
push,
|
||||
fetchRemote,
|
||||
checkConflicts,
|
||||
loadHistory,
|
||||
loadWorkingDiff,
|
||||
loadCommitDiff,
|
||||
loadRemote,
|
||||
clearSelectedCommit,
|
||||
togglePanel,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
8
frontend/src/stores/index.ts
Normal file
8
frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useNotesStore } from './notes'
|
||||
export { useProjectsStore } from './projects'
|
||||
export { useTasksStore } from './tasks'
|
||||
export { useUiStore } from './ui'
|
||||
export { useWebSocketStore } from './websocket'
|
||||
export { useGitStore } from './git'
|
||||
export { useWorkspaceStore } from './workspace'
|
||||
export { useThemeStore } from './theme'
|
||||
131
frontend/src/stores/notes.ts
Normal file
131
frontend/src/stores/notes.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Note, NoteSummary } from '../types'
|
||||
import { notesApi } from '../api/client'
|
||||
|
||||
export const useNotesStore = defineStore('notes', () => {
|
||||
// State
|
||||
const notes = ref<NoteSummary[]>([])
|
||||
const currentNote = ref<Note | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadingNote = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
|
||||
// Getters
|
||||
const sortedNotes = computed(() =>
|
||||
[...notes.value].sort((a, b) => {
|
||||
const dateA = a.updated ? new Date(a.updated).getTime() : 0
|
||||
const dateB = b.updated ? new Date(b.updated).getTime() : 0
|
||||
return dateB - dateA
|
||||
})
|
||||
)
|
||||
|
||||
const getNoteById = computed(() => (id: string) =>
|
||||
notes.value.find(n => n.id === id)
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function loadNotes() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
notes.value = await notesApi.list()
|
||||
} catch (err) {
|
||||
error.value = `Failed to load notes: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNote(id: string) {
|
||||
try {
|
||||
loadingNote.value = true
|
||||
error.value = null
|
||||
currentNote.value = await notesApi.get(id)
|
||||
saveStatus.value = 'idle'
|
||||
} catch (err) {
|
||||
error.value = `Failed to load note: ${err}`
|
||||
currentNote.value = null
|
||||
} finally {
|
||||
loadingNote.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
try {
|
||||
error.value = null
|
||||
const newNote = await notesApi.create()
|
||||
await loadNotes()
|
||||
return newNote
|
||||
} catch (err) {
|
||||
error.value = `Failed to create note: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote(content: string) {
|
||||
if (!currentNote.value) return
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
currentNote.value = await notesApi.update(currentNote.value.id, content)
|
||||
saveStatus.value = 'saved'
|
||||
await loadNotes() // Refresh list to update timestamps
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
saveStatus.value = 'error'
|
||||
error.value = `Failed to save note: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote(id?: string) {
|
||||
const noteId = id || currentNote.value?.id
|
||||
if (!noteId) return
|
||||
|
||||
try {
|
||||
error.value = null
|
||||
await notesApi.delete(noteId)
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = null
|
||||
}
|
||||
await loadNotes()
|
||||
} catch (err) {
|
||||
error.value = `Failed to archive note: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function clearCurrentNote() {
|
||||
currentNote.value = null
|
||||
saveStatus.value = 'idle'
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
notes,
|
||||
currentNote,
|
||||
loading,
|
||||
loadingNote,
|
||||
error,
|
||||
saveStatus,
|
||||
// Getters
|
||||
sortedNotes,
|
||||
getNoteById,
|
||||
// Actions
|
||||
loadNotes,
|
||||
loadNote,
|
||||
createNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
clearCurrentNote,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
84
frontend/src/stores/projects.ts
Normal file
84
frontend/src/stores/projects.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Project } from '../types'
|
||||
import { projectsApi } from '../api/client'
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
// State
|
||||
const projects = ref<Project[]>([])
|
||||
const currentProject = ref<Project | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const sortedProjects = computed(() =>
|
||||
[...projects.value].sort((a, b) => a.name.localeCompare(b.name))
|
||||
)
|
||||
|
||||
const getProjectById = computed(() => (id: string) =>
|
||||
projects.value.find(p => p.id === id)
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function loadProjects() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
projects.value = await projectsApi.list()
|
||||
} catch (err) {
|
||||
error.value = `Failed to load projects: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject(id: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
currentProject.value = await projectsApi.get(id)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load project: ${err}`
|
||||
currentProject.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(name: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const newProject = await projectsApi.create(name)
|
||||
await loadProjects()
|
||||
return newProject
|
||||
} catch (err) {
|
||||
error.value = `Failed to create project: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function clearCurrentProject() {
|
||||
currentProject.value = null
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
projects,
|
||||
currentProject,
|
||||
loading,
|
||||
error,
|
||||
// Getters
|
||||
sortedProjects,
|
||||
getProjectById,
|
||||
// Actions
|
||||
loadProjects,
|
||||
loadProject,
|
||||
createProject,
|
||||
clearCurrentProject,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
235
frontend/src/stores/tasks.ts
Normal file
235
frontend/src/stores/tasks.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Task, TaskWithContent } from '../types'
|
||||
import { tasksApi } from '../api/client'
|
||||
|
||||
export const useTasksStore = defineStore('tasks', () => {
|
||||
// State
|
||||
const tasks = ref<Task[]>([])
|
||||
const allTasks = ref<Task[]>([])
|
||||
const currentProjectId = ref<string | null>(null)
|
||||
const selectedTask = ref<TaskWithContent | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const activeTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.completed && t.section !== 'Backlog')
|
||||
)
|
||||
|
||||
const completedTasks = computed(() =>
|
||||
tasks.value.filter(t => t.completed || t.section === 'Completed')
|
||||
)
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.completed && t.section === 'Backlog')
|
||||
)
|
||||
|
||||
const pendingTasks = computed(() =>
|
||||
allTasks.value.filter(t => !t.completed)
|
||||
)
|
||||
|
||||
const tasksByProject = computed(() => (projectId: string) =>
|
||||
allTasks.value.filter(t => t.project_id === projectId)
|
||||
)
|
||||
|
||||
/** All unique tags used across tasks in the current project */
|
||||
const projectTags = computed(() => {
|
||||
const tagSet = new Set<string>()
|
||||
for (const task of tasks.value) {
|
||||
if (task.tags) {
|
||||
for (const tag of task.tags) {
|
||||
tagSet.add(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...tagSet].sort()
|
||||
})
|
||||
|
||||
// Actions
|
||||
async function loadAllTasks() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
allTasks.value = await tasksApi.listAll()
|
||||
} catch (err) {
|
||||
error.value = `Failed to load tasks: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectTasks(projectId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
currentProjectId.value = projectId
|
||||
tasks.value = await tasksApi.list(projectId)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load project tasks: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTask(projectId: string, taskId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
selectedTask.value = await tasksApi.get(projectId, taskId)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load task: ${err}`
|
||||
selectedTask.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask(projectId: string, title: string, section?: string, parentId?: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const task = await tasksApi.create(projectId, title, section, parentId)
|
||||
|
||||
// Refresh tasks list
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
return task
|
||||
} catch (err) {
|
||||
error.value = `Failed to create task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskContent(projectId: string, taskId: string, content: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const task = await tasksApi.updateContent(projectId, taskId, content)
|
||||
selectedTask.value = task
|
||||
|
||||
// Refresh tasks list to update timestamps
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
return task
|
||||
} catch (err) {
|
||||
error.value = `Failed to update task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTask(projectId: string, taskId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
await tasksApi.toggle(projectId, taskId)
|
||||
|
||||
// Refresh tasks
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
// Update selected task if it's the one being toggled
|
||||
if (selectedTask.value?.id === taskId) {
|
||||
await loadTask(projectId, taskId)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to toggle task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskMeta(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
meta: { title?: string; section?: string; priority?: string; due_date?: string; is_active?: boolean; tags?: string[]; recurrence?: string; recurrence_interval?: number }
|
||||
) {
|
||||
try {
|
||||
error.value = null
|
||||
await tasksApi.updateMeta(projectId, taskId, meta)
|
||||
|
||||
// Refresh tasks
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
// Update selected task if it's the one being updated
|
||||
if (selectedTask.value?.id === taskId) {
|
||||
await loadTask(projectId, taskId)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to update task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(projectId: string, taskId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
await tasksApi.delete(projectId, taskId)
|
||||
|
||||
// Clear selected task if it was deleted
|
||||
if (selectedTask.value?.id === taskId) {
|
||||
selectedTask.value = null
|
||||
}
|
||||
|
||||
// Refresh tasks
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to delete task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function selectTask(task: Task | null) {
|
||||
if (task && currentProjectId.value) {
|
||||
loadTask(currentProjectId.value, task.id)
|
||||
} else {
|
||||
selectedTask.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelectedTask() {
|
||||
selectedTask.value = null
|
||||
}
|
||||
|
||||
function clearProjectTasks() {
|
||||
tasks.value = []
|
||||
currentProjectId.value = null
|
||||
selectedTask.value = null
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tasks,
|
||||
allTasks,
|
||||
currentProjectId,
|
||||
selectedTask,
|
||||
loading,
|
||||
error,
|
||||
// Getters
|
||||
activeTasks,
|
||||
completedTasks,
|
||||
backlogTasks,
|
||||
pendingTasks,
|
||||
tasksByProject,
|
||||
projectTags,
|
||||
// Actions
|
||||
loadAllTasks,
|
||||
loadProjectTasks,
|
||||
loadTask,
|
||||
createTask,
|
||||
updateTaskContent,
|
||||
toggleTask,
|
||||
updateTaskMeta,
|
||||
deleteTask,
|
||||
selectTask,
|
||||
clearSelectedTask,
|
||||
clearProjectTasks,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
76
frontend/src/stores/theme.ts
Normal file
76
frontend/src/stores/theme.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'system'
|
||||
|
||||
const STORAGE_KEY = 'ironpad-theme'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
// Default to dark mode
|
||||
const mode = ref<ThemeMode>('dark')
|
||||
|
||||
// Load saved preference
|
||||
function loadSavedTheme() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved && ['dark', 'light', 'system'].includes(saved)) {
|
||||
mode.value = saved as ThemeMode
|
||||
}
|
||||
}
|
||||
|
||||
// Get the effective theme (resolves 'system' to actual theme)
|
||||
function getEffectiveTheme(): 'dark' | 'light' {
|
||||
if (mode.value === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return mode.value
|
||||
}
|
||||
|
||||
// Apply theme to document
|
||||
function applyTheme() {
|
||||
const effectiveTheme = getEffectiveTheme()
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme)
|
||||
|
||||
// Also set class for easier CSS targeting
|
||||
document.documentElement.classList.remove('theme-dark', 'theme-light')
|
||||
document.documentElement.classList.add(`theme-${effectiveTheme}`)
|
||||
}
|
||||
|
||||
// Set theme mode
|
||||
function setTheme(newMode: ThemeMode) {
|
||||
mode.value = newMode
|
||||
localStorage.setItem(STORAGE_KEY, newMode)
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
// Toggle between dark and light
|
||||
function toggleTheme() {
|
||||
const current = getEffectiveTheme()
|
||||
setTheme(current === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
loadSavedTheme()
|
||||
|
||||
// Ensure data-theme attribute is set (even if same as default)
|
||||
applyTheme()
|
||||
|
||||
// Listen for system theme changes when in system mode
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (mode.value === 'system') {
|
||||
applyTheme()
|
||||
}
|
||||
})
|
||||
|
||||
// Theme is now initialized
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
getEffectiveTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
applyTheme,
|
||||
init
|
||||
}
|
||||
})
|
||||
125
frontend/src/stores/ui.ts
Normal file
125
frontend/src/stores/ui.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SearchResult } from '../types'
|
||||
import { searchApi } from '../api/client'
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
// State
|
||||
const showSearch = ref(false)
|
||||
const showTasks = ref(false)
|
||||
const showPreview = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<SearchResult[]>([])
|
||||
const isSearching = ref(false)
|
||||
const globalError = ref<string | null>(null)
|
||||
const sidebarSection = ref<'notes' | 'projects' | 'daily'>('notes')
|
||||
|
||||
// Getters
|
||||
const hasSearchResults = computed(() => searchResults.value.length > 0)
|
||||
|
||||
// Actions
|
||||
function openSearch() {
|
||||
showSearch.value = true
|
||||
showTasks.value = false
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
showSearch.value = false
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
if (showSearch.value) {
|
||||
closeSearch()
|
||||
} else {
|
||||
openSearch()
|
||||
}
|
||||
}
|
||||
|
||||
function openTasks() {
|
||||
showTasks.value = true
|
||||
showSearch.value = false
|
||||
}
|
||||
|
||||
function closeTasks() {
|
||||
showTasks.value = false
|
||||
}
|
||||
|
||||
function toggleTasks() {
|
||||
if (showTasks.value) {
|
||||
closeTasks()
|
||||
} else {
|
||||
openTasks()
|
||||
}
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
showPreview.value = !showPreview.value
|
||||
// Persist preference
|
||||
localStorage.setItem('ironpad-show-preview', String(showPreview.value))
|
||||
}
|
||||
|
||||
function loadPreviewPreference() {
|
||||
const saved = localStorage.getItem('ironpad-show-preview')
|
||||
if (saved !== null) {
|
||||
showPreview.value = saved === 'true'
|
||||
}
|
||||
}
|
||||
|
||||
async function search(query: string) {
|
||||
if (query.length < 2) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSearching.value = true
|
||||
searchQuery.value = query
|
||||
searchResults.value = await searchApi.search(query)
|
||||
} catch (err) {
|
||||
globalError.value = `Search failed: ${err}`
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setSidebarSection(section: 'notes' | 'projects' | 'daily') {
|
||||
sidebarSection.value = section
|
||||
}
|
||||
|
||||
function setGlobalError(message: string | null) {
|
||||
globalError.value = message
|
||||
}
|
||||
|
||||
function clearGlobalError() {
|
||||
globalError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
showSearch,
|
||||
showTasks,
|
||||
showPreview,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
isSearching,
|
||||
globalError,
|
||||
sidebarSection,
|
||||
// Getters
|
||||
hasSearchResults,
|
||||
// Actions
|
||||
openSearch,
|
||||
closeSearch,
|
||||
toggleSearch,
|
||||
openTasks,
|
||||
closeTasks,
|
||||
toggleTasks,
|
||||
togglePreview,
|
||||
loadPreviewPreference,
|
||||
search,
|
||||
setSidebarSection,
|
||||
setGlobalError,
|
||||
clearGlobalError
|
||||
}
|
||||
})
|
||||
67
frontend/src/stores/websocket.ts
Normal file
67
frontend/src/stores/websocket.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { FileLock } from '../types'
|
||||
|
||||
export const useWebSocketStore = defineStore('websocket', () => {
|
||||
// State
|
||||
const connected = ref(false)
|
||||
const clientId = ref<string | null>(null)
|
||||
const fileLocks = ref<Map<string, FileLock>>(new Map())
|
||||
const gitConflicts = ref<string[]>([])
|
||||
|
||||
// Actions
|
||||
function setConnected(value: boolean) {
|
||||
connected.value = value
|
||||
}
|
||||
|
||||
function setClientId(id: string | null) {
|
||||
clientId.value = id
|
||||
}
|
||||
|
||||
function addFileLock(lock: FileLock) {
|
||||
fileLocks.value.set(lock.path, lock)
|
||||
}
|
||||
|
||||
function removeFileLock(path: string) {
|
||||
fileLocks.value.delete(path)
|
||||
}
|
||||
|
||||
function isFileLocked(path: string): FileLock | undefined {
|
||||
return fileLocks.value.get(path)
|
||||
}
|
||||
|
||||
function isFileLockedByOther(path: string): boolean {
|
||||
const lock = fileLocks.value.get(path)
|
||||
return lock !== undefined && lock.client_id !== clientId.value
|
||||
}
|
||||
|
||||
function setGitConflicts(files: string[]) {
|
||||
gitConflicts.value = files
|
||||
}
|
||||
|
||||
function clearGitConflicts() {
|
||||
gitConflicts.value = []
|
||||
}
|
||||
|
||||
function clearAllLocks() {
|
||||
fileLocks.value.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
connected,
|
||||
clientId,
|
||||
fileLocks,
|
||||
gitConflicts,
|
||||
// Actions
|
||||
setConnected,
|
||||
setClientId,
|
||||
addFileLock,
|
||||
removeFileLock,
|
||||
isFileLocked,
|
||||
isFileLockedByOther,
|
||||
setGitConflicts,
|
||||
clearGitConflicts,
|
||||
clearAllLocks
|
||||
}
|
||||
})
|
||||
76
frontend/src/stores/workspace.ts
Normal file
76
frontend/src/stores/workspace.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Project } from '../types'
|
||||
import { projectsApi } from '../api/client'
|
||||
|
||||
const STORAGE_KEY = 'ironpad-active-project'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
// State
|
||||
const activeProjectId = ref<string | null>(null)
|
||||
const activeProject = ref<Project | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const hasActiveProject = computed(() => activeProjectId.value !== null)
|
||||
|
||||
// Actions
|
||||
async function setActiveProject(projectId: string | null) {
|
||||
if (projectId === activeProjectId.value) return
|
||||
|
||||
activeProjectId.value = projectId
|
||||
|
||||
if (projectId) {
|
||||
// Persist to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, projectId)
|
||||
|
||||
// Load project details
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
activeProject.value = await projectsApi.get(projectId)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load project: ${err}`
|
||||
activeProject.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
activeProject.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSavedProject() {
|
||||
const savedId = localStorage.getItem(STORAGE_KEY)
|
||||
if (savedId) {
|
||||
await setActiveProject(savedId)
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveProject() {
|
||||
activeProjectId.value = null
|
||||
activeProject.value = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
activeProjectId,
|
||||
activeProject,
|
||||
loading,
|
||||
error,
|
||||
// Getters
|
||||
hasActiveProject,
|
||||
// Actions
|
||||
setActiveProject,
|
||||
loadSavedProject,
|
||||
clearActiveProject,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
242
frontend/src/style.css
Normal file
242
frontend/src/style.css
Normal file
@@ -0,0 +1,242 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/*
|
||||
* Theme variables are defined in App.vue
|
||||
* This file only contains component styles
|
||||
*/
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Note list */
|
||||
.note-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.note-item.active {
|
||||
background: var(--color-border);
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.note-item-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Editor */
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
resize: none;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status.saving {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.status.saved {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
padding: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Button group */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Type badge */
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
175
frontend/src/types/index.ts
Normal file
175
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// Types for Ironpad
|
||||
|
||||
export interface NoteSummary {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
note_type: string
|
||||
updated?: string
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string
|
||||
path: string
|
||||
note_type: string
|
||||
frontmatter: Record<string, unknown>
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
created: string
|
||||
}
|
||||
|
||||
export interface ProjectWithContent extends Project {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ProjectNote {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
project_id: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface ProjectNoteWithContent extends ProjectNote {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
title: string
|
||||
completed: boolean
|
||||
section: string
|
||||
priority?: string
|
||||
due_date?: string
|
||||
is_active: boolean
|
||||
tags: string[]
|
||||
parent_id?: string
|
||||
recurrence?: string
|
||||
recurrence_interval?: number
|
||||
project_id: string
|
||||
path: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface TaskWithContent extends Task {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
path: string
|
||||
title: string
|
||||
matches: { line_number: number; line_content: string }[]
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
is_repo: boolean
|
||||
branch?: string
|
||||
has_changes: boolean
|
||||
files: { path: string; status: string }[]
|
||||
last_commit?: { id: string; message: string; timestamp: string }
|
||||
conflicts?: string[]
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
id: string
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface CommitDetail {
|
||||
id: string
|
||||
short_id: string
|
||||
message: string
|
||||
author: string
|
||||
timestamp: string
|
||||
files_changed: number
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
origin: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
header: string
|
||||
lines: DiffLine[]
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
path: string
|
||||
status: string
|
||||
additions: number
|
||||
deletions: number
|
||||
hunks: DiffHunk[]
|
||||
}
|
||||
|
||||
export interface DiffStats {
|
||||
files_changed: number
|
||||
insertions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
export interface DiffInfo {
|
||||
files: FileDiff[]
|
||||
stats: DiffStats
|
||||
}
|
||||
|
||||
export interface RemoteInfo {
|
||||
name: string
|
||||
url: string
|
||||
has_upstream: boolean
|
||||
ahead: number
|
||||
behind: number
|
||||
}
|
||||
|
||||
export interface DailyNote {
|
||||
id: string
|
||||
date: string
|
||||
path: string
|
||||
content: string
|
||||
frontmatter: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface FileLock {
|
||||
path: string
|
||||
client_id: string
|
||||
lock_type: 'editor' | 'task_view'
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
export type WsMessageType =
|
||||
| 'Connected'
|
||||
| 'FileCreated'
|
||||
| 'FileModified'
|
||||
| 'FileDeleted'
|
||||
| 'FileRenamed'
|
||||
| 'FileLocked'
|
||||
| 'FileUnlocked'
|
||||
| 'GitConflict'
|
||||
| 'Ping'
|
||||
|
||||
export interface WsMessage {
|
||||
type: WsMessageType
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
export interface WsConnectedPayload {
|
||||
client_id: string
|
||||
}
|
||||
|
||||
export interface WsFilePayload {
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface WsFileLockPayload {
|
||||
path: string
|
||||
client_id: string
|
||||
lock_type: 'editor' | 'task_view'
|
||||
}
|
||||
426
frontend/src/views/CalendarView.vue
Normal file
426
frontend/src/views/CalendarView.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTasksStore, useProjectsStore, useWorkspaceStore } from '../stores'
|
||||
import { dailyApi } from '../api/client'
|
||||
import type { Task, DailyNote } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const tasksStore = useTasksStore()
|
||||
const projectsStore = useProjectsStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
// Current month being displayed
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
const currentMonth = ref(new Date().getMonth()) // 0-indexed
|
||||
|
||||
// Daily notes dates
|
||||
const dailyDates = ref<Set<string>>(new Set())
|
||||
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
]
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
const monthLabel = computed(() => `${monthNames[currentMonth.value]} ${currentYear.value}`)
|
||||
|
||||
// Build calendar grid
|
||||
const calendarDays = computed(() => {
|
||||
const year = currentYear.value
|
||||
const month = currentMonth.value
|
||||
|
||||
// First day of the month
|
||||
const firstDay = new Date(year, month, 1)
|
||||
// Day of week (0=Sun, 1=Mon...) - shift to Mon=0
|
||||
let startDow = firstDay.getDay() - 1
|
||||
if (startDow < 0) startDow = 6
|
||||
|
||||
// Days in this month
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
|
||||
// Days in previous month (for padding)
|
||||
const daysInPrevMonth = new Date(year, month, 0).getDate()
|
||||
|
||||
const days: { date: string; day: number; isCurrentMonth: boolean; isToday: boolean }[] = []
|
||||
|
||||
// Previous month padding
|
||||
for (let i = startDow - 1; i >= 0; i--) {
|
||||
const d = daysInPrevMonth - i
|
||||
const m = month === 0 ? 11 : month - 1
|
||||
const y = month === 0 ? year - 1 : year
|
||||
days.push({
|
||||
date: formatDate(y, m, d),
|
||||
day: d,
|
||||
isCurrentMonth: false,
|
||||
isToday: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Current month
|
||||
const today = new Date()
|
||||
const todayStr = formatDate(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = formatDate(year, month, d)
|
||||
days.push({
|
||||
date: dateStr,
|
||||
day: d,
|
||||
isCurrentMonth: true,
|
||||
isToday: dateStr === todayStr,
|
||||
})
|
||||
}
|
||||
|
||||
// Next month padding (fill to 6 rows * 7 = 42 cells, or at least complete the row)
|
||||
const remaining = 7 - (days.length % 7)
|
||||
if (remaining < 7) {
|
||||
for (let d = 1; d <= remaining; d++) {
|
||||
const m = month === 11 ? 0 : month + 1
|
||||
const y = month === 11 ? year + 1 : year
|
||||
days.push({
|
||||
date: formatDate(y, m, d),
|
||||
day: d,
|
||||
isCurrentMonth: false,
|
||||
isToday: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return days
|
||||
})
|
||||
|
||||
function formatDate(y: number, m: number, d: number): string {
|
||||
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Tasks grouped by due date
|
||||
const tasksByDate = computed(() => {
|
||||
const map = new Map<string, Task[]>()
|
||||
for (const task of tasksStore.allTasks) {
|
||||
if (task.due_date && !task.completed) {
|
||||
const existing = map.get(task.due_date) || []
|
||||
existing.push(task)
|
||||
map.set(task.due_date, existing)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function getTasksForDate(dateStr: string): Task[] {
|
||||
return tasksByDate.value.get(dateStr) || []
|
||||
}
|
||||
|
||||
function hasDailyNote(dateStr: string): boolean {
|
||||
return dailyDates.value.has(dateStr)
|
||||
}
|
||||
|
||||
// Navigation
|
||||
function prevMonth() {
|
||||
if (currentMonth.value === 0) {
|
||||
currentMonth.value = 11
|
||||
currentYear.value--
|
||||
} else {
|
||||
currentMonth.value--
|
||||
}
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (currentMonth.value === 11) {
|
||||
currentMonth.value = 0
|
||||
currentYear.value++
|
||||
} else {
|
||||
currentMonth.value++
|
||||
}
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const today = new Date()
|
||||
currentYear.value = today.getFullYear()
|
||||
currentMonth.value = today.getMonth()
|
||||
}
|
||||
|
||||
function clickDate(dateStr: string) {
|
||||
// Navigate to daily note for this date
|
||||
router.push({ name: 'daily-note', params: { date: dateStr } })
|
||||
}
|
||||
|
||||
function clickTask(task: Task) {
|
||||
workspaceStore.setActiveProject(task.project_id)
|
||||
router.push({
|
||||
name: 'project-tasks',
|
||||
params: { id: task.project_id, taskId: task.id }
|
||||
})
|
||||
}
|
||||
|
||||
function projectName(projectId: string): string {
|
||||
const p = projectsStore.getProjectById(projectId)
|
||||
return p?.name || projectId
|
||||
}
|
||||
|
||||
function formatDueClass(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diff = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diff < 0) return 'overdue'
|
||||
if (diff === 0) return 'today'
|
||||
if (diff <= 3) return 'soon'
|
||||
return ''
|
||||
}
|
||||
|
||||
// Load data
|
||||
async function loadDailyDates() {
|
||||
try {
|
||||
const notes: DailyNote[] = await dailyApi.list()
|
||||
dailyDates.value = new Set(notes.map(n => n.date))
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
tasksStore.loadAllTasks(),
|
||||
projectsStore.loadProjects(),
|
||||
loadDailyDates(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-view">
|
||||
<!-- Header -->
|
||||
<div class="calendar-header">
|
||||
<div class="header-left">
|
||||
<button @click="prevMonth" title="Previous month">‹</button>
|
||||
<h2>{{ monthLabel }}</h2>
|
||||
<button @click="nextMonth" title="Next month">›</button>
|
||||
<button class="small today-btn" @click="goToToday">Today</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Day names -->
|
||||
<div class="calendar-grid day-names">
|
||||
<div v-for="name in dayNames" :key="name" class="day-name">{{ name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<div class="calendar-grid calendar-body">
|
||||
<div
|
||||
v-for="(day, idx) in calendarDays"
|
||||
:key="idx"
|
||||
:class="['calendar-cell', {
|
||||
'other-month': !day.isCurrentMonth,
|
||||
'is-today': day.isToday,
|
||||
'has-tasks': getTasksForDate(day.date).length > 0,
|
||||
}]"
|
||||
>
|
||||
<div class="cell-header" @click="clickDate(day.date)">
|
||||
<span class="cell-day">{{ day.day }}</span>
|
||||
<span v-if="hasDailyNote(day.date)" class="daily-dot" title="Daily note"></span>
|
||||
</div>
|
||||
<div class="cell-tasks">
|
||||
<div
|
||||
v-for="task in getTasksForDate(day.date).slice(0, 3)"
|
||||
:key="task.id"
|
||||
:class="['cell-task', formatDueClass(day.date)]"
|
||||
@click.stop="clickTask(task)"
|
||||
:title="`${projectName(task.project_id)}: ${task.title}`"
|
||||
>
|
||||
{{ task.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getTasksForDate(day.date).length > 3"
|
||||
class="cell-more"
|
||||
@click="clickDate(day.date)"
|
||||
>
|
||||
+{{ getTasksForDate(day.date).length - 3 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left button {
|
||||
font-size: 18px;
|
||||
padding: 4px 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
font-size: 12px !important;
|
||||
padding: 4px 10px !important;
|
||||
}
|
||||
|
||||
.calendar-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Grid layout */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.day-names {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.calendar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
grid-auto-rows: minmax(100px, 1fr);
|
||||
}
|
||||
|
||||
/* Calendar cells */
|
||||
.calendar-cell {
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 4px;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-cell:nth-child(7n) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.calendar-cell.other-month {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.calendar-cell.is-today {
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
.calendar-cell.is-today .cell-day {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cell-header:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.cell-day {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.daily-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tasks in cells */
|
||||
.cell-tasks {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.cell-task {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cell-task:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.cell-task.overdue {
|
||||
border-left-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.cell-task.today {
|
||||
border-left-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.cell-task.soon {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.cell-more {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell-more:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
294
frontend/src/views/DailyView.vue
Normal file
294
frontend/src/views/DailyView.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { dailyApi } from '../api/client'
|
||||
import { useGitStore } from '../stores'
|
||||
import type { DailyNote } from '../types'
|
||||
import MilkdownEditor from '../components/MilkdownEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
date?: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const gitStore = useGitStore()
|
||||
|
||||
const currentDate = computed((): string => {
|
||||
if (props.date) return props.date
|
||||
const routeDate = route.params.date
|
||||
if (typeof routeDate === 'string') return routeDate
|
||||
return getTodayDate()
|
||||
})
|
||||
|
||||
// Note state
|
||||
const dailyNote = ref<DailyNote | null>(null)
|
||||
const editorContent = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const noteExists = ref(false) // Track if the note file actually exists
|
||||
let saveTimeout: number | null = null
|
||||
|
||||
// Default template for daily notes
|
||||
function getDefaultTemplate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
const formatted = date.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
return `# ${formatted}
|
||||
|
||||
## Today's Focus
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ]
|
||||
`
|
||||
}
|
||||
|
||||
function getTodayDate(): string {
|
||||
return new Date().toISOString().split('T')[0] as string
|
||||
}
|
||||
|
||||
function formatDateDisplay(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
router.push({ name: 'daily' })
|
||||
}
|
||||
|
||||
function goToPrevDay() {
|
||||
const date = new Date(currentDate.value + 'T00:00:00')
|
||||
date.setDate(date.getDate() - 1)
|
||||
const prevDate = date.toISOString().split('T')[0]
|
||||
router.push({ name: 'daily-note', params: { date: prevDate } })
|
||||
}
|
||||
|
||||
function goToNextDay() {
|
||||
const date = new Date(currentDate.value + 'T00:00:00')
|
||||
date.setDate(date.getDate() + 1)
|
||||
const nextDate = date.toISOString().split('T')[0]
|
||||
router.push({ name: 'daily-note', params: { date: nextDate } })
|
||||
}
|
||||
|
||||
function scheduleAutoSave() {
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
saveStatus.value = 'idle'
|
||||
saveTimeout = window.setTimeout(saveNote, 1000)
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
// Don't save if content is just the template or empty
|
||||
const template = getDefaultTemplate(currentDate.value)
|
||||
const trimmedContent = editorContent.value.trim()
|
||||
const trimmedTemplate = template.trim()
|
||||
|
||||
if (!trimmedContent || trimmedContent === trimmedTemplate) {
|
||||
// Don't save empty/template-only content
|
||||
saveStatus.value = 'idle'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
|
||||
if (!noteExists.value) {
|
||||
// Create the note first
|
||||
dailyNote.value = await dailyApi.create(currentDate.value, editorContent.value)
|
||||
noteExists.value = true
|
||||
} else {
|
||||
// Update existing note using the date
|
||||
await dailyApi.update(currentDate.value, editorContent.value)
|
||||
}
|
||||
|
||||
saveStatus.value = 'saved'
|
||||
gitStore.loadStatus()
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
saveStatus.value = 'error'
|
||||
error.value = `Failed to save: ${err}`
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDailyNote() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
noteExists.value = false
|
||||
|
||||
try {
|
||||
// Try to get existing daily note (don't auto-create)
|
||||
dailyNote.value = await dailyApi.get(currentDate.value)
|
||||
editorContent.value = dailyNote.value?.content ?? ''
|
||||
noteExists.value = true
|
||||
} catch (err) {
|
||||
// Note doesn't exist - that's fine, show template but don't create file
|
||||
dailyNote.value = null
|
||||
editorContent.value = getDefaultTemplate(currentDate.value)
|
||||
noteExists.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for content changes - auto-save
|
||||
watch(editorContent, (newContent, oldContent) => {
|
||||
// Only trigger auto-save if content actually changed (not on initial load)
|
||||
if (oldContent !== undefined && newContent !== oldContent) {
|
||||
scheduleAutoSave()
|
||||
}
|
||||
})
|
||||
|
||||
watch(currentDate, () => {
|
||||
loadDailyNote()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="daily-view">
|
||||
<div class="view-header">
|
||||
<div class="date-nav">
|
||||
<button @click="goToPrevDay" title="Previous day">←</button>
|
||||
<h2>{{ formatDateDisplay(currentDate) }}</h2>
|
||||
<button @click="goToNextDay" title="Next day">→</button>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<span :class="['status', saveStatus]">
|
||||
<template v-if="saveStatus === 'saving'">Saving...</template>
|
||||
<template v-else-if="saveStatus === 'saved'">Saved</template>
|
||||
<template v-else-if="saveStatus === 'error'">Save failed</template>
|
||||
</span>
|
||||
<span v-if="!noteExists" class="note-status">Draft</span>
|
||||
<button @click="goToToday" v-if="currentDate !== getTodayDate()">Today</button>
|
||||
<button @click="saveNote" :disabled="saveStatus === 'saving'">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
<button @click="error = null">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading daily note...</div>
|
||||
|
||||
<div v-else class="view-content">
|
||||
<div class="editor-container">
|
||||
<MilkdownEditor
|
||||
v-model="editorContent"
|
||||
placeholder="What's on your mind today?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.daily-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.date-nav button {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status.saving { color: var(--color-primary); }
|
||||
.status.saved { color: var(--color-success); }
|
||||
.status.error { color: var(--color-danger); }
|
||||
|
||||
.note-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-message button {
|
||||
background: transparent;
|
||||
border: 1px solid white;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
</style>
|
||||
415
frontend/src/views/DashboardView.vue
Normal file
415
frontend/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectsStore, useTasksStore, useWorkspaceStore } from '../stores'
|
||||
import type { Task } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
const tasksStore = useTasksStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
// Group tasks by project
|
||||
const projectSummaries = computed(() => {
|
||||
return projectsStore.sortedProjects.map(project => {
|
||||
const projectTasks = tasksStore.allTasks.filter(t => t.project_id === project.id)
|
||||
const active = projectTasks.filter(t => !t.completed && t.is_active)
|
||||
const backlog = projectTasks.filter(t => !t.completed && !t.is_active)
|
||||
const completed = projectTasks.filter(t => t.completed)
|
||||
const overdue = active.filter(t => {
|
||||
if (!t.due_date) return false
|
||||
return new Date(t.due_date) < new Date()
|
||||
})
|
||||
|
||||
return {
|
||||
...project,
|
||||
activeTasks: active,
|
||||
backlogCount: backlog.length,
|
||||
completedCount: completed.length,
|
||||
overdueCount: overdue.length,
|
||||
totalCount: projectTasks.length,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function formatDueDate(dateStr?: string) {
|
||||
if (!dateStr) return null
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays < 0) return { text: 'Overdue', class: 'overdue' }
|
||||
if (diffDays === 0) return { text: 'Today', class: 'today' }
|
||||
if (diffDays === 1) return { text: 'Tomorrow', class: 'soon' }
|
||||
if (diffDays <= 7) return { text: `${diffDays}d`, class: 'soon' }
|
||||
return { text: date.toLocaleDateString(), class: '' }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function goToProject(projectId: string) {
|
||||
workspaceStore.setActiveProject(projectId)
|
||||
router.push({ name: 'project', params: { id: projectId } })
|
||||
}
|
||||
|
||||
function goToProjectTasks(projectId: string) {
|
||||
workspaceStore.setActiveProject(projectId)
|
||||
router.push({ name: 'project-tasks', params: { id: projectId } })
|
||||
}
|
||||
|
||||
function goToTask(projectId: string, task: Task) {
|
||||
workspaceStore.setActiveProject(projectId)
|
||||
router.push({ name: 'project-tasks', params: { id: projectId, taskId: task.id } })
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Project name:')
|
||||
if (!name) return
|
||||
|
||||
try {
|
||||
const project = await projectsStore.createProject(name)
|
||||
await workspaceStore.setActiveProject(project.id)
|
||||
router.push({ name: 'project', params: { id: project.id } })
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
projectsStore.loadProjects(),
|
||||
tasksStore.loadAllTasks()
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard-header">
|
||||
<div class="header-left">
|
||||
<h2>Dashboard</h2>
|
||||
<span class="project-count">{{ projectSummaries.length }} projects</span>
|
||||
</div>
|
||||
<button class="primary" @click="createProject">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<div v-else-if="projectSummaries.length === 0" class="empty-state">
|
||||
<h3>Welcome to Ironpad</h3>
|
||||
<p>Create your first project to get started.</p>
|
||||
<button class="primary" @click="createProject" style="margin-top: 16px">Create Project</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="dashboard-grid">
|
||||
<div
|
||||
v-for="project in projectSummaries"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="card-header" @click="goToProject(project.id)">
|
||||
<h3 class="card-title">{{ project.name }}</h3>
|
||||
<div class="card-stats">
|
||||
<span class="stat active-stat" :title="`${project.activeTasks.length} active`">
|
||||
{{ project.activeTasks.length }} active
|
||||
</span>
|
||||
<span v-if="project.backlogCount > 0" class="stat backlog-stat" :title="`${project.backlogCount} backlog`">
|
||||
{{ project.backlogCount }} backlog
|
||||
</span>
|
||||
<span v-if="project.overdueCount > 0" class="stat overdue-stat" :title="`${project.overdueCount} overdue`">
|
||||
{{ project.overdueCount }} overdue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Tasks List -->
|
||||
<div class="card-tasks" v-if="project.activeTasks.length > 0">
|
||||
<div
|
||||
v-for="task in project.activeTasks.slice(0, 5)"
|
||||
:key="task.id"
|
||||
class="card-task-item"
|
||||
@click="goToTask(project.id, task)"
|
||||
>
|
||||
<span class="task-checkbox">☐</span>
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
<div class="task-meta">
|
||||
<span
|
||||
v-for="tag in task.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
class="task-tag"
|
||||
>{{ tag }}</span>
|
||||
<span
|
||||
v-if="task.due_date && formatDueDate(task.due_date)"
|
||||
:class="['task-due', formatDueDate(task.due_date)?.class]"
|
||||
>{{ formatDueDate(task.due_date)?.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="project.activeTasks.length > 5"
|
||||
class="card-task-more"
|
||||
@click="goToProjectTasks(project.id)"
|
||||
>
|
||||
+{{ project.activeTasks.length - 5 }} more tasks...
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card-empty">
|
||||
No active tasks
|
||||
</div>
|
||||
|
||||
<!-- Card Footer -->
|
||||
<div class="card-footer">
|
||||
<span class="completed-count" v-if="project.completedCount > 0">
|
||||
{{ project.completedCount }} completed
|
||||
</span>
|
||||
<button class="link-btn" @click="goToProjectTasks(project.id)">View All Tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 20px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* Project Card */
|
||||
.project-card {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.active-stat {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.backlog-stat {
|
||||
background: rgba(153, 153, 153, 0.15);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.overdue-stat {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Tasks in card */
|
||||
.card-tasks {
|
||||
padding: 0 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.card-task-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.card-task-item .task-checkbox {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-task-item .task-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-due {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.task-due.overdue {
|
||||
color: var(--color-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-due.today {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.task-due.soon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card-task-more {
|
||||
padding: 8px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-task-more:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
padding: 16px 20px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Card Footer */
|
||||
.card-footer {
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.completed-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.loading,
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text);
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
358
frontend/src/views/NotesView.vue
Normal file
358
frontend/src/views/NotesView.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useNotesStore, useWebSocketStore, useGitStore } from '../stores'
|
||||
import MilkdownEditor from '../components/MilkdownEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const notesStore = useNotesStore()
|
||||
const wsStore = useWebSocketStore()
|
||||
const gitStore = useGitStore()
|
||||
|
||||
const editorContent = ref('')
|
||||
let saveTimeout: number | null = null
|
||||
|
||||
// CRITICAL: Separate key for editor recreation - only update AFTER content is ready
|
||||
const editorKey = ref<string | null>(null)
|
||||
|
||||
// Track the last saved/loaded content to detect actual user changes
|
||||
// This prevents unnecessary saves when just opening a note
|
||||
const lastSavedContent = ref<string | null>(null)
|
||||
|
||||
// Track which note ID the pending save is for
|
||||
let pendingSaveNoteId: string | null = null
|
||||
|
||||
const noteId = computed(() => props.id || (route.params.id as string))
|
||||
const selectedNote = computed(() => notesStore.getNoteById(noteId.value))
|
||||
|
||||
const isReadOnly = computed(() => {
|
||||
if (!notesStore.currentNote) return false
|
||||
return wsStore.isFileLockedByOther(notesStore.currentNote.path)
|
||||
})
|
||||
|
||||
function clearPendingSave() {
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout)
|
||||
saveTimeout = null
|
||||
}
|
||||
pendingSaveNoteId = null
|
||||
}
|
||||
|
||||
// Save current content immediately before switching notes
|
||||
async function saveBeforeSwitch() {
|
||||
const noteIdToSave = pendingSaveNoteId
|
||||
const contentToSave = editorContent.value
|
||||
const currentNoteId = notesStore.currentNote?.id
|
||||
|
||||
console.log('[NotesView] saveBeforeSwitch called:', {
|
||||
noteIdToSave,
|
||||
currentNoteId,
|
||||
contentLength: contentToSave?.length,
|
||||
hasCurrentNote: !!notesStore.currentNote
|
||||
})
|
||||
|
||||
// Clear pending state first
|
||||
clearPendingSave()
|
||||
|
||||
// Only save if we had a pending save for the current note
|
||||
if (!noteIdToSave || !notesStore.currentNote) {
|
||||
console.log('[NotesView] Skipping save - no pending save or no current note')
|
||||
return
|
||||
}
|
||||
if (notesStore.currentNote.id !== noteIdToSave) {
|
||||
console.log('[NotesView] Skipping save - note ID mismatch:', { currentNoteId: notesStore.currentNote.id, noteIdToSave })
|
||||
return
|
||||
}
|
||||
|
||||
// Only save if content actually changed
|
||||
if (contentToSave === lastSavedContent.value) {
|
||||
console.log('[NotesView] Skipping save before switch - content unchanged')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[NotesView] Saving content before switch:', { noteIdToSave, contentLength: contentToSave.length })
|
||||
try {
|
||||
await notesStore.saveNote(contentToSave)
|
||||
lastSavedContent.value = contentToSave
|
||||
console.log('[NotesView] Save completed successfully')
|
||||
} catch (err) {
|
||||
console.error('[NotesView] Save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save with debounce
|
||||
function scheduleAutoSave() {
|
||||
console.log('[NotesView] scheduleAutoSave called for note:', noteId.value)
|
||||
clearPendingSave()
|
||||
notesStore.saveStatus = 'idle'
|
||||
// Capture the current note ID for this save operation
|
||||
pendingSaveNoteId = noteId.value || null
|
||||
saveTimeout = window.setTimeout(async () => {
|
||||
const noteIdToSave = pendingSaveNoteId
|
||||
const contentToSave = editorContent.value
|
||||
|
||||
// Clear pending state
|
||||
pendingSaveNoteId = null
|
||||
saveTimeout = null
|
||||
|
||||
// Verify we're still on the same note - critical check to prevent overwrites
|
||||
if (!noteIdToSave || !notesStore.currentNote || noteId.value !== noteIdToSave) {
|
||||
console.log('[NotesView] Skipping save - note changed:', { noteIdToSave, currentNoteId: noteId.value })
|
||||
return
|
||||
}
|
||||
|
||||
// Double-check the current note ID matches
|
||||
if (notesStore.currentNote.id !== noteIdToSave) {
|
||||
console.log('[NotesView] Skipping save - currentNote mismatch:', { noteIdToSave, currentNoteId: notesStore.currentNote.id })
|
||||
return
|
||||
}
|
||||
|
||||
// Final check: only save if content actually changed from last saved
|
||||
if (contentToSave === lastSavedContent.value) {
|
||||
console.log('[NotesView] Skipping save - content unchanged from last save')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await notesStore.saveNote(contentToSave)
|
||||
// Update last saved content on success
|
||||
lastSavedContent.value = contentToSave
|
||||
gitStore.loadStatus()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
clearPendingSave()
|
||||
if (!notesStore.currentNote) return
|
||||
|
||||
try {
|
||||
await notesStore.saveNote(editorContent.value)
|
||||
// Update last saved content on success
|
||||
lastSavedContent.value = editorContent.value
|
||||
gitStore.loadStatus()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote() {
|
||||
if (!confirm('Archive this note?')) return
|
||||
try {
|
||||
await notesStore.deleteNote()
|
||||
router.push({ name: 'home' })
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for note changes
|
||||
watch(noteId, async (newId, oldId) => {
|
||||
console.log('[NotesView] noteId changed:', { oldId, newId, pendingSaveNoteId })
|
||||
|
||||
// Save any pending content from the previous note BEFORE switching
|
||||
if (oldId && pendingSaveNoteId) {
|
||||
console.log('[NotesView] Has pending save, calling saveBeforeSwitch')
|
||||
await saveBeforeSwitch()
|
||||
} else {
|
||||
console.log('[NotesView] No pending save, clearing')
|
||||
clearPendingSave()
|
||||
}
|
||||
notesStore.saveStatus = 'idle'
|
||||
|
||||
if (newId) {
|
||||
console.log('[NotesView] Loading note:', newId)
|
||||
await notesStore.loadNote(newId)
|
||||
|
||||
// CRITICAL: Set content BEFORE updating editorKey
|
||||
const loadedContent = notesStore.currentNote?.content ?? ''
|
||||
console.log('[NotesView] Setting editor content, length:', loadedContent.length)
|
||||
editorContent.value = loadedContent
|
||||
|
||||
// Track this as the "original" content - only save if user makes changes
|
||||
lastSavedContent.value = loadedContent
|
||||
|
||||
// NOW update the editor key - this triggers editor recreation with correct content
|
||||
editorKey.value = newId
|
||||
console.log('[NotesView] Updated editorKey to:', newId)
|
||||
} else {
|
||||
notesStore.clearCurrentNote()
|
||||
editorContent.value = ''
|
||||
lastSavedContent.value = null
|
||||
editorKey.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for content changes - ONLY save when content differs from last saved
|
||||
watch(editorContent, (newContent) => {
|
||||
// Skip if no note loaded or read-only
|
||||
if (!notesStore.currentNote || isReadOnly.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Only schedule auto-save if content actually differs from last saved/loaded
|
||||
// This prevents unnecessary saves when just opening a note
|
||||
if (lastSavedContent.value !== null && newContent !== lastSavedContent.value) {
|
||||
console.log('[NotesView] Content changed from last saved, scheduling auto-save')
|
||||
scheduleAutoSave()
|
||||
}
|
||||
})
|
||||
|
||||
// Milkdown is WYSIWYG - no separate preview needed
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notes-view">
|
||||
<template v-if="notesStore.currentNote && editorKey">
|
||||
<div class="view-header">
|
||||
<h2>{{ selectedNote?.title ?? notesStore.currentNote.id }}</h2>
|
||||
<div class="button-group">
|
||||
<span :class="['status', notesStore.saveStatus]">
|
||||
<template v-if="notesStore.saveStatus === 'saving'">Saving...</template>
|
||||
<template v-else-if="notesStore.saveStatus === 'saved'">Saved</template>
|
||||
<template v-else-if="notesStore.saveStatus === 'error'">Save failed</template>
|
||||
</span>
|
||||
<span v-if="wsStore.connected" class="ws-status connected" title="Real-time sync active">●</span>
|
||||
<span v-else class="ws-status" title="Connecting...">○</span>
|
||||
<button @click="saveNote" :disabled="notesStore.saveStatus === 'saving'">Save</button>
|
||||
<button class="danger" @click="deleteNote">Archive</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isReadOnly" class="read-only-banner">
|
||||
🔒 This file is being edited elsewhere. Read-only mode.
|
||||
</div>
|
||||
|
||||
<div class="view-content">
|
||||
<div v-if="notesStore.loadingNote" class="loading">Loading note...</div>
|
||||
<div v-else class="editor-container">
|
||||
<MilkdownEditor
|
||||
v-model="editorContent"
|
||||
:editor-key="editorKey"
|
||||
:readonly="isReadOnly"
|
||||
placeholder="Start writing..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<h2>No note selected</h2>
|
||||
<p>Select a note from the sidebar or create a new one.</p>
|
||||
<p class="shortcuts">
|
||||
<kbd>Ctrl+K</kbd> Search · <kbd>Ctrl+S</kbd> Save
|
||||
</p>
|
||||
<button class="primary" @click="notesStore.createNote().then(n => router.push({ name: 'note', params: { id: n.id } }))" style="margin-top: 16px">
|
||||
Create Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notes-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status.saving { color: var(--color-primary); }
|
||||
.status.saved { color: var(--color-success); }
|
||||
.status.error { color: var(--color-danger); }
|
||||
|
||||
.ws-status {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ws-status.connected {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.read-only-banner {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shortcuts kbd {
|
||||
background: var(--color-border);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
529
frontend/src/views/ProjectNotesView.vue
Normal file
529
frontend/src/views/ProjectNotesView.vue
Normal file
@@ -0,0 +1,529 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWorkspaceStore, useGitStore } from '../stores'
|
||||
import { projectsApi } from '../api/client'
|
||||
import type { ProjectNote, ProjectNoteWithContent } from '../types'
|
||||
import MilkdownEditor from '../components/MilkdownEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
noteId?: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const gitStore = useGitStore()
|
||||
|
||||
const projectId = computed(() => props.id || (route.params.id as string))
|
||||
const currentNoteId = computed(() => props.noteId || (route.params.noteId as string | undefined))
|
||||
|
||||
// Notes list state
|
||||
const notes = ref<ProjectNote[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Editor state
|
||||
const selectedNote = ref<ProjectNoteWithContent | null>(null)
|
||||
const editorContent = ref('')
|
||||
const editorLoading = ref(false)
|
||||
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
let saveTimeout: number | null = null
|
||||
|
||||
// CRITICAL: Separate key for editor recreation - only update AFTER content is ready
|
||||
// This prevents the race condition where the editor recreates before content loads
|
||||
const editorKey = ref<string | null>(null)
|
||||
|
||||
// Track the last saved/loaded content to detect actual user changes
|
||||
// This prevents unnecessary saves when just opening a note
|
||||
const lastSavedContent = ref<string | null>(null)
|
||||
|
||||
// Track which note ID the pending save is for
|
||||
let pendingSaveNoteId: string | null = null
|
||||
|
||||
async function loadNotes() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
notes.value = await projectsApi.listNotes(projectId.value)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load notes: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNote(noteId: string) {
|
||||
editorLoading.value = true
|
||||
try {
|
||||
selectedNote.value = await projectsApi.getNote(projectId.value, noteId)
|
||||
|
||||
// CRITICAL: Set content BEFORE updating editorKey
|
||||
// This ensures when the editor recreates, it has the correct defaultValue
|
||||
const newContent = selectedNote.value?.content ?? ''
|
||||
editorContent.value = newContent
|
||||
console.log('[ProjectNotesView] Loaded note content, length:', newContent.length)
|
||||
|
||||
// Track this as the "original" content - only save if user makes changes
|
||||
lastSavedContent.value = newContent
|
||||
|
||||
// NOW update the editor key - this triggers editor recreation with correct content
|
||||
editorKey.value = noteId
|
||||
console.log('[ProjectNotesView] Updated editorKey to:', noteId)
|
||||
} catch {
|
||||
selectedNote.value = null
|
||||
editorContent.value = ''
|
||||
lastSavedContent.value = null
|
||||
editorKey.value = null
|
||||
} finally {
|
||||
editorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
const title = prompt('Note title (optional):')
|
||||
try {
|
||||
const note = await projectsApi.createNote(projectId.value, title || undefined)
|
||||
await loadNotes() // Refresh list
|
||||
// Select the new note
|
||||
const filename = note.path.split('/').pop()?.replace('.md', '')
|
||||
if (filename) {
|
||||
router.push({ name: 'project-notes', params: { id: projectId.value, noteId: filename } })
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to create note: ${err}`
|
||||
}
|
||||
}
|
||||
|
||||
function selectNote(note: ProjectNote) {
|
||||
const filename = note.path.split('/').pop()?.replace('.md', '')
|
||||
if (filename) {
|
||||
router.push({ name: 'project-notes', params: { id: projectId.value, noteId: filename } })
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingSave() {
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout)
|
||||
saveTimeout = null
|
||||
}
|
||||
pendingSaveNoteId = null
|
||||
}
|
||||
|
||||
// Save current content immediately before switching notes
|
||||
async function saveBeforeSwitch() {
|
||||
const noteIdToSave = pendingSaveNoteId
|
||||
const contentToSave = editorContent.value
|
||||
|
||||
console.log('[ProjectNotesView] saveBeforeSwitch called:', {
|
||||
noteIdToSave,
|
||||
hasSelectedNote: !!selectedNote.value,
|
||||
contentLength: contentToSave?.length
|
||||
})
|
||||
|
||||
// Clear pending state first
|
||||
clearPendingSave()
|
||||
|
||||
// Only save if we had a pending save for the current note
|
||||
if (!noteIdToSave || !selectedNote.value) {
|
||||
console.log('[ProjectNotesView] Skipping save - no pending save or no selected note')
|
||||
return
|
||||
}
|
||||
|
||||
// Only save if content actually changed
|
||||
if (contentToSave === lastSavedContent.value) {
|
||||
console.log('[ProjectNotesView] Skipping save before switch - content unchanged')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[ProjectNotesView] Saving content before switch:', { noteIdToSave, contentLength: contentToSave.length })
|
||||
try {
|
||||
await projectsApi.updateNote(projectId.value, noteIdToSave, contentToSave)
|
||||
lastSavedContent.value = contentToSave
|
||||
console.log('[ProjectNotesView] Save completed successfully')
|
||||
gitStore.loadStatus()
|
||||
} catch (err) {
|
||||
console.error('[ProjectNotesView] Save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoSave() {
|
||||
console.log('[ProjectNotesView] scheduleAutoSave called for note:', currentNoteId.value)
|
||||
clearPendingSave()
|
||||
saveStatus.value = 'idle'
|
||||
// Capture the current note ID for this save operation
|
||||
pendingSaveNoteId = currentNoteId.value || null
|
||||
saveTimeout = window.setTimeout(saveNoteContent, 1000)
|
||||
}
|
||||
|
||||
async function saveNoteContent() {
|
||||
const noteIdToSave = pendingSaveNoteId
|
||||
const contentToSave = editorContent.value
|
||||
|
||||
// Clear pending state
|
||||
pendingSaveNoteId = null
|
||||
saveTimeout = null
|
||||
|
||||
// Verify we're still on the same note - critical check to prevent overwrites
|
||||
if (!noteIdToSave || !selectedNote.value || currentNoteId.value !== noteIdToSave) {
|
||||
console.log('[ProjectNotesView] Skipping save - note changed:', { noteIdToSave, currentNoteId: currentNoteId.value })
|
||||
return
|
||||
}
|
||||
|
||||
// Final check: only save if content actually changed from last saved
|
||||
if (contentToSave === lastSavedContent.value) {
|
||||
console.log('[ProjectNotesView] Skipping save - content unchanged from last save')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
const savedNote = await projectsApi.updateNote(projectId.value, noteIdToSave, contentToSave)
|
||||
|
||||
// Only update state if we're still on the same note
|
||||
if (currentNoteId.value === noteIdToSave) {
|
||||
selectedNote.value = savedNote
|
||||
lastSavedContent.value = contentToSave
|
||||
saveStatus.value = 'saved'
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
}
|
||||
gitStore.loadStatus()
|
||||
await loadNotes() // Refresh list to update timestamps
|
||||
} catch (err) {
|
||||
if (currentNoteId.value === noteIdToSave) {
|
||||
saveStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
clearPendingSave()
|
||||
if (!selectedNote.value || !currentNoteId.value) return
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
selectedNote.value = await projectsApi.updateNote(projectId.value, currentNoteId.value, editorContent.value)
|
||||
lastSavedContent.value = editorContent.value
|
||||
saveStatus.value = 'saved'
|
||||
gitStore.loadStatus()
|
||||
await loadNotes() // Refresh list to update timestamps
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
saveStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote() {
|
||||
if (!selectedNote.value || !currentNoteId.value) return
|
||||
if (!confirm('Are you sure you want to delete this note?')) return
|
||||
|
||||
try {
|
||||
await projectsApi.deleteNote(projectId.value, currentNoteId.value)
|
||||
selectedNote.value = null
|
||||
editorContent.value = ''
|
||||
router.push({ name: 'project-notes', params: { id: projectId.value } })
|
||||
await loadNotes()
|
||||
} catch (err) {
|
||||
alert(`Failed to delete note: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(note: ProjectNote) {
|
||||
const filename = note.path.split('/').pop()?.replace('.md', '')
|
||||
return filename === currentNoteId.value
|
||||
}
|
||||
|
||||
// Watch for content changes - ONLY save when content differs from last saved
|
||||
watch(editorContent, (newContent) => {
|
||||
// Skip if no note loaded
|
||||
if (!selectedNote.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Only schedule auto-save if content actually differs from last saved/loaded
|
||||
// This prevents unnecessary saves when just opening a note
|
||||
if (lastSavedContent.value !== null && newContent !== lastSavedContent.value) {
|
||||
console.log('[ProjectNotesView] Content changed from last saved, scheduling auto-save')
|
||||
scheduleAutoSave()
|
||||
}
|
||||
})
|
||||
|
||||
watch(projectId, () => {
|
||||
// Clear any pending saves when switching projects
|
||||
clearPendingSave()
|
||||
editorContent.value = ''
|
||||
lastSavedContent.value = null
|
||||
selectedNote.value = null
|
||||
|
||||
loadNotes()
|
||||
}, { immediate: true })
|
||||
|
||||
watch(currentNoteId, async (noteId, oldNoteId) => {
|
||||
console.log('[ProjectNotesView] currentNoteId changed:', { oldNoteId, noteId, pendingSaveNoteId })
|
||||
|
||||
// Save any pending content from the previous note BEFORE switching
|
||||
if (oldNoteId && pendingSaveNoteId) {
|
||||
console.log('[ProjectNotesView] Has pending save, calling saveBeforeSwitch')
|
||||
await saveBeforeSwitch()
|
||||
} else {
|
||||
clearPendingSave()
|
||||
}
|
||||
saveStatus.value = 'idle'
|
||||
|
||||
if (noteId) {
|
||||
console.log('[ProjectNotesView] Loading note:', noteId)
|
||||
await loadNote(noteId)
|
||||
} else {
|
||||
selectedNote.value = null
|
||||
editorContent.value = ''
|
||||
lastSavedContent.value = null
|
||||
editorKey.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
if (workspaceStore.activeProjectId !== projectId.value) {
|
||||
workspaceStore.setActiveProject(projectId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notes-split-view">
|
||||
<!-- Notes List Panel -->
|
||||
<div class="notes-list-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Notes</h3>
|
||||
<button class="primary small" @click="createNote">+ New</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-small">Loading...</div>
|
||||
|
||||
<div v-else-if="notes.length === 0" class="empty-list">
|
||||
<p>No notes yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="notes-list">
|
||||
<div
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
:class="['note-item', { selected: isSelected(note) }]"
|
||||
@click="selectNote(note)"
|
||||
>
|
||||
<div class="note-title">{{ note.title || 'Untitled' }}</div>
|
||||
<div class="note-meta">{{ formatDate(note.updated) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Panel -->
|
||||
<div class="editor-panel">
|
||||
<template v-if="currentNoteId && selectedNote && editorKey">
|
||||
<div class="editor-header">
|
||||
<h3>{{ selectedNote.title || 'Untitled' }}</h3>
|
||||
<div class="editor-actions">
|
||||
<span :class="['status', saveStatus]">
|
||||
<template v-if="saveStatus === 'saving'">Saving...</template>
|
||||
<template v-else-if="saveStatus === 'saved'">Saved</template>
|
||||
<template v-else-if="saveStatus === 'error'">Error</template>
|
||||
</span>
|
||||
<button @click="saveNote" :disabled="saveStatus === 'saving'">Save</button>
|
||||
<button class="danger" @click="deleteNote">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-content">
|
||||
<MilkdownEditor
|
||||
v-model="editorContent"
|
||||
:editor-key="editorKey"
|
||||
:project-id="projectId"
|
||||
placeholder="Write your note..."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="editorLoading">
|
||||
<div class="editor-placeholder">
|
||||
<p>Loading note...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="editor-placeholder">
|
||||
<h3>Select a note</h3>
|
||||
<p>Choose a note from the list or create a new one.</p>
|
||||
<button class="primary" @click="createNote">+ New Note</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notes-split-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Notes List Panel */
|
||||
.notes-list-panel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button.small {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.note-item.selected {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.note-item.selected .note-meta {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.note-title {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-small,
|
||||
.empty-list {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Editor Panel */
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-header h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status.saving { color: var(--color-primary); }
|
||||
.status.saved { color: var(--color-success); }
|
||||
.status.error { color: var(--color-danger); }
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.editor-placeholder h3 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.editor-placeholder p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
188
frontend/src/views/ProjectView.vue
Normal file
188
frontend/src/views/ProjectView.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useProjectsStore, useGitStore, useWorkspaceStore } from '../stores'
|
||||
import { projectsApi } from '../api/client'
|
||||
import type { ProjectWithContent } from '../types'
|
||||
import MilkdownEditor from '../components/MilkdownEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
const gitStore = useGitStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
const projectId = computed(() => props.id || (route.params.id as string))
|
||||
const project = computed(() => projectsStore.getProjectById(projectId.value))
|
||||
const projectContent = ref<ProjectWithContent | null>(null)
|
||||
const editorContent = ref('')
|
||||
const loading = ref(false)
|
||||
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
let saveTimeout: number | null = null
|
||||
|
||||
function goToTasks() {
|
||||
router.push({ name: 'project-tasks', params: { id: projectId.value } })
|
||||
}
|
||||
|
||||
function scheduleAutoSave() {
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
saveStatus.value = 'idle'
|
||||
saveTimeout = window.setTimeout(saveNote, 1000)
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
if (!projectContent.value) return
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
projectContent.value = await projectsApi.updateContent(projectId.value, editorContent.value)
|
||||
saveStatus.value = 'saved'
|
||||
gitStore.loadStatus()
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
saveStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectContent() {
|
||||
loading.value = true
|
||||
try {
|
||||
projectContent.value = await projectsApi.getContent(projectId.value)
|
||||
editorContent.value = projectContent.value?.content ?? ''
|
||||
} catch {
|
||||
projectContent.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(editorContent, (newContent, oldContent) => {
|
||||
if (projectContent.value && oldContent !== undefined && newContent !== oldContent) {
|
||||
scheduleAutoSave()
|
||||
}
|
||||
})
|
||||
|
||||
watch(projectId, () => {
|
||||
loadProjectContent()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await projectsStore.loadProject(projectId.value)
|
||||
|
||||
// Set as active project
|
||||
if (workspaceStore.activeProjectId !== projectId.value) {
|
||||
await workspaceStore.setActiveProject(projectId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-view">
|
||||
<div class="view-header">
|
||||
<h2>{{ project?.name ?? projectId }}</h2>
|
||||
<div class="button-group">
|
||||
<span :class="['status', saveStatus]">
|
||||
<template v-if="saveStatus === 'saving'">Saving...</template>
|
||||
<template v-else-if="saveStatus === 'saved'">Saved</template>
|
||||
<template v-else-if="saveStatus === 'error'">Save failed</template>
|
||||
</span>
|
||||
<button class="primary" @click="goToTasks">View Tasks</button>
|
||||
<button @click="saveNote" :disabled="saveStatus === 'saving'">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading project...</div>
|
||||
|
||||
<div v-else-if="projectContent" class="view-content">
|
||||
<div class="editor-container">
|
||||
<MilkdownEditor
|
||||
v-model="editorContent"
|
||||
:project-id="projectId"
|
||||
placeholder="Write about this project..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<h3>Project not found</h3>
|
||||
<p>This project doesn't exist or couldn't be loaded.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
max-height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status.saving { color: var(--color-primary); }
|
||||
.status.saved { color: var(--color-success); }
|
||||
.status.error { color: var(--color-danger); }
|
||||
|
||||
.view-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
132
frontend/src/views/ProjectsView.vue
Normal file
132
frontend/src/views/ProjectsView.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectsStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Project name:')
|
||||
if (!name) return
|
||||
|
||||
try {
|
||||
const project = await projectsStore.createProject(name)
|
||||
router.push({ name: 'project', params: { id: project.id } })
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
function selectProject(id: string) {
|
||||
router.push({ name: 'project', params: { id } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
projectsStore.loadProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="projects-view">
|
||||
<div class="view-header">
|
||||
<h2>Projects</h2>
|
||||
<button class="primary" @click="createProject">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<div v-if="projectsStore.loading" class="loading">Loading projects...</div>
|
||||
|
||||
<div v-else-if="projectsStore.projects.length === 0" class="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create your first project to get started.</p>
|
||||
<button class="primary" @click="createProject" style="margin-top: 16px">Create Project</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="projects-grid">
|
||||
<div
|
||||
v-for="project in projectsStore.sortedProjects"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
<h3>{{ project.name }}</h3>
|
||||
<p class="project-path">{{ project.path }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.projects-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.project-card h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-path {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
1624
frontend/src/views/TasksView.vue
Normal file
1624
frontend/src/views/TasksView.vue
Normal file
File diff suppressed because it is too large
Load Diff
16
frontend/tsconfig.app.json
Normal file
16
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:3000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user